dgb-wallet/packages/p2p/src/gcs.ts
Digibyte.X 690959a983 p2p: BIP158 matching in WebAssembly (gcs.c), Number-based JS fallback; Blockbook upstream → Atomic
The daemon's wallet scan hashed every watched script against every block
filter with a BigInt SipHash: ~3 filters/s on the VPS once the gap limits
went to 200/100 (~3,300 scripts), i.e. days for a one-year window. The
matcher now lives in dgbpow.wasm next to the PoW hashes — scripts are
uploaded once per scan, each filter is one call, the set is decoded once
and each target binary-searched — ~5,500 filters/s locally, ~2,000/s on
the VPS with block fetches. The JS path was rewritten with uint32 lanes
and exact 32×32 partial products as a fallback (module missing or filter
beyond its buffers). scripts/gcs-check.ts cross-checks both against a
BigInt reference on random filters; `npm run gcs:test -w @dgb-wallet/p2p`.

NOWNodes stopped accepting the proxy's API key ("Unknown API_key" on every
endpoint), which is why the wallet showed "Blockbook HTTP 401" for every
path. The vhost mirror now proxies /blockbook/ to Atomic Wallet's public
Blockbook; ops/blockbook-upstream.sh switches the live vhost either way.
2026-10-11 23:37:28 +02:00

243 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { sipKey, siphash24Pair, type SipKey } from './siphash.js';
import { decodeVarint } from './varint.js';
import { loadPowWasm } from './pow.js';
// BIP158 Golomb-Coded Set filter operations.
// Parameters for the "basic" filter type (0x00):
export const P = 19;
export const M = 784931n;
const M_NUM = 784931;
// Two implementations of the same thing:
//
// • native — gcs.c inside ../native/dgbpow.wasm. The watched scripts
// are uploaded into the module once per script set (the scan driver
// passes the same array for a whole scan) and each filter is then
// one call. ~30 ns per script; a few thousand scripts × a year of
// DigiByte blocks is minutes, not hours.
// • JS — Number arithmetic throughout (a filter's range N·M is far
// below 2^53, and the one overflowing step, (h·F) >> 64, is done
// with 32×32-bit partial products). ~3 µs per script. Used when the
// module is missing or the script set exceeds its static buffers.
//
// Both are checked against a BigInt reference by scripts/gcs-check.ts.
// Derive the SipHash key from a block hash: first 16 bytes of the
// block hash's LITTLE-endian byte order.
// (Our stored block hashes are hex big-endian display form, so we
// have to reverse them before slicing.)
export function keyFromBlockHash(blockHashHex: string): Buffer {
const be = Buffer.from(blockHashHex, 'hex');
const le = Buffer.from(be).reverse();
return Buffer.from(le.subarray(0, 16));
}
// ---------------------------------------------------------------- native
interface Native {
mem: Uint8Array;
scriptsPtr: number; offsetsPtr: number; keyPtr: number; filterPtr: number; matchesPtr: number;
maxScripts: number; scriptBytes: number; filterBytes: number;
setCount: (n: number) => void;
match: (nSet: number, bodyLen: number, wantAll: number) => number;
}
let native: Native | null | undefined; // undefined = not tried yet
let forceJs = false;
/** Test hook: `gcsUseJs(true)` disables the native path for this process. */
export function gcsUseJs(on: boolean): void { forceJs = on; uploaded = null; }
export function gcsBackend(): 'wasm' | 'js' { return !forceJs && getNative() ? 'wasm' : 'js'; }
function getNative(): Native | null {
if (native !== undefined) return native;
try {
const w = loadPowWasm();
const x = w.exports;
if (typeof x.gcs_match !== 'function') { native = null; return null; }
const limitsPtr = (x.gcs_limits_ptr as () => number)();
const limits = new Uint32Array(w.memory.buffer, limitsPtr, 3);
native = {
mem: w.mem,
scriptsPtr: (x.gcs_scripts_ptr as () => number)(),
offsetsPtr: (x.gcs_offsets_ptr as () => number)(),
keyPtr: (x.gcs_key_ptr as () => number)(),
filterPtr: (x.gcs_filter_ptr as () => number)(),
matchesPtr: (x.gcs_matches_ptr as () => number)(),
maxScripts: limits[0], scriptBytes: limits[1], filterBytes: limits[2],
setCount: x.gcs_set_count as (n: number) => void,
match: x.gcs_match as (nSet: number, bodyLen: number, wantAll: number) => number,
};
} catch {
native = null;
}
return native;
}
// The script set currently uploaded into the module, by array identity
// and length (the scan driver never mutates its array mid-scan, but a
// grown array is re-uploaded anyway).
let uploaded: { arr: Buffer[]; len: number } | null = null;
function ensureUploaded(n: Native, elements: Buffer[]): boolean {
if (uploaded && uploaded.arr === elements && uploaded.len === elements.length) return true;
if (elements.length > n.maxScripts) return false;
let total = 0;
for (const e of elements) total += e.length;
if (total > n.scriptBytes) return false;
const offsets = new Uint32Array(n.mem.buffer, n.offsetsPtr, elements.length + 1);
let off = 0;
for (let i = 0; i < elements.length; i++) {
offsets[i] = off;
n.mem.set(elements[i], n.scriptsPtr + off);
off += elements[i].length;
}
offsets[elements.length] = off;
n.setCount(elements.length);
uploaded = { arr: elements, len: elements.length };
return true;
}
function nativeMatch(n: Native, filterBody: Buffer, blockHash: string, elements: Buffer[], wantAll: boolean): number | null {
if (!ensureUploaded(n, elements)) return null;
const { value: nBig, bytesRead } = decodeVarint(filterBody, 0);
const nSet = Number(nBig);
if (nSet === 0) return 0;
const body = filterBody.subarray(bytesRead);
if (body.length > n.filterBytes) return null;
n.mem.set(keyFromBlockHash(blockHash), n.keyPtr);
n.mem.set(body, n.filterPtr);
const r = n.match(nSet, body.length, wantAll ? 1 : 0);
return r < 0 ? null : r; // -1: filter too big for the module's buffers → JS
}
// -------------------------------------------------------------------- JS
// Exact 32×32 → 64-bit product as [hi, lo] uint32 (via 16-bit halves).
const mulOut = new Float64Array(2);
function mul32(a: number, b: number): void {
const al = a & 0xffff, ah = a >>> 16, bl = b & 0xffff, bh = b >>> 16;
const ll = al * bl, mid = al * bh + ah * bl, hh = ah * bh;
const lo = ll + (mid % 65536) * 65536;
const carry = Math.floor(lo / 4294967296);
mulOut[1] = lo - carry * 4294967296;
mulOut[0] = hh + Math.floor(mid / 65536) + carry;
}
// Map an element to the filter's [0, N·M) range: fastReduce of the
// SipHash output over N·M, i.e. (h · F) >> 64. Matches BIP158's
// `hashToRange`. `F` is split once per filter into fh·2^32 + fl.
const hashOut = new Uint32Array(2);
function hashToRange(key: SipKey, element: Buffer, fh: number, fl: number): number {
siphash24Pair(key, element, hashOut);
const hi = hashOut[0], lo = hashOut[1];
// h·F = hi·fh·2^64 + (hi·fl + lo·fh)·2^32 + lo·fl
mul32(lo, fl); const zHi = mulOut[0];
mul32(hi, fl); const xHi = mulOut[0], xLo = mulOut[1];
mul32(lo, fh); const yHi = mulOut[0], yLo = mulOut[1];
const l = xLo + yLo + zHi;
const c = Math.floor(l / 4294967296);
return hi * fh + xHi + yHi + c;
}
function splitF(n: number): { fh: number; fl: number } {
const F = n * M_NUM;
const fh = Math.floor(F / 4294967296);
return { fh, fl: F - fh * 4294967296 };
}
// Decode all values of a Golomb-Rice-coded set (unary quotient, P-bit
// remainder, cumulative) into a sorted Float64Array. The body is what
// `cfilter.filter` returns AFTER the varint N prefix has been consumed.
function decodeGcsValues(body: Buffer, n: number): Float64Array {
const values = new Float64Array(n);
const bitLen = body.length * 8;
let pos = 0;
let last = 0;
const bit = (p: number): number => (body[p >>> 3] >> (7 - (p & 7))) & 1;
for (let i = 0; i < n; i++) {
let q = 0;
while (pos < bitLen && bit(pos) === 1) { q++; pos++; }
if (pos + 1 + P > bitLen) throw new Error('GCS: past end of buffer');
pos++; // the terminating 0 of the unary prefix
let r = 0;
for (let k = 0; k < P; k++) r = r * 2 + bit(pos++);
last += q * 524288 + r; // q·2^P + r
values[i] = last;
}
return values;
}
function targetsFor(key: SipKey, elements: Buffer[], n: number): Float64Array {
const { fh, fl } = splitF(n);
const t = new Float64Array(elements.length);
for (let i = 0; i < elements.length; i++) t[i] = hashToRange(key, elements[i], fh, fl);
return t.sort();
}
// ------------------------------------------------------------------ API
// Test whether ANY of `elements` are in the filter. Returns true on
// the first match; false if none matched. This is the primary
// wallet-side operation: pass every scriptPubKey (and any input
// script data for spend detection) we care about.
export function filterMatchesAny(
filterBody: Buffer,
blockHash: string,
elements: Buffer[],
): boolean {
if (elements.length === 0) return false;
if (!forceJs) {
const n = getNative();
if (n) {
const r = nativeMatch(n, filterBody, blockHash, elements, false);
if (r !== null) return r > 0;
}
}
const { value: nBig, bytesRead } = decodeVarint(filterBody, 0);
const n = Number(nBig);
if (n === 0) return false;
const key = sipKey(keyFromBlockHash(blockHash));
const targets = targetsFor(key, elements, n);
const setValues = decodeGcsValues(filterBody.subarray(bytesRead), n);
let ti = 0;
for (let i = 0; i < n; i++) {
const v = setValues[i];
while (ti < targets.length && targets[ti] < v) ti++;
if (ti === targets.length) return false;
if (targets[ti] === v) return true;
}
return false;
}
// Return the subset of elements that matched the filter — useful when
// probing one filter against many address candidates and needing to
// know WHICH matched. Slower than matchesAny (can't short-circuit).
export function filterMatches(
filterBody: Buffer,
blockHash: string,
elements: Buffer[],
): Buffer[] {
if (elements.length === 0) return [];
if (!forceJs) {
const nat = getNative();
if (nat) {
const r = nativeMatch(nat, filterBody, blockHash, elements, true);
if (r !== null) {
const idx = new Uint32Array(nat.mem.buffer, nat.matchesPtr, r);
return Array.from(idx, (i) => elements[i]);
}
}
}
const { value: nBig, bytesRead } = decodeVarint(filterBody, 0);
const n = Number(nBig);
if (n === 0) return [];
const key = sipKey(keyFromBlockHash(blockHash));
const { fh, fl } = splitF(n);
const withHash = elements.map((el) => ({ el, target: hashToRange(key, el, fh, fl) }));
withHash.sort((a, b) => a.target - b.target);
const setValues = decodeGcsValues(filterBody.subarray(bytesRead), n);
const matched: Buffer[] = [];
let ti = 0;
for (let i = 0; i < n; i++) {
const v = setValues[i];
while (ti < withHash.length && withHash[ti].target < v) ti++;
if (ti === withHash.length) break;
while (ti < withHash.length && withHash[ti].target === v) matched.push(withHash[ti++].el);
}
return matched;
}