Frigate Fork — Modifications

This page documents the diffs between orangesurf/frigate and upstream sparrowwallet/frigate. The fork exists to make Frigate’s index usable directly from a zero-trust browser scanner — which, in upstream, is not possible because there’s no raw-batch HTTP endpoint and no compressed-key column.

Fork state: rebased onto sparrowwallet/frigate v1.4.1 (upstream tip e556c0f) with two commits on top — c59379c (port of browser-scanner support) and 247eb9d (a startup-recursion fix). The original per-feature fork commits (c1c0677, f8402cb, …) are preserved on the backup/pre-rebase branch/tag if you want to see them individually.

Contents

1. Why fork

Upstream Frigate is an Electrum server. Its clients (Sparrow Wallet, other BIP-352-aware wallets) speak Electrum RPC and optionally hand over ephemeral scan keys so Frigate can run the match query server-side using its unified ufsecp DuckDB extension (Metal / CUDA / OpenCL / CPU). That architecture is a nice power/privacy tradeoff, but it implies the server is in the trust path for that session.

The browser scanner takes a different position: “keys never leave the tab, even ephemerally.” For that to work, Frigate has to expose the raw index over HTTP in a shape the browser can stream efficiently. The fork adds that, plus a few infrastructure changes to make the indexer practical to run on a single mid-range machine.

2. UTXO-only indexing mode

c1c0677 Add UTXO-only indexing mode for Silent Payments

Upstream Frigate has one index schema, a per-transaction tweak table that never deletes rows:

-- upstream
CREATE TABLE tweak (
  txid       BLOB,        -- 32 bytes
  height     INTEGER,
  tweak_key  BLOB,        -- 64 bytes
  outputs    BIGINT[]     -- array of 8-byte x-coord prefixes
);

This is correct and complete — every SP-eligible transaction is preserved forever — but for a single-user wallet it’s wasteful. You mostly care about unspent outputs. The fork adds a new IndexMode enum with two values:

// frigate/src/main/java/com/sparrowwallet/frigate/index/IndexMode.java
public enum IndexMode {
    FULL,        // upstream behavior — the tweak table
    UTXO_ONLY    // new — the utxo table, per-output, prunable
}

In UTXO_ONLY mode, Frigate creates a different table:

-- fork, UTXO_ONLY mode
CREATE TABLE utxo (
  txid                  BLOB,
  output_index          INTEGER,
  height                INTEGER,
  tweak_key             BLOB,     -- 64 bytes
  compressed_tweak_key  BLOB,     -- 33 bytes (see §4)
  output_hash_prefix    BIGINT,   -- signed i64
  value                 BIGINT,
  PRIMARY KEY (txid, output_index)
);

Differences from the upstream schema that matter:

Trade-off. UTXO_ONLY mode cannot answer “did anything ever pay me?” once an output is spent. It’s a wallet-style index, not a historical ledger. If you need the full history (e.g. a watch-only view of all payments a wallet has ever received), run in FULL mode or keep a secondary index.

Config keys, in upstream’s nested TOML format (~/.frigate/config.toml):

[index]
mode = "UTXO_ONLY"     # or "FULL"
utxoMinValue = 1000    # sats, output-level dust filter

Legacy JSON configs (pre-1.4.0) are migrated on first run and the old file is kept as config.bak.

3. Bootstrap from dumptxoutset

f8402cb Add UTXO bootstrap from dumptxoutset for fast initial sync

Naive first-sync means Frigate replays every block from Taproot activation (709 632) to the current tip, scanning each transaction. That’s many hours to days on a regular disk. The fork adds a --bootstrap CLI flag that instead asks Bitcoin Core for the current UTXO set via the dumptxoutset RPC, parses the resulting snapshot, and builds the index from just the outputs that are unspent right now.

New files:

FileRole
bitcoind/UtxoBootstrap.java Orchestrates the bootstrap. Calls the dumptxoutset RPC, hands the resulting file to the parser, then for each surviving entry fetches the full transaction to recover its inputs and compute the SP tweak key.
bitcoind/UtxoSnapshotParser.java Streams Bitcoin Core’s binary UTXO snapshot (magic "utxo", VarInt-encoded entries with compressed amounts & scripts) and yields only P2TR outputs above utxoMinValue.
bitcoind/DumpTxOutSetResult.java DTO for the dumptxoutset RPC response (path, block height, hash, coins count).
bitcoind/ScanTxOutSetResult.java Helper DTO for scantxoutset RPC (used for incremental fallback).

Order of operations (timings in parentheses are what the fork docs project; actual measured times on this box were ~3× faster — see footnote):

  1. bitcoind dumptxoutset ~/.frigate/frigate-bootstrap-utxos.dat (serialized by Bitcoin Core, ~9 GB, fork docs say 5–15 min)
  2. Parser streams the file, filters to P2TR outputs meeting utxoMinValue (~15 sec)
  3. Group surviving outputs by txid, then batch-fetch full transactions via getrawtransaction (parallel stream, 100 txs per batch) to recover their inputs
  4. For each, compute tweak_key and insert into utxo (fork docs: ~90 min on a commodity machine)
  5. Record the snapshot block height in config; the next startup resumes ordinary block-by-block indexing from snapshot_height + 1

Measured on this box (bitcoind 30.2.0, local NVMe, txindex=1, Apple Silicon, 165 213 395 total UTXOs → 6 735 888 P2TR candidates → 5 611 253 unique txs → 3 975 362 with computable tweaks):

Rule of thumb: on a local bitcoind with a warm disk and txindex=1, budget ~30 minutes, not 90.

Prerequisites: txindex=1 in bitcoin.conf (required for step 3’s getrawtransaction), and the bootstrap only makes sense in UTXO_ONLY mode since the snapshot only contains unspent outputs.

Trade-off. Bootstrapping produces an index of the UTXO set at the snapshot height, not a record of every SP transaction that ever happened. Payments that were received and then spent before the snapshot are invisible. This is an explicit acceptance of the UTXO_ONLY semantics.

4. Compressed tweak-key column

Upstream stores tweak_key in a 64-byte raw format that packs x and y little-endian. This is optimal for the DuckDB secp256k1 extension — it skips the point-decompression step that normally precedes every scalar multiply. But it’s terrible for HTTP transport: 64 bytes base64s to 88 chars, and the browser needs the 33-byte compressed form anyway because that’s what tiny-secp256k1 accepts.

The fork stores both: tweak_key stays as 64-byte raw (for any future server-side scan queries), and a new compressed_tweak_key BLOB column holds the 33-byte form. Index.java populates both columns during ingestion via a compressRawKey() helper.

Why this matters for the browser:

The /api/info response exposes a has_compressed_column flag so the browser can pick the right field automatically.

5. HTTP batch API

New file http/HttpApiServer.java.

Upstream Frigate speaks Electrum RPC. The fork adds a plain HTTP server on port 8081, loopback-only, CORS-enabled, serving two endpoints:

GET /api/info

{
  "table": "utxo",
  "total_records": 12021187,
  "batch_size": 20000,
  "key_format": "compressed",
  "key_size": 33,
  "encoding": "base64",
  "mode": "duckdb",
  "has_compressed_column": true
}

Schema advertisement. The browser uses this to decide which fields exist, which key format to expect, and how big the table is (for progress calculations).

GET /api/batch

Parameters:

ParamPurpose
offsetRow offset for pagination
limitPage size (max 20 000)
scan=1Minimal-field mode — returns {k, p, t, o, h, v} instead of the verbose names
start_heightInclusive block-height lower bound
end_heightInclusive block-height upper bound

Response shape (in scan=1 mode, which the browser uses):

{
  "offset": 0,
  "count": 20000,
  "total": 12021187,
  "has_more": true,
  "query_ms": 30.57,
  "records": [
    {
      "k": "AikSKQswQz/2WW4bC0yxsT34gZ5YRoWYR0rc8gFeq2da",  // tweak_key
      "p": "1918350031342774883",                           // prefix (string!)
      "t": "oKbKPek9NVZumRWZ64ZbJ8El9ZIWmf4NUkjZ9t0ZHwQ=",  // txid
      "o": 0,                                               // vout
      "h": 934358,                                          // height
      "v": 1197086                                          // value, sats
    },
    ...
  ]
}

Notes on the encoding:

The HTTP server is started from Frigate.java alongside the Electrum server, so both are available at once and there’s no config knob to disable either.

6. Other changes

Fork-specific additions

Inherited from upstream during the v1.4.1 rebase

Everything listed below is upstream work that the fork now carries because we rebased onto the current v1.4.1 tip rather than staying on an older base:

7. Commit summary

Active on master

CommitSubjectImpact
247eb9d Fix Config recursion when IndexConfig.setLastIndexedBlockHeight fires during load Guards flush() inside the setter against being called while Config.INSTANCE is still null, which happens during initial TOML deserialization. Without the guard, startup logged four 1k-line Jackson stack traces and briefly risked overwriting the config file with blanks.
c59379c Port browser-scanner support (UTXO mode, HTTP API, bootstrap) to v1.4.1 Squashed port of the fork’s four pre-rebase commits (UTXO-only mode, dumptxoutset bootstrap, bootstrap-dir fix + revert, compressed/value columns + HTTP API) replayed on top of upstream v1.4.1’s restructured TOML config and unified ufsecp extension.
e556c0f isolate desktop dependency instantiation to macos (upstream v1.4.1 tip) Everything below this commit is upstream work.

Preserved on backup/pre-rebase

The original five per-feature fork commits are kept on a backup branch (pushed to origin) as backup/pre-rebase and backup/pre-rebase-2026-04-15. Useful if you want to see the per-feature diffs without the v1.4.1 noise:

CommitSubject
4bc319c Add HTTP batch API and compressed/value columns for browser scanners
810aea1 Revert “Fix bootstrap failing when ~/.frigate directory doesn’t exist”
4b43685 Fix bootstrap failing when ~/.frigate directory doesn’t exist
f8402cb Add UTXO bootstrap from dumptxoutset for fast initial sync
c1c0677 Add UTXO-only indexing mode for Silent Payments

For the system-level picture of how these pieces plug into the browser, see System Docs.