Skip to content

fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg - #13

Open
evanlinjin wants to merge 9 commits into
mainfrom
claude/issue-12-verify-fix-68dq8f
Open

fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg#13
evanlinjin wants to merge 9 commits into
mainfrom
claude/issue-12-verify-fix-68dq8f

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Fixes #12. Fixes #19.

A reorg that moves a transaction into a different block of the same height leaves its Electrum script status untouched — the status is a hash over txid-height pairs — so the server never notifies the script hash. The anchor we already delivered goes on pointing at a block that is no longer in the chain, nothing ever asks again, and the transaction stops being canonical for good.

The fix drives the repair from the chain tip, which is the one thing that does report the eviction, and routes it through the existing SpkJob rather than a new job type: on eviction we raise the script hash notification the server will not send.

  1. A completed chain job compares the checkpoint chain before and after (evicted_heights).
  2. SpkHistories::spk_hashes_at_heights says which scripts had a transaction at those heights.
  3. Each affected script gets SpkJob::new(&cache, spk_hash, Some(status)) — its own notification, raised locally. The status is cached with the history it stands for, so the replay costs no round-trip.
  4. advance_anchors re-resolves every (height, txid) pair against whichever block the chain now has at that height. The new block is a cache.anchors miss, so the proof is refetched.

A second, independent way to end up on a chain the server has left is fixed here too: ChainJob::new replaces chain_job on every tip notification, but the replacement asks for the same heights, so its request::Headers is byte-identical and ReqQueuer deduplicates it against the request already in flight. Nothing is sent, and the answer already on its way fills the new job with the superseded chain. That is #19, and it is fixed at both layers. ReqCoord::forget_job drops the replaced job's in-flight requests, so the stale answer is ignored and the replacement's request actually goes on the wire — the target check alone would not do this, since the dedup entry only clears when the response is popped. And ChainJob now keeps the block it was created to reach and checks the assembled update against it, so a batch answered from a chain the server never announced — a reorg between receiving the request and replying to it — is rejected rather than adopted. forget_job alone would not do that either: the request is current, only the answer is not.

The last commit is separable: it folds the three co-varying spk maps into one SpkHistories with private fields, so the status can no longer drift from the history it stands for, and makes the cache serde-persistable. response::Tx is Deserialize-only upstream, so histories round-trip through a private mirror until bitcoindevkit/electrum_streaming_client#20 lands.

Breaking changes (→ 0.6.0)

  • Cache loses failed_anchors. spk_histories keeps its name but changes type from HashMap<ElectrumScriptStatus, Vec<response::Tx>> to SpkHistories — a silent type change, so it breaks at construction and use, not at a field read. Cache::default() is unaffected.
  • SpkJobStage::ProcessingTxsAndAnchors gains anchors_resolved — exhaustive patterns break.
  • ChainJob::try_finish returns ChainJobOutcome rather than Result<CheckPoint, ChainJob>.
  • ReqCoord::pop returns Option<PoppedRequest>; ReqCoord::clear is removed (no callers, and State::reset deliberately resends pending_requests()).

Additive: PoppedRequest, ReqCoord::bump_chain_generation, ReqCoord::forget_job, ChainJobOutcome, SpkHistories, State::spk_histories(), and Serialize/Deserialize on Cache and SpkHistories. New dependency serde, already in the tree via serde_json.

Testing

cargo test — 19 deterministic (tests/state.rs), 5 live (tests/env.rs), 6 unit. Each fix was reverted in turn to confirm a test catches it.

reorg_to_same_height_block_refetches_anchor_live confirms a tracked tx, then re-mines it into a different block at the same height. Fails on main, passes here.


Rebased onto main (2250918), and force-pushed since to mark the breaking commits, so review comments anchored to old SHAs will show as outdated. Earlier review rounds are addressed in the threads themselves.

@LLFourn LLFourn 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.

Thanks for this — the diagnosis in #12 is right, the chain-driven design is the right
answer to it, and the write-up made the review much faster than it would otherwise have
been. I reproduced the bug against a live electrs and confirmed this branch fixes it.

Requesting changes. The first two are defects in the new code, both on the path this PR
exists to fix, and I have a failing test for each; the third is the modelling issue they
share, which I think is the one worth acting on first:

  1. state.rs:256 — the stale-response guard covers the successful merkle answer but
    not the error answer.
    A server answers from the chain it had when it received the
    request; if a reorg has landed since, that error is about a block we have left
    behind. give_up_on_anchor blames it on the replacement block and writes two
    irreversible records — a permanent failed_anchors verdict, and the deletion of the
    txid from txids_by_height, which is the only record that would let the eviction
    path ask again. Since advance_anchors treats a failed anchor as resolved by
    omission, the job reports success with the anchor silently missing and the
    transaction stops being canonical for good. Three-line fix.

  2. anchor_job.rs:55 — the refetch depends on the server answering in request order.
    A same-height reorg is applied by ChainJob's short-circuit without ever caching the
    replacement header, so the job asks for the header and the proof together. Proof
    first ⇒ cancel_jobsanchor_job = None, unrecoverable; the header arriving
    second returns Ok(None) without advancing anything, so it cannot rescue it either.
    The protocol carries request ids precisely because ordering is not promised.

Underneath both: failed_anchors is a monotonic negative cache, which is only sound
for negatives that were proved. Right now it has two writers — a merkle root mismatch
(a proof) and any JSON-RPC error on get_merkle (not one). Keeping the connection up on
an error is right and well argued; recording a permanent disproof from it is the part I
would drop. Enforcing "only a verified proof writes failed_anchors" would make defect 1
unwriteable rather than fixed.

  1. state.rs:468failed_anchors is a monotonic negative cache, and one of its two
    writers has not proved anything.
    A merkle root mismatch is a proof; a JSON-RPC error
    is not, and the predicate is the method name alone. So a rate limit, a syncing index or
    a daemon hiccup each permanently unconfirm a transaction. Worth stressing that the fix
    in (1) does not cover this — that guard closes the stale-chain case, this is a fault
    on the current chain. I ran the live unconfirming reorg against electrs to see what
    real classification would have to work with: the payload is a bare JSON string,
    "tx not found or is unconfirmed", which conflates the fault with the benign case. So
    I'd drop the durable write rather than try to classify it, and keep a classifier only
    as an optimisation.

The rest is smaller: cancel_jobs(JobId::Anchor) being terminal for the one job nothing
rebuilds; the GetHeader generation guard passing all 5 tests when deleted; the
breaking-change list missing SpkJobStage; txids_by_height retaining far more than the
~21 heights that can ever be read, which I'd like bounded. Details inline.

On testing — tests/env.rs runs fine here (cargo test --test env, 3 passed, 20s), so
the live path was never exercised in either direction. I have written two live tests:
one reproduces #12 end to end (fails on main, passes here), the other pins the
connection-survival fix (fails at 388d60a, passes here). Happy to hand both over along
with the fixes as a patch.

Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/anchor_job.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/anchor_job.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/req.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs
Comment thread bdk_electrum_streaming/tests/state.rs
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/spk_job.rs
@evanlinjin
evanlinjin force-pushed the claude/issue-12-verify-fix-68dq8f branch 2 times, most recently from 539f5ac to fa7b73a Compare August 21, 2026 11:07
Comment thread bdk_electrum_streaming/src/spk_job.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
Comment thread bdk_electrum_streaming/src/state.rs Outdated
evanlinjin and others added 8 commits August 21, 2026 11:31
`try_finish` had its two log messages on the wrong branches, reporting "not
finished" on completion and vice versa. `elapsed_seconds` subtracted without
saturating, so a backwards clock step would panic a log line.
… set

Anchors were drained from a set and staged into `tx_update` as they resolved,
so a reorg landing midway had the job emit anchors from the chain it started on
alongside anchors from the chain it ended on. Resolve the whole set afresh each
pass and hand it back only once all of it resolved, so an update can only ever
describe one chain.

The loop moves to `advance_anchors` and now walks anchors and checkpoints
together from the top, resolving every height in one pass over the chain.

BREAKING CHANGE: `SpkJobStage::ProcessingTxsAndAnchors` gains an
`anchors_resolved` field, so exhaustive patterns over it no longer compile.
… reorg

An Electrum script status is a hash over txid-height pairs, so a reorg moving a
transaction into a different block of the same height leaves it untouched and
the server never notifies the script hash. The anchor keeps pointing at a block
no longer in the chain and nothing asks again, so the transaction stops being
canonical for good.

The chain tip is the one thing that reports it. On eviction, every affected
script gets the notification the server will not send: `SpkJob::new` replayed
from the status and history already cached, so it costs no round-trip. Not a
new job type, because `SpkJob` is already rebuilt by the next real
notification if it is ever dropped.

A replay never displaces a job already in `spk_jobs` — that one carries a
status at least as new as anything replayable from cache.

`spk_hashes_by_height` is pruned to `SPK_HASHES_BY_HEIGHT_HORIZON` below the
tip, since only heights inside `ChainJob`'s window can ever be evicted.

BREAKING CHANGE: `Cache` gains `spk_statuses` and `spk_hashes_by_height`, so
struct-literal construction no longer compiles. `Cache::default()` is
unaffected.
… proof

A proof is verified against the merkle root of the block we have at that
height, so asking for both in one pass only worked if the server answered in
request order — which the protocol does not promise. If the proof won, the
handler could not find the header and cancelled the job; the header arriving
second returned `Ok(None)` and advanced nothing.

Withhold the proof until the header is cached, and always advance waiting jobs
from a `GetHeader` response, leaving only the checkpoint insert conditional.

`blockchain.block.header` is keyed by height, so if our chain claims a
different block there than the server, no request can ever satisfy the waiting
job. That happens below `ChainJob`'s suffix, which nothing rewrites. Cancel
those jobs rather than re-asking forever.
…ot give

Two answers to a proof request were being read as evidence about our own block,
and neither is.

An error means the server has no proof at that height — the everyday reorg, a
transaction back in the mempool. That took the whole connection down. It is
answered in place now, and the job is left stashed rather than cancelled: not
advancing it is what stops a re-ask loop, keeping it is what makes recovery
automatic, since the same-height reorg this all exists for sends no
notification to rebuild it with.

A mismatching proof means the server proved inclusion in whichever block *it*
has at that height, so our chain and the server's disagree there. It says
nothing about whether the transaction is in *our* block. That is the same
conclusion `GetHeader` draws from the same kind of evidence, so it gets the
same treatment — drop the jobs, record nothing.

`Cache::failed_anchors` therefore goes. It could only ever be written from a
mismatch, and a mismatch only happens when the two chains disagree, so it never
held a true disproof — only an artifact, permanently, since nothing pruned it.
A chain that later came back to that block would consult a verdict that had
never been established and skip the anchor for good.

BREAKING CHANGE: `Cache::failed_anchors` is removed.
A server answers from the chain it had when it received the request, so a
response arriving after a reorg may describe a block we have left behind. A
stale proof was verified against the replacement block and its valid anchor
written off permanently; a stale error was blamed on the replacement; a stale
header could be spliced into the checkpoint chain.

`ReqCoord` stamps each request with a chain generation, bumped whenever the
local chain drops blocks, so `pop` can report whether a reorg landed since. All
three handlers now discard such a response and ask again.

This closes the stale-chain case only; a genuine fault on the current chain is
handled in the previous commit by recording nothing.

`ReqCoord::pop` returns `PoppedRequest` rather than a tuple. Drops
`ReqCoord::clear`, which had no callers — `State::reset` deliberately resends
`pending_requests()` instead.

BREAKING CHANGE: `ReqCoord::pop` returns `Option<PoppedRequest>` rather than
`Option<(JobRequest, BTreeSet<JobId>)>`, and `ReqCoord::clear` is removed.
Group the server's per-script histories behind `SpkHistories`, holding each
status with the history it stands for so the two cannot desync, and keep the
height index private since it is derived from them.

Move it and `Cache` to `cache.rs` and give both serde impls. The height index
is rebuilt on load rather than stored, and `anchors` is written as a sequence
because its tuple key cannot be a JSON map key.

BREAKING CHANGE: `Cache::spk_histories` keeps its name but changes type from
`HashMap<ElectrumScriptStatus, Vec<response::Tx>>` to `SpkHistories`, and
`Cache` loses `spk_statuses` and `spk_hashes_by_height` to it. The free
`SPK_HASHES_BY_HEIGHT_HORIZON` is now `SpkHistories::HEIGHT_INDEX_HORIZON`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every existing test has the server answer `GetTx` before the merkle proof, so
the anchors resolve on the job's final pass and nothing runs after them. Pin
the other order, where a later pass re-resolves anchors already in hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanlinjin
evanlinjin force-pushed the claude/issue-12-verify-fix-68dq8f branch from b151cd8 to d1bd6c5 Compare August 21, 2026 11:32
…left

Two ways a chain job could write a chain the server is not on into the local
checkpoint chain. Neither recovers on its own: the server has already announced
the tip it is on, and will not announce it again until the next block.

`ChainJob::new` replaces `chain_job` on every tip notification, but the new job
asks for the same heights as the one it replaced, so its `request::Headers` is
byte-identical and `ReqQueuer` deduplicates it against the request already in
flight. Nothing is sent, and the answer already on its way fills the new job
with the chain the server has just left. The chain job's in-flight requests are
now forgotten when it is replaced. A request another job still wants stays in
flight and merely loses `JobId::Chain` as an owner; one that nothing else wants
is dropped outright, so the stale answer is ignored on arrival and the
replacement's request is actually sent.

A batch is also answered from whichever chain the server holds when it replies,
not the one it announced when the request went out. A reorg in between returns
blocks for a tip no notification ever mentioned, and that batch is the reorg's
only appearance. `ChainJob` now keeps the block it was created to reach and
checks the assembled update against it. A mismatch abandons the job rather than
retrying: the server only answers from a chain it has moved to, and moving is
what makes it announce a new tip, so the notification that rebuilds the job is
already on its way.

Neither half subsumes the other. Forgetting the replaced job's requests is what
gets a request back on the wire; the target check is what stops an unannounced
chain being believed once one is answered.

Fixes #19.

BREAKING CHANGE: `ChainJob::try_finish` returns `ChainJobOutcome` rather than
`Result<CheckPoint, ChainJob>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanlinjin
evanlinjin force-pushed the claude/issue-12-verify-fix-68dq8f branch from 88ba685 to 674a2a3 Compare August 21, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants