Status: Draft · Version: 1 · Scheme id: 1 (secp256k1 + Keccak-256 view tags)
Reference implementations: opaquecash/ethereum, opaquecash/solana · License: CC0-1.0
This document specifies the stealth-address payment layer that Opaque implements identically on Ethereum and Solana. It documents behaviour that is already deployed and live (Ethereum Sepolia, Solana Devnet), not a future design. The Programmable Stealth Reputation layer is specified separately in
PSR.md; the cross-chain announcement transport inUAB.md.
CSAP defines a dual-key stealth address protocol (DKSAP) over the secp256k1 curve that lets a sender pay a recipient at a fresh, unlinkable on-chain address without any interaction, and lets the recipient discover those payments by scanning public announcements with a viewing key. The scheme is byte-compatible with EIP-5564 announcements, ERC-6538 registration, and the EIP-5564 view-tag optimisation, and it derives identical key material and identical stealth identifiers on both Ethereum and Solana — so a single scanner, a single meta-address, and a single key set work across both chains. Where Opaque deviates from EIP-5564 (notably the byte order of the serialised meta-address), this document states the deviation explicitly.
EIP-5564 standardised stealth addresses for Ethereum but stops at one chain, and no equivalent standard exists on Solana. A user who wants private receipt on both ecosystems must today run two unrelated systems with two key sets and two scanners. CSAP specifies one protocol that:
- Produces the same meta-address and the same per-payment stealth identifier on both chains, so one scan covers both.
- Requires no server and no trusted party — keys come from a wallet signature via HKDF, and proving/scanning are client-side.
- Is backwards compatible with EIP-5564 / ERC-6538 tooling at the event, registry, and view-tag level, so existing Ethereum infrastructure (indexers, wallets) interoperates.
- Gives wallets a single integration target for cross-chain private payments.
The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
| Symbol | Meaning |
|---|---|
G, n |
secp256k1 generator and curve order |
v, s |
viewing / spending private keys (32-byte scalars in [1, n-1]) |
V = v·G, S = s·G |
viewing / spending public keys (compressed, 33 bytes) |
r, R = r·G |
ephemeral private / public key, fresh per payment (R compressed, 33 bytes) |
Keccak256 |
Keccak-256 (the EVM hash), used for the shared-secret hash and address |
SHA-256 |
used only inside HKDF for key derivation |
‖ |
byte concatenation |
DOMAIN |
the ASCII string "opaque-cash-v1" |
Constants fixed by this version:
- HKDF info string:
DOMAIN = "opaque-cash-v1". - Scheme id
1= secp256k1 keys with Keccak-256 view tags. (On Ethereum the on-chain type isuint256; on Solana it isu64. The encoded value is identical.) - Meta-address length:
66bytes (33 + 33). - Stealth identifier length:
20bytes (an EVM-style address; see §2.3).
A stealth meta-address is the concatenation of two compressed secp256k1 public keys:
metaAddress = compressed(V) ‖ compressed(S) // 33 + 33 = 66 bytes
^ viewing pubkey ^ spending pubkey
It is serialised as a 0x-prefixed hex string of 66 bytes (132 hex chars). The viewing key comes first.
⚠ Deviation from EIP-5564. EIP-5564 serialises the meta-address as
st:eth:0x<spendingPubKey><viewingPubKey>— spending key first. CSAP places the viewing key first (V‖S). The two encodings are byte-reversed at the 33-byte boundary. Implementers interoperating with EIP-5564 tooling MUST convert. See §4 (Backwards Compatibility); whether to keep this deviation or align with EIP-5564 in a future scheme id is an open decision recorded there.
Keys are derived deterministically from a single wallet signature. There is no key server.
- The wallet signs the canonical derivation message (a fixed UTF-8 string; see below). Let
sigbe the raw signature bytes (65 bytes for an Ethereumpersonal_sign/secp256k1 signature; 64 bytes for a Solana ed25519signMessage). - Expand with HKDF:
okm = HKDF(hash = SHA-256, ikm = sig, salt = ∅, info = DOMAIN, L = 64) v = okm[0:32] // viewing private key s = okm[32:64] // spending private key V = v·G,S = s·G, both compressed;metaAddress = V ‖ S(§2.1).
v and s MUST be valid secp256k1 scalars in [1, n-1]. The probability that an HKDF output is 0 or ≥ n is ≈ 2⁻¹²⁸ and is treated as a derivation failure (see §6).
Canonical derivation message. This version fixes the message to the chain-neutral string:
Sign this message to derive your Opaque Cash stealth keys. This does not approve any transaction.
All clients MUST sign exactly this string (no chain suffix, no trailing data) so that a given wallet always derives the same keys regardless of which Opaque frontend it uses.
Implementation note (known divergence to fix). Some current Solana entry points sign a different string (
"… stealth keys on Solana. This is not a transaction and does not move funds."). That produces a different key set for the same wallet depending on the onboarding path. Clients MUST converge on the canonical message above; a migration that scans both the canonical and the legacy-string-derived key sets is REQUIRED to avoid orphaning existing Solana users.
Cross-chain identity. Because the signature itself is the entropy, an Ethereum wallet and a Solana wallet sign different bytes over the same message and therefore derive different key sets. A single cross-chain identity is achieved by deriving once (from one wallet) and registering that one meta-address on both chains (§2.7), not by re-deriving per chain.
Given a recipient meta-address (V, S):
- Generate a fresh ephemeral key pair
r(random, per payment),R = r·G(compressed). - Shared secret (ECDH, raw point — no extra hashing inside ECDH):
s_point = r·V; letsec = compressed(s_point)(33 bytes). s_h = Keccak256(sec)(32 bytes).- View tag
t = s_h[0](the most significant / first byte). - Stealth public key:
P_stealth = S + (s_h mod n)·G. - Stealth identifier (scanner-matching address):
i.e. drop the
stealthAddress = Keccak256( uncompressed(P_stealth)[1:65] )[12:32] // 20 bytes, EVM-style0x04prefix byte, Keccak-256 the 64-bytex‖y, take the last 20 bytes. - Publish an announcement (§2.5) carrying
R,metadatawithmetadata[0] = t, andstealthAddress, under scheme id1.
Solana fund custody. On Solana the 20-byte stealthAddress from step 6 is used only as the scanner-matching identifier (so one scanner matches both chains). The account that actually holds funds is a deterministic ed25519 keypair both parties can recompute from the stealth point:
solanaSeed = SHA-256( "opaque-solana-stealth-v1" ‖ uncompressed(P_stealth) )[0:32]
solanaKeypair = ed25519_from_seed(solanaSeed)
The one-time spending key for P_stealth is p_stealth = (s + (s_h mod n)) mod n (recipient side; see §2.8 step 5).
The view tag is the single byte s_h[0] = the most significant byte of Keccak256(sharedSecret). It is carried as the first byte of the announcement metadata. During scanning, a recipient computes its own s_h[0] and rejects any announcement whose metadata[0] differs without performing the elliptic-curve point addition of step 5/§2.8. A non-owned announcement passes the filter with probability 1/256, so ≈ 99.6% of foreign announcements are rejected before any EC work.
A single announcement carries: scheme id, the ephemeral public key R (33-byte compressed), metadata (≥ 1 byte; metadata[0] = view tag; remaining bytes reserved for sender-defined data such as an encrypted memo), and the stealth identifier.
Ethereum (EIP-5564, exact match). StealthAddressAnnouncer emits:
event Announcement(
uint256 indexed schemeId,
address indexed stealthAddress,
address indexed caller,
bytes ephemeralPubKey,
bytes metadata
);
// function announce(uint256 schemeId, address stealthAddress, bytes ephemeralPubKey, bytes metadata)Solana. stealth_announcer exposes:
// announce(scheme_id: u64, stealth_address: Vec<u8>, ephemeral_pub_key: Vec<u8> /*33*/, metadata: Vec<u8>)
#[event] pub struct Announcement {
pub scheme_id: u64,
pub stealth_address: Vec<u8>, // 20-byte EVM-style identifier (scanner-matching)
pub caller: Pubkey,
pub ephemeral_pub_key: Vec<u8>, // 33-byte compressed secp256k1
pub metadata: Vec<u8>, // metadata[0] = view tag
}ephemeral_pub_key MUST be 33 bytes and metadata MUST be non-empty (both enforced on-chain). An optional announce_with_log(... , log_id: [u8;32]) additionally writes a PDA at seeds ["announcement", caller, log_id] so indexers can use getProgramAccounts in addition to log parsing.
For cross-chain relay (specified fully in UAB.md, implemented in Phase 1), an announcement is re-encoded into a fixed 96-byte payload so a view-tag filter works without parsing chain-specific event ABIs:
offset size field
0 1 view_tag (= metadata[0])
1 33 ephemeral_pubkey (compressed secp256k1)
34 32 stealth_address (Solana: 32-byte pubkey; Ethereum: 20-byte addr, left-padded with 12 zero bytes)
66 2 source_chain_id (protocol id; Ethereum = 0x0001, Solana = 0x534F)
68 4 scheme_id (= 1)
72 24 metadata (reserved; metadata[0] = 0x00 standard, 0xB2 = PSR attestation marker)
This source_chain_id is a CSAP protocol identifier and is distinct from Wormhole's transport chain IDs (defined in UAB.md). The on-chain native formats of §2.5 remain canonical for single-chain operation; the 96-byte payload is the transport encoding only. Exact field values are finalised with Phase 1.
Registration binds a public account to a meta-address so senders can resolve a recipient by address rather than by sharing 66 raw bytes.
Ethereum (ERC-6538, exact match). StealthMetaAddressRegistry:
mapping(address registrant => mapping(uint256 schemeId => bytes)) stealthMetaAddressOfregisterKeys(uint256 schemeId, bytes stealthMetaAddress)and aliasregister(...)registerKeysOnBehalf(address registrant, uint256 schemeId, bytes signature, bytes stealthMetaAddress)— verifies an EIP-712 signature (type hashErc6538RegistryEntry(uint256 schemeId,bytes stealthMetaAddress,uint256 nonce), domain name"ERC6538Registry", version"1.0") with an EIP-1271 fallback for contract accounts; consumesnonceOf[registrant].incrementNonce()to invalidate outstanding signatures.- Events:
StealthMetaAddressSet(address indexed registrant, uint256 indexed schemeId, bytes stealthMetaAddress),NonceIncremented(address indexed registrant, uint256 newNonce).
Solana. stealth_registry:
register_keys(scheme_id: u64, stealth_meta_address: Vec<u8> /*MUST be 66*/)→ PDA at seeds["stealth_meta", registrant, scheme_id.to_le_bytes()].register_keys_on_behalf(...)with an ed25519-authorised flow and a nonce PDA at seeds["nonce", registrant].increment_nonce(),resolve(); clients MAY also read the PDA directly.- Event
StealthMetaAddressSet { registrant, scheme_id, stealth_meta_address }.
The stored stealthMetaAddress bytes use the §2.1 ordering (V‖S).
A scanner holds the viewing private key v and the spending public key S (the spending private key is needed only to spend). For each announcement (R, metadata, stealthAddress):
sec = compressed(v·R).s_h = Keccak256(sec);t' = s_h[0].- View-tag filter: if
t' ≠ metadata[0], skip (no EC addition). - Else compute
P_stealth = S + (s_h mod n)·Gandaddr' = Keccak256(uncompressed(P_stealth)[1:65])[12:32]. - If
addr' == stealthAddress, the payment is owned. To spend, reconstruct the one-time keyp_stealth = (s + (s_h mod n)) mod n(requires the spending private keys).
A scanner that has only v (not s) can detect all incoming payments but cannot spend — enabling delegated/watch-only scanning. This is the basis for the viewing-key disclosure features in PSR.md / Phase 5.
A meta-address MAY additionally be published under an existing name service so senders can resolve a human-readable name instead of a registry lookup. The record key is the reverse-DNS string com.opaque.meta (ENSIP-5 style): on ENS it is a text record (text(node, "com.opaque.meta")), on SNS the equivalent record field of the .sol domain. The record value is the §2.1 serialisation — the 0x-prefixed 132-hex-char V‖S meta-address — optionally prefixed with st:opq: for self-description; resolvers MUST accept both forms and MUST validate that both 33-byte halves are valid compressed secp256k1 points before use. The on-chain registry (§2.7) remains authoritative where both exist: a resolver finding a conflict SHOULD prefer the registry entry for the address the name resolves to. This record is the read-path seed for the Opaque Name Service (alice.opq.eth, specified in ONS.md); writers without an ONS name set the record manually through their name-service tooling.
- secp256k1 + Keccak-256. Chosen so a derived stealth identifier is a valid Ethereum address with no extra mapping, and so the identical math runs on Solana (via the
k256crate /@noble/curves). This is what makes one scanner cover both chains. - Raw-point ECDH (not RFC-5903 with an extra KDF). Matches EIP-5564 exactly so announcements interoperate with EIP-5564 scanners.
- View tags. An 8-bit tag removes ~99.6% of EC work during scanning at the cost of leaking 8 bits of a per-payment value that is meaningless without the viewing key.
- HKDF-from-signature. Deterministic, serverless key recovery from any wallet; the viewing/spending split allows watch-only delegation.
- 20-byte identifier on Solana. Using the EVM-style address as the matching key (while custody uses a derived ed25519 account) lets the universal scanner compare a single value type across chains.
| Aspect | EIP-5564 / ERC-6538 | CSAP | Compatible? |
|---|---|---|---|
Announcement event signature |
as in EIP-5564 | identical | ✅ Yes |
| View tag (MSB of hashed secret, first metadata byte) | yes | identical | ✅ Yes |
Registry storage + registerKeys/registerKeysOnBehalf + EIP-712/EIP-1271 |
ERC-6538 | identical | ✅ Yes |
| Meta-address byte order | spending ‖ viewing |
viewing ‖ spending |
⚠ Reversed — convert |
schemeId integer type |
uint256 |
uint256 (EVM) / u64 (Solana) |
✅ value-compatible |
Consequences of the meta-address deviation: a standard EIP-5564 client that parses an Opaque meta-address as spending‖viewing will swap the two keys, derive wrong shared secrets, and fail to match. Interop requires reversing the two 33-byte halves. Open decision: keep the deviation (document it as the CSAP scheme-1 ordering) or introduce an EIP-5564-aligned ordering under a new scheme id. Recorded in the project execution plan.
Vectors use the deterministic inputs already present in the reference Rust scanner tests (opaquecash/ethereum/scanner/src/scanner.rs):
viewing_private_key = 0xaaaa…aa (32 bytes, all 0xAA)
spending_private_key = 0xbbbb…bb (32 bytes, all 0xBB)
ephemeral_private_key = 0xcccc…cc (32 bytes, all 0xCC)
Canonical vector 1 (all values verified to agree across three independent implementations — see below):
{
"description": "scheme-1 secp256k1 DKSAP round trip (matches Rust scanner test inputs)",
"scheme_id": 1,
"viewing_private_key": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"spending_private_key": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"ephemeral_private_key":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"viewing_public_key": "0x026a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3",
"spending_public_key": "0x0268680737c76dabb801cb2204f57dbe4e4579e4f710cd67dc1b4227592c81e9b5",
"meta_address": "0x026a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb30268680737c76dabb801cb2204f57dbe4e4579e4f710cd67dc1b4227592c81e9b5",
"ephemeral_public_key": "0x02b95c249d84f417e3e395a127425428b540671cc15881eb828c17b722a53fc599",
"shared_secret": "0x03bb26c34c778b763da72856a7b640f7402aac1b6bb41e5e880abd7d8f72f97067",
"s_h": "0xe1641025b6abb6decc4d599b0f215df6c011542d8c0ffabd2187bb8d3620a3b4",
"view_tag": 225,
"stealth_address": "0xa5847a467208cbcd5d238369865a90716310183a",
"one_time_private_key": "0x9d1fcbe17267729a88091556cadd19b3c11e33029883163d1d7118bc21a61e2e"
}The full set — one HKDF derivation vector (§2.2) plus three DKSAP round-trip vectors — lives in opaquecash/circuits/test/test_vectors.json, generated by circuits/test/generate_vectors.py.
Cross-validation (Task 0.5, done). Every output above was produced by three independent implementations and confirmed byte-for-byte equal:
- Pure-Python (
generate_vectors.py) — its own secp256k1 / Keccak-256 / HKDF-SHA256, depending on neither of the below (replaces thepy_eccreference); - Rust scanner (
k256) — pinned inscanner.rs::matches_csap_test_vectors(cargo test, green); - TypeScript (
@noble/curves,@noble/hashes) — the exact primitives the@opaquecash/opaqueSDK and both frontends use.
A vector with s_h ≥ n is intentionally avoided: the Rust scanner rejects rather than reduces (see §6, Security Considerations), so all three agree only when s_h < n.
- Anonymity set. CSAP hides which address a payment landed in, not the existence of payments. Privacy is proportional to the number of concurrent users; a small set is weak against timing analysis. See the execution plan §17 (anonymity-set growth).
- No amount privacy. Transfer amounts remain visible on-chain. Amount privacy is a separate layer (Privacy Pool / Phase 3).
- Fee/gas linkage. Sweeping or announcing from a funded public wallet links that wallet to stealth activity until a relayer market exists (Phase 4). Implementations SHOULD warn users.
- Canonical message discipline. Keys are only recoverable if the wallet signs exactly the canonical message (§2.2). The current Solana message inconsistency MUST be fixed with a scan-both-strings migration, or users lose access to funds at the legacy-derived key set.
- Scalar edge cases.
(s_h mod n)and the derived private keys must be valid non-zero scalars< n. The TypeScript path reducess_h mod n; the Rust scanner currently rejectss_h ≥ nrather than reducing. These agree except on a ≈ 2⁻¹²⁸ set; the canonical behaviour is reduce mod n, and the scanner SHOULD be reconciled to match. Point-at-infinity and zero-scalar results MUST be rejected. - Ephemeral key freshness.
rMUST be unique and unpredictable per payment. Reusingracross two recipients links them. The deterministic "announcer" ephemeral key (HKDF(metaAddress‖"opaque-announcer-v1", info="opaque-announcer-ephemeral")) is a special single-recipient ghost-receive construction and MUST NOT be used for normal sends. - Viewing-key delegation. Sharing
vdiscloses all incoming payments to the holder. Selective/threshold disclosure is addressed in Phase 5.
Copyright and related rights waived via CC0-1.0.
| Chain | Contract / Program | Address / Program ID |
|---|---|---|
| Ethereum Sepolia | StealthMetaAddressRegistry |
0x77425e04163d608B876c7f50E34A378624A12067 |
| Ethereum Sepolia | StealthAddressAnnouncer |
0x840f72249A8bF6F10b0eB64412E315efBD730865 |
| Solana Devnet | stealth_registry |
E9LBRG5eP2kvuNfveouqQ9tA5P6nrpyLyWFjH9MFYVno |
| Solana Devnet | stealth_announcer |
HGFn2fH7bVQ5cSuiG52NjzN9m11YrB3FZUfoN9b9A5jf |