Silent Payments Scanner — System Docs

A browser-based BIP-352 wallet scanner that performs all elliptic-curve math client-side against an untrusting index server. This page explains how the pieces fit together, what data moves between them, and the privacy properties that fall out of the design.

Contents

1. System architecture

Three processes cooperate, with keys that never leave the last one:

Bitcoin Core Frigate (forked v1.4.1) Browser tab ──────────── ─────────────────────── ─────────────── bitcoind RPC ────blocks──▶ Java indexer ──HTTP─▶ bench.js (main) DuckDB (utxo) │ ufsecp extension │ postMessage (Metal / CUDA / CPU) ▼ HttpApiServer :8081 worker.js × N tiny-secp256k1 WASM │ ▼ scan_priv (local) spend_pub (local) matches (local)

The important invariant: only Bitcoin Core and Frigate need to run persistently. The browser tab is stateless except for localStorage, and neither of the server processes ever sees wallet keys or match results.

2. Components

ProcessRolePort
bitcoind Source of truth — supplies blocks over JSON-RPC and the UTXO set via dumptxoutset 8332 (RPC)
frigate (Java 25) Indexes SP-eligible outputs into DuckDB. Exposes two endpoints in parallel: a read-only HTTP batch API (used by the browser scanner, keys never transmitted) and an Electrum server (used by wallets that hand over scan keys for server-side GPU scans). Loads the unified ufsecp DuckDB extension on startup and auto-selects Metal / CUDA / OpenCL / CPU. 8081 (HTTP) + 57001 (Electrum)
sp-web (Python) Static file server for the built scanner bundle (dist/) 8080 (HTTP)
browser tab Pool of Web Workers running tiny-secp256k1 WASM; compares computed output keys against Frigate’s prefixes

The static server and the API server are independent — you could host dist/ on any static CDN and point the scanner at a Frigate running anywhere on the tailnet.

3. Server-side: indexing pipeline

For each confirmed block, Frigate walks every transaction that satisfies BIP-352’s input eligibility rules and, if any Taproot output could plausibly be a Silent Payment output, writes one row to the utxo table:

CREATE TABLE utxo (
  txid                  BLOB,      -- 32 bytes
  output_index          INTEGER,
  height                INTEGER,
  tweak_key             BLOB,      -- 64 bytes, Frigate's internal format
  compressed_tweak_key  BLOB,      -- 33 bytes, added by this fork
  output_hash_prefix    BIGINT,    -- first 8 bytes of the actual x-coord
  value                 BIGINT,    -- sats
  PRIMARY KEY (txid, output_index)
);

The critical thing to notice: none of these columns depend on any wallet. tweak_key is computed from the transaction’s inputs (sum(pubkeys) · input_hash) and output_hash_prefix is extracted straight from the transaction output’s x-coordinate. The index is built once, used by everyone.

Frigate’s custom DuckDB extension (ufsecp in v1.4.1+, which auto-selects Metal / CUDA / OpenCL / CPU based on available hardware) can do the EC math server-side for authenticated Electrum clients that hand over their scan key. The browser path intentionally does not use this — instead it pulls raw rows over HTTP and does the math locally in the tab, so the server never sees any wallet key material.

For the full list of fork-specific indexing changes (UTXO-only mode, dumptxoutset bootstrap, compressed-key column, HTTP batch API), see Frigate Fork.

4. Browser-side: the scan loop

The scanner is a webpack-bundled ES module. Entry point is src/bench.js, which becomes dist/bench.js. On load it:

When you click Scan, the main thread becomes a fan-out dispatcher: it fetches ~20 000-record pages from /api/batch, splits them into ~4 000-record sub-batches, and round-robins them to workers. As workers return matches, the main thread dedupes by txid:output_index and renders them in the results table.

Each worker, for each record, runs the BIP-352 detection:

shared  = tweak_key · scan_priv                  // EC point mul
t_k     = SHA256("BIP0352/SharedSecret" tag,
                 shared || ser32(k))             // tagged hash, k=0
P_k     = spend_pub + t_k · G                    // EC point add
prefix  = int64_be(P_k.x[0..8])                  // first 8 bytes
match   = prefix == record.output_hash_prefix

Steps 1 and 3 are the expensive ones — two EC operations per record. For the current index of ~12M rows that’s ~24M secp256k1 scalar operations, which is why the work is parallelized across every CPU core via WASM.

On a match, the worker performs one extra step: it re-derives P_k, t_k, and the output x-only pubkey, and returns them alongside the basic match fields as “spend info.” This is a handful of EC operations per match — matches are rare (~1 in 107 records), so the extra work has zero measurable impact on the hot loop but gives the main thread enough data to export a signing-wallet handoff without another round-trip to the worker.

5. End-to-end data flow

Tracing a single record from chain to UI:

#WhereOperation
1Frigate indexer Read block from bitcoind, iterate transactions
2Frigate indexer Check SP input eligibility (BIP-352 rules)
3Frigate indexer Compute input_hash = hash(outpoints), tweak_key = Σ(pubkeys) · input_hash
4Frigate indexer Extract first 8 bytes of each Taproot output x-coord → output_hash_prefix (signed i64)
5DuckDB INSERT row into utxo table (plus compressed_tweak_key)
6Browser → Frigate GET /api/batch?offset=N&limit=20000&scan=1
7Frigate HTTP SELECT rows, base64-encode blobs, stringify BIGINT, serialize JSON, gzip
8Browser main Decompress (automatic), parse JSON, push into batch cache
9Browser main Dispatch ~4k-record slice to next free worker via postMessage
10Worker Base64-decode k, BigInt-parse p
11Worker (WASM) shared = tweak_key · scan_priv
12Worker t_k = tagged_hash(shared || 0x00000000)
13Worker (WASM) P_k = spend_pub + t_k · G
14Worker Compare first 8 bytes of P_k.x against p
15Worker (WASM, match only) Re-derive t_k, P_k, output x-only pubkey; attach scriptPubKey, tweakKey, k, privKeyTweak to the match record
16Worker → main postMessage({type: 'result', matches: [...]})
17Browser main Dedupe by txid:vout, render in results table with an expandable “Spend info” panel per match, persist to localStorage

Note: UTXO amounts (sats) are included inline in the /api/batch response as the v field — the browser never needs a third-party API call to display balances. The only wallet data that ever reaches a third party is what you choose to click on (e.g. the mempool.space transaction-detail link on each row).

6. Privacy properties

Core guarantee: Frigate sees the same bytes for every user. A scanner and a browser that never scans are indistinguishable from Frigate’s logs.

What the server can observe

What the server cannot observe

Why this works

The index entries (tweak_key, output_hash_prefix) are derived only from public chain data. They are a description of the transaction, not of any recipient. Turning that description into “this output is mine” requires the scan private key, which only the browser holds.

Contrast this with the ephemeral-key variant (“Remote Scanner” mode) from Frigate’s upstream Electrum protocol, where clients hand the server their scan key for a single session and the server does the EC math. That’s faster — especially with GPU acceleration — but it requires trusting the server not to log keys. The browser path we’re documenting here trades speed for zero-trust.

What the browser persists locally

Per spend-pubkey in localStorage:

{
  "matches": [
    {
      "txid":         "aed6abcf...dc5cc646",
      "outputIndex":  0,
      "height":       934358,
      "value":        44999,
      "scriptPubKey": "5120",
      "outputXOnly":  "",
      "tweakKey":     "02f1c098...fa56b",
      "k":            0,
      "privKeyTweak": "",
      "hashPrefix":   "405998946281409198"
    }
  ],
  "lastScannedHeight": 935000,
  "updatedAt":         "2026-04-15T..."
}

This enables incremental scans (just pull start_height = lastScannedHeight + 1) and restoring results on page reload. Each match record contains everything a BIP-341 signer needs except the spend private key — see §8 Spend info below. Anyone with access to your browser profile can read this; there’s no keyring integration.

7. Performance

The browser path and the server-side Electrum path use the same index but run the cryptography in very different places. Headline rates:

PathRateTime for ~12M records
Browser (WASM, 10 workers) — this scanner ~11 000 / sec (measured on M4 Pro) ~18 min
Frigate server-side via ufsecp, CPU backend ~67 000 / sec (upstream benchmark) ~3 min
Frigate server-side via ufsecp, Metal backend (this box’s M4 Pro) Not measured — but the extension auto-selects it at startup and benchmarks on Apple Silicon land in the hundreds-of-thousands/sec range seconds
Frigate server-side via ufsecp, CUDA backend (Linux + NVIDIA) ~5 000 000 / sec (upstream benchmark) ~3 sec

The important thing to notice: the browser is always the slowest path by a wide margin. It’s also the only path where Frigate doesn’t learn anything about the scanning wallet. The tradeoff is deliberate.

In practice the scanner is used in two ways:

If you want the fast server-side path and can accept handing over the scan private key for a single session, speak Electrum directly to Frigate on port 57001 with the blockchain.silentpayments.subscribe method. That path uses the GPU backend when available.

8. Spend info & handoff to a signer

The scanner detects UTXOs but holds only the spend public key, so it cannot sign. To actually spend a detected UTXO, a separate signing wallet needs enough data to compute the output private key and construct a BIP-341 Taproot key-path signature.

Every match row in the results table has an expand button (▸) that reveals a “Spend info” panel with all the fields a signer needs. A “Copy JSON” button emits the whole record in one paste.

What's in the handoff

FieldPurpose
txid, outputIndex The outpoint to spend
value (sats) Input amount for the BIP-341 sighash (required for the hashAmounts and hashScriptPubKeys commitments)
scriptPubKey (34-byte hex, 5120<x>) The actual output script; needed for PSBT WITNESS_UTXO and the sighash
outputXOnly (32-byte hex) Same as the last 32 bytes of scriptPubKey; this is the Taproot output public key
tweakKey (33-byte compressed hex) Frigate’s per-transaction SP tweak. A signing wallet can re-derive t_k from this and the scan private key if it wants to verify the scanner’s math.
k (integer) BIP-352 k parameter. Always 0 for the hot-path match; non-zero only when a single SP tx had multiple outputs to the same recipient and check_k_plus found a higher match.
privKeyTweak (32-byte hex) Pre-computed t_k. The signer adds this to the spend private key modulo the curve order to get the output private key.

How the signer uses it

Given a handoff record plus the spend private key d_spend:

d_out  = (d_spend + int(privKeyTweak)) mod n   // output private key
P_out  = d_out · G                             // must match outputXOnly
sig    = Schnorr.sign(d_out, sighash(tx, scriptPubKey, value, …))

And that’s it — the 64-byte Schnorr signature is the sole witness for a Taproot key-path spend. No tapscript, no control block, no merkle proof.

Note that privKeyTweak is a convenience: a signer that has the scan private key can re-derive it independently from tweakKey and k as tagged_hash("BIP0352/SharedSecret", (tweakKey · scan_priv) || ser32(k)). Exporting privKeyTweak means the signing wallet doesn’t need the scan private key at all — it only needs the spend private key and the tweak. This keeps the two keys independently useful: the scan private key stays in the scanner, the spend private key stays in the signer, and neither component ever sees both halves.

What’s not in the handoff

9. Caveats & limits

The browser scanner only tells you what Frigate has indexed. If Frigate’s SP-eligibility check misses a transaction (or has not yet backfilled it), the browser will honestly report no match even for a UTXO that actually belongs to you. Both code paths agree because both read from the same DuckDB rows. If you see suspicious misses, check the row directly in DuckDB before assuming the scanner is broken.

For what’s specifically been changed in Frigate to support this workflow — UTXO-only mode, snapshot bootstrap, the HTTP API, the compressed-key column — see Frigate Fork.

10. Server-side vs client-side scan

Both scan modes target the same Frigate index, but the work happens in very different places. For a user who is not running their own Frigate server — i.e. who is connecting to someone else’s — the tradeoff is essentially privacy versus speed and bandwidth.

Dimension Server-side scan
(Electrum blockchain.silentpayments.subscribe)
Client-side scan
(browser WASM via /api/batch)
First sync time < 1 second (Metal GPU); a few seconds on CPU ~12 minutes for the full ~13M-UTXO index, less for higher start heights (see Plots)
Subsequent sync time < 1 second < 1 minute, since the scanner persists lastScannedHeight in localStorage and only fetches new blocks
Dust cut-through Not needed — the server scans the whole table indiscriminately Needed to minimise scan time and bandwidth — Frigate’s utxoMinValue (default 1000 sat) filters dust at index time
Spent-output cut-through Not needed (UTXO-only mode already trims spent outputs from the index, but the server doesn’t care either way) Needed to minimise scan time and bandwidth — without the UTXO-only mode the index would carry every historical tweak forever
Full transaction history Yes — the server can scan any height range and return all matches that ever existed No — the index only carries currently-unspent P2TR outputs above the dust floor
Bandwidth (full initial scan) A few kB of JSON-RPC traffic — just the match list ~530 MB of gzipped tweak keys (~2 GB raw) — every record streams to the client
Compute load One ECMUL + tagged hash per indexed UTXO, on the server’s CPU or GPU Same per-record work, but on the user’s machine across all Web Workers
Trust The scan private key is sent to the server. A malicious or compromised server can link every UTXO and every future payment to your address. The scan private key never leaves the browser. The server only sees raw /api/batch hits — no information about which records (if any) matched.

Practical guidance:

Worked example — a basic wallet on a monthly cadence. Picture the simplest possible SP wallet: user opens the app, generates an SP address for donations, writes down 12 seed words plus the current block height as their wallet birthday, then closes the app. A month later they open it again. The app fetches the last 4,320 blocks (≈ 30 days at 144 blocks/day) of non-dust, non-spent UTXOs from a third-party Frigate over the public network, then privately tweaks every key in the browser to see which ones belong to them.
StepCost (against this index, tip 950,694)
UTXOs in the 4,320-block window837,628
Wire download (gzipped)~34 MB
Wire download (raw JSON, no gzip)~132 MB
Client-side scan time @ 18,275 tweaks/sec (M4 Pro)~46 seconds
UTXOs the user actually finds~1 (whatever they received)
What the server learns about the userNothing beyond an HTTP fetch from their IP

On subsequent opens the scanner advances lastScannedHeight in localStorage, so the cost is bounded by the actual gap since last sync — a wallet that gets opened weekly pays ~6.6 MB / ~9 s per check-in.

Try it yourself on the Plots page — the curve is the same data; the right axis toggles between scan time and download size.

11. Comparison with BIP-352 Appendix A

BIP-352 Appendix A sketches a Neutrino-style light-client design for Silent Payments that the BIP itself flags as out of scope and open research. Our index is a deliberate departure from it. The two designs address the same problem — privacy-preserving SP scanning without trusting a server with the scan key — but make different trade-offs.

BIP-352 Appendix A in one paragraph

The index serves one 33-byte tweak point per SP transaction (tweak = Σ(input_pubkeys) · input_hash). The client downloads those tweaks, computes the expected output key P_k locally, and then queries a BIP-158 GCS block filter to ask “does this block contain 5120<P_k.x>?” On a filter hit the client fetches the full block (or its prevouts only) to confirm and extract the outpoint/amount. The BIP also mentions optional cut-through (skip txs whose P2TR outputs have all been spent — saves ~75% of bandwidth for periodic scanners) and out-of-band sender-side notifications as an alternative to scanning.

What this project does

Index one row per unspent P2TR output of an SP transaction, carrying both the 33-byte tweak key and an 8-byte hash prefix derived from the actual output public key (output_hash_prefix = first 8 bytes of P_k.x). The client computes its expected P_k the same way, then matches the 8 bytes directly in the row — no BIP-158 filter probe, no block fetch, no second round-trip. False-positive rate is ~1/264 instead of ~1% per filter, but the row also carries output_index, height, and value inline, so a match is immediately spendable without further server hops.

Dimension BIP-352 Appendix A This project
Index granularity 1 row per SP transaction 1 row per unspent P2TR output of an SP tx
Bytes shipped per row 33 B (tweak only) ~41 B gzipped (tweak + 8-byte prefix + txid + vout + height + value)
Match check Compute P_k → BIP-158 filter probe → on hit, fetch block Compute P_k → compare 8 bytes against the row's prefix
False-positive rate ~1% per filter (GCS parameter M) → triggers block fetch ~1/264 (effectively zero)
Round-trips per match ≥ 2 (tweaks + filter + block) 1 (just the batch fetch)
Cut-through Optional optimisation Always on (UTXO-only mode default)
Dust filter Not in BIP Always on at index time (utxoMinValue, default 1000 sats)
Outpoint disambiguation Examine fetched block to find the matching vout output_index is already in the matched row
Server backend Any Bitcoin Core node with BIP-158 (Neutrino-capable) Custom Frigate fork + DuckDB
Privacy from server Server doesn't learn the match set Same — server only sees raw batch fetches
Sender-side notifications Optional, sketched Not implemented

Bandwidth — much closer than it looks

The BIP and this project end up at similar bandwidth budgets for very different structural reasons:

Window BIP-352 (no cut-through) BIP-352 (with cut-through) This index
1 month catch-up ~30–50 MB ~30 MB ~34 MB
Full sync since Taproot (~241k blocks) ~2.3 GB ~575 MB ~528 MB

The BIP's per-tx row is smaller (33 B vs ~41 B gzipped per output), and a single tx with multiple P2TR outputs produces multiple rows here. But we only ship currently-unspent outputs and the dust floor trims aggressively, so the two compositions converge. Bandwidth is not the meaningful axis of difference between the designs.

The real architectural choice

The fundamental fork in the road is where the “did this match?” check happens after the EC math:

BIP-352 Appendix A optimises for… This project optimises for…
Reuse of existing infrastructure — any Bitcoin Core node with BIP-158 filters works. No custom server needed. Latency in a browser — every extra HTTP fetch is expensive, especially the unbounded “fetch the matching block” pattern.
Minimum bytes on the wire at the cost of occasional follow-up round-trips for filter hits. Deterministic per-record match at the cost of ~8 extra bytes per row — paid once, amortised over a streamed gzipped batch.
Sparse matchers — wallets that don't expect many matches and won't trigger filter hits often. Co-deployed servers — we control both ends, so the “index ↔ filter ↔ block” round-trip chain is replaced by a single fat batch endpoint.

What this design gives up

What this design gets that the BIP doesn't

The two designs are composable. A wallet could use BIP-352 Appendix A against a generic Neutrino-capable node for the privacy-preserving cold start, then switch to this index's /api/batch against a trusted Frigate for fast monthly catch-up. The two endpoints don't conflict — they're addressing slightly different sweet spots in the same problem.