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 check34/34 checks passed
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.
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 graphIf 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.
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 addressFund 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> --broadcastPay 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.
| 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 |
- 28 official BIP352 vectors, including the adversarial ones: NUMS-point
inputs, malleated P2PKH scriptSigs, keys summing to the point at infinity,
the
K_max = 2323scan 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 atk=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
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 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.
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.
- BIP352 — the spec. Genuinely readable; the footnotes explain the why.
- Test vectors — vendored as
vectors.json.
MIT