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.

6 Likes

Update — March 22, 2026

Major thanks to @Adaptive_Blocksize_Limit for an incredibly thorough review that caught several critical issues and pushed the implementation to production-grade quality.

Key changes since the original post:

1. BIP352 Key Derivation (breaking change)
Stealth keys now use a separate BIP352 tree instead of BIP44. Scan and spend are behind independent hardened gates — compromising one gives zero access to the other:

m/352'/145'/0'
  ├── 0'  spend  (hardened)
  │    └── 0   key
  └── 1'  scan   (hardened)
       └── 0   key

This follows the BIP352 (Silent Payments) structure. Previous path m/44'/145'/0'/2'/x is deprecated.

2. Raw key support (SHA256 domain separation)
Stealth now works with raw hex keys, not just seed phrases:

scanPriv  = SHA256("bch-stealth-scan"  || rawKey)
spendPriv = SHA256("bch-stealth-spend" || rawKey)

Cross-wallet tested: 12-word wallet can send to raw-key wallet and vice versa.

3. P2PKH Pubkey Indexer
Built a public indexer that serves all P2PKH input compressed pubkeys by block range — zero privacy leak to the server. Enables stealth scanning, RPA scanning, and any future ECDH-based protocol:

GET https://0penw0rld.com/api/pubkeys?from=943000&to=943100

Documentation: 0penw0rld.com/indexer.html

Full spec & audit log: 0penw0rld.com/stealth.html
WizardConnect spec: 0penw0rld.com/wizard.html
Indexer spec: 0penw0rld.com/indexer.html

All 8 test scenarios passed (12-word, raw key, cross-wallet, advanced scan, spend, balance separation, export, determinism).

2 Likes

BCH Stealth Addresses: Brainstorming Review, Scanning Infrastructure, and Derivation Path Proposal

I’ve been reviewing @0pen in TG we were brainstorming also around this and the below is collective summary of architecture i’ve been working with @0pen including the ECDH stealth implementation (00-Wallet, stealthDerive()) and cross-referencing with BIP352 (Silent Payments), BIP47 (Reusable Payment Codes), Umbra/ERC-5564, Monero’s hardware wallet stealth model, and the WizardConnect protocol from RiftenLabs. Here’s what I found and what I think we should standardize. Some is already implemented or in implementation currently.

The ECDH core

The stealthDerive() function is solid. Raw ECDH on secp256k1, takes keys as parameters, doesn’t care where they come from. The tweak SHA256(SHA256(shared_x) || outpoint) uses the sender’s input outpoint as nonce. No OP_RETURN, no notification tx, nothing on-chain that says “this is stealth.” Looks like any P2PKH.

Scanning: extract sender pubkey from P2PKH scriptSig, do the same ECDH, check if any output matches. Spending: p_stealth = b + c (mod n). Full recovery from seed by rescanning chain.

No issues with the crypto itself.


Scanning acceleration: P2PKH pubkey indexer

The beaconless design (no prefix, no OP_RETURN) means the receiver checks every tx on chain. That’s the privacy tradeoff: no metadata leak, but slow scan.

Proposed solution: standalone P2PKH input pubkey indexer.

Reads blocks via BCHN JSON-RPC. Extracts all compressed pubkeys from P2PKH scriptSig inputs (txid, input_index, compressed_pubkey, outpoint). Serves via GET /pubkeys?start={height}&end={height}. The wallet downloads ALL pubkeys for a block range and filters locally with the scan key. The server learns nothing about who you are or what you’re looking for. Zero prefix leak.

How this compares to RPA scanning model

RPA uses a different tradeoff. On the sender side, the sender “grinds” the transaction: it tries different input selections or nonce values until the first byte of the transaction hash matches a prefix derived from the receiver’s paycode. This grinding is what makes RPA transactions filterable without an OP_RETURN. The prefix is deterministic given the paycode, typically 1 byte (rpaPrefixHex), which narrows candidates to roughly 1/256 of all transactions.

On the receiver side, Fulcrum’s blockchain.rpa.get_history() returns only transactions matching that prefix. This is fast, but the prefix is public information for anyone who knows the paycode. An observer with the paycode can query the same Fulcrum endpoint, see the same ~1/256 candidate set, and estimate the receiver’s payment volume and timing. The prefix accelerates scanning at the cost of narrowing the anonymity set.

The pubkey indexer takes the opposite approach: no prefix, no grinding on the sender side, no filtering on the server side. The indexer serves ALL P2PKH input pubkeys for a block range. The wallet downloads everything and filters locally with the scan key. The server cannot distinguish one wallet’s queries from another’s. Same privacy as downloading full blocks, ~much less data (only compressed pubkeys and outpoints instead of full transactions).

RPA (prefix grinding) Stealth (pubkey indexer)
Sender work Grinding (find matching prefix) None (normal P2PKH)
Server filtering Yes (prefix match via Fulcrum) None (serves all pubkeys)
Server learns Which prefix you’re watching Nothing
Anonymity set ~1/256 of txs All P2PKH txs
Data transferred Less (filtered subset) More (all pubkeys, but less than full blocks)
On-chain footprint None (grinding is off-chain) None

Both approaches avoid OP_RETURN. RPA trades some anonymity for scan speed. The pubkey indexer preserves full anonymity at the cost of more bandwidth.

Deployment plan:

  1. Start9 OS package, BCHN as dependency, Tor automatic.
  2. Binary that works on any system along with BCHN.
  • Long term: propose as native Fulcrum method (blockchain.block.inputpubkeys).

This indexer also benefits @Bastian work. His “rebuild from mnemonic” requirement for beaconless RPA privacy UTXOs is the same scanning problem.

Two notification layers work together: Nostr DM for instant detection, pubkey indexer chain scan for recovery and periodic scanning.


Derivation path: the standardization question

This is the part that matters most right now, before more wallets implement stealth.

The problem with nesting under BIP44

The current implementation derives stealth keys inside the BIP44 tree at m/44'/145'/0'/2'/0 (scan) and m/44'/145'/0'/2'/1 (spend). Two issues:

Scan/spend isolation. Both are non-hardened children of the same hardened parent (2'). The xpub at 2' derives both public keys. If a wallet shares the scan key with a third party (light client, indexer, notification server), the spend branch is reachable from the same parent xpub.

Purpose squatting. Stealth is nested inside 44'. No Ledger/Trezor firmware will implement a custom child under 44' without a spec and a registered purpose. Recovery tools won’t scan it. BIP47 got its own purpose (47'). BIP352 got 352'. Every privacy protocol gets its own branch. Nesting inside 44' breaks that convention.

Proposal was to adopt BIP352 derivation structure

BIP352 (Silent Payments on BTC) defines a two-key derivation with independent hardened gates for scan and spend:

m/352'/145'/0'
├── 0'  spend  (hardened gate)
│    └── 0     key (non-hardened)
└── 1'  scan   (hardened gate)
     └── 0     key (non-hardened)

spend: m/352'/145'/0'/0'/0
scan: m/352'/145'/0'/1'/0

BIP352’s on-chain protocol is BTC-specific (P2TR, input aggregation), but the derivation structure is protocol-agnostic. It just defines how to derive a scan/spend keypair from a BIP32 seed with proper hardened isolation. Coin type 145' separates BCH from BTC, which is exactly what coin_type exists for.

Same depth as the current approach (4 hardened, 1 non-hardened). The difference is where the split happens: at the leaf level (shared parent, current) vs at a hardened boundary (independent parents, BIP352). Better isolation, same complexity.

BIP352 also explicitly states: “wallet software MUST use hardened derivation for both the spend and scan key” so that “it is safe to export the scan private key without exposing the master key or spend private key.” That’s the design rationale.

BCH ecosystem derivation tree

Three independent purpose branches from the same seed, no collisions:


master seed
├── m/44'/145'/0'     ← regular wallet (BIP44)
│    ├── /0  receive  (non-hardened)
│    └── /1  change   (non-hardened)
│
├── m/47'/145'/0'     ← RPA (BCH Reusable Payment Addresses - BIP47)
│    ├── /0' spend branch (hardened gate)  <- isolated from scan
│    │    └── /0      key (non-hardened) (m/47'/145'/0'/0'/0)
│    └── /1' scan branch  (hardened gate)  <- isolated from spend
│         └── /0      key (non-hardened) (m/47'/145'/0'/1'/0)
│
└── m/352'/145'/0'    ← BCH Stealth (BIP352 structure)
     ├── /0' spend branch (hardened gate)  <- isolated from scan
     │    └── /0      key (non-hardened) (m/352'/145'/0'/0'/0)
     └── /1' scan branch  (hardened gate)  <- isolated from spend
          └── /0      key (non-hardened) (m/352'/145'/0'/1'/0)


Each protocol owns its own tree. Recoverable by any wallet that knows the purpose numbers.

Note: the BCH RPA spec (imaginaryusername/Reusable_specs) is deliberately path-agnostic. EC implementation loosely follows BIP47 at m/47'/145'/0' but there’s no strict standard. Getting the stealth path right now avoids the compatibility mess that happened when Bitcoin.com used m/44'/0'/0' instead of m/44'/145'/0' after the fork.


WizardConnect compatibility

WizardConnect (RiftenLabs) is BCH-first, Nostr NIP-17 transport, sends named BIP32 xpubs in the handshake. Current PathNames: receive, change, defi.

WizardConnect has no signMessage. Only sign_transaction_request. So stealth integration works through the HD xpub approach, which is cleaner anyway.

With BIP352 structure, the wallet sends two stealth xpubs in the handshake:


type PathName = "receive" | "change" | "defi" | "stealth_scan" | "stealth_spend";

const paths = [
  { 
    name: "stealth_scan",  
    xpub: "xpub..." // Derived from m/352'/145'/0'/1'
  },
  { 
    name: "stealth_spend", 
    xpub: "xpub..." // Derived from m/352'/145'/0'/0'
  }
];

Dapp derives /0 (non-hardened) from each to get the actual public keys. Two PathNames needed because scan and spend are under separate hardened branches. Can’t derive one from the other’s xpub. That’s the whole point of the isolation.

Stealth sending: dapp does the ECDH, constructs normal P2PKH, wallet signs via sign_transaction_request. Wallet doesn’t need to know it’s stealth.

Stealth spending: dapp passes a stealthTweak per input so the wallet can compute p_stealth = spendPriv + tweak (mod n) and sign with the tweaked key.

This stays pure HD. No signMessage needed, no signature malleability concerns, no message format ambiguity, no recovery fragility.


Multi-wallet-type key sourcing

For wallets that don’t use HD derivation (raw hex import, WalletConnect with signMessage), stealth keys can be derived via domain-separated SHA256:

  • Raw hex: scanPriv = SHA256("bch-stealth-scan" || rawKey), spendPriv = SHA256("bch-stealth-spend" || rawKey)
  • signMessage path: wallet signs deterministic message, canonicalize low-S, then SHA256("bch-stealth-scan" || sig) and SHA256("bch-stealth-spend" || sig)

The “bch” domain tags prevent cross-chain key reuse. Domain-separated SHA256 (not raw signature splitting) provides cryptographic independence between scan and spend.

This path has additional requirements: explicit signing key path pinning, message format pinning, separate stealth key backup (signature-based recovery is more fragile than HD). For the HD path via WizardConnect/BIP352 derivation, none of these are concerns.


Ecosystem architecture

  • Sending stealth: any wallet via WizardConnect/WalletConnect. Dapp does ECDH, wallet signs P2PKH. Works today.
  • Receiving stealth: requires scan private key. 00-Wallet today. EC plugin natural next step (has plugin system, secp256k1, Fulcrum, Ledger/Trezor, Nostr, RPA, CashFusion).
  • Hardware wallets: sign stealth txs, scanning stays software side. Same model as Monero.
  • Privacy pipeline (future): stealth receive → Joiner/Fusion (wallet level)
    @Bastian ZKP pool → nullifier spend (This is done via script and wallet level).

All of these should be recoverable from seed if each layer derives deterministically.


What in progress

  1. I think this is a variant implementation of SRPA that have very well designed sepcs so we need to settle standard derivation path before more wallets adopt stealth addresses. BIP352 structure (m/352'/145'/0') is already proposed for this.
  2. Build pubkey indexer + Start9 package for scanning acceleration.
  3. PR stealth_scan and stealth_spend PathNames to WizardConnect.
  4. Formalize multi-wallet-type key sourcing (HD preferred, signMessage as fallback with additional security requirements).

Reference documentation

https://0penw0rld.com/indexer.html
https://0penw0rld.com/stealth.html
https://0penw0rld.com/wizard.html

2 Likes

I have packaged this as BCH Stealth Protocol in a dedicated repo:

What’s in it:

  • Stealth addresses (ECDH one-time P2PKH)
  • Fusion/Joiner (serverless CoinJoin, Nostr-coordinated)
  • Onion relay (layered ECDH+AES output blinding)
  • Dependency: BCH Pubkey Indexer for scanning acceleration BCH Pubkey Indexer

Note: i have included the indexer but i still need to test them in all platforms.

This repo can be implemented by any wallet. Below is an EC plugin built by @0pen as a fully working proof of concept for other wallets to reference using the indexer:



1 Like

Unified Handshake for Bitcoin Cash Stealth Addresses and Reusable Payment Addresses

1. Technical Specification

Derivation follows the BIP352 logic with tweaked cryptography, adapted for coin\_type = 145'. The boundary for XPub export is fixed at Level 4 (Branch) to create a hardened security firewall.

Protocol Account Branch (Hardened Gate) Key (Non-Hardened)
BCH Stealth m/352'/145'/0' /0' (Spend) / /1' (Scan) /0
BCH RPA m/47'/145'/0' /0' (Spend) / /1' (Scan) /0

Explicit Full Paths:

  • stealth_spend: m/352'/145'/0'/0'/0
  • stealth_scan: m/352'/145'/0'/1'/0
  • rpa_spend: m/47'/145'/0'/0'/0
  • rpa_scan: m/47'/145'/0'/1'/0

2. WizardConnect Handshake Payload

The following naming convention must be used in the handshake for consistency:


"extensions": {
  "bch_stealth_bip352": {
    "spend_path": "m/352'/145'/0'/0'",
    "scan_path": "m/352'/145'/0'/1'"
  },
  "rpa_bip47": {
    "spend_path": "m/47'/145'/0'/0'",
    "scan_path": "m/47'/145'/0'/1'"
  }
}


3. Implementation: The Hardened Gate Standard

To ensure maximum security and isolation of the master seed, this standard employs a “Hardened Gate” approach:

  • Wallet Role (The Gate): The wallet derives the tree to the hardened level (e.g., m/352'/145'/0'/0') and exports only the xpub. This ensures the DApp never sees a private key or an unhardened parent key.
  • DApp Role (Final Derivation): The DApp receives the xpub and is strictly responsible for deriving the final non-hardened /0 child (e.g., m/352'/145'/0'/0'/0) locally for ECDH, prefix grinding, and address generation.

To ensure maximum security and isolation of the master seed, this standard defines strict roles:

  • Wallet (The Gate & Receiver): Derives to the hardened level (e.g., m/352’/145’/0’/0’) and exports the xpub. It is solely responsible for scanning and receiving funds via input scanning or BCH pubkey indexer.
  • DApp (The Sender & Constructor): Receives the xpub and derives the final non-hardened /0 child locally. It utilizes these keys strictly for Sending Logic, including constructing transactions, deriving Stealth Change, and performing sender-side ECDH math.

4. Output Payload Mapping

When implementing these extensions, the Wallet must populate the paths array in the handshake response using these standardized names:

paths: [
  { name: "receive",            xpub: "..." }, // m/44'/145'/0'/0
  { name: "change",             xpub: "..." }, // m/44'/145'/0'/1
  { name: "stealth_spend",  xpub: "..." }, // from stealth.spend_path
  { name: "stealth_scan",   xpub: "..." }, // from stealth.scan_path
  { name: "rpa_spend",          xpub: "..." }, // from rpa.spend_path
  { name: "rpa_scan",           xpub: "..." }  // from rpa.scan_path
]

5. Path Reference Table

Feature Payload Key Name Hardened Source Path Final Working Key (DApp)
Bitcoin Cash Stealth Addresses stealth_spend m/352'/145'/0'/0' .../0
Bitcoin Cash Stealth Addresses stealth_scan m/352'/145'/0'/1' .../0
Reusable Payment Addresses rpa_spend m/47'/145'/0'/0' .../0
Reusable Payment Addresses rpa_scan m/47'/145'/0'/1' .../0

Reference documentation

https://0penw0rld.com/indexer.html
https://0penw0rld.com/stealth.html
https://0penw0rld.com/wizard.html
https://0penw0rld.com/oniondoc.html
https://0penw0rld.com/joinerdoc.html

3 Likes