Thanks @namnc for the series, and @Skanislav, ivanmmurciaua and siriusthemaster for the follow-ups. I’ve been building on this problem for the last 7–8 months. SPECTER is a working ML-KEM-based SAP that runs end to end: key generation, meta-address publication, sending, on-chain announcement, scanning and claiming. We arrived independently at a construction that is essentially your scheme 2. That includes deriving the view tag separately from the tweak, which has been in our code since February. It is also the “ML-KEM discovery + k1 spending” bridge ivanmmurciaua describes, with a plain EOA as the k1 spender. Below are the concrete construction, a few implementation findings that seem relevant to the ERC, and where we fall short.
References (new account, so links aren’t allowed yet):
Live app specterpq.com
Protocol (Rust) github.com/pranshurastogi/SPECTER
SDK (npm, WASM) npmjs.com/package/@specterpq/sdk
SDK source github.com/specter-privacy/specter-sdk
Playground github.com/specter-privacy/playground
Docs / API docs.specterpq.com (OpenAPI: docs.specterpq.com/openapi.yaml)
1. Keys and meta-address
Spending: secp256k1, (k, K = kG), with K compressed (33 B).
Viewing: ML-KEM-768 (FIPS 203, RustCrypto ml-kem), (dk, ek) with |ek| = 1184 B and |dk| = 2400 B.
Meta-address: version(1) ‖ K(33) ‖ ek(1184) = 1218 B, i.e. your st:eth:0x<spendingPubKey><ek> with a version byte in front.
2. Hashing and domain separation
Every value derived from the shared secret r goes through one SHAKE256 wrapper with a length-prefixed domain:
H_D(x, n) = SHAKE256( LE32(|D|) ‖ D ‖ x )[0..n]
Output
Domain
tweak h
SPECTER_STEALTH_TWEAK_V2
view tag
SPECTER_VIEW_TAG_V1
metadata AEAD key
SPECTER_META_ENC_KEY_V1
metadata AEAD nonce
SPECTER_META_ENC_NONCE_V1
3. Sending
(r, c) ← ML-KEM-768.Encaps(ek) // |c| = 1088 B, |r| = 32 B
h = H_TWEAK(r ‖ ctr, 32) // ctr = 0,1,... until h ∈ [1, n)
P = K + h·G
tag = H_VIEWTAG(r, 32)[0]
Recipient spend key: p = k + h mod n. The sender knows h but not k.
Address formats: one P covers both chain families. EVM uses keccak256(X‖Y)[12:]. Sui uses blake2b-256(0x01 ‖ compressed(P)), where 0x01 is the secp256k1 scheme flag. So one meta-address receives on EVM chains and on Sui.
Why the tag is safe against a CRQC: the tag is H(DS2‖r)[0] and the tweak is H(DS1‖r‖ctr). A CRQC that recovers log_G(P) and every log_G(K_i) learns each candidate h_i, but that says nothing about the tag. So the 1/256 recipient-linkage filter from your post doesn’t apply, and the tag’s privacy rests entirely on r.
4. Announcement format
Our announcer follows the ERC-5564 shape, except that the log carries a hash of the ciphertext:
event Announcement(
uint256 schemeId,
address indexed stealthAddress,
address indexed caller,
bytes32 ephemeralKeyHash, // keccak256(c)
bytes metadata
);
function announce(address stealthAddress, bytes calldata ephemeralPubKey, bytes calldata metadata) external;
function announce(uint256 schemeId, address stealthAddress, bytes calldata ephemeralPubKey, bytes calldata metadata) external;
function announceMany(address[] calldata, bytes[] calldata, bytes[] calldata) external; // ≤ 50
Ciphertext lives in calldata. The 1088 B c is passed as calldata (length-checked on-chain) and only keccak256(c) goes into the log. Indexers decode c from the announce() calldata and check it against the logged hash. This avoids paying for c twice: under the standard EVM schedule, it saves about 8.7k gas of LOG data per announcement. The calldata cost of c stays.
Metadata (93 B): tag(1) ‖ AES-256-GCM(payment_tx_hash(32) ‖ amount(32) ‖ source_chain_id(8) ‖ reserved(4)) ‖ GCM tag(16). The key and nonce come from r under the two META domains, so each one is used exactly once. Only the tag is in the clear.
schemeId: a placeholder (1000) for now. We’d rather use whatever ID this series settles on.
5. Implementation findings
a) The view tag can’t prefilter ML-KEM, so don’t design storage around that. Logging only keccak256(c) invites an optimization: fetch c only for announcements whose tag matches. That’s circular, because checking the tag needs r, and r needs Decaps(dk, c). This is the same point as your “a full decapsulation even if we keep the view-tag”. So we resolve c from calldata at index time (an Envio HyperIndex indexer plus a TypeScript poller, both checking the keccak hash). Scanning then costs one Decaps plus one SHAKE256 for every announcement, and EC work only for tag matches.
b) Implicit rejection makes the tag the only cryptographic filter, so a tag match must not count as a discovery. Decaps never errors on a foreign c; it returns a pseudorandom r'. About 1/256 of foreign announcements therefore pass the tag. A scanner has to confirm a match with P' == stealthAddress (or with the metadata AEAD verifying) before reporting it. We found this in our own scanner and are tightening it. It may be worth stating explicitly in the ERC text.
c) Shared test vectors would have saved us time. At one point our SDK and our backend derived different tweaks. The result was silent: scans came back empty rather than failing. A common vector set across implementations would catch this early, and we’d contribute ours.
6. Architecture
Client-side: the web app does key generation, scanning, spend-key derivation and transaction signing locally through the WASM SDK. No API endpoint receives a secret key.
Self-sovereign recovery: a standalone script (@specterpq/sdk + viem) scans the announcer straight from an RPC: getLogs → fetch tx → decode calldata → check keccak → Decaps → tag → derive. No SPECTER infrastructure is involved.
Meta-address distribution: IPFS, referenced from an ENS text record specter (ipfs://CID, falling back to EIP-1577 contenthash), with no ERC-6538 registration. As siriusthemaster recommends, this keeps registration from exposing the registrant’s own account. The meta-address can still be replaced by whoever controls the name’s ECDSA key, though. So the authority gap @Skanislav raised is moved up a layer, not closed. ivanmmurciaua’s “authorization lives in the account” framing is the cleaner answer.
Relaying: announce() is sent by a gas-sponsored relayer that first checks the payment tx paid P the stated amount (native or ERC-20). Payments can be on Ethereum, Arbitrum Sepolia, Monad testnet or Sui, and all announcements go to one announcer on Monad. One caveat: the announced stealthAddress is the funded address, so this does not give the payment/announcement unlinkability of your “stealthier SAP”. It only keeps the payer’s account out of caller.
7. Where we fall short
Encapsulation happens on our server in the web send flow today. The API runs Encaps(ek) and holds r (KEK-wrapped) until it publishes the announcement, so our backend can currently link payments to recipients. The SDK already supports local encapsulation. Moving Encaps and metadata encryption to the sender’s device is the next change, so that only the sender and the dk holder ever see r.
Our announcements form a separate, tiny anonymity set. We use our own announcer, our own schemeId, and a testnet. siriusthemaster’s numbers make the cost of that fragmentation concrete, and it’s the main reason we’d like to converge on a standard scheme 3 announcement rather than keep a bespoke one.
No hybrid yet. It’s ML-KEM only. Scheme 3, as @namnc frames it, is the natural next step: the combiner SHA3-256(DS ‖ r_ec ‖ r_pq ‖ epk ‖ c ‖ V ‖ ek), with the tag and tweak derived from the combined r and never from r_ec alone, under a fresh meta-address rather than an extended scheme-1 one.
Spending is an EOA, and k is sampled from a CSPRNG rather than derived as H(seed). Both point toward the scheme 6 shape. One option is a counterfactual account derived from the ML-KEM commitment, as ivanmmurciaua tested, with k1 authorization now and PQ authorization later. Seed-derived k matters if we want the PQ-recoverability path, and it has to be in place before it’s needed.
Happy to go deeper on any part. Everything above is in the open repos.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | ERC-8403: an account as one hash, and post-quantum keys | 0 | 9.01 | 11-10-2026 |
| 2 | Private Stablecoin on Ethereum | 0 | 12.12 | 09-10-2026 |
| 3 | Informed Diffusion: making home builders publish fast | 0 | 9.3 | 13-10-2026 |
| 4 | EIP Editing Office Hour (EIP + ERC ) Meeting #108, July 28, 2026 | 0 | 18.32 | 27-07-2026 |
| 5 | Rapidblocks, take two: a pipelined consensus for independent blocks | 0 | 5.78 | 13-10-2026 |
| 6 | EIP Editing Office Hour (EIP + ERC ) Meeting #111, Aug 18, 2026 | 0 | 18.71 | 13-08-2026 |
| 7 | Deterministic Random Number in EVM for Independent Recomputability | 0 | 12.36 | 30-07-2026 |
| 8 | EIP-0000 Preferential Gas Costs for Native ETH Operations – Draft for feedback | 0 | 13.65 | 05-08-2026 |
| 9 | EIP-8037: State Creation Gas Cost Increase | 0 | 19.67 | 28-07-2026 |
| 10 | ERC-8337: Agent Memory State | 0 | 7.65 | 26-07-2026 |