ERC-5564 defines stealth addresses and a schemeId namespace, and specifies schemeId 1, on secp256k1.
This ERC specifies schemeId 3. Its payment secret combines a Module-Lattice-Based Key-Encapsulation Mechanism (ML-KEM) encapsulation, at parameter set ML-KEM-768, with a secp256k1 Elliptic-Curve Diffie-Hellman (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 a quantum attacker.
Each stealth address is still an ordinary externally owned account (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 secret
ML-KEM-768 shared secret combined with a secp256k1 ECDH secret
announcement
1122 B per payment: an ephemeral public key, an ML-KEM ciphertext and a view tag
meta-address
1250 B, registered once via ERC-6538
spending
secp256k1 Elliptic Curve Digital Signature Algorithm (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.
Conveniently, ERC-5564’s announce() and ERC-6538’s registerKeys both take unbounded bytes,
hence the announcement layer can move to a post-quantum key-encapsulation mechanism (KEM) now, with no new contract.
On the other hand, spending is a different problem with a later deadline,
as theft needs the CRQC to exist while the funds are still in the account.
Besides, moving accounts off secp256k1 may also need account or protocol changes that are not yet available.
Hence 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 secp256k1 private scalar MUST be encoded as a 32-byte big-endian integer in this range.
Points use the compressed encoding of Standards for Efficient Cryptography 1 (SEC1): 33 bytes with tag 0x02 or 0x03,
whereas 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 as specified in Federal Information Processing Standard (FIPS) 180-4,
and SHA3-256 is SHA3-256 (FIPS 202)
while 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 hybrid payment secret ss produced by the combiner in Section 1.1:
base = SHA256("pq-stealth/offset/v1" || ss)
if 0 < u256be(base) < n: H(ss) = u256be(base)
else: fail
view_tag(ss) = 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).
As 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(stealth_pk) = keccak256(uncompressed(stealth_pk)[1..65])[12..32]
These derivations differ from ERC-5564’s schemeId 1 in four ways:
The hashed secret is the hybrid ss of Section 1.1, not the ECDH shared point.
ERC-5564’s hash function h is made explicit SHA-256 under the domain separator "pq-stealth/offset/v1".
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.
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.
schemeId 3 uses DS = "pq-stealth/hybrid-payment/v1" (28 bytes) and output ss (Section 2.4).
ss_ec MUST be the 32-byte x-coordinate alone.
epk and viewing_pk_ec MUST be their 33-byte compressed encodings.
ct MUST be the 1088-byte ML-KEM ciphertext as announced,
and ek the 1184-byte encapsulation key as registered.
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 National Institute of Standards and Technology (NIST) Special Publication (SP) 800-227 §4.6.3 (an instance of Eq. (15)):
SP 800-227 input
here
role
K1, K2
ss_ec, ss_pq
the two shared secrets
c1
epk
the ECDH “ciphertext”: the sender’s ephemeral public key
c2
ct
the ML-KEM ciphertext
ek1
viewing_pk_ec
the ECDH “encapsulation key”: the recipient’s viewing key
ek2
ek
the ML-KEM encapsulation key
domain_sep
DS
names 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 key derivation function (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 a KEM that is indistinguishable under adaptive chosen-ciphertext attack (IND-CCA) 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 (Section 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 || ct, view_tag(ss)); 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.
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 pseudorandom function (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, which is why it cannot be allowed to carry the spending key.
Key generation MUST fail if spending_seed equals viewing_ec_seed, d, or z.
Key generation has three outputs:
output
contents
size
disposition
meta-address
spending_pk || viewing_pk_ec || ek
1250 B
published via ERC-6538 (Section 2.2)
master key
spending_sk
32 B
never leaves the owner
tracking key
viewing_ec || d || z
96 B
MAY be delegated to a scanning service
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) 1250 B
A decoder MUST reject any length other than 1250 bytes.
In ERC-5564’s text form, a meta-address is st:eth:0x followed by the 2500 hex digits of meta.
The string does not carry the schemeId, so a parser MUST NOT read it as two schemeId 1 keys;
its length tells the two forms apart.
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 yields 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 random bit generator (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) 1088 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
condition
behaviour
schemeId other than 3
not processed under this scheme’s rules (Section 2.3)
ephemeralPubKey not 1121 bytes, or metadata empty
skip
epk not a valid compressed point
skip
view tag mismatch
skip
H(ss) fails its range check (Section 1)
skip
announced stealthAddress differs from the derived address
skip
decapsulation “fails”
cannot happen, because the 1121-byte check fixes ct at 1088 bytes, dk is expanded from the scanner’s own (d, z), and ML-KEM rejects implicitly
keygen seed not 128 bytes
error, at key generation
spending_seed or viewing_ec_seed not a valid scalar
error, at key generation
spending_seed equal to viewing_ec_seed, d or z
error, at key generation
meta-address not 1250 bytes, or either point invalid
error, at decoding
ek fails FIPS 203’s encapsulation input check
error, 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 1250-byte meta of Section 2.2.
checkStealthAddress(stealthAddress, ephemeralPubKey, viewingKey, spendingPubKey) derives the payment secret
and compares the derived address with stealthAddress (Section 2.5).
For well-formed caller keys, it returns false for malformed ephemeralPubKey,
an invalid offset or stealth public key, or an address mismatch.
It receives neither schemeId nor metadata; a scanner checks those separately.
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:
argument
value
length
schemeId
3
stealthAddress
address from Section 2.4
20 B
ephemeralPubKey
epk || ct
exactly 1121 B
metadata
view_tag, then optional ERC-5564 token metadata
at least 1 B
ephemeralPubKey MUST be exactly epk || ct, in that order: epk in bytes 0 to 32 and
ct in bytes 33 to 1120.
The view tag MUST be the first byte of metadata, as ERC-5564 requires.
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 1121 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 Transport Layer Security (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.
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 2400 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 are Prague figures:
the gasUsed of real transactions on a local node running the Prague hardfork.
Both contracts run their canonical deployed runtime bytecode at their mainnet addresses:
the ERC-5564 announcer at 0x55649E01B5Df198D18D95b5cc5051630cfD45564
and the ERC-6538 registry at 0x6538E6bf4B0eBd30A8Ea093027Ac2422ce5d6538.
The schemeId 3 rows use a real announcement and meta-address.
The schemeId 1 rows use constructed payloads of the same lengths with no zero byte.
The schemeId 3 receipt equals the EIP-7623 calldata floor exactly: 21000 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 1220 gas more.
Registration, a first-time registerKeys call with a fresh registrant:
schemeId
meta-address
vs schemeId 1’s 66 B
gas
1 (classical)
66 B
1.0x
115310
3
1250 B
18.9x
964737
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:
announce
fund
spend
total gas
schemeId 3
69330
21000
21000
111330
The measured payment 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 1184 bytes.
Its 1250-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 1121 bytes rather than one 33-byte point,
so a tool that assumes schemeId 1 lengths has to branch on schemeId first.
Test Cases
Conformance vectors are in the two files below.
Each row states the requirement it pins, its inputs and the expected output,
and, where a mistake is likely, the wrong output it distinguishes.
Section 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 V3- prefix identifies schemeId 3 vectors.
V1-06 was withdrawn when counter-based offset retries were removed; the remaining vector identifiers are retained.
ML-KEM-768 values come from NIST’s Automated Cryptographic Validation Protocol (ACVP) test data for FIPS 203,
and each row that uses one names its ACVP test case.
Every other value follows from this document.
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
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 assumed
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.
PQShield’s evaluation of ML-KEM for Japan’s Cryptography Research and Evaluation Committees (CRYPTREC), report 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 under chosen-ciphertext attack
and its underlying public-key encryption scheme 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 on 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.
That includes every future payment until the recipient registers a new meta-address.
A tracking key cannot be revoked, so ending a delegation means replacing viewing_ec and (d, z).
Before a CRQC it cannot spend them.
Section 2.6 explains why disclosing a one-time key to it is forbidden,
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:
it requires senders that implement schemeId 3 to prefer it,
and recommends that recipients who need the protection 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.
Measured with a Rust implementation of this scheme on one core of a 2.1 GHz server processor,
a schemeId 3 scan costs about 150 µs per announcement,
or about 100 µs when the expanded dk is cached (Section 2.8).
The single ECDH of schemeId 1 costs about 55 µs,
so a scan costs about 2 to 3 times as much as under schemeId 1, close to the 2.45x an announcement costs in gas (Cost).
A match shows that an announcement was addressed to the recipient,
not that anything was paid:
as with schemeId 1, a wallet learns what arrived from the stealth address’s balances.
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.