Skip to content

Esplora: page /txs with after_txid, 404 past a block's last page, omit empty witness and unknown fee targets - #943

Merged
bkeroack merged 2 commits into
release/0.6from
fix/esplora-paging-json
Oct 8, 2026
Merged

bkeroack merged 2 commits into
release/0.6from
fix/esplora-paging-json

Conversation

@bkeroack

@bkeroack bkeroack commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #938 (merge that first).

Problem

Four places where satd's Esplora REST surface answered differently from upstream Esplora (Blockstream's electrs and mempool.space's electrs, src/rest.rs):

  1. ?after_txid= was ignored. /address/:addr/txs and /scripthash/:hash/txs took no query string (esplora-handlers/src/handlers/address.rs:85-91, 136-142), so ?after_txid=<txid> returned the first page every time. mempool.space's frontend and backend page an address with that parameter ("load more"), so a client paging this way against satd got page 1 forever. mempool.space's electrs continues after the cursor and answers 422 after_txid not found for a cursor it cannot find (rest.rs, the address/:addr/txs route, find_txid / confirmed_after_txid). The path form /txs/chain/:last_seen_txid (what BDK uses) was already right.
  2. /block/:hash/txs/:start_index past the end returned [] (handlers/block.rs:207-209; the comment claimed upstream does this, it does not) and accepted any start. mempool.space answers 404 start index out of range at or past the end and 400 start index must be a multiple of 25 for an unaligned start; blockstream.info answers 400 for both. Upstream's API.md: "The start_index value must be a multiple of 25."
  3. "witness": [] on every legacy input (handlers/tx.rs:198, 371). Both upstreams make witness an Option set only for a non-empty witness, so a non-segwit input has no witness key.
  4. /fee-estimates invented 1 sat/vB with no fee signal. With an empty mempool and no confirmed-block fee history (IBD, a cold start) the blend resolver falls back to the relay floor with Confidence::Low (node/src/mempool/estimate.rs:606-610), and the handler reported that floor for all 28 targets (handlers/mempool.rs:177-192). Upstream builds the map from estimatesmartfee and leaves out every target bitcoind cannot estimate (daemon.rs estimatesmartfee_batch), so with no estimate the answer is {}.

Fix

  • /address/:addr/txs and /scripthash/:hash/txs take ?after_txid=. A confirmed cursor gives the next 25 confirmed transactions (the same page as /txs/chain/:cursor); a mempool cursor gives the mempool transactions after it, then the first 25 confirmed; a cursor in neither list is 422 after_txid not found. An empty value is the first page and a malformed one is 400, like a malformed path cursor. The cursor is looked up in this script's own history only.
  • To make a mempool cursor stable, a script's mempool transactions are now listed in the order this node admitted them (oldest first, then by txid), which is the order upstream keeps them in. Before, they came in HashSet iteration order, which can change between two requests. This also applies to /txs/mempool.
  • /block/:hash/txs/:start_index: 404 start index out of range at or past the end (checked first), 400 start index must be a multiple of 25 otherwise, as mempool.space.
  • vin[].witness is skipped when empty.
  • /fee-estimates leaves out every Confidence::Low target (in blend mode, exactly the targets that fell back to the floor), and answers {} when none is left. Without fee history those are the deepest targets, so the monotone clamp never carried a floor into a target that is kept. Values for the targets that remain are unchanged and still agree with estimatefees.
  • New error variants: NotFoundReason (404 with upstream's text) and Unprocessable (422).
  • Manual (docs/manual/src/esplora.md): the block-page, /txs, /txs/mempool and /fee-estimates rows, and the wire-shape gotchas, now say what satd does. The old text documented the [] page, the HashSet order and the 1 sat/vB floor.

Tests

Unit tests (new files next to the handlers):

  • handlers::block::esplorapaging_tests::block_page_bounds_past_the_end_is_404_start_index_out_of_range, block_page_bounds_unaligned_start_is_400, plus the must-serve block_page_bounds_serves_aligned_starts_inside_the_block.
  • handlers::tx::esplorapaging_tests::vin_without_a_witness_has_no_witness_key, plus vin_with_a_witness_lists_it.
  • handlers::mempool::esplorapaging_tests::fee_estimates_map_is_empty_without_a_fee_signal (empty mempool, fresh estimator), plus fee_estimates_map_lists_every_target_with_an_estimate (seeded history: all 28 targets).
  • handlers::address::esplorapaging_tests::*: cursor location (confirmed, mempool, in both → confirmed, neither → 422), after_txid parsing (absent, empty, txid, malformed → 400), admission-order sort, a just-confirmed transaction listed once.
  • mempool::pool::esplorapaging_tests::admission_times_reports_each_pooled_txid_and_skips_the_rest.

E2E (satd/tests/e2e/esplora_paging.rs, --features e2e):

  • test_e2e_esplora_txs_after_txid_continues_the_history: 30 confirmed txs, page 1 = heights 30..6, ?after_txid= = heights 5..1 and equals /txs/chain/:cursor and the scripthash form, then []; an empty cursor is page 1; a real txid outside the history and the zero txid are 422; a malformed cursor is 400; two mempool-only txs page one after the other.
  • test_e2e_esplora_block_txs_start_index_rules: a two-tx block serves /txs and /txs/0; /txs/1 is 400, /txs/25 and /txs/usize::MAX are 404, /txs/1 of a one-tx block is 404.
  • test_e2e_esplora_vin_witness_left_out_when_empty: the genesis coinbase input has no witness key; a P2WPKH spend lists its two items.

Edited in place (they pinned the old behaviour): test_e2e_esplora_fee_estimates_keys → test_e2e_esplora_fee_estimates_empty_without_a_fee_signal, test_esplora_fee_estimates_returns_complete_map → test_esplora_fee_estimates_empty_without_a_fee_signal, test_esplora_block_txs_pagination_past_end_returns_empty → test_esplora_block_txs_pagination_past_end_is_404 (also checks /txs/0 still serves).

Fails without the fix: yes. Every test above was run first against the unfixed handlers:

  • block_page_bounds_past_the_end_is_404_start_index_out_of_range / _unaligned_start_is_400: expected the start index to be refused: 1..1 / 1..2.
  • vin_without_a_witness_has_no_witness_key: the serialized input had "witness":[].
  • fee_estimates_map_is_empty_without_a_fee_signal and both edited fee-estimates tests: all 28 targets at 1.0 instead of {}.
  • test_e2e_esplora_txs_after_txid_continues_the_history: ?after_txid= returned heights 30..6 (page 1 again) instead of 5..1.
  • test_e2e_esplora_block_txs_start_index_rules: /txs/1 was 200 with one tx instead of 400.
  • test_e2e_esplora_vin_witness_left_out_when_empty: the genesis coinbase input carried "witness":[].
  • test_esplora_block_txs_pagination_past_end_is_404: 200 instead of 404.

Local gate: cargo clippy --all-targets --all-features -D warnings, cargo clippy -p node --no-default-features --all-targets -D warnings, cargo test -p esplora-handlers --lib (41), cargo test -p satd --features e2e --test e2e esplora (22), cargo test -p satd --test regtest esplora (67): all pass.

Notes

  • Status code past the end of a block: mempool.space answers 404, blockstream.info 400 (same text). This PR follows mempool.space (404, and the range check before the alignment check); the manual says so.
  • The after_txid cursor is looked up in this script's history. mempool.space looks it up anywhere (mempool, then chain), so for a txid that exists but is not in the script's history it returns [] (confirmed) or the first confirmed page (mempool) where satd answers 422. A client only ever passes a txid from the previous page, where the two agree.
  • mempool.space's /txs also takes max_txs and sizes its pages by it (50 total by default). Not added; satd keeps Blockstream's 50 mempool + 25 confirmed shape and 25 per confirmed page.
  • Ordering a script's mempool transactions reads their admission times through a new Mempool::admission_times (one read lock, no transaction cloned); only the at most 50 transactions on the page are fetched, as before. Bounding the rest of the address-history work is the next PR in this lane.
  • A transaction in both a script's mempool list and its confirmed history (a block just connected, the mempool has not dropped it yet) is listed once, as confirmed, as Blockstream's prepare_history does, and an after_txid is looked up in the confirmed history first.
  • /fee-estimates is an unauthenticated surface whose answer now changes shape (fewer keys) on a node without fee data. BDK's convert_fee_rate and the mempool.space SDK already handle missing targets, because Blockstream's Esplora leaves them out too. The wire-shape canary checks only that the body is an object.
  • Not done here: inner_redeemscript_asm / inner_witnessscript_asm on vin (upstream has them, satd never did; additive).
  • Not changed: /txs/chain/:last_seen_txid with an unknown cursor still returns [] (blockstream.info does the same).

Release notes

Esplora: /address/:addr/txs and /scripthash/:hash/txs now honour mempool.space's ?after_txid= cursor, so explorers that page with "load more" get the next page instead of the first one again; an unknown cursor is 422 after_txid not found. A script's mempool transactions are listed in the order the node admitted them. /block/:hash/txs/:start_index answers 404 start index out of range at or past the end of the block and 400 for a start that is not a multiple of 25, instead of an empty page. Legacy inputs no longer carry an empty witness array. /fee-estimates leaves out the targets the node has no estimate for, and answers {} during IBD or right after startup instead of reporting 1 sat/vB for every target.

🤖 Generated with Claude Code

bkeroack and others added 2 commits October 8, 2026 12:15
…ness and unknown fee targets

Four answers differed from upstream Esplora (Blockstream's and
mempool.space's electrs, src/rest.rs):

- /address/:addr/txs and /scripthash/:hash/txs ignored ?after_txid=, so a
  client paging with mempool.space's cursor got the first page on every
  request. They now continue after the cursor: the next 25 confirmed for a
  confirmed cursor (the /txs/chain/:last_seen_txid page), or the mempool
  transactions after it and then the first 25 confirmed for a mempool
  cursor. A cursor in neither list is 422 "after_txid not found", as
  mempool.space answers; a malformed one is 400 and an empty one is the
  first page. A script's mempool transactions are now listed in the order
  the node admitted them (then by txid), the order upstream keeps them in,
  instead of HashSet order, so a mempool cursor is stable between requests.
- /block/:hash/txs/:start_index returned [] at or past the end and served
  any start. It now answers 404 "start index out of range" there and 400
  "start index must be a multiple of 25" for an unaligned start, as
  mempool.space does (blockstream.info answers 400 for both).
- Every legacy input carried "witness": []. Upstream sets witness only
  when the input has one; satd now skips the empty array.
- /fee-estimates reported the relay floor for every target when the node
  had no fee signal (empty mempool, no confirmed-block fee history, as in
  IBD or right after startup). Upstream leaves out each target bitcoind
  cannot estimate and answers {} when there is none. satd now leaves out
  the Confidence::Low targets, which in blend mode are exactly the ones
  that fell back to the floor.

The Esplora manual chapter documented the old answers; it now describes
these.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-confirmed one once

Ordering a script's mempool transactions by admission time fetched every
one of them with Mempool::get, which clones the whole entry and its
transaction, where the earlier code fetched at most the 50 it served. The
order now comes from Mempool::admission_times, which reads the times of the
given txids under one read lock without cloning anything, and only the
transactions on the page are fetched.

Between a block connecting and the mempool dropping its transactions, a
transaction can be in a script's mempool list and in its confirmed history
at once. /txs listed it in both parts, and an after_txid naming it resolved
in the mempool list, so the page restarted the confirmed history at the
cursor itself. It is now listed once, as confirmed, as Blockstream's
electrs does (prepare_history), and a cursor is looked up in the confirmed
history first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bkeroack
bkeroack force-pushed the fix/esplora-paging-json branch from 5d445f8 to fe3cdd9 Compare October 8, 2026 18:15
@bkeroack
bkeroack merged commit 2958b91 into release/0.6 Oct 8, 2026
12 checks passed
@bkeroack
bkeroack deleted the fix/esplora-paging-json branch October 8, 2026 18:15
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.

1 participant