Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Silent Payments (BIP352) from scratch

A hand-rolled BIP352 implementation in TypeScript, verified against every official test vector, with a working wallet you can run on signet.

Built to understand the protocol — not to be a dependency.

npm install
npm run check
34/34 checks passed

What silent payments are

Publish one address forever. Every payment to it lands on a fresh, unlinkable taproot output.

Before BIP352 you had two bad options: reuse one address and let everyone see your entire payment history grouped together, or hand out a fresh address to every sender, which requires talking to each of them first.

Silent payments give you both. You publish a single sp1… address. Each sender derives a one-time taproot output from their own transaction inputs combined with your address, using ECDH. On-chain it's an ordinary P2TR payment — no marker, no OP_RETURN, no notification transaction. Someone holding your published address still cannot search the chain for your payments, because finding them requires your scan private key.

sender:    a = sum of MY input private keys
           t = hash(input_hash · a · B_scan  ||  k)
           pay to taproot output  B_spend + t·G

receiver:  A = sum of the tx's input PUBLIC keys   (these are on-chain)
           t = hash(input_hash · b_scan · A  ||  k)   ← same t
           is  B_spend + t·G  one of this tx's outputs?

a·B_scan == b_scan·A is Diffie–Hellman. The trick is that the sender's own inputs are the ephemeral key, so nothing extra goes on chain.

To spend what you received: privkey = b_spend + t.


What they are not

Silent payments do not hide where funds came from. This is the most common misunderstanding, and it leads people to trust a tool that cannot protect them.

Fixed Not fixed
Address reuse Your inputs' history — fully visible
Linking payments to the same recipient Amounts
Needing a fresh address from the receiver The transaction graph

They hide whose an output is. They do nothing against someone following a coin forward from a known starting point.

Four runnable demos, two of which exist to show the limits:

npm run demo            # what it hides, and how a recipient can destroy it
npm run demo:threat     # why it does not help when the sender is already known
npm run demo:peelchain  # why routing through your own keys is worthless
npm run demo:coinjoin   # the mechanism that does break the graph

If your goal is "move funds out of a doxxed wallet untraceably", this is the wrong tool — demo:peelchain traces that exact plan and shows an observer following it end to end at 100% certainty.


Try it

Signet by default. Real funds require --network=mainnet, explicitly.

npm run wallet -- new          # generates a mnemonic, writes .env — back it up
npm run wallet -- keys         # your tsp1… address + a funding address

Fund the tb1… address from a signet faucet, wait for one confirmation, then:

npm run wallet -- send 5000                    # builds and signs, broadcasts nothing
npm run wallet -- scan <raw-hex>               # verify BEFORE broadcasting
npm run wallet -- send 5000 --broadcast
npm run wallet -- scan <txid>                  # find it as the receiver would
npm run wallet -- sweep <txid> <addr> --broadcast

Pay someone else with send 5000 tsp1…. Change goes to your own label-0 silent payment output, so a transaction reuses no address.

Command
new generate a mnemonic into .env
keys show your silent payment and funding addresses
send <sats> [sp1…] pay an address, or yourself if omitted
scan <txid | raw-hex> find your outputs and their spend keys
sweep <txid> <addr> spend received outputs back out
drain <addr> empty the funding address
Flag
--network=signet|testnet4|mainnet default signet
--broadcast actually publish; without it you get raw hex and a decode
--fee=<sat/vB> override the suggested fee rate

.env is the entire wallet. Every address is a pure function of the mnemonic; nothing else is stored and nothing needs to be running. The derivation paths are the BIP352 standard ones, so any compatible wallet recovers from the same phrase.


What's here

File
src/bip352.ts All the derivation. The file to actually read.
src/check.ts 28 official vectors + 6 of our own
src/wallet.ts Build, sign, broadcast, scan, sweep, drain
src/demo-*.ts What the protocol does and doesn't do

The checks

  • 28 official BIP352 vectors, including the adversarial ones: NUMS-point inputs, malleated P2PKH scriptSigs, keys summing to the point at infinity, the K_max = 2323 scan bound
  • 2 wallet round trips — sender and scanner must agree on the input pubkey
  • 1 label-0 change round trip — payment at k=0, change at k=1
  • 1 third-party payment — recipient finds only theirs, sender only the change
  • 2 sighash oracles — BIP143 and BIP341 verified against real confirmed mainnet signatures Bitcoin Core already accepted, each with a negative assertion so the test cannot become vacuous

Things that were not obvious

Notes from getting the vectors to pass.

Input eligibility is narrow. Only P2PKH, P2SH-P2WPKH, P2WPKH and P2TR inputs contribute to the shared secret. Anything with conditional branches or multiple keys is excluded — otherwise a coinjoin participant could help derive the output, then re-sign with different keys, sending the money somewhere the receiver can never find.

Taproot inputs use the output key, not the internal key. Since a taproot output key is x-only, the usable scalar is whichever of d / n-d gives even y. Backwards, and you silently derive a shared secret the recipient can't reproduce.

The NUMS exception. A taproot script-path spend whose internal key is the NUMS point H is skipped — nobody knows that key, so it can't participate.

P2PKH pubkey extraction can't trust the scriptSig's shape. scriptSig is malleable, so scan it backwards for the 33-byte window that actually hash160s to the committed pubkey hash. Vector 20 exists purely to catch this.

input_hash binds to the lowest outpoint. This stops the same inputs ever deriving the same output twice, and both sides agree on it with zero communication.

k increments across the whole group, not per address. Two labeled addresses of one recipient in a single tx must not reuse t_k — otherwise subtracting the outputs reveals the difference between two published addresses and identifies the recipient.

Change is a labeled payment to yourself. Label m=0 is reserved for it.

Dropping a dust change output is safe only because it is the last k. Omitting a middle output makes every later one undiscoverable — the receiver stops incrementing k at the first miss.

A test that signs and verifies with the same sighash function proves nothing. This cost a rejected mainnet broadcast: 30 checks green while the wallet produced signatures no node would accept, because the sighash was wrong in both directions and therefore self-consistent. The bug was that BIP143's scriptCode for a P2WPKH input is 76a914<h160>88ac, not the scriptPubKey 0014<h160>. Bitcoin Core was the first honest check in the loop.

Privacy fails at the wallet, not the cryptography. A recipient who spends several received outputs together proves they share an owner, retroactively linking every one of them. Coin control isn't a feature here, it's load-bearing.


The real engineering cost is scanning

The cryptography is ~380 lines. Finding your payments is the hard part.

Scanning requires each candidate transaction's prevout scriptPubKeys to recompute the input pubkey sum. No compact block filter carries that, so a light client fundamentally cannot do this today — the BIP notes it as open research. Real wallets run a full node or trust an indexer.

This repo cheats: it asks mempool.space about one transaction, which returns prevouts. Enough to prove the protocol works, and emphatically not wallet-grade scanning. A real implementation walks every block from the wallet's birth height — and must not leak to an API which transactions it cares about.


Status

Educational. Not audited, not a library, no stable API.

Every derivation is checked against the spec vectors and it has moved real money on mainnet — but one person wrote it to learn, and the bug notes above should tell you how much confidence that warrants.

If you want silent payments in production, use a reviewed implementation. If you want to understand them, read src/bip352.ts.

References

  • BIP352 — the spec. Genuinely readable; the footnotes explain the why.
  • Test vectors — vendored as vectors.json.

License

MIT

About

BIP-352 silent payments implemented from scratch in TypeScript, verified against the official test vectors

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages