aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/silentpay.js40
-rw-r--r--src/wallet.js40
-rw-r--r--tools/sp-indexer.js23
3 files changed, 83 insertions, 20 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;
diff --git a/tools/sp-indexer.js b/tools/sp-indexer.js
index 09f9a19..934c55c 100644
--- a/tools/sp-indexer.js
+++ b/tools/sp-indexer.js
@@ -16,7 +16,7 @@
import { Database } from 'bun:sqlite';
import net from 'node:net';
import http from 'node:http';
-import { inputPubKey, silentPaymentTweak } from '../src/silentpay.js';
+import { inputPubKey, silentPaymentTweak, buildBloom } from '../src/silentpay.js';
const RPC = process.env.CORE_RPC || 'http://127.0.0.1:18443';
const RPC_USER = process.env.RPC_USER || 'admin1';
@@ -216,13 +216,22 @@ const server = Bun.serve({
const b = qGetBlock.get(Number(m[1]));
return json(b ? JSON.parse(b.items).map((i) => i.tweak) : []);
}
- // All SP-eligible tx items from height `from` to the tip, in one response —
- // each tagged with its height. Lets a client scan a range without one fetch
- // per block. (A future BIP-158/Bloom filter will cut this for large ranges.)
+ // Catch-up over a height range: per block, the tweaks (dust-filtered) + a
+ // Bloom filter over that block's taproot output keys — NOT the outputs. The
+ // client derives its candidate key per tweak, tests the filter, and only
+ // fetches /block for the rare hits. dustLimit (sats) drops txs whose taproot
+ // outputs are all below it (fewer tweaks to check).
if ((m = p.match(/^\/scan\/(\d+)$/))) {
- const items = [];
- for (const row of qScanFrom.all(Number(m[1]))) for (const it of JSON.parse(row.items)) items.push({ height: row.height, ...it });
- return json({ tip: indexed, items });
+ const dust = Number(url.searchParams.get('dustLimit') || 0);
+ const blocks = [];
+ for (const row of qScanFrom.all(Number(m[1]))) {
+ let items = JSON.parse(row.items);
+ if (dust > 0) items = items.filter((it) => it.outputs.some((o) => o.value >= dust));
+ if (!items.length) continue;
+ const outKeys = items.flatMap((it) => it.outputs.map((o) => o.xonly));
+ blocks.push({ height: row.height, tweaks: items.map((it) => it.tweak), filter: buildBloom(outKeys) });
+ }
+ return json({ tip: indexed, blocks });
}
// Current unconfirmed (mempool) SP-eligible txs — for the pending catch-up on
// connect; live updates arrive as {type:'mempool'} WS pushes.