Stealth addresses¶
TL;DR — ERC-5564 stealth meta-addresses let a provider register once and receive every payment at a fresh, unlinkable address. Same provider, different recipient every call.
Status: SDK + provider registry + facilitator integration all live. 100-payment offline test passing. Live POST 200 against running dev server. Needs first provider to register.
The problem it solves¶
Today every payment to OpenAI's x402 endpoint pays the same address. An outside observer can build a graph: "wallet X paid OpenAI 40 times this hour — they're running an arbitrage bot using OpenAI's gpt-5o feed at known intervals."
The ASP and the pool hide the payer. They don't hide the receiver. Provider correlation breaks the privacy claim end-to-end.
The fix¶
ERC-5564 stealth addresses, secp256k1 scheme 1 (the audited variant). Each provider:
- One-time: generates a public stealth meta-address (one address that derives
infinite receiving addresses) via
generateMetaAddress(). - Registers with zBase via
POST /api/providers/register, persisted todata/providers.json. - Per payment, the facilitator calls
deriveStealthAddress(metaAddress, randomNonce)and routes the withdrawal to the derived address. The ephemeral pubkey is published on-chain alongside. - Provider's scanner watches recent blocks, uses view-tag fast path (256× faster than full scan) to identify which stealth addresses are theirs, sweeps to treasury whenever convenient.
Result: a thousand payments to the same provider land at a thousand different addresses. Outside observers see unrelated transfers. Provider's accounting still reconciles cleanly via the scanning key.
What landed in this update¶
| Component | What it is |
|---|---|
| Stealth SDK | ERC-5564 SDK (446 lines, secp256k1 scheme 1) |
| Provider registry route | Provider registry (POST/GET) |
| Facilitator settle route (modified) | Derives stealth address if payTo matches a registered meta-address |
| Stealth invariant test | 100-payment invariant test |
| Provider route test | Route smoke test (8 assertions) |
| Provider integration doc | Provider onboarding doc |
SDK surface¶
// Provider-side, one-time
const meta = generateMetaAddress(seed?, chainTag?);
// → { metaAddress, spendingPublicKey, viewingPublicKey,
// spendingPrivateKey, viewingPrivateKey }
// Facilitator-side, per call
const stealth = deriveStealthAddress(metaAddress, ephemeralNonce?);
// → { stealthAddress, ephemeralPublicKey, viewTag }
// Provider-side, scanning
const matches = scanForPayments(
viewingPrivateKey, ephemeralPubkeys, spendingPublicKey, expectedViewTags?
);
// Provider-side, sweep
const sk = computeStealthPrivateKey(
spendingPrivateKey, viewingPrivateKey, ephemeralPublicKey
);
Live test on Sepolia (2026-05-29)¶
| Test | Result |
|---|---|
| 100 stealth derivations from one meta-address | All unique, none collide with meta-address |
| View-tag scan (100 addresses) | 164.0 ms |
| Full scan (100 addresses) | 172.1 ms |
| Private-key recovery (all 100) | All match facilitator-derived addresses |
| Determinism (same nonce → same address) | Passes |
| Seeded generation (same seed → same meta-address) | Passes |
| Live POST /api/providers/register | 200, persisted to disk |
Backwards compatibility¶
If paymentDetails.payTo is a plain Ethereum address (not a registered stealth
meta-address), the facilitator settles to that address as before. Nothing
breaks for existing providers. Stealth is opt-in.
If paymentDetails.payTo looks like a meta-address but isn't registered, the
facilitator returns 400 with a clear error message so debugging is fast.
Things to be honest about¶
- An observer who controls the facilitator could correlate
ephemeralPubkey → recipientif they also knew the provider's viewing key. They don't, because the viewing key never leaves the provider's machine. - Stealth addresses don't hide the payment amount.
- The provider's scanner is a real piece of infrastructure they have to run. Hosted scanning is on the roadmap as a paid managed service.
Next → Merkle tree & proofs