ECDH Stealth Addresses on Bitcoin Cash — Implementation & Code

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.htmlstealthDerive()

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:

  1. Extract the sender’s public key from the input’s scriptSig
  2. Compute the same ECDH shared secret: ECDH(a, sender_pub)
  3. Derive the expected stealth address
  4. 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 symmetricECDH(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.

1 Like