EIP.tools

EIP.tools

⚠️ DraftStandards Track: ERC

ERC-8441: Hybrid post-quantum stealth address scheme

ERC-5564 schemeId 3, announcing with ML-KEM-768 combined with secp256k1 ECDH while spending stays on secp256k1

Authors
Created2026-08-28
Discussion Linkhttps://ethereum-magicians.org/t/erc-8441-hybrid-post-quantum-stealth-address-scheme/29923
Requires

Markdown

https://raw.githubusercontent.com/ethereum/ERCs/re...
Pull Request#2059PR open

EIP-GPT summary

Contents
AbstractMotivationSpecification1. Common definitions1.1 The hybrid combiner2. schemeId 32.1 Keys and seeds2.2 Meta-address encoding2.3 Registration and scheme selection2.4 Sender2.5 Scanner2.6 Recipient2.7 Errors and skips2.8 ERC-5564 methods3. Wire formatRationaleWhy a hybridWhy ML-KEM-768Why these hash functionsWhy the offset and view tag differ from schemeId 1Why the view tag comes after the KEMWhy ct is carried in ephemeralPubKeyWhy the tracking key holds the seed (d, z)Why the sender's randomness is drawn, not derivedCostBackwards CompatibilityTest CasesReference ImplementationSecurity ConsiderationsWhat this scheme protects, and what it does notKEM anonymity is requiredAgainst a quantum adversarySpending once a CRQC existsDelegated scanningOne-time key and ssSender randomnessDowngradeScanner cost and denial of serviceView tagCopyright

Abstract

ERC-5564 defines stealth addresses and a schemeId namespace, and specifies schemeId 1, on secp256k1. This ERC specifies schemeId 3. Its payment secret combines an ML-KEM-768 encapsulation with a secp256k1 ECDH secret. As long as ML-KEM-768 holds, an adversary who later gains access to a quantum computer still cannot tell from the public announcement log who was the receiver.

The scheme protects the announcement layer only. Spending stays vulnerable to quantum attacker. Each stealth address is still an ordinary EOA controlled by a secp256k1 key, and a quantum adversary can break those keys as it can break any EOA's. It uses the deployed ERC-5564 announcer and ERC-6538 registry as is, with no protocol change and no new contract.

schemeId 3
payment secretML-KEM-768 shared secret combined with a secp256k1 ECDH secret
announcement1 122 B per payment: an ephemeral public key, an ML-KEM ciphertext and a view tag
meta-address1 250 B, registered once via ERC-6538
spendingsecp256k1 ECDSA from a plain EOA

Motivation

As defined in ERC-5564, stealth-address announcement is public, permanent, and centralized in one contract. Under schemeId 1, an announcement's privacy relies on secp256k1 ECDH. Anyone who can compute discrete logarithms (i.e., with a cryptographically relevant quantum computer (CRQC) built in the future) can take each registered viewing key, recompute the shared secret for every announcement, and thus learn which recipient each payment went to. As such privacy loss is retroactive, post-quantum migration for the anonymity layer matters now.

Spending is a different problem with a later deadline, as theft needs the CRQC to exist while the funds are still in the account. Beside, moving accounts off secp256k1 may also need account or protocol changes that are not yet available.

Conveniently, ERC-5564's announce() and ERC-6538's registerKeys both take unbounded bytes, hence the announcement layer can move to a post-quantum KEM now, with no new contract. And spending can stay on secp256k1 until a later and separate migration.

The ECDH half yields no protection against a quantum adversary but a hedge for the extreme case where ML-KEM-768, or an implementation of it, is broken classically. In such case, the payment secret still has the protection schemeId 1 has today.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

1. Common definitions

Notation:

  • a || b is byte concatenation, and x[i..j] is bytes i (inclusive) to j (exclusive) of x.
  • u256be(b) reads 32 bytes as an unsigned integer, most significant byte first.
  • n is the order of the secp256k1 group and G is its generator. A valid scalar is an integer k with 0 < k < n. A 32-byte secret's u256be value must be a valid scalar.
  • Points use SEC1-compressed encoding: 33 bytes with tag 0x02 or 0x03. uncompressed(P) is the 65-byte SEC1 form 0x04 || x || y.
  • ECDH(k, P).x is the 32-byte big-endian x-coordinate of k·P.
  • SHA256 is SHA-256 (FIPS 180-4) and SHA3-256 is SHA3-256 (FIPS 202). keccak256 is Ethereum's original Keccak-256.
  • Domain separators are ASCII strings used as written, with no length prefix and no terminator.

ML-KEM-768 is as specified in FIPS 203. We use it as follows:

  • Key generation starts from the 64-byte seed (d, z): (ek, dk) = ML-KEM.KeyGen_internal(d, z) (FIPS 203 Algorithm 16). The seed can be stored in place of the decapsulation key and only expanded when needed.
  • Encapsulation is FIPS 203 ML-KEM.Encaps(ek) (Algorithm 20). We use the deterministic ML-KEM.Encaps_internal(ek, m) (Algorithm 17) only in the test vectors (Test Cases).
  • Decapsulation is FIPS 203 ML-KEM.Decaps(dk, ct), or ML-KEM.Decaps_internal on a dk expanded from (d, z). Note that ML-KEM rejects implicitly: a well-formed ciphertext that was not intended for dk decapsulates to a pseudorandom value, not to an error.

Offset and view tag. From the 32-byte payment secret ss of Section 1.1:

base = SHA256("pq-stealth/offset/v1" || ss)
if 0 < u256be(base) < n:  H(ss) = u256be(base)
else:                     fail

view_tag = SHA256("pq-stealth/view-tag/v1" || ss)[0..1]                       1 B

Sender and recipient MUST both run exactly this procedure. An implementation MUST NOT reduce base mod n or derive another candidate. On failure, the sender discards the announcement's randomness and draws again (Section 2.4), and a scanner skips the announcement (Section 2.7). base is out of range with probability below 2^-127. Test vectors reach the failure by supplying base directly (Test Cases).

The stealth key pair and its address keep ERC-5564's structure:

stealth_pk = spending_pk + H(ss)·G
stealth_sk = (spending_sk + H(ss)) mod n
address    = keccak256(uncompressed(stealth_pk)[1..65])[12..32]

These derivations differ from ERC-5564's schemeId 1 in four ways:

  1. The hashed secret is the hybrid ss of Section 1.1, not the ECDH shared point.
  2. ERC-5564's hash function h is made explicit SHA-256 under the domain separator "pq-stealth/offset/v1".
  3. ERC-5564 takes the view tag from the first byte of the digest that also becomes the scalar. Here the view tag is a separate digest under its own domain separator.
  4. ERC-5564 uses the digest as the scalar with no range check. Here we make explicit that the range check above yields a valid scalar or fails.

1.1 The hybrid combiner

hybrid_combine(DS, ss_ec, ss_pq, epk, ct, viewing_pk_ec, ek)
    = SHA3-256(DS || ss_ec || ss_pq || epk || ct || viewing_pk_ec || ek)               32 B

schemeId 3 uses DS = "pq-stealth/hybrid-payment/v1" (28 bytes) and output ss (Section 2.4).

  1. ss_ec MUST be the 32-byte x-coordinate alone.
  2. epk and viewing_pk_ec MUST be their 33-byte compressed encodings. ct MUST be the 1 088-byte ML-KEM ciphertext as announced, and ek the 1 184-byte encapsulation key as registered.
  3. The input order MUST be exactly as shown, with DS first, and the output MUST be the full 32-byte digest.

Every field has a fixed length, so plain concatenation encodes the inputs unambiguously.

Our combiner has the shape of KeyCombineCCA_H in NIST SP 800-227 §4.6.3 (an instance of Eq. (15)):

SP 800-227 inputhererole
K1, K2ss_ec, ss_pqthe two shared secrets
c1epkthe ECDH "ciphertext": the sender's ephemeral public key
c2ctthe ML-KEM ciphertext
ek1viewing_pk_ecthe ECDH "encapsulation key": the recipient's viewing key
ek2ekthe ML-KEM encapsulation key
domain_sepDSnames this scheme and this combiner

It departs from the exact shape in three ways:

  • DS comes first rather than last.
  • The inputs are concatenated rather than given as a list. SP 800-227 §4.6.2 allows plain concatenation when the encoding is unambiguous, as it is here.
  • We use a bare hash, not SP 800-56C's one-step KDF, which prefixes a 32-bit counter.

Note that security is still kept: i.e., with the hash modelled as a random oracle, hashing both shared secrets together with both ciphertexts yields an IND-CCA KEM if either component KEM is IND-CCA. The two encapsulation keys are not needed but as SP 800-227 notes, including them binds the secret to the recipient's identity.

2. schemeId 3

Each payment carries one ML-KEM encapsulation and one ephemeral secp256k1 key. Spending stays secp256k1 ECDSA on an ordinary EOA, so it needs no new verifier and no consensus change.

sender    : esk, epk    <- a fresh secp256k1 key pair                         (2.4)
            (ct, ss_pq) <- ML-KEM-768.Encaps(ek)
            ss          <- hybrid_combine(DS, ECDH(esk, viewing_pk_ec).x, ss_pq,
                                          epk, ct, viewing_pk_ec, ek)
            announce(3, address(stealth_pk), epk, view_tag(ss) || ct); pay the address
scanner   : ss_ec <- ECDH(viewing_ec, epk).x;  ss_pq <- ML-KEM-768.Decaps(dk, ct)
            ss    <- the same hybrid_combine call; compare the view tag, then the address
recipient : stealth_sk = (spending_sk + H(ss)) mod n

2.1 Keys and seeds

Key generation takes a 128-byte seed and MUST be deterministic: i.e., the same seed MUST produce the same three outputs.

seed     = spending_seed(32) || viewing_ec_seed(32) || kem_seed(64)
kem_seed = d(32) || z(32)

An implementation MUST reject a seed of any other length rather than pad or truncate it.

  • spending_seed is the master spending key spending_sk. It MUST be a valid scalar, and key generation MUST fail otherwise.
  • viewing_ec_seed is the viewing scalar viewing_ec. It MUST be a valid scalar, and key generation MUST fail otherwise.
  • kem_seed is ML-KEM's (d, z). Key generation computes (ek, dk) = ML-KEM.KeyGen_internal(d, z), and the tracking key is (d, z).

The three components MUST be generated independently of one another. Each MUST be either drawn from a cryptographically secure random number generator (CSPRNG) or derived from a master secret by a PRF in a way that gives each component an independent output. In particular, a wallet MUST NOT use the spending key as the viewing key. The tracking key is meant to be handed to a scanning service, so it must not carry the spending key. Key generation MUST fail if spending_seed equals viewing_ec_seed, d, or z.

Key generation has three outputs:

outputcontentssizedisposition
meta-address`spending_pkviewing_pk_ec
master keyspending_sk32 Bnever leaves the owner
tracking key`viewing_ecd

Here spending_pk = spending_sk·G and viewing_pk_ec = viewing_ec·G.

2.2 Meta-address encoding

meta = spending_pk(33) || viewing_pk_ec(33) || ek(1184)                       1 250 B

A decoder MUST reject any length other than 1 250 bytes. Before the meta-address is used for anything, the decoder MUST check that both 33-byte fields carry tag 0x02 or 0x03 and decode to points on secp256k1. It MUST NOT accept other SEC1 forms. ek MUST pass FIPS 203's encapsulation input check (§7.2) before its first use.

2.3 Registration and scheme selection

A recipient MUST register the encoded meta-address with ERC-6538 registerKeys(3, meta).

A recipient MAY register under several schemeIds. A scanner MUST process each announcement only under the rules of the schemeId it carries, with the recipient's keys for that schemeId. A recipient who clears a schemeId (below) SHOULD keep scanning it for payments made before the change.

A sender that implements schemeId 3 and finds the recipient registered under it MUST use schemeId 3, even if the recipient is also registered under another scheme such as schemeId 1. A recipient who wants post-quantum announcement privacy SHOULD register only schemeId 3. They SHOULD clear an existing schemeId 1 entry by registering an empty value under it, and a sender MUST treat an empty entry as unregistered.

2.4 Sender

Each announcement needs a fresh ephemeral scalar esk and fresh encapsulation randomness. Freshness is per announcement, not per schemeId: it holds across recipients, across schemeIds, and across payments to the same recipient. Reusing esk repeats epk, which links the two announcements to one sender. Reusing both against the same recipient also repeats ss, and ends up the same stealth address, which merges two payments onto one key.

A sender MUST draw this randomness from a cryptographically secure random number generator. For esk it draws 32 bytes, and if they are not a valid scalar, discards them and draws again. For the encapsulation it calls ML-KEM.Encaps(ek), whose randomness FIPS 203 §3.3 requires to come from an approved RBG with a security strength of at least 192 bits for ML-KEM-768. If H(ss) below fails (Section 1), the sender discards both and draws again.

The sender then computes:

epk         = SEC1-compressed(esk·G)                                          33 B
ss_ec       = ECDH(esk, viewing_pk_ec).x                                      32 B
(ct, ss_pq) = ML-KEM-768.Encaps(ek)                                 1 088 B / 32 B
ss          = hybrid_combine("pq-stealth/hybrid-payment/v1",
                             ss_ec, ss_pq, epk, ct, viewing_pk_ec, ek)        32 B
stealth_pk  = spending_pk + H(ss)·G                                    (Section 1)
address     = keccak256(uncompressed(stealth_pk)[1..65])[12..32]              20 B

It calls ERC-5564 announce(3, address, epk || ct, view_tag(ss)) (Section 3) and pays address. The stealthAddress argument MUST be address, because scanners compare against it. The sender MAY append ERC-5564's token metadata after the view tag. The sender learns stealth_pk but not stealth_sk.

2.5 Scanner

Given a tracking key viewing_ec || d || z, the recipient's meta-address, and an announcement under schemeId 3 whose fields have the lengths of Section 3:

epk   <- ephemeralPubKey[0..33]
ct    <- ephemeralPubKey[33..1121]
ss_ec <- ECDH(viewing_ec, epk).x
ss_pq <- ML-KEM-768.Decaps(dk, ct)                       dk expanded from (d, z)
ss    <- hybrid_combine("pq-stealth/hybrid-payment/v1",
                        ss_ec, ss_pq, epk, ct, viewing_pk_ec, ek)
if view_tag(ss) != metadata[0]:                 skip
stealth_pk <- spending_pk + H(ss)·G
if address(stealth_pk) != stealthAddress:       skip
match (stealthAddress, ss)

The combiner takes viewing_pk_ec and ek from the registered meta-address. Before scanning, a scanner SHOULD recompute viewing_ec·G and ek from the tracking key and compare them with the registered values. Otherwise a corrupted tracking key fails silently.

The address comparison decides correctness of matching. The one-byte view tag only lets a scanner skip the offset, the point arithmetic and the address hash for 255 of 256 foreign announcements. Decapsulation never fails, so a scanner MUST NOT treat a successful decapsulation as a match.

The view tag is a function of ss, so it cannot be checked before both the ECDH and the decapsulation. Every schemeId 3 announcement therefore costs a scanner at least one ECDH and one ML-KEM-768 decapsulation, and there is no cheaper prefilter (see also Rationale).

announce() is permissionless, so anyone can replay a real announcement. A scanner SHOULD deduplicate matches by stealth address.

2.6 Recipient

stealth_sk = (spending_sk + H(ss)) mod n

The recipient SHOULD check that stealth_sk·G gives the matched address before using the key.

A one-time key and its ss together give the master spending key: spending_sk = (stealth_sk - H(ss)) mod n. Implementations MUST NOT disclose both for the same payment. A scanning service already holds every ss, so handing it any one-time key hands it the master key.

2.7 Errors and skips

conditionbehaviour
schemeId other than 3not processed under this scheme's rules (Section 2.3)
ephemeralPubKey not 1 121 bytes, or metadata emptyskip
epk not a valid compressed pointskip
view tag mismatchskip
H(ss) fails its range check (Section 1)skip
announced stealthAddress differs from the derived addressskip
decapsulation "fails"cannot happen, because ML-KEM rejects implicitly
keygen seed not 128 byteserror, at key generation
spending_seed or viewing_ec_seed not a valid scalarerror, at key generation
spending_seed equal to viewing_ec_seed, d or zerror, at key generation
meta-address not 1 250 bytes, or either point invaliderror, at decoding
ek fails FIPS 203's encapsulation input checkerror, at encapsulation
viewingKey, spendingPubKey or spendingKey malformed (Section 2.8)error

2.8 ERC-5564 methods

Per ERC-5564 our scheme also defines generateStealthAddress, checkStealthAddress and computeStealthKey. schemeId 3 uses ERC-5564's signatures unchanged. ct is carried in ephemeralPubKey (Section 3).

  • generateStealthAddress(stealthMetaAddress) implements Section 2.4 and returns address, epk || ct as ephemeralPubKey, and view_tag(ss) as viewTag. stealthMetaAddress is the 1 250-byte meta of Section 2.2.
  • checkStealthAddress(stealthAddress, ephemeralPubKey, viewingKey, spendingPubKey) implements Section 2.5. It takes no view tag, so it compares addresses only, and it returns false wherever Section 2.7 says skip.
  • computeStealthKey(stealthAddress, ephemeralPubKey, viewingKey, spendingKey) implements Section 2.6.
  • viewingKey is the 96-byte tracking key, from which viewing_pk_ec and ek are recomputed. spendingPubKey is spending_pk, and spendingKey is spending_sk.

viewingKey, spendingPubKey and spendingKey are the caller's own keys, not announcement data. checkStealthAddress and computeStealthKey fail, rather than return false, when viewingKey is not 96 bytes or its viewing_ec is not a valid scalar, when spendingPubKey is not a valid compressed point (Section 2.2), or when spendingKey is not a valid scalar. Returning false would turn a broken key into a scan that finds nothing.

computeStealthKey MUST fail rather than return a key when ephemeralPubKey is malformed or when address(stealth_sk·G) differs from stealthAddress. This is the check of Section 2.6, and the reason ERC-5564 passes stealthAddress in.

checkStealthAddress receives no meta-address, so it cannot compare the recomputed viewing_pk_ec and ek with the registered ones (Section 2.5), and a corrupted viewingKey returns false for every announcement. A wallet that scans with it SHOULD make that comparison once before scanning. viewingKey is the same on every call, so an implementation MAY compute viewing_pk_ec, ek and the expanded dk once and reuse them.

3. Wire format

Announcements use ERC-5564's announce(schemeId, stealthAddress, ephemeralPubKey, metadata) unchanged:

argumentvaluelength
schemeId3
stealthAddressaddress from Section 2.420 B
ephemeralPubKey`epk
metadataview_tag, then optional ERC-5564 token metadataat least 1 B
  1. ephemeralPubKey MUST be exactly epk || ct, in that order: epk in bytes 0 to 32 and ct in bytes 33 to 1 120.
  2. The view tag MUST be the first byte of metadata, as ERC-5564 requires.
  3. A sender MAY follow the view tag with the 56 bytes of token metadata ERC-5564 recommends. schemeId 3 gives no meaning to any byte after the first. A scanner MUST read only metadata[0], and MUST NOT skip an announcement because metadata is longer than one byte. These bytes are not bound into ss, so, as under schemeId 1, they are the sender's unauthenticated claim.

A scanner MUST skip a schemeId 3 announcement whose ephemeralPubKey is not 1 121 bytes or whose metadata is empty (Section 2.7). Meta-addresses are registered with ERC-6538 registerKeys(3, meta) (Section 2.3).

Rationale

Why a hybrid

ML-KEM is new, and so are its implementations. Combining it with ECDH means that recovering ss needs both secrets, so ss is at least as hard to recover as the harder of the two. Against a classical adversary that is today's ECDH. Against a quantum adversary it is ML-KEM-768 alone. The ECDH half costs a 33-byte epk per announcement, a 33-byte viewing key in the meta-address, and one ECDH on each side.

Why ML-KEM-768

ML-KEM is NIST's standardised KEM (FIPS 203). ML-KEM-768 is its security category 3 parameter set, and it is also used by the hybrid key exchange deployed in TLS.

Why these hash functions

We make explicit the hash function for consistent independent implementations. The combiner is only run off-chain thus it can use SHA3-256 as SP 800-227's KeyCombineCCA_H takes a SHA-3 hash and X-Wing uses SHA3-256. The offset and the view tag use domain-separated SHA-256, which every wallet stack provides.

Why the offset and view tag differ from schemeId 1

  • A separate view-tag digest. In ERC-5564 the view tag is the first byte of the digest that is used as the scalar, so publishing it gives away 8 bits of that digest. ERC-5564 notes the reduction from 128 to 124 bits. Here the tag comes from its own digest and says nothing about H(ss).
  • A range check instead of none. A digest equal to 0 or at least n is not a valid scalar. Reducing mod n would turn a digest equal to n into offset 0, which makes the stealth address the address of spending_pk itself and links the payment to the registered key in public. Failing instead avoids this and keeps the offset uniform. Upon failure, the sender can draw fresh randomness and gets a new ss.

Why the view tag comes after the KEM

A view tag derived from ss_ec alone would let scanners skip the decapsulation for 255 of 256 announcements. But a quantum adversary can compute ss_ec for every registered viewing key, so such a tag would let it rule out about 255 of 256 candidate recipients for each announcement, which is enough to deanonymise. A tag derived from ss is visible only to someone who can decapsulate.

Why ct is carried in ephemeralPubKey

ERC-5564 describes ephemeralPubKey as the ephemeral public key used by the sender with no bounded size. An ML-KEM ciphertext is the sender's per-payment contribution to the shared secret, the KEM counterpart of an ephemeral public key. Putting epk || ct in ephemeralPubKey leaves metadata as ERC-5564 lays it out, so the token metadata convention and ERC-5564's method signatures apply unchanged. While putting ct in metadata instead would displace the token metadata and would need extra method parameters to reach ct.

Why the tracking key holds the seed (d, z)

The seed is 64 bytes. The expanded decapsulation key is 2 400 bytes. FIPS 203 §3.3 allows the seed to be stored and expanded with ML-KEM.KeyGen_internal, and seed-form keys are now common in ML-KEM libraries. ek commits to d but not to z. A scanner holding a wrong z still finds every payment, because z only selects the implicit-rejection output for unintended ciphertexts.

Why the sender's randomness is drawn, not derived

The scanner only decapsulates, so the sender's source of randomness does not affect interoperability, and we can pick the simplest safe source. Determinism is only needed in the test vectors, to pin ct.

Cost

These figures are Prague figures. They come from real transactions on a local anvil node with --hardfork prague, and gasUsed is read from each receipt. Both contracts run their canonical deployed runtime bytecode, installed at their mainnet addresses and pinned by hash in each benchmark's measured.json: the ERC-5564 announcer at 0x55649E01B5Df198D18D95b5cc5051630cfD45564 and the ERC-6538 registry at 0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538. The schemeId 3 rows use the real fixture announcement and meta-address, identified by fixture.sha256 in measured.json. schemeId 1 has no fixture, so its rows use constructed payloads of the same lengths with no zero byte.

Announcement, a standalone announce() call:

schemeIdephemeralPubKey + metadatacalldatagaspricing rulevs classical
1 (classical)34 B292 B28 313standard1.00x
31 122 B1 380 B69 330EIP-7623 floor2.45x
3, with token metadata1 178 B1 412 B70 550EIP-7623 floor2.49x

The schemeId 3 receipt equals the EIP-7623 calldata floor exactly: 21 000 plus 10 per calldata token. So execution is not charged, and the figure is set by calldata size alone. The classical receipt is above its floor and pays the standard rate. The ratios therefore compare two pricing rules, and any calldata repricing will move them. The first schemeId 3 row carries the view tag alone in metadata. The second appends ERC-5564's 56-byte native-token metadata for 1 ETH, which Section 3 allows, and costs 1 220 gas more.

Registration, a first-time registerKeys call with a fresh registrant:

schemeIdmeta-addressvs schemeId 1's 66 Bgas
1 (classical)66 B1.0x115 310
31 250 B18.9x964 737

ERC-6538 writes the meta-address to contract storage, one slot per 32 bytes. Storage writes make up most of this cost, and it is paid once per recipient.

Payment end to end, for native ETH only:

announcefundspendtotal gas
schemeId 369 33021 00021 000111 330

The run announces, funds the derived address, and spends from it with the derived key. A token payment adds the token transfer. Unless the sender sponsors gas, it also needs a transaction that funds the stealth EOA, which links addresses (Security Considerations).

Backwards Compatibility

This ERC needs no consensus change, no new opcode and no new contract. It calls ERC-5564's announce() and ERC-6538's registerKeys as deployed. Existing schemeId 1 deployments are unaffected, and a recipient can register under both schemes without migrating (but see Section 2.3). ERC-5564 requires the ERC that standardises a scheme to declare its schemeId, and this ERC declares schemeId 3.

schemeId 3 departs from only one ERC-5564 convention, the meta-address length. ERC-5564 defines meta-addresses of length n or 2n for a scheme whose public keys are n bytes long. schemeId 3 has keys of two lengths, 33 and 1 184 bytes. Its 1 250-byte meta-address is not of that form.

It keeps ERC-5564's other conventions. The view tag is the first byte of metadata, which can carry ERC-5564's token metadata after it, and the methods of Section 2.8 have ERC-5564's signatures. Its ephemeralPubKey is 1 121 bytes rather than one 33-byte point, so a tool that assumes the length of schemeId 1's must check upon schemeId first.

Test Cases

Conformance vectors are in the two files below, with a SHA-256 digest of each in vectors/manifest.json. vectors/PLAN.md states, for each row, the requirement it pins and the wrong output it distinguishes.

filerowswhat it pins
vectors/section-1.json6Section 1: the offset, its range check, byte order and the view tag
vectors/section-2.json23Section 2: keys and seeds, the meta-address and its encapsulation key check, the combiner and its bindings, the stealth key pair and its address, the wire mapping, and what counts as a skip

The generator, tools/gen_vectors.py, does its arithmetic in tools/vecprim.py and imports nothing from the reference implementation. ML-KEM values come from NIST's ACVP files, vendored at vectors/tier1/ml-kem-768-acvp.json. Running python3 tools/gen_vectors.py --check in the directory that holds vectors/ and tools/ re-derives every vector and compares it with the files above. It needs only the Python standard library.

Three conventions apply to the vectors:

  • The range check's failure is tested synthetically. No findable ss produces a base that is 0 or at least n, so V1-03 and V1-04 supply base directly.
  • Encapsulation is pinned through Encaps_internal. The vectors fix m and use ML-KEM.Encaps_internal(ek, m), so they pin ct and ss_pq. A sender using ML-KEM.Encaps(ek) produces different, equally valid announcements. Every scanner-side vector applies to it unchanged.
  • ML-KEM's edge cases are NIST's own. V3-14 takes an ACVP modified ciphertext case, so implicit rejection is checked against NIST's expected value, and V3-17 a valid decapsulation case. ACVP gives those keys only in expanded form, so both rows also give ss_pq for an implementation that holds keys as (d, z) seeds. V3-19 takes an encapsulation key that NIST's key check rejects.

Two vectors from section-1.json:

V1-01  ss       = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
       base     = d8b37e9fb5fd784cc30b719589811092fce202abc0f500d6609a2f69f78b171f
       H(ss)    = d8b37e9fb5fd784cc30b719589811092fce202abc0f500d6609a2f69f78b171f
V1-07  view_tag = fe                                                  (same ss)

V1-03  base     = 0000000000000000000000000000000000000000000000000000000000000000
       H(ss)    fails

Reference Implementation

The reference implementation is a Rust workspace in the repository linked from the discussion thread. Its per-payment crate implements this scheme on three others: kem (ML-KEM-768, checked against NIST ACVP), ec (secp256k1) and core (the interface the scheme implements). It passes every vector above. Its erc5564 module provides the three methods of Section 2.8, taking and returning the bytes that ERC-5564's signatures do.

Its sender, announce_random, draws esk and ML-KEM's m from the operating system's CSPRNG, as Section 2.4 requires. A second, seeded announce takes that randomness as input instead. It exists so that the vectors, the demonstration and the gas harness are reproducible, and it is not a sender for real payments. The harness feeds it seeds from SHAKE256 over a fixed secret and a counter, and takes its keys from a fixed 128-byte keygen seed. The implementation also provides a keygen seed derivation from a master key with HKDF-SHA256. It is one instance of the requirements in Section 2.1 and not part of the standard.

The gas figures under Rationale come from three harnesses in the same repository: one for the announcement, one for the registration and one for the whole payment. Each commits its receipts as measured.json.

The implementation has had no external cryptographic review.

Security Considerations

What this scheme protects, and what it does not

schemeId 3 protects the announcement layer: the link between an announcement and its recipient, against an adversary who reads every announcement and every registered meta-address, now or after building a CRQC. It does nothing for the rest of the on-chain transaction graph:

  • The sender's funding transfer to the stealth address is public, as in any stealth-address scheme.
  • A recipient who spends from or sweeps several stealth addresses into one account links them, to each other and to that account.
  • A stealth EOA that receives only tokens needs ETH for gas. Whoever funds it is linked to it, so ERC-5564's discussion of recipients' transaction costs applies unchanged.
  • Timing and amounts can correlate payments.

KEM anonymity is required

The ERC-6538 registry gives an adversary every candidate encapsulation key. If an ML-KEM-768 ciphertext revealed which ek it was intended for, every payment could be linked to its recipient without decrypting anything. schemeId 3 therefore assumes that ML-KEM-768 is ANO-CCA (anonymous under chosen-ciphertext attack) as well as IND-CCA. FIPS 203 does not claim anonymity.

CRYPTREC's evaluation of ML-KEM (PQShield, CRYPTREC-EX-3502-2025, Section 4) concludes that it yields anonymity in the quantum random-oracle model when ML-KEM is used with a symmetric scheme, provided ML-KEM is strongly collision-free CCA secure and its underlying K-PKE is strongly disjoint-simulatable and correct. Anonymity is stated here as an assumption rather than inherited.

The other announcement fields reveal nothing about the recipient, as long as ss stays secret. epk is independent of the recipient's keys. The view tag and stealthAddress are functions of ss.

Against a quantum adversary

A quantum adversary can compute ss_ec for every announcement and every registered viewing key, hence ss's security relies entirely through ss_pq, which relies on ML-KEM-768's IND-CCA security and the combiner of Section 1.1. Conversely, if ML-KEM-768 is broken classically, ss_ec keeps ss secret from a classical adversary.

Spending once a CRQC exists

Spending is secp256k1, so a CRQC breaks it the way it breaks every EOA. A spend reveals the stealth address's public key, from which the private key can then be computed. A CRQC also recovers spending_sk from the registered spending_pk. From then on, anyone who holds a payment's ss can spend that payment, and that includes a delegated scanner or anyone who has obtained the tracking key. What survives a CRQC is the privacy of announcements already published, for as long as ML-KEM-768 holds. Funds need a separate migration before that point.

Delegated scanning

A scanning service given the tracking key sees every payment to the recipient, with its timing and count. Before a CRQC it cannot spend them. Section 2.6 explains why a one-time key must never be disclosed to it, and the previous subsection explains why the tracking key becomes a spending capability after a CRQC.

One-time key and ss

A leaked one-time key together with its ss yields the master spending key. A leaked one-time key alone yields nothing. This follows from ERC-5564's additive derivation and is not new here.

Sender randomness

Section 2.4 states the consequences of reusing an announcement's randomness: linked announcements, and payments merged onto one key. A sender's security therefore relies on its random number generator.

Downgrade

A recipient registered under both schemeId 1 and schemeId 3 can be paid under schemeId 1 by any sender, and that payment has no post-quantum announcement privacy. Section 2.3 is the mitigation: senders that implement schemeId 3 must prefer it, and recipients who need the protection should register only schemeId 3.

Scanners still find payments made under schemeId 1, including those made before a recipient already cleared that entry (Section 2.3), so a downgraded payment loses its post-quantum privacy but not its funds. A wallet can show which payments were announced under schemeId 1, since those are the ones a future quantum adversary can link to the recipient.

Scanner cost and denial of service

Each schemeId 3 announcement costs a scanner one ECDH, one ML-KEM-768 decapsulation and one hash over about 2.4 kB, with no cheaper prefilter. Anyone can publish announcements, so an attacker can make scanning more expensive by paying calldata gas per announcement. Every malformed field is a skip (Section 2.7), so malformed input cannot halt a scan. ERC-5564's discussion of denial-of-service countermeasures applies.

View tag

The view tag is one byte of a digest of ss, separate from the digest that yields the offset. It tells an observer without ss nothing, and unlike schemeId 1 it does not shorten the offset digest.

Copyright and related rights waived via CC0.

EIP.tools

EIP.tools

Search, read, and map Ethereum improvement proposals, ERCs, RIPs, and CAIPs from one focused interface.

Farcaster
by @apoorveth