Repository navigation
Esplora: page /txs with after_txid, 404 past a block's last page, omit empty witness and unknown fee targets - #943
Merged
Conversation
bkeroack
changed the base branch from
fix/electrum-status-hash-order
to
release/0.6
October 8, 2026 18:11
…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
force-pushed
the
fix/esplora-paging-json
branch
from
October 8, 2026 18:15
5d445f8 to
fe3cdd9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):?after_txid=was ignored./address/:addr/txsand/scripthash/:hash/txstook 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 422after_txid not foundfor a cursor it cannot find (rest.rs, theaddress/:addr/txsroute,find_txid/confirmed_after_txid). The path form/txs/chain/:last_seen_txid(what BDK uses) was already right./block/:hash/txs/:start_indexpast the end returned[](handlers/block.rs:207-209; the comment claimed upstream does this, it does not) and accepted any start. mempool.space answers 404start index out of rangeat or past the end and 400start index must be a multiple of 25for an unaligned start; blockstream.info answers 400 for both. Upstream's API.md: "Thestart_indexvalue must be a multiple of 25.""witness": []on every legacy input (handlers/tx.rs:198, 371). Both upstreams makewitnessanOptionset only for a non-empty witness, so a non-segwit input has nowitnesskey./fee-estimatesinvented 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 withConfidence::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 fromestimatesmartfeeand leaves out every target bitcoind cannot estimate (daemon.rsestimatesmartfee_batch), so with no estimate the answer is{}.Fix
/address/:addr/txsand/scripthash/:hash/txstake?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 422after_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.HashSetiteration order, which can change between two requests. This also applies to/txs/mempool./block/:hash/txs/:start_index: 404start index out of rangeat or past the end (checked first), 400start index must be a multiple of 25otherwise, as mempool.space.vin[].witnessis skipped when empty./fee-estimatesleaves out everyConfidence::Lowtarget (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 withestimatefees.NotFoundReason(404 with upstream's text) andUnprocessable(422).docs/manual/src/esplora.md): the block-page,/txs,/txs/mempooland/fee-estimatesrows, 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-serveblock_page_bounds_serves_aligned_starts_inside_the_block.handlers::tx::esplorapaging_tests::vin_without_a_witness_has_no_witness_key, plusvin_with_a_witness_lists_it.handlers::mempool::esplorapaging_tests::fee_estimates_map_is_empty_without_a_fee_signal(empty mempool, fresh estimator), plusfee_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_txidparsing (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/:cursorand 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/txsand/txs/0;/txs/1is 400,/txs/25and/txs/usize::MAXare 404,/txs/1of a one-tx block is 404.test_e2e_esplora_vin_witness_left_out_when_empty: the genesis coinbase input has nowitnesskey; 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/0still 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_signaland both edited fee-estimates tests: all 28 targets at1.0instead 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/1was 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
after_txidcursor 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./txsalso takesmax_txsand 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.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.prepare_historydoes, and anafter_txidis looked up in the confirmed history first./fee-estimatesis an unauthenticated surface whose answer now changes shape (fewer keys) on a node without fee data. BDK'sconvert_fee_rateand 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.inner_redeemscript_asm/inner_witnessscript_asmonvin(upstream has them, satd never did; additive)./txs/chain/:last_seen_txidwith an unknown cursor still returns[](blockstream.info does the same).Release notes
Esplora:
/address/:addr/txsand/scripthash/:hash/txsnow 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 422after_txid not found. A script's mempool transactions are listed in the order the node admitted them./block/:hash/txs/:start_indexanswers 404start index out of rangeat 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 emptywitnessarray./fee-estimatesleaves 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