diff options
| author | Adam Soltys <[email protected]> | 2026-06-27 17:17:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-27 17:17:58 -0700 |
| commit | 56a2736be51d2f35d68ca88b477a948efe3398ce (patch) | |
| tree | 9506c94c067f34ac5561f8d00a43f454c5956d6a /src | |
| parent | ef183514e078169bf171e29b88c85aa16c8ecc75 (diff) | |
| download | coinosv3-56a2736be51d2f35d68ca88b477a948efe3398ce.tar.gz coinosv3-56a2736be51d2f35d68ca88b477a948efe3398ce.zip | |
Catch-up scaling: dust filter + per-block Bloom filters
silentpay.js: silentPaymentCandidate() (our P_0 for a tweak) + buildBloom/bloomHas
(double-hashed sha256, ~0.8% FP). Validated: candidate == P_0 on all no-label
vectors.
Indexer /scan now returns, per block, the tweaks + a Bloom over that block's
taproot output keys (no outputs), with an optional dustLimit. The wallet derives
a candidate per tweak, tests the filter, and only fetches /block on a hit — so a
big historical scan costs candidate math + a handful of fetches instead of
downloading and scanning every block. Live WS pushes still carry full items.
SP_DUST_LIMIT defaults to 0 (receive everything; the indexer honors any value).
Verified end-to-end on regtest: the filter flow finds the output.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/silentpay.js | 40 | ||||
| -rw-r--r-- | src/wallet.js | 40 |
2 files changed, 67 insertions, 13 deletions
diff --git a/src/silentpay.js b/src/silentpay.js index 16de66b..1732ced 100644 --- a/src/silentpay.js +++ b/src/silentpay.js @@ -292,3 +292,43 @@ export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))]; return bech32m.encode(hrp, words, 1023); } + +// ---- catch-up scaling: candidate keys + per-block Bloom filters ----------- +// For a large historical scan, downloading every block's outputs is wasteful. +// The indexer gives, per block, the tweaks + a Bloom filter over that block's +// taproot output keys. The client derives its candidate key P_0 from each tweak +// and tests the filter; only on a hit does it pull the block's outputs and run +// the full scan. (P_0 always exists if we're paid, so testing it alone suffices.) + +// Our candidate output key (P_0 = B_spend + t_0·G, x-only hex) for one tweak. +export function silentPaymentCandidate({ scanPriv, spendPub, tweak }) { + const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true); + const t0 = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(0))))); + return bytesToHex(Point.fromHex(spendPub).add(G.multiply(t0)).toRawBytes(true).slice(1)); +} + +// k bit indices for a 32-byte x-only key (hex) in an m-bit filter, via +// double-hashing one sha256(key): idx_i = (h1 + i·h2) mod m. +function* bloomIndices(keyHex, m, k) { + const h = sha256(hexToBytes(keyHex)); + const u32 = (o) => (h[o] | (h[o + 1] << 8) | (h[o + 2] << 16) | (h[o + 3] * 0x1000000)) >>> 0; + const h1 = u32(0); + const h2 = u32(4) | 1; // odd, so it's coprime-ish with m + for (let i = 0; i < k; i++) yield ((h1 + i * h2) >>> 0) % m; +} + +// Build a Bloom filter over x-only keys (hex). { m (bits), k (hashes), bits (hex) }. +export function buildBloom(keysHex, bitsPerKey = 10, k = 7) { + const m = Math.max(8, keysHex.length * bitsPerKey); + const bytes = new Uint8Array(Math.ceil(m / 8)); + for (const key of keysHex) for (const idx of bloomIndices(key, m, k)) bytes[idx >> 3] |= 1 << (idx & 7); + return { m, k, bits: bytesToHex(bytes) }; +} + +// Is an x-only key (hex) possibly in the filter? (false = definitely not.) +export function bloomHas(filter, keyHex) { + if (!filter || !filter.m) return true; // no filter ⇒ can't rule out → scan it + const bytes = hexToBytes(filter.bits); + for (const idx of bloomIndices(keyHex, filter.m, filter.k)) if (!(bytes[idx >> 3] & (1 << (idx & 7)))) return false; + return true; +} diff --git a/src/wallet.js b/src/wallet.js index 5b5ed90..7b4c8ae 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -24,7 +24,7 @@ import { concatBytes } from '@scure/btc-signer/utils.js'; import { Api, pool, getBackend, explorerWeb, electrumCandidates, spIndexerUrl } from './api.js'; import { ElectrumApi } from './electrum.js'; import { NostrSync, getSyncConfig } from './nostr.js'; -import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey } from './silentpay.js'; +import { isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas } from './silentpay.js'; import { schnorr } from '@noble/curves/secp256k1'; // No look-ahead: stop scanning a chain at the first unused address. This wallet @@ -39,6 +39,11 @@ const USED_HIT_DELAY_MS = 500; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// Silent-payment catch-up dust limit (sats): the indexer drops tweaks for txs +// whose taproot outputs are all below this, cutting the candidate math on a big +// scan. 0 = receive everything (default, so no small payment is ever missed). +const SP_DUST_LIMIT = 0; + const NETS = { mainnet: { net: btc.NETWORK, coin: 0 }, testnet: { net: btc.TEST_NETWORK, coin: 1 }, @@ -319,21 +324,30 @@ export class Wallet { try { if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } const from = (this.lastSpScan || 0) + 1; - const res = await (await fetch(`${indexer}/scan/${from}`)).json(); + // Catch-up: per block we get tweaks + a Bloom filter (no outputs). Derive a + // candidate key per tweak, test the filter, and only fetch the full block on + // a hit — so most blocks cost just the candidate math, no download/scan. + const res = await (await fetch(`${indexer}/scan/${from}?dustLimit=${SP_DUST_LIMIT}`)).json(); const tip = res.tip || 0; const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); const found = []; - for (const it of res.items || []) { - const matches = silentPaymentScan({ - scanPriv: keys.scanPriv, spendPub: keys.spendPub, - tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly), - }); - for (const m of matches) { - const o = it.outputs.find((o) => o.xonly === m.output); - const id = `${it.txid}:${o.vout}`; - if (have.has(id)) continue; - have.add(id); - found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + for (const block of res.blocks || []) { + let hit = false; + for (const tw of block.tweaks) { + const cand = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tw) }); + if (bloomHas(block.filter, cand)) { hit = true; break; } + } + if (!hit) continue; + const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); + for (const it of blk.items || []) { + const matches = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); + for (const m of matches) { + const o = it.outputs.find((o) => o.xonly === m.output); + const id = `${it.txid}:${o.vout}`; + if (have.has(id)) continue; + have.add(id); + found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); + } } } if (tip > this.lastSpScan) this.lastSpScan = tip; |
