fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg - #13
fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg#13evanlinjin wants to merge 9 commits into
Conversation
LLFourn
left a comment
There was a problem hiding this comment.
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:
-
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_anchorblames it on the replacement block and writes two
irreversible records — a permanentfailed_anchorsverdict, and the deletion of the
txid fromtxids_by_height, which is the only record that would let the eviction
path ask again. Sinceadvance_anchorstreats 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. -
anchor_job.rs:55— the refetch depends on the server answering in request order.
A same-height reorg is applied byChainJob's short-circuit without ever caching the
replacement header, so the job asks for the header and the proof together. Proof
first ⇒cancel_jobs⇒anchor_job = None, unrecoverable; the header arriving
second returnsOk(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.
state.rs:468—failed_anchorsis 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.
b35e9dd to
a029317
Compare
a029317 to
1542f99
Compare
370ed36 to
ba5805e
Compare
539f5ac to
fa7b73a
Compare
`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>
b151cd8 to
d1bd6c5
Compare
…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>
88ba685 to
674a2a3
Compare
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
SpkJobrather than a new job type: on eviction we raise the script hash notification the server will not send.evicted_heights).SpkHistories::spk_hashes_at_heightssays which scripts had a transaction at those heights.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.advance_anchorsre-resolves every(height, txid)pair against whichever block the chain now has at that height. The new block is acache.anchorsmiss, so the proof is refetched.A second, independent way to end up on a chain the server has left is fixed here too:
ChainJob::newreplaceschain_jobon every tip notification, but the replacement asks for the same heights, so itsrequest::Headersis byte-identical andReqQueuerdeduplicates 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_jobdrops 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. AndChainJobnow 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_jobalone 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
SpkHistorieswith private fields, so the status can no longer drift from the history it stands for, and makes the cache serde-persistable.response::TxisDeserialize-only upstream, so histories round-trip through a private mirror until bitcoindevkit/electrum_streaming_client#20 lands.Breaking changes (→
0.6.0)Cachelosesfailed_anchors.spk_historieskeeps its name but changes type fromHashMap<ElectrumScriptStatus, Vec<response::Tx>>toSpkHistories— a silent type change, so it breaks at construction and use, not at a field read.Cache::default()is unaffected.SpkJobStage::ProcessingTxsAndAnchorsgainsanchors_resolved— exhaustive patterns break.ChainJob::try_finishreturnsChainJobOutcomerather thanResult<CheckPoint, ChainJob>.ReqCoord::popreturnsOption<PoppedRequest>;ReqCoord::clearis removed (no callers, andState::resetdeliberately resendspending_requests()).Additive:
PoppedRequest,ReqCoord::bump_chain_generation,ReqCoord::forget_job,ChainJobOutcome,SpkHistories,State::spk_histories(), andSerialize/DeserializeonCacheandSpkHistories. New dependencyserde, already in the tree viaserde_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_liveconfirms a tracked tx, then re-mines it into a different block at the same height. Fails onmain, 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.