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:
- Start9 OS package, BCHN as dependency, Tor automatic.
- 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
- 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.
- Build pubkey indexer + Start9 package for scanning acceleration.
- PR
stealth_scan and stealth_spend PathNames to WizardConnect.
- 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