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.
Three processes cooperate, with keys that never leave the last one:
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.
| Process | Role | Port |
|---|---|---|
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.
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.
The scanner is a webpack-bundled ES module. Entry point is
src/bench.js, which becomes dist/bench.js. On
load it:
GET /api/info for total record count and table
metadataWorker per CPU core using the separate
dist/worker.js bundle{type: 'init'} message containing
the scan private key and spend public key; workers
import tiny-secp256k1 dynamically, which triggers WASM
instantiation{type: 'ready'}
before enabling the Scan buttonWhen 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.
Tracing a single record from chain to UI:
| # | Where | Operation |
|---|---|---|
| 1 | Frigate indexer | Read block from bitcoind, iterate transactions |
| 2 | Frigate indexer | Check SP input eligibility (BIP-352 rules) |
| 3 | Frigate indexer | Compute input_hash = hash(outpoints),
tweak_key = Σ(pubkeys) · input_hash |
| 4 | Frigate indexer | Extract first 8 bytes of each Taproot output x-coord →
output_hash_prefix (signed i64) |
| 5 | DuckDB | INSERT row into utxo table (plus
compressed_tweak_key) |
| 6 | Browser → Frigate | GET /api/batch?offset=N&limit=20000&scan=1 |
| 7 | Frigate HTTP | SELECT rows, base64-encode blobs, stringify BIGINT, serialize JSON, gzip |
| 8 | Browser main | Decompress (automatic), parse JSON, push into batch cache |
| 9 | Browser main | Dispatch ~4k-record slice to next free worker via
postMessage |
| 10 | Worker | Base64-decode k, BigInt-parse p |
| 11 | Worker (WASM) | shared = tweak_key · scan_priv |
| 12 | Worker | t_k = tagged_hash(shared || 0x00000000) |
| 13 | Worker (WASM) | P_k = spend_pub + t_k · G |
| 14 | Worker | Compare first 8 bytes of P_k.x against
p |
| 15 | Worker (WASM, match only) | Re-derive t_k, P_k, output x-only
pubkey; attach scriptPubKey, tweakKey,
k, privKeyTweak to the match record |
| 16 | Worker → main | postMessage({type: 'result', matches: [...]}) |
| 17 | Browser 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).
/api/info and
/api/batch with certain offset,
limit, and optional height-range parametersThe 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.
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.
The browser path and the server-side Electrum path use the same index but run the cryptography in very different places. Headline rates:
| Path | Rate | Time 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:
lastScannedHeight per spend-pubkey
in localStorage.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.
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.
| Field | Purpose |
|---|---|
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. |
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.
privKeyTweak is already pre-computed./api/batch response includes the UTXO amount inline
(v field), populated from Frigate’s
utxo.value column. The browser never contacts
mempool.space for balances, so there’s no third-party leak of
your match set.check_k_plus path to chase k > 0 when a
k = 0 hit occurs, up to k = 10.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.
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:
lastScannedHeight + localStorage),
and the server learns nothing about which addresses you scan for.| Step | Cost (against this index, tip 950,694) |
|---|---|
| UTXOs in the 4,320-block window | 837,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 user | Nothing 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.
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.
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.
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 |
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 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. |
/api/batch is a
single-deployment HTTP shape, not a standard.value field — no third-party API call to
mempool.space or similar.blockchain.silentpayments.subscribe
Electrum method that runs the scan on the server's CPU or GPU. Trust
model is different (server sees the scan key), but useful when you're
connecting to your own instance — see §10./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.