Skip to content

esplora: serve the BlindBit oracle v1 API for silent-payment wallets - #1169

Merged
reardencode merged 7 commits into
reardencode:masterfrom
bkeroack:sp/blindbit-v1-routes
Oct 24, 2026
Merged

reardencode merged 7 commits into
reardencode:masterfrom
bkeroack:sp/blindbit-v1-routes

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #1119. With --sp-tweaks, the Esplora listener serves the BlindBit oracle v1 HTTP API under /blindbit/v1/, the API spdk's backends/blindbit-v1 (Dana wallet) calls. Code: crates/rbitcoin-esplora/src/blindbit.rs. No store, schema or flag change.

  • Tweaks (tweaks, tweak-index, utxos) read the existing thin tweak index. dustLimit uses BlindBit's edge (largest P2TR output >= dustLimit), not --sp-tweaks-dust.
  • Filters and spent index are built per request from the block's own txs (Query::block_sp_outpoints): one txout span for the keys, input edges plus the txid sidefile for the spends. No parent tx is read. As a result the spent index hashes every non-coinbase input, not only P2TR prevouts; spdk only tests membership of its own outpoints, so this answers the same. Below the tweak index origin both are empty and read nothing.
  • Where spdk and BlindBit differ, spdk wins: spent-index is always an object, forward-tx returns the txid as a JSON string, and info.network is the Core chain name.
  • Reorgs: each answer is pinned to the header it started from. If that height moves mid-read, the answer is 503 chain view moved.
  • Not ready: info and block-height are 503 until the index passes its origin (not height: 0).
  • Load: one semaphore across the prefix, half the blocking pool. It is taken before a listener slot, and each read holds its permit until it returns, even after a timeout or disconnect.

Interaction with #1180: whichever lands second needs a small rebase so both routers share the listener's GlobalConcurrencyLimitLayer. Until then, taking the BlindBit permit before the listener slot can't be tested, because master's listener limit is per route.

Evidence

  • tests/blindbit_journey.rs covers every route's values: dust edges, cut-through, spent hashes against an independent sha256, both filters decoded with rust-bitcoin BlockFilter::match_any as spdk does, reorg pins, empty and lagging index, below-origin heights, a mempool-rejected forward-tx, the mount gate, and the shared limit. The guards fail named assertions when removed.

  • spdk (c58d7339) scanned a regtest node end to end: filters matched, the spent output was detected, and forward-tx broadcast.

  • Mainnet cost. These numbers come from a synced node on HDD storage, so expect other machines to be faster. Throughput is measured with curl; CPU is the node's utime+stime. The node had no other load.

    Both filters, per block Concurrency Blocks/s Node CPU / block
    earlier head (read parents), 930000–934000 1 / 32 13 / 138 66 / 176 ms
    earlier head (read parents), from 783000 32 80 326 ms
    this head, 930000 / 960000 1 17 / 23 11 / 15 ms
    this head, 783000 / 805000 / 934000 / 955000 32 298 / 401 / 335 / 576 ~10 ms
    spent-index, this head, from 789000 32 675 8 ms
    tweak-index (stored), from 937000 32 1800 1.4 ms
    Both filters below origin, from 600000 32 1228 0.4 ms

    A full scan from Taproot activation (~263k blocks) now costs about 45 CPU-minutes, down from 5–24 CPU-hours.

Merge Danger

Door: two-way

Blast Radius: new routes under /blindbit/v1/, mounted only with --sp-tweaks; existing routes unchanged. rbitcoin-query adds load_thin_tweaks_range_with_header and block_sp_outpoints; load_thin_tweaks_range is unchanged for Electrum. forward-tx admits through the same mempool call as POST /tx.

🤖 Generated with Claude Code

bkeroack and others added 2 commits October 23, 2026 15:06
With --sp-tweaks, the Esplora listener serves the v1 HTTP API that
spdk's BlindBit backend calls (Dana): info, block-height, block-hash,
tweaks and tweak-index with dustLimit, utxos, both BIP158 filters,
spent-index, and forward-tx.

Tweaks and utxos read the thin tweak index; tweaks is cut-through.
dustLimit keeps a tx whose largest P2TR output is at least the limit,
as BlindBit does; it is not --sp-tweaks-dust. The filters and the spent
index come from one index-window read of the block and the parents it
spends, so nothing new is stored.

Where spdk and BlindBit differ, spdk wins: spent-index is always an
object, forward-tx answers the txid as a JSON string, and info.network
is a Core chain name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…asks

Each answer read confirmed[h] once for the hash and again for the data.
A reorg between the two reads could key a filter or salt the spent index
with one block's hash over another block's contents. The header fk from
the first read now travels with the request: the index window is read
only if confirmed[h] still names it, and a thin-index read is checked
after. A moved or disconnected height is 503 chain view moved.

The filter path no longer reads witnesses. It took txids by asking for
the tweak window, which also reads seqsigwit of every P2TR-output tx.
Txids now come from one page-grouped read of only the parents whose
spent output is P2TR.

All /blindbit/v1 routes share one limit of 32 requests in flight. A
spdk scan keeps about 200 filter asks open; each reads a block and its
parents. axum layers each route separately, so this is one global
semaphore rather than a per-route limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

REQUEST_CHANGES. I would not merge c7798301 as-is. The BlindBit v1 shapes spdk parses match the upstream client and the v1 oracle on the cases checked below, and /blindbit/v1 stays unmounted unless --sp-tweaks was on when the listener bound. Two problems are enough to hold it: filter and spent-index builds are an uncached parent-window read on the public listener, and GET /info reports height 0 when the index has no served height.

High

Per-request filter and spent-index reads will not survive the client this API is for. block_sp_elements calls index_heights(h, h, Some(h)) and read_index_window. That loads the block's outputs and input edges, every parent txout the block spends, and, because the tweaks flag is set, seqsigwit for every transaction with a P2TR output. The spent hash only needs the parent txid; the witness bytes are unused. The comment that this is bounded by the block is wrong: parent txout bodies live in earlier blocks. One spending block can pull output records from many prior transactions, each up to the 64 MiB body-read cap.

spdk's v1 backend runs buffered(200) (CONCURRENT_FILTER_REQUESTS) and, per height, requests tweaks then /filter/new-utxos then /filter/spent. Those two filter routes each do a full window read. Esplora's default cap is 256 connections, and the request timeout is the 120s idle timeout. spawn_join uses spawn_blocking. When the timeout drops the handler, dropping the JoinHandle detaches that task, so the concurrency slot frees while the read keeps going. A Dana scan, or anyone else holding ~200 of these open, is a sustained multi-gigabyte disk and RAM load. Nothing is cached. served_block only rejects h > served tip, so /filter/* and /spent-index also run this for every height from 0 up to the index tip, including pre-Taproot blocks that thin_rows correctly 404s.

Medium

GET /info advertises genesis when the index cannot serve. served_height is None while the table is empty or still below sptweaks_origin (mainnet origin is Taproot activation, and backfill lags the tip). info then sends "height": 0. block-height returns 503 in that same state. spdk deserializes height as bitcoin::absolute::Height, and Height::from_consensus(0) is Height::ZERO, so the client accepts genesis as the indexed tip. A real indexed genesis (regtest, origin 0, next height 1) is indistinguishable from not ready.

Low

store_error turns every QueryError into 500 and e.to_string(). The rest of Esplora maps Stale, Rejected (including body read too large), and Pruned to 503 or 404. A parent span that trips the body cap, or a disconnect racing index_heights, becomes a 500 with the internal message.

Nits

  • The journey covers the happy path (dust >= boundary, cut-through vs full index, empty spent-index object, filter membership, flag off, one accepted forward-tx). It does not cover an empty index, a height below origin, the index lagging the tip, a reorg, or a broadcast the mempool must reject.
  • max_eligible: usize::MAX does not change single-height behavior: load_thin_tweaks_range always keeps the first height even when the default cap is set.

Checked, and sound

No Cargo.toml or Cargo.lock change. Routes are merged only when sptweaks_enabled() is already true at run_esplora; startup index mode runs before the listener binds. The off test gets 404 on /blindbit/v1/info and 200 on /blocks/tip/height.

Wire format, checked against setavenger/blindbit-oracle branch v1 (cfilter.go, spentutxos.go, api.go) and cygnet3/spdk backends/blindbit-v1:

  • dustLimit keeps a row when the largest P2TR value is >= the limit. Cut-through (/tweaks) uses outputs still unspent at the tip; /tweak-index uses every P2TR output. That matches BlindBit's HighestValue >= dustLimit and its mutable cut-through index. Disconnect truncates the tweak table through the new tip, so a replaced height is not served until it is rewritten.
  • Spent hash is sha256(internal txid || vout_le || internal spending-block hash)[..8], the same layout as spdk input_hashes_map and BlindBit SerialiseToOutpoint plus the reversed block hash. Empty spent-index is an object, which is what spdk can parse.
  • Filters are BIP158 GCS P=19, M=784931, keyed by the internal block hash the same way encode_basic_filter and btcsuite DeriveKey do, filter_type 4. New-utxos elements are the x-only key of every P2TR output, eligible or not. GcsFilterWriter dedupes via BTreeSet; N is inside the filter, so a repeated key still matches. is_p2tr requires a 34-byte script before the [2..] copy.
  • network is bitcoin::Network::to_core_arg() (main / test / signet / regtest), which is what Network::from_core_arg accepts. The test only checks regtest.
  • POST /forward-tx is the same admit path as POST /tx: accept_tx_async to accept_tx_from(..., None), waive is false, then mark_local_origin. It does not skip standardness, fee, or RBF. The existing 1 MiB RequestBodyLimitLayer caps the body. The success body is a JSON txid string, which spdk parses as Txid. A policy failure is 400 {"error"}, which fails that parse. This is not a relay bypass.

Build and tests

Detached worktree at c7798301d845be839836344ae1a6a04cd117ffc8, toolchain 1.95.0.

  • cargo fmt -p rbitcoin-esplora -- --check: exit 0
  • cargo clippy -p rbitcoin-esplora --all-targets -- -D warnings: exit 0, Finished dev in 16.22s, no warnings
  • cargo test -p rbitcoin-esplora --test suite -- --quiet: exit 0, 36 passed, 0 failed, 0.85s

Those 36 are the whole esplora suite, including both new tests (blindbit_v1_routes_serve_the_thin_tweak_index, blindbit_v1_routes_need_the_tweak_index) via include! in tests/server.rs.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
Json(json!({
// `spdk` parses this with `Network::from_core_arg`.
"network": st.network.to_core_arg(),
"height": served_height(&st.query).unwrap_or(0),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unwrap_or(0) makes "index not ready" look like genesis. served_height is None until next_height is past sptweaks_origin (mainnet: Taproot activation, and backfill lags). spdk deserializes InfoResponse.height as bitcoin::absolute::Height, and Height::from_consensus(0) succeeds (Height::ZERO), so a client treats 0 as the indexed tip. block_height already returns 503 in this state. Return that same unavailable status from /info, and add a test where tweaks are enabled, origin is above 0, and nothing has been indexed yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

#[allow(clippy::result_large_err)] // axum Response, as in handlers
fn served_block(query: &Query, raw: &str) -> Result<(u32, [u8; 32], u32), Response> {
let h = parse_height(raw)?;
if served_height(query).is_none_or(|top| h > top) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only rejects h > served tip. Heights below the tweak origin still pass, and header_at_height succeeds for them. thin_rows then 404s, but /filter/new-utxos, /filter/spent, and /spent-index go on to block_sp_elements. On a synced node that is every block from genesis through the index tip, not the Taproot-onward range COMPAT.md describes. Reject h < sptweaks_origin here with the same 404 as an unindexed height.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
h: u32,
block_hash: &[u8; 32],
) -> Result<BlockSpElements, QueryError> {
let heights = query.index_heights(h, h, Some(h))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some(h) sets IndexHeight.tweaks, so each call reads the block, every parent txout it spends, and seqsigwit of every P2TR-output tx. The filter and spent-index only need output scripts and parent txids; the witness bytes are unused. Parent txout bodies are not bounded by this block. spdk issues these two filter routes with buffered(200), and the listener default is 256 in-flight requests with a 120s timeout. spawn_join detaches the spawn_blocking task when that timeout fires, so the concurrency slot frees while the read continues. There is no cache. This needs a stored or memoized result, or a small semaphore that actually cancels the blocking read, before it is safe on the public listener. A test that only checks match_any on a 2-tx chain does not cover this cost.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

REQUEST_CHANGES. Head 3fd53615a1e33da6fdeb1a72cd208cd6fa4c4e96 (merge base cc070629f1fc7856c811ae6e0bd0cd43dbcc32ff). The follow-up from c7798301 drops the witness reads and pins a filter answer to one header. The parent-txout read and the info height sentinel are still there.

Prior findings

HIGH — not fixed. block_sp_elements still calls index_heights + read_index_window for /filter/new-utxos, /filter/spent, and /spent-index. read_parents still loads every parent txout the block spends, uncached, per request. /filter/new-utxos never reads spent, so it pays for those parents anyway. served_block admits every h <= min(tip, index top). Origin is taproot activation, so pre-taproot heights take the full read. The "bounded by the block" comment is still in the source.

Witnesses are actually gone: index_heights(..., None) keeps tweaks false, so the window does not read seqsigwit, and txids are one page-grouped txids_get_many of only the P2TR parents (order preserved, duplicates included).

The 32-wide GlobalConcurrencyLimitLayer sits on the nested router, inside TimeoutLayer (default 120s). Handlers spawn_join into spawn_blocking. On timeout the permit drops with the handler and the JoinHandle detaches, so read_index_window continues and the next request starts another. The process blocking pool is available_parallelism (minimum 4); 32 of these reads fill it. The new test holds incomplete forward-tx bodies and probes /blocks/tip/height, which does not use spawn_join.

MEDIUM — not fixed. GET /info still sends "height": 0 when served_height is None (empty index, or next <= sptweaks_origin, including backfill that has not passed taproot). GET /block-height returns 503 no height indexed in that state. spdk treats info.height as a block height, so 0 is genesis. No test.

LOW — partial. Stale is 503, and the reorg path uses it. Rejected (body read too large from ReadJob in the window read) and Pruned still become 500 via e.to_string(). store_err on the rest of the listener maps those to 503 and 404. Keep the JSON {"error"} body and use the same arms.

Nits — partial. In-process reorg of a captured ServedBlock is tested (disconnect, and a replacement block) and returns chain view moved. Still missing: empty index, height below origin, tweak index lagging the tip, and a mempool-rejected forward-tx. The inflight test does not run a filter read.

Nit on the new pin: thin_rows loads by height, then still_confirmed. A height that moves away and back between those calls can return the other block's rows. utxos does this twice. The filter path captures header_fk in index_heights and reads that snapshot, which holds. The stay-moved case is what the test locks.

Checked, no separate finding

txids_get_many zips in request order. is_p2tr requires 34 bytes before the key copy. The GCS writer dedups through a BTreeSet. forward-tx uses the same mempool admit as POST /tx and returns the txid as a JSON string. No new crate, no schema change.

Built and tested

cargo fmt --all -- --check passed. cargo clippy -p rbitcoin-esplora --all-targets -- -D warnings passed. cargo test -p rbitcoin-esplora --test suite -- --quiet: 37 passed, 0 failed.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
if heights.first().map(|x| x.header_fk) != Some(block.header_fk) {
return Err(StoreError::Stale(CHAIN_MOVED));
}
let window = query.read_index_window(&heights)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read_index_window is still the body of /filter/new-utxos, /filter/spent, and /spent-index. read_parents loads every parent txout the block spends, uncached, on every request. /filter/new-utxos only uses new_keys from this block's own outputs, so that route still pays for the parent read.

served_block (line 148) rejects only h > served_height, and served_height is min(tip, index top). Origin is taproot activation, so a pre-taproot height is served and takes this full read even though the block cannot contain a P2TR output or a P2TR spend. The comment above still calls that bounded by the block. The parent set is one txout-body read per spent create, anywhere in the chain, repeated per request.

Dropping seqsigwit via index_heights(..., None) and limiting txids_get_many to P2TR parents is the right cut. It leaves the parent-txout read in place. A cap has to be held until that read returns (see the layer on line 54); rejecting h < sptweaks_origin before index_heights removes the pre-taproot case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
.route("/filter/new-utxos/{height}", get(filter_new_utxos))
.route("/filter/spent/{height}", get(filter_spent))
.route("/forward-tx", post(forward_tx))
.layer(GlobalConcurrencyLimitLayer::new(BLINDBIT_MAX_INFLIGHT)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This semaphore is inside TimeoutLayer in run_esplora (outer → inner: connection cap, body limit, timeout, then the router). The default timeout is idle_timeout, 120s. Filter and spent-index handlers call spawn_join, which is spawn_blocking. When the timeout drops the handler future, this permit is released and dropping the JoinHandle detaches the blocking task, so read_index_window runs to completion. The next request takes the freed permit and starts another read. Thirty-two of those already fill the process blocking pool (available_parallelism, minimum 4), which the other spawn_join routes share.

blindbit_v1_routes_share_one_inflight_limit holds incomplete forward-tx bodies (the permit is taken in the extractor, before spawn_blocking) and then requests /blocks/tip/height, which reads the tip on the async worker. That shows the HTTP semaphore is shared across the prefix. It does not show a timed-out filter read keeping its slot until the window read returns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
Json(json!({
// `spdk` parses this with `Network::from_core_arg`.
"network": st.network.to_core_arg(),
"height": served_height(&st.query).unwrap_or(0),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

served_height is None while the tweak index is empty or next <= sptweaks_origin (backfill that has not passed taproot activation). This sends "height": 0. block_height returns 503 no height indexed for the same state. spdk parses info.height as a block height, so 0 is indistinguishable from a real genesis. Return 503 here as well (or omit the field) until served_height is Some. The suite never starts the listener in that state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

fn store_error(e: QueryError) -> Response {
match e {
StoreError::Stale(m) => error(StatusCode::SERVICE_UNAVAILABLE, m),
e => error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale is 503, which is what the reorg path needs. This wildcard still turns every other QueryError into 500 with e.to_string(). read_index_window can return Rejected("body read too large") from ReadJob. The listener's store_err maps Rejected to 503 and Pruned to 404 (and NotFound to 404). Same status codes, with this JSON {"error"} body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(waited, "a full BlindBit limit makes the next ask wait");
let (st, _) = tokio::time::timeout(quick, http_get(addr, "/blocks/tip/height"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: /blocks/tip/height reads query.tip_height() on the async worker and does not call spawn_join. It stays fast while BlindBit permits are held in the body extractor, and it would also stay fast if those permits were inside read_index_window on the blocking pool. The assertion does not show the rest of the listener is clear of the filter read.

Same gap as the previous nits: nothing covers an empty tweak index (info vs block-height), a height below sptweaks_origin, the index lagging the tip, or a consensus-valid forward-tx the mempool rejects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

@rearden-grok

rearden-grok Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Follow-up: BlindBit filter routes vs Electrum tweak serving, and a fix path without a store change

Two follow-ups to the review above: why the filter and spent routes are heavier than the existing Electrum silent-payment tweak serving, and what a fix looks like given that a store or schema change is off the table.

Comparison with Electrum tweak serving

The High finding stands for the three filter and spent routes, and it is worse than Electrum tweak serving. /tweaks itself is the same precomputed table Electrum already serves.

Electrum blockchain.tweaks.subscribe BlindBit /tweaks, /tweak-index, /utxos BlindBit /filter/new-utxos, /filter/spent, /spent-index
Source Thin tweak table, written when the index builder already ran read_index_window once (crates/rbitcoin-query/src/index_writebehind.rs:272) Same table via load_thin_tweaks_range (crates/rbitcoin-esplora/src/blindbit.rs:185) Not stored. Each request calls index_heights + read_index_window (blindbit.rs:329 and blindbit.rs:340)
What one request reads Indexed: one sequential txout span of eligible txs, no parent txouts (crates/rbitcoin-query/src/sp_tweaks.rs:403). Pre-taproot: empty JSON, no store (crates/rbitcoin-electrum/src/tweaks.rs:98, tweaks.rs:251) Same span, one height, max_eligible: usize::MAX (blindbit.rs:180) The block's outputs and edges, plus every parent txout that block spends. /filter/new-utxos throws the parent result away. The two filters and spent-index each do this read
Caps 128 heights and 16,384 eligible txs per wave (sp_tweaks.rs:32). One socket reads one line, then one blocking wave, and the next wave waits on that socket (server.rs:581, server.rs:1186, server.rs:1363). Pre-taproot collapses 1,024 empty heights into one notify (tweaks.rs:220) 32 in-flight HTTP requests for the whole prefix (blindbit.rs:37). No pre-taproot short-circuit; below origin is a 404 after the height check Same 32, but the permit sits inside the 120s timeout, so a timed-out spawn_blocking keeps reading after the slot is free
Hole / other method A missing thin row falls back to tweaks_for_height, which does read parents, and only for P2TR-output txs (sp_read.rs:56, sp_tweaks_uring.rs:172). Frigate blockchain.silentpayments.subscribe always uses that fallback, limited to the last 256 blocks, 256 heights per chunk, 3 scans process-wide (silent_scan.rs:16, silent_scan.rs:107, server.rs:1122) No parent read Heavier than that fallback: every spent parent, every height through the tip including pre-taproot, uncached

spdk keeps about 200 HTTP calls open and asks for both filters per height, so one rescan is hundreds of full window reads. Cake and kiss-bdk sit on one Electrum socket; the server will not start the next wave until it has written the last one, and pre-taproot costs nothing.

/tweaks is fine. The High applies to the three routes that rebuild filters from read_index_window. Reject h < sptweaks_origin before any window read so pre-taproot stays the empty Electrum answer. (Storing filters at index time would be the obvious fix, but a store change is off the table; see below.)

Fix path without a store or schema change

Constraint from the maintainer: no store or schema change, so precomputing filters, keys, or spent hashes at index time is out. Within that:

The filter routes can be served without a schema change. The current read_index_window path is what is expensive, and spdk will not scan if those routes are missing.

spdk's scan (backends/blindbit-v1/src/backend.rs) runs buffered(200) height tasks. Each task calls tweaks or tweak-index, then /filter/new-utxos, then /filter/spent. spent-index and utxos run only after a filter hit (scanners/local/src/scanner.rs around the check_match_inputs / check_match_outputs branches). A tweaks-only API fails that scan.

  • /tweaks, /tweak-index, /utxos. Already the thin table, same read as Electrum. Leave them.
  • /filter/new-utxos. No parent read. The thin rows are only eligible txs, and spdk's candidates are derived from those tweaks, so the x-only keys on a full (not cut-through) load_thin_tweaks_range are enough for spdk. BlindBit's filter also contains ineligible P2TR outputs. Those are this block's own outputs: body_ranges plus one with_body_span and packed_p2tr_from_raw (crates/rbitcoin-store/src/tx_table/mod.rs:1615 and :1636 and :1650). One sequential span, no parents.
  • /filter/spent and /spent-index. The hash is sha256(prev_txid ‖ vout ‖ spending_block_hash)[..8] (blindbit.rs:90). spdk builds the same hash for outpoints it already owns and tests membership (backends/blindbit-v1/src/utils.rs, structs.rs check_match_inputs). Those outpoints are P2TR, but the input edge does not say so: InputEdge is only parent fk and vout (crates/rbitcoin-store/src/input.rs:29). The script is only in the parent txout. Classifying P2TR without that body is not possible here. Hashing every spent outpoint is possible: input_edges_span (store.rs:834) plus txids_get_many (store.rs:787). That is a superset of BlindBit's P2TR-only set. spdk still matches, and a GCS false-positive rate stays about 1/M. An unstamped create has no edge and would fall back to seqsigwit; stamped input.body is the normal case.
  • Below the tweak origin. Empty filters and an empty spent index, no IO. No silent-payment output exists before taproot, so nothing spdk can own is created or spent there.
  • An LRU, a permit held until the read ends, and a lower concurrency cap only bound the current window read. A cold rescan still reads every parent txout, twice per height. They are not a substitute.

Recommendation: (a) fixable without a store change. Stop calling read_index_window on these routes. Build new-utxos from this block's txout span (or from the thin rows if a BlindBit-exact filter is not required). Build the spent filter and spent index from input edges and the txid sidefile, including every spend. Return empty below sptweaks_origin. The High stands on this PR while the window read is still there. It is not a reason to drop the API.

…ed under

thin_rows checked confirmed[h] after the read, which an A -> B -> A move
between the plan and the check would pass. The plan reads confirmed[h]
and the tweak row together under the sp_tweaks lock, so return that fk
from the loader and compare it with the pinned block. An unindexed height
whose block left the chain is now a moved view (503), not 404.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

REQUEST_CHANGES. Head b4369771acfc1f56366ad6939b037a5a63d945be (merge base cc070629f1fc7856c811ae6e0bd0cd43dbcc32ff). The two commits since 3fd53615 pin thin tweak rows to the header they were planned under, and the new COMPAT sentences match the code below sptweaks_origin once a height is served. The parent-txout read and the info height sentinel are still in the handler.

Prior findings

HIGH — still open. block_sp_elements still calls index_heights and read_index_window for /filter/new-utxos, /filter/spent, and /spent-index. read_parents still loads every parent txout the block spends, uncached, on every request. /filter/new-utxos builds its key list from that same window and leaves spent unused. served_block admits every h <= min(tip, index top). After the index passes the origin, pre-taproot heights take the full read. The comment "Bounded by the block, not the chain" is still on the function. Parent txout bodies live in earlier blocks, each read capped at 64 MiB. COMPAT now says block-hash, filter/*, and spent-index answer those heights. That matches the code and leaves the read in place.

The 32-wide GlobalConcurrencyLimitLayer sits on the nested router, inside TimeoutLayer (120s idle) and inside client-disconnect cancellation. Handlers spawn_join into spawn_blocking. Dropping the JoinHandle detaches the task, so the permit returns while read_index_window keeps running. The node runtime caps that pool at available_parallelism (minimum 4) in blocking_pool_size. The inflight test still holds incomplete forward-tx bodies and probes /info plus tweak-index/0. It never starts a filter read.

MEDIUM — still open. GET /info sends "height": 0 when served_height returns None: the tweak table is empty, or sptweaks_next_height <= sptweaks_origin (backfill that has not passed taproot). GET /block-height returns 503 no height indexed in that same state. spdk reads info.height as a block height, so 0 is genesis. A real indexed genesis (regtest, origin 0, next height 1) is the same JSON. The new COMPAT /info row says height is the served height. No test.

LOW — still open. Stale is 503, and the reorg path uses it. Rejected (body read too large from ReadJob in the window read) and Pruned still become 500 via e.to_string(). store_err on the rest of the listener maps those to 503 and 404.

Nits — partly addressed. The new assertion locks thin_rows at 503 after disconnect_tip while the height is unindexed. That is the empty-plan arm plus still_confirmed. Still missing: an empty index, a height below origin, the tweak index lagging the tip, and a mempool-rejected forward-tx. The inflight test does not run a filter read. The steady-state replacement block was already 503 before this pin, because still_confirmed failed after the load.

What the new commits do

f572fd57 adds header_fk to HeightPlan. thin_tweak_height_plans reads confirmed[h] and get_eligible_range in one sp_tweaks critical section. thin_rows returns those rows only when that fk is the caller's ServedBlock. The txout join runs after the lock, keyed by header_txs of that fk. Disconnect leaves Class A fks and header_txs in place, so an A → B → A move cannot attach the other block's rows to the old fk. The new test does not interleave that move. The structure does.

Below the origin, locate returns None for height < origin, the plan is empty, and thin_rows answers 404 when the served header is still there. That is what COMPAT says for tweaks, tweak-index, and utxos. block-hash, filter/*, and spent-index go through served_block only, so they answer any height up to the served height, including below the origin, and the filter routes do the window read. Until next > origin, served_height is None and every height route is 404. COMPAT's "up to the served height" matches that only once a served height exists.

On the matching-fk arm, thin_rows returns without still_confirmed. A reorg that commits during the txout join is a 200 of the planned block. A check on that arm is safe now that the rows are bound to fk, and it would answer 503. The comment that a later confirmed[h] check would accept A → B → A describes the old load-by-height gap. This load does not re-read the tweak table by height.

No Cargo.toml or schema change. load_thin_tweaks_range keeps its old return type.

Built and tested

Worktree at b4369771acfc1f56366ad6939b037a5a63d945be, toolchain 1.95.0.

  • cargo fmt --all -- --check: exit 0
  • cargo clippy -p rbitcoin-esplora --all-targets -- -D warnings: exit 0
  • cargo clippy -p rbitcoin-query --all-targets -- -D warnings: exit 0
  • cargo test -p rbitcoin-esplora --test suite -- --quiet: exit 0, 37 passed, 0 failed, 0.96s

Those 37 are the esplora suite, including blindbit_v1_routes_serve_the_thin_tweak_index (with the new 503 assertion), blindbit_v1_routes_need_the_tweak_index, and blindbit_v1_routes_share_one_inflight_limit.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
if heights.first().map(|x| x.header_fk) != Some(block.header_fk) {
return Err(StoreError::Stale(CHAIN_MOVED));
}
let window = query.read_index_window(&heights)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH. This is still index_heights plus read_index_window for /filter/new-utxos, /filter/spent, and /spent-index. read_parents loads every parent txout the block spends, uncached, per request. /filter/new-utxos never uses spent and still pays for those parents. served_block allows every h <= min(tip, index top), so once the index has passed sptweaks_origin a pre-taproot height takes the same read. The "Bounded by the block, not the chain" comment above is wrong: parent txout bodies are earlier blocks, each capped at 64 MiB inside ReadJob. COMPAT now says these routes answer below the origin, which matches this function and leaves the read in place. Stop calling read_index_window here: new-utxos from this block's txout span, spent filter and spent index from input edges plus txids_get_many, and an empty answer below sptweaks_origin with no IO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
.route("/filter/new-utxos/{height}", get(filter_new_utxos))
.route("/filter/spent/{height}", get(filter_spent))
.route("/forward-tx", post(forward_tx))
.layer(GlobalConcurrencyLimitLayer::new(BLINDBIT_MAX_INFLIGHT)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH. This semaphore is inside TimeoutLayer (120s) and inside client-disconnect cancellation. The handlers spawn_join into spawn_blocking, and dropping that JoinHandle detaches the task, so the permit comes back while read_index_window is still on the blocking pool. The node runtime sizes that pool with blocking_pool_size (available_parallelism, minimum 4). Hold the permit until the read returns. blindbit_v1_routes_share_one_inflight_limit fills the limit with incomplete forward-tx bodies and never runs a filter read, so it does not lock this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
Json(json!({
// `spdk` parses this with `Network::from_core_arg`.
"network": st.network.to_core_arg(),
"height": served_height(&st.query).unwrap_or(0),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM. served_height is None when the tweak index is empty or next <= sptweaks_origin, and this sends "height": 0. block_height returns 503 no height indexed in that state. spdk accepts 0 as genesis, and a real indexed genesis is the same JSON. COMPAT's /info row says height is the served height. Use the same 503 as block-height when there is no served height, and add a test for the empty index and for a next height still at the origin.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

fn store_error(e: QueryError) -> Response {
match e {
StoreError::Stale(m) => error(StatusCode::SERVICE_UNAVAILABLE, m),
e => error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW. Stale is 503. Rejected (body read too large from the window ReadJob) and Pruned still fall through to 500 and e.to_string(). store_err maps those to 503 and 404. Keep the {"error"} body and use the same arms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
// Checking `confirmed[h]` again afterwards would pass an A -> B -> A move.
match query.load_thin_tweaks_range_with_header(Height(block.height), limits) {
Ok(mut batch) => match batch.pop() {
Some((_, fk, rows)) if fk == block.header_fk => Ok(rows),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. This arm returns the planned block's rows with no still_confirmed after the txout join. A reorg that commits during that join is a 200 for a height that has moved. The rows are already bound to fk (eligible tweaks captured with confirmed[h] under the sp_tweaks lock, txouts joined from that header's first_id), so a still_confirmed here would 503 and would not bring back the old A → B → A mix. The comment above describes that old load-by-height gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

matches!(block_sp_elements(&q, &served), Err(rbitcoin_store::StoreError::Stale(m)) if m == CHAIN_MOVED),
"a disconnected height is a moved view, not a 500"
);
let status = thin_rows(&q, &served, false).err().map(|r| r.status());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. This locks the empty-plan arm: after disconnect_tip, thin_rows is 503 rather than 404. It does not cover an empty index, a height below sptweaks_origin, the tweak index lagging the tip, or a mempool-rejected forward-tx. The replacement-block 503 further down was already true before the header pin, because still_confirmed failed on the other block. Nothing here interleaves A → B → A, and the inflight test still never reads a filter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in fdcf3d0.

…ore a listener slot

Per review on reardencode#1169:
- /filter/* and /spent-index no longer call read_index_window. Keys come
  from one txout span of the block, spends from input edges plus the txid
  sidefile (Query::block_sp_outpoints). No parent txout is read, so every
  spent outpoint is hashed, a superset of BlindBit's P2TR-only set that spdk
  matches the same way. Below the tweak origin both are empty with no IO.
- The BlindBit permit is a middleware outside the listener layers, so asks
  queued on it hold no listener slot. Handlers move it into the blocking
  read (forward-tx holds it across the body), so a timeout or disconnect no
  longer frees it while the read runs. Default limit is half the blocking
  pool; EsploraConfig::blindbit_max_inflight sets it.
- /info is 503 with no served height, not height 0.
- Store errors use the listener's statuses with the {"error"} body.
- thin_rows checks confirmed[h] after the txout join on the matching arm.
- Tests: empty index, below origin, index lagging the tip, a mempool-rejected
  forward-tx, and asks queued on BlindBit leaving a listener slot free.
@bkeroack

Copy link
Copy Markdown
Contributor Author

Addressed in fdcf3d0:

  • High (parent reads): /filter/* and /spent-index no longer call read_index_window. They read one txout span of the block, plus input edges and txids_get_many (Query::block_sp_outpoints). The spent set is every non-coinbase input, a superset of BlindBit's set that spdk matches the same way. Below sptweaks_origin both are empty with no IO. The mainnet cost dropped from 66–326 to about 10 ms CPU per block (table in the description).
  • High (permit): the BlindBit permit is now a middleware outside the listener layers, so asks queued on it hold no listener slot. Handlers move it into the blocking read, and forward-tx holds it across the body, so a timeout or disconnect doesn't free it early. The limit is half the blocking pool, not 32.
  • Medium: /info is 503 with no served height.
  • Low: store_error uses store_err's statuses with the {"error"} body.
  • Nits: thin_rows checks still_confirmed after the join. New tests cover an empty index, below origin, an index lagging the tip, and a mempool-rejected forward-tx. The limit test now proves queued asks leave a listener slot free. Permit-before-listener ordering can only be tested once esplora: one request limit for the whole listener, and --esplora-max-inflight #1180 lands, because master's listener limit is per route.

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REQUEST_CHANGES.

  1. Fixed. /filter/* and /spent-index build from Query::block_sp_outpoints and return empty below sptweaks_origin with no read.
  2. Still open for forward_tx. The other routes park the permit inside held.
  3. Fixed. /info is 503 when served_height is None.
  4. Fixed. store_error maps NotFound and Pruned to 404, and Stale and Rejected to 503.
  5. Fixed. thin_rows calls still_confirmed on the matching header after the join, and block_sp_elements calls it after the read.

1 bug, 1 nit.

Comment thread crates/rbitcoin-esplora/src/blindbit.rs Outdated
Ok(t) => t,
Err(_) => return error(StatusCode::BAD_REQUEST, "invalid tx"),
};
match mp.accept_tx_async(tx).await {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forward_tx awaits accept_tx_async, which spawn_blockings the admit and waits on the JoinHandle. Timeout and client disconnect drop the handler, so _permit drops with it and the detached admit keeps running with the semaphore already free. The next post takes that permit and starts another admit. Call synchronous accept_tx_from inside held. Awaiting accept_tx_async from held would sit one blocking thread on another. The inflight test never gets here; those posts stall in the body extractor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9d19e19: the synchronous admit runs inside held. New test blindbit_forward_tx_holds_its_permit_through_the_admit stalls the admit on the pool lock and fails without the fix.

assert_eq!(queued.len(), 3, "a full BlindBit limit makes the next asks wait");
let (st, _) = tokio::time::timeout(
Duration::from_millis(1000),
http_get(addr, "/blocks/tip/height"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. /blocks/tip/height is on the main router, whose ConcurrencyLimitLayer this test leaves empty. A 200 does not show that a BlindBit request waiting on the semaphore holds no listener slot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. With master's per-route listener limit this check can't fail. adff4c2 says so in the test; it becomes a real check once #1180's listener-wide limit lands.

bkeroack and others added 2 commits October 24, 2026 13:17
The admit ran in accept_tx_async's own spawn_blocking, so a timeout or
disconnect that dropped the handler freed the permit while the admit
kept running. Run the synchronous admit inside held instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE.

  1. Fixed. forward_tx runs accept_tx_from inside held, so a dropped handler leaves the permit with the admit.
  2. Fixed. The tip-height check says it passes either way while each route has its own listener limit.

0 bugs.

@reardencode
reardencode merged commit 57974e2 into reardencode:master Oct 24, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

silent payment tweaks over HTTP

2 participants