How Silent Payments Work

otto
9 min readJun 21, 2024

--

Silent Payments or BIP352 is an idea proposed by Ruben Somsen and implemented together with josibake to enable static payment addresses without requiring a notification transaction. This unique SP address is shared by the receiver and later decoded by the senders, who use it to generate new addresses for each payment.

Let’s see the benefits of using it and write a typescript example using bitcoinjs-lib.

shhhh

1. Use Cases

If you’re familiar with BIP47, which I have an article about, you’ve probably realized the main use case is exactly the same here: to have a single address, capable of receiving many payments, but without address reuse.

The other benefit is related to UX: wallets that implement SP are able to keep a contact list, just like banking apps. Instead of requesting a new address every time a user wants to send money to someone, they can now simply tap a contact. The wallet will use the contact’s SP address to generate the actual BTC address.

No wallets have implemented this yet, so I’ll show what BIP47 looks like on Samourai and Sparrow wallets to give you an idea:

BIP47 on Samourai and Sparrow wallets
TODO: replace with an actual SP wallet

Compared to BIP47, the biggest benefit of Silent Payments is that it does not need a notification transaction, which means less fees and less linkability between parties. The downside, as we’ll see, is that it requires more computation and storage.

2. Summary

In our example, Alice is going to pay Bob. In order to do so:

  1. Alice gets Bob’s silent payment address. This address contains 2 public keys (Bscan and Bspend) derived from Bob’s xpub at derivation path 352;
  2. Alice calculates the hash of her input(s) and multiplies the result by Bob’s scan public key to obtain a tweak: t = input_hash•Bscan . By multiplying this tweak by the private key(s) she is using for the payment, she obtains an ECDH shared secret s = t•a ;
  3. She then concatenates this secret with an integer k , which will be explained, and hashes it to get a new tweak tk = hash(s || k) . Finally, with P = Bspend + tk•G , with G being the generator point, she obtains a public key, which will be Bob’s P2TR address;
  4. After the transaction is published, Bob will calculate the same input_hash she did, and a tweak t = input_hash•A , with A being the public key(s) Alice used in her payment. With it, he can obtain the same shared secret she got: s = t•bscan , with bscan being the private key of Bscan ;
  5. Finally, he finds tk = hash(s || k) , just like she did and then P = Bspend + tk•G ;

Both parties were able to independently arrive at the same shared secret: Alice did s = input_hash•Bscan•a , whereas Bob did s = input_hash•bscan•A .
This is the crux of ECDH: the public key of one participant, multiplied by the private key of the other participant, is the same as the private key of the former times the public key of the latter: A•b = b•A .

3. Implementation

I created a project called silent-payments, then installed the dependencies bech32, bip32, bip39, bitcoinjs-lib, ecpair and tiny-secp256k1:

mkdir silent-payments && cd silent-payments
npm init -y
npm i bech32@^2.0.0 bip32@^4.0.0 bip39@^3.1.0 bitcoinjs-lib@^6.1.1 ecpair@^2.1.0 tiny-secp256k1@^2.2.1

Then, imported and initialised everything:

import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from 'tiny-secp256k1';
import { BIP32Factory } from 'bip32';
import * as bip39 from 'bip39';
import { bech32m } from 'bech32';
import { ECPairFactory } from 'ecpair';
const ECPair = ECPairFactory(ecc);
const bip32 = BIP32Factory(ecc);
bitcoin.initEccLib(ecc);
const network = bitcoin.networks.testnet;

3.1 Scenario

Bob has been writing nice articles on his blog, just like the one you’re reading, and, since Alice likes the content, she decided to send him a donation. Bob uses a BIP352 compatible wallet, so he chose to write his bech32m-encoded SP address on his profile page:

tsp1qq0u04ck46vgktprldj3seh9ndrk5wcahfl4qfvsxp8f5kd8n7kzquqhvtlswaek65zcvndmnxsrqsalrxlm7yksecd9cp62xhcu0r7gqvcphvz5p

That code is composed of the public key on path m/352'/0'/0'/1'/0 (called scan public key) and the one on m/352'/0'/0'/0'/0 (called spend public key). His seed phrase is life 12 times and the passphrase isbob :

const root = bip32.fromSeed(bip39.mnemonicToSeedSync('life '.repeat(12).trim(), 'bob'));
const scanXprv = root.derivePath("m/352'/1'/0'/1'/0");
const spendXprv = root.derivePath("m/352'/1'/0'/0'/0");
const Bscan = scanXprv.publicKey;
const Bspend = spendXprv.publicKey;

const bech32Version = 0;
const words = [bech32Version].concat(bech32m.toWords(Buffer.concat([Bscan, Bspend])));
const address = bech32m.encode('tsp', words, 1023);
console.log(address);
// tsp1qq0u04ck46vgktprldj3seh9ndrk5wcahfl4qfvsxp8f5kd8n7kzquqhvtlswaek65zcvndmnxsrqsalrxlm7yksecd9cp62xhcu0r7gqvcphvz5p

Note I used path m/352'/1' because I’m using signet.
Alice has scanned or pasted the code into her wallet, which obviously decodes the same two public keys:

function decodeSpAddress(address: string): Buffer[] {
const words = bech32m.decode(address, 1023).words;
words.shift(); // drop version
const addressDec = bech32m.fromWords(words); // base32 to decimal
const addressBytes = Buffer.from(addressDec);
const bobScanPubkey = addressBytes.subarray(0, 33);
const bobSpendPubkey = addressBytes.subarray(33);

return [bobScanPubkey, bobSpendPubkey];
}

const [Bscan, Bspend] = decodeSpAddress('tsp1qq0u04ck46vgktprldj3seh9ndrk5wcahfl4qfvsxp8f5kd8n7kzquqhvtlswaek65zcvndmnxsrqsalrxlm7yksecd9cp62xhcu0r7gqvcphvz5p');
// it's ok to redefine consts, this is running on Alice's wallet

3.2 Inputs

Alice is sending Bob 415,111 satoshis, so her wallet selects the following 2 coins for the payment:

| value   | prevout                                                            | WIF                                                  |
| ------- | ------------------------------------------------------------------ | ---------------------------------------------------- |
| 384,478 | bc8c51c0be7acd843c8ad319018bd93bcec2b067ec9988a10dc111c35165d973:0 | cRHXLUzd4gy9Ct7Q8BQqJiEcKfrP891p4LCnLwpcNYZ9tyjJSwXS |
| 67,260 | 1f277c338f960f23d59756179e1c52987870192c699593d696ca23141b2c8d6d:1 | cU2YZaJNquygppL2mFHjFMAKqom9VJnyRZUTZVYK8EMfM8enDmpz |

First, the private keys have to be added. Both coins are taproot outputs, so we must first get the tweaked private key, and, if the Y-coordinate of the public key is odd, negate it.
Note the following function is handling only outputs for segwit v0 and taproot keypath spend. There are specific rules for other types.

function sumPrivKeys(keys: (boolean | string)[][]): Buffer {
const actualKeys: Buffer[] = [];
for (const tuple of keys) {
const ecpair = ECPair.fromWIF(tuple[0] as string, network);
const isTaproot = tuple[1] as boolean;

let priv = ecpair.privateKey as Buffer;
if (isTaproot) {
const tweakedECPair = ecpair.tweak(bitcoin.crypto.taggedHash('TapTweak', ecpair.publicKey.subarray(1)));
if (tweakedECPair.publicKey[0] === 0x03) {
priv = Buffer.from(ecc.privateNegate(tweakedECPair.privateKey as Buffer));
} else {
priv = tweakedECPair.privateKey as Buffer;
}
}

actualKeys.push(priv);
}

let sum = actualKeys[0];
if (actualKeys.length === 1) {
return sum;
}

for (let i = 1; i < actualKeys.length; i++) {
sum = Buffer.from(ecc.privateAdd(sum, actualKeys[i]) as Uint8Array);
}

return sum;
}

const sumPriv = sumPrivKeys([
['cRHXLUzd4gy9Ct7Q8BQqJiEcKfrP891p4LCnLwpcNYZ9tyjJSwXS', true],
['cU2YZaJNquygppL2mFHjFMAKqom9VJnyRZUTZVYK8EMfM8enDmpz', true],
]);

const sumPub = ECPair.fromPrivateKey(sumPriv, { network }).publicKey;

With the public keys added, Alice now needs to find the smallest of her outpoints lexicographically…

function smallestOutpoint(prevouts: string[]): Buffer {
const prevoutsBuffer: Buffer[] = [];
for (const prev of prevouts) {
const [txid, vout] = prev.split(':');
const txidBytes = Buffer.from(txid, 'hex').reverse();
const voutBytes = Buffer.alloc(4);
voutBytes.writeUInt32LE(Number(vout));
prevoutsBuffer.push(Buffer.concat([txidBytes, voutBytes]));
}

return prevoutsBuffer.sort(Buffer.compare)[0];
}

const prevout = smallestOutpoint([
'bc8c51c0be7acd843c8ad319018bd93bcec2b067ec9988a10dc111c35165d973:0',
'1f277c338f960f23d59756179e1c52987870192c699593d696ca23141b2c8d6d:1',
]);

… to finally obtain the input_hash , which is the tagged hash of the outpoint and public key. Since it uses tag BIP0352/Inputs and bitcoinjs-lib only accepts the standard taproot tags, we have to write our own function:

function taggedHash(tag: string, data: Buffer): Buffer {
const tagHash = bitcoin.crypto.sha256(Buffer.from(tag, 'utf8'));
return bitcoin.crypto.sha256(Buffer.concat([tagHash, tagHash, data]));
}

const inputHash = taggedHash('BIP0352/Inputs', Buffer.concat([prevout, sumPub]));

3.3 Outputs

Having the input_hash , the secret hash can easily be obtained with s = input_hash•Bscan•a :

const tweak = ecc.pointMultiply(Bscan, inputHash) as Uint8Array;
const sharedSecret = ecc.pointMultiply(tweak, sumPriv) as Uint8Array;

Alice’s wallet is almost there. It should now calculate the tagged hash of s || k , to get a new tweak tk and finally get the actual payment output with P = Bspend + tk•G .
The variable k is a number that should be incremented if there are more SP outputs to the same recipient in this transaction, otherwise both outputs would go to the same address. We only have 1 here, so:

const k = 0;
const kBuffer = Buffer.alloc(4);
kBuffer.writeUint32BE(k);

const tk = taggedHash('BIP0352/SharedSecret', Buffer.concat([sharedSecret, kBuffer]));
if (!ecc.isPrivate(tk)) {
throw new Error('tweak is invalid, try different UTXOs');
}

const tkPubkey = ECPair.fromPrivateKey(tk, { network }).publicKey;
const P = ecc.pointAdd(Bspend, tkPubkey) as Uint8Array;

const actualAddress = bitcoin.payments.p2tr({ pubkey: Buffer.from(P).subarray(1), network }).address;
console.log(actualAddress);
// tb1peswez0y6kwnmed8jp8ya80yfhl0cxfud0kl8dwypjg3f6ceftnpqcqxhmk

Her wallet can include any further outputs (like change) it needs: as long as one of them pays to tb1peswe…xhmk, Bob’s wallet will be able to find it.

3.4 Testing

To test if it worked, I opened Alice’s wallet in Sparrow and sent 415,111 sats to that address. Then I checked Bob’s wallet in the BlindBit implementation and saw the payment was correctly recognized:

BlindBit successfully identified the new UTXO

3.5 Receiving the Payment

So, how is Bob’s wallet able to identify the payment? There isn’t a notification transaction to provide it the necessary info to arrive at that same output.
This is exactly the trade-off here and there’s a spoiler in the upper left corner of the last image: his wallet has been actively scanning for silent payment eligible transactions and performing ECDH calculations.

I believe one successful SP approach, at least on an individual level, will be Electrum servers, such as Fulcrum or Electrs adding some extra index(es) to handle SP payments, which obviously require more storage on disk.

In our case, a software called BlindBit Oracle has been building and keeping an index of tweaks t = input_hash•A, which the BlindBit Daemon, which is part of the wallet, queries from the /tweaks/$block endpoint.
The oracle gets all that info from the input side of the transaction. So, for Alice’s payment:

const prevout = smallestOutpoint([
'1f277c338f960f23d59756179e1c52987870192c699593d696ca23141b2c8d6d:1',
'bc8c51c0be7acd843c8ad319018bd93bcec2b067ec9988a10dc111c35165d973:0',
]);

const pubkey0 = Buffer.from('0217f22adddf0cb6218cfbbc7a9ed1b8a15b4a0ed9c2f7c7bcf2fab3a68f7dcda7', 'hex');
const pubkey1 = Buffer.from('025f3cbc74603236eb7a625631a6788b23c2856ba45b68d630df929d0acfc1495a', 'hex');
const sumPub = ecc.pointAdd(pubkey0, pubkey1) as Uint8Array;

const inputHash = taggedHash('BIP0352/Inputs', Buffer.concat([prevout, sumPub]));
const tweak = ecc.pointMultiply(sumPub, inputHash) as Uint8Array;

Bob can then find the very same shared secret Alice got, but using his scan private key and her (added) public key (which was just multiplied by the input_hash ). Alice did the opposite: she used his scan public key and the sum of her private keys. Code:

const root = bip32.fromSeed(bip39.mnemonicToSeedSync('life '.repeat(12).trim(), 'bob'));
const scanXprv = root.derivePath("m/352'/1'/0'/1'/0");
const spendXprv = root.derivePath("m/352'/1'/0'/0'/0");
const bscan = scanXprv.privateKey as Buffer;
const bspend = spendXprv.privateKey as Buffer;

const sharedSecret = ecc.pointMultiply(tweak, bscan) as Uint8Array;

In this final step, he’ll do the same Alice did: calculate tk , which is the tagged hash of s || k and P = Bspend + tk•G . He’s going to compare each transaction output with P . If he gets a match, it means it’s a payment to him:

const outputsToCheck = [
Buffer.from('cc1d913c9ab3a7bcb4f209c9d3bc89bfdf83278d7dbe76b88192229d63295cc2', 'hex'),
Buffer.from('ace454b89bb34618de90d44f9783f13e9275afd5b194e1bde3117cb80f973958', 'hex'),
];
const tweaksFound: Buffer[] = [];

let k = 0;
const kBuffer = Buffer.alloc(4);
for (const output of outputsToCheck) {
kBuffer.writeUint32BE(k);
const tk = taggedHash('BIP0352/SharedSecret', Buffer.concat([sharedSecret, kBuffer]));
if (!ecc.isPrivate(tk)) { // skip output
continue;
}

const tkPubkey = ECPair.fromPrivateKey(tk, { network }).publicKey;
const P = ecc.pointAdd(Bspend, tkPubkey) as Uint8Array;
if (Buffer.from(P.subarray(1)).equals(output)) {
tweaksFound.push(tk);
k++;
} else {
// TODO: check labels
}
}

For each output in the transaction, Bob’s wallet performs the ECDH and obtainsP = Bspend•tk•G the same way Alice did. If P matches an output, the wallet knows it’s a payment to Bob and saves tk in a separate list.

Now, how can Bob spend it? We will see in a moment, but first, did you notice the check labels comment?

3.5.1 Labels

Labels are optional and I didn’t implement them in this example, but here’s the idea: suppose Bob wants to have a 2nd SP address. He wants, for example, to post a donation address on his Twitter profile, but also on his YouTube channel.

If he generates an independent address, this whole computation will be necessary for the other(s) address(es) too, making it infeasible. This is where labels comes in handy: instead of encoding the SP address with Bscan || Bspend , he could use Bscan || Bm , with Bm being the public key of the tagged hash of bscan || m . That means he will use m , which is an integer tweak, to get a different address.

Any number starting from 1 can be used for m (zero is reserved for change outputs from SP payments) and the wallet can map each m to a nice human-readable label. This way Bob can identify that payment A was received by the Twitter address and payment B came from the YouTube one.

Labels are meant to help with organization, not privacy. It’s clear both addresses belong to the same user, since they share the same Bscan .
Another example could be exchanges that want to send one reusable payment address to each user.

3.6 Spending the SP Output

Alright, back to our transaction: which private key should Bob use to spend that payment? Remember the public key of that output is P = Bspend + tk•G ? Well, Bob owns the private key of Bspend , so he can easily find the private key of P with d = bspend + tk•G :

for (const tweak of tweaksFound) {
const d = ecc.privateAdd(bspend, tweak) as Uint8Array;
const keyPair = ECPair.fromPrivateKey(Buffer.from(d), { network });
const { address } = bitcoin.payments.p2tr({ pubkey: keyPair.publicKey.subarray(1), network });
console.log(`Address: ${address}`);
console.log(`WIF: ${keyPair.toWIF()}`);
}

Output:

Address: tb1peswez0y6kwnmed8jp8ya80yfhl0cxfud0kl8dwypjg3f6ceftnpqcqxhmk
WIF: cUxqbuHSiAxivapY5X2r9YHUBN6z9UJd2txw1KXMPC4KRSnC9s58

Another interesting feature is bspend is not required to identify payments, only to spend them, which makes Silent Payments compatible with watch-only wallets.

4. Conclusion

I hope this helps to clarify the “magic” behind Silent Payments.
So far, Cake Wallet seems to be the first released wallet with SP implemented. As a new proposal, SP is still getting traction and I expect wallets and indexers to start implementing it to improve Bitcoin privacy and UX.

--

--