aboutsummaryrefslogtreecommitdiffstats
path: root/src/silentpay.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-27 17:17:58 -0700
committerAdam Soltys <[email protected]>2026-06-27 17:17:58 -0700
commit56a2736be51d2f35d68ca88b477a948efe3398ce (patch)
tree9506c94c067f34ac5561f8d00a43f454c5956d6a /src/silentpay.js
parentef183514e078169bf171e29b88c85aa16c8ecc75 (diff)
downloadcoinosv3-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/silentpay.js')
-rw-r--r--src/silentpay.js40
1 files changed, 40 insertions, 0 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;
+}