We’ve implemented ECDH stealth addresses on BCH — no new opcodes, no script changes, standard P2PKH. The code is live and open source, looking for review on the cryptographic scheme.
Repo: https://github.com/00-Protocol/00-Wallet
Implementation: wallet.html — stealthDerive()
Key Derivation
Stealth keys are derived from the same BIP44 seed at a hardened path:
m/44'/145'/0'/2'/0 → scan keypair (a, A)
m/44'/145'/0'/2'/1 → spend keypair (b, B)
The scan private key ( a ) is used to detect incoming payments.
The spend private key ( b ) is used to spend them.
Only the public parts ( A , B ) need to be shared with senders.
Sending to a Stealth Address
Given a recipient’s scan_pub (A) and spend_pub (B) , the sender derives a one-time address:
shared_x = ECDH(sender_priv, A).x // shared secret
c = SHA256( SHA256(shared_x) || outpoint ) // deterministic tweak
P_stealth = B + c·G // one-time public key
addr = RIPEMD160(SHA256(P_stealth)) // standard P2PKH
Where outpoint = the sender’s input txid:vout (36 bytes), acting as a unique nonce per transaction.
From the code:
function stealthDerive(senderPriv, senderPub, recipScanPub, recipSpendPub, outpointBytes) {
const sharedPoint = senderPriv
? secp256k1.getSharedSecret(senderPriv, recipScanPub) // sender side
: secp256k1.getSharedSecret(_stealthScanPriv, senderPub); // receiver side
const sharedX = sharedPoint.slice(1, 33);
const c = sha256(concat(sha256(sharedX), outpointBytes));
const cBig = BigInt('0x' + b2h(c)) % N_SECP;
const spendPoint = secp256k1.ProjectivePoint.fromHex(recipSpendPub);
const tweakPoint = secp256k1.ProjectivePoint.BASE.multiply(cBig);
const stealthPoint = spendPoint.add(tweakPoint);
const stealthPub = stealthPoint.toRawBytes(true);
const stealthAddr = pubHashToCashAddr(Array.from(ripemd160(sha256(stealthPub))));
return { addr: stealthAddr, pub: stealthPub, cBig };
}
The resulting address is a normal P2PKH address — it looks like any other BCH address on-chain. An observer has no way to tell it’s a stealth output.
Scanning (Receiver Side)
The recipient scans the chain using their scan private key ( a ). For every transaction, they:
- Extract the sender’s public key from the input’s scriptSig
- Compute the same ECDH shared secret:
ECDH(a, sender_pub) - Derive the expected stealth address
- Check if any output in the transaction matches
async function stealthScanChain() {
for (const txid of allTxIds) {
const inputs = parseTxAllInputPubkeys(rawHex);
const outputs = parseTxHex(rawHex);
for (const inp of inputs) {
const { addr, pub, cBig } = stealthDerive(
null, inp.pubkey, _stealthScanPub, _stealthSpendPub, inp.outpoint
);
const expectedScript = b2h(p2pkhScript(ripemd160(sha256(pub))));
const matchIdx = outputs.findIndex(o => o.script === expectedScript);
if (matchIdx !== -1) {
// Found a stealth output — derive spending key
const priv = stealthSpendingKey(_stealthSpendPriv, cBig);
_stealthUtxos.push({ txid, vout: matchIdx, value: outputs[matchIdx].value, priv, addr });
}
}
}
}
This works because the ECDH shared secret is symmetric — ECDH(a, S) == ECDH(s, A) — so both sender and receiver derive the same tweak c without any out-of-band communication.
Spending
The spending private key for a stealth output:
p_stealth = b + c (mod n)
function stealthSpendingKey(spendPriv, cBig) {
const bBig = BigInt('0x' + b2h(spendPriv));
const pBig = (bBig + cBig) % N_SECP;
return h2b(pBig.toString(16).padStart(64, '0'));
}
This is a standard P2PKH spend — no custom script, no OP_RETURN metadata, nothing special on-chain.
Recovery
Full recovery from seed phrase alone — no extra data needed. From the seed, derive a (scan priv) and b (spend priv), then scan every transaction on-chain using the process above. Computationally heavier than HD gap-limit scanning, but fully deterministic.
Properties
- Standard P2PKH — works on BCH today, no consensus changes
- No on-chain metadata — no OP_RETURN, no notification transaction, the sender’s input pubkey IS the ephemeral key
- Unlinkable — each output uses a unique address derived from a unique outpoint nonce
- xpub-resistant — stealth addresses can’t be derived from the xpub, only from the scan private key
- Symmetric ECDH — sender and receiver derive the same result independently
Open Questions
- Is
SHA256(SHA256(shared_x) || outpoint)a sound tweak derivation? Any reason to prefer a different construction (HMAC, tagged hash)? - Using the input’s outpoint as nonce means the sender’s pubkey is visible in the scriptSig. Is there a better approach that doesn’t require an OP_RETURN notification?
- Any edge cases we’re missing with P2PKH scriptSig pubkey extraction?
Would appreciate any review on the scheme. Full code is in the repo.