Skip to content

fix(bdk_electrum_streaming): Key anchors by block hash so a same-height reorg is recoverable - #18

Open
LLFourn wants to merge 1 commit into
evanlinjin:mainfrom
LLFourn:anchors-are-facts
Open

fix(bdk_electrum_streaming): Key anchors by block hash so a same-height reorg is recoverable#18
LLFourn wants to merge 1 commit into
evanlinjin:mainfrom
LLFourn:anchors-are-facts

Conversation

@LLFourn

@LLFourn LLFourn commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #12. An alternative to #13, which fixes the same bug — see the comparison
below. Based on main (2250918). One commit.

The bug, and the shape of the fix

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 has no
reason to notify. No subscription can be arranged that would tell us. The chain dropping the block
is the only signal the protocol carries.

The current code keys pending anchors by (height, txid) (spk_job.rs:22) and re-resolves that
height through the chain on every pass (spk_job.rs:270-299). A height is a mutable key; a block
hash is not.
That single choice is what lets a reorg silently retarget a proof onto the wrong
block.

So the fix is to stop treating an anchor as a claim about the chain and start treating it as what it
is: a fact about a block. "This txid is in this block", established by checking a merkle proof
against the header fetched for that height, and keyed by that block's hash. Facts do not become
false. A reorg only makes one inertLocalChain consults its own tip, canonical_iter scans
every anchor a transaction has and takes whichever is in the chain, so a stale anchor is ignored
rather than harmful. Anchors and chain updates therefore need neither agree nor travel together.

Three things fall out of that rather than being arranged:

  • Anchoring never reads the chain. An anchor resolves from the header fetched for its height and
    nothing else, so it works whatever the tip is doing. an_anchor_resolves_without_the_chain_catching_up
    pins this with a tip that never moves at all.
  • failed_anchors goes. A non-match cannot distinguish "not in that block" from "that header is
    from a chain the server has left", so it is a retry and never a durable verdict. The type that
    could record a permanent disproof no longer exists.
  • chain_job.rs is untouched. Eviction is detected by diffing the tip across a chain update —
    CheckPoint is Arc-backed, so cloning it first is free — rather than by asking the chain job
    what it forked at. The trigger asks the question it actually means and works whatever the chain
    job is.

The one thing that must be arranged is the re-ask, because nothing else will prompt it. A
(height, txid) record survives a re-ask that comes back empty, because a transaction can be
reorged out and back in at the same height with the status identical either side — a client that
misses the middle is otherwise never told again.

Answers are filed against the chain they were asked about

ReqQueuer::enqueue merges a request into an identical one already in flight, and req_to_job
holds that entry until the response lands. So a re-ask raised during a burst of same-height reorgs
puts nothing on the wire, and the one answer that does arrive describes whichever chain the server
held when it read the first request.

Anchoring that first block is not wrong — the transaction really was in it, and it is inert once its
block is gone. The failure is settling there: no anchor for the last block is ever fetched and
the transaction reads unconfirmed. The property is liveness, not correctness:

After the last chain change, every tracked (height, txid) is resolved from an observation whose
request was issued after that change.

Observed::Awaiting(epoch) stamps an outstanding request with the eviction counter it went out
under. record is the single choke point every answer passes through — success and error alike —
and an answer stamped older than the counter is dropped rather than stored, so the next pass's ask
reaches the wire because pop has just released the request it would have merged into.

Two things it deliberately is not. A stale: HashSet<height> that discards the first answer after
any eviction burns a round trip whenever nothing was in flight. A generation bumped on every tip
change re-fetches on every ordinary block — the counter moves on evictions only, and
a_new_block_does_not_invalidate_an_answer_in_flight is the only test standing between it and that
anti-pattern.

Relationship to #13

#13 diagnoses #12 correctly and its chain-driven trigger is the right instinct. Several conclusions
here were reached independently and agree exactly: fetch the header before the proof; a response
must advance whatever asked for it; a get_merkle error is an answer, not a reason to drop the
connection; a stale answer must be discarded; and CHAIN_SUFFIX_LENGTH is the real reorg horizon
either way. Where they differ:

#13 this
anchor key (height, txid), re-resolved against the local chain each pass the block's hash, resolved from the header fetched for the height
what makes a reorg self-correcting re-resolution picks up whichever block the chain now has the anchor is a fact, so a stale one is inert; an eviction diff asks again
chain dependency anchoring reads the chain (tip.get(height)) and cannot resolve a height the chain lacks anchoring never reads the chain
repair trigger replays the whole SpkJob by raising the script-hash notification locally, from spk_statuses + spk_hashes_by_height a reanchor set of (height, txid) pairs, advanced directly
negative cache failed_anchors kept, narrowed to a verified mismatch deleted; a mismatch is a retry, never a verdict
stale-answer stamp on the request, in ReqCoord, keyed by request id on the cache slot the request owns
files touched req.rs, spk_job.rs, state.rs spk_job.rs, state.rs

The trade is real in both directions and worth being straight about. #13's stamp placement is the
better one
: a generation recorded against the request id is stamped exactly when the request hits
the wire, which makes the merged-re-ask case correct by construction. Here the stamp lives on the
cache slot and only entry().or_insert() keeps a merged re-ask from refreshing it — the same
property, held by a smaller margin. Both designs handle the burst.

What this buys instead is that the anchor stops depending on the chain at all, which is what
removes the class rather than the instance: there is no height to re-resolve, no negative cache to
get wrong, no job to replay, and no reason for chain_job.rs or req.rs to change.

What #13's review flagged that also applied here

Reviewing the two together turned up three items from #13's review that landed on this branch too.
Two were guards that existed but that nothing exercised, and an unexercised guard is one somebody
deletes:

  • the stale-answer guard on the error answer (fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg #13's state.rs:256). Covered here by
    construction, since record handles both arms — but not pinned. It is the branch where being
    wrong costs more: a recorded refusal is consumed on read and abandons the re-ask, so the anchor
    stays on a block that left the chain and only a further reorg at that height would try again.
    Now an_error_that_predates_a_reorg_is_not_taken_as_a_refusal.
  • the guard on splicing a stale header into the chain (fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg #13's state.rs:289, where deleting it
    passed all 5 tests). Deleting it here passed all 13. Reaching it needs a sparse chain forked below
    the anchor's height and deeper than the chain pass's window, which no fixture had. Now
    a_header_fetched_before_a_reorg_is_not_spliced_into_the_chain.
  • unbounded growth (fix(bdk_electrum_streaming): Refetch anchors for blocks evicted by a reorg #13's state.rs:670). anchored_at is the direct analogue of
    spk_hashes_by_height and the same argument bounds it: an eviction cannot reach below the window
    the chain pass rewrites, and the gap fill never conflicts because it only writes where the chain
    holds nothing. headers_at was worse — a tip notification records a header for every announced
    block, so it gained an entry per block for the life of the process. Both now prune to
    EVICTION_HORIZON, leaving alone any slot an outstanding request owns.

One flag does not carry over: ReqCoord::clear is still dead code, but this branch does not touch
req.rs, so the "already extending it" argument for fixing it here does not apply.

Testing

cargo test -p bdk_electrum_streaming — 15 deterministic (tests/state.rs), 5 live
(tests/env.rs, ~39s against bdk_testenv's bitcoind + electrs), 4 unit. cargo fmt --all --check
clean; cargo clippy --all-targets at the same 4 warnings as main.

Two live tests cover this end to end, and were written independently of #13's:

  • reorg_to_same_height_block_refetches_anchor_live — confirms a tracked tx, then re-mines it into a
    different block at the same height. Asserts the transaction comes back confirmed via the
    replacement block
    , and that the anchor to the abandoned block is still held and simply ignored.
  • reorg_unconfirming_a_tx_keeps_the_connection_alive — invalidates the confirming block and mines
    two empty blocks so it cannot be re-mined, then requires the connection to keep serving.

Three cases cannot be reached live at any effort, because they need control over the order responses
come back in: the mid-fetch reorg, the proof held before its header, and the reorg burst.

Mutation table

Each guard removed in turn, against cargo test --test state:

Mutation Tests failed
drop the issued < epoch arm from record a_burst_of_reorgs… — and nothing else
bump the counter on every chain update, not only evictions a_new_block_does_not_invalidate_an_answer_in_flight — and nothing else
let a stale header be spliced into the chain a_header_fetched_before_a_reorg_is_not_spliced_into_the_chain
drop the stale guard on the error answer only an_error_that_predates_a_reorg_is_not_taken_as_a_refusal
do not cache the header a tip notification carries a_reorg_landing_mid_fetch_anchors_the_block_the_proof_proves

The last one is worth flagging as a difference from #13, whose table records the same mutation as
failing nothing. Here it is load-bearing rather than an optimisation: the announced header is the
observation a proof in flight during a reorg gets checked against.

The burst test runs at three speeds, where speed is how many reorgs the server folds into one
announcement — [1; 11], [4, 1, 6], [11]. The folded ones are invisible to the client, so a fast
burst is not a slow one sped up, it is one the client is told less about, and a client that saw a
single chain change could have been right by accident. Each schedule was checked to fail on its own
with the guard removed.

Breaking changes (→ 0.6.0)

From a diff of the public surface against 2250918. No version bump here — this repo cuts releases
in their own PRs.

  • Cache swaps failed_anchors and headers for headers_at, proofs, anchored_at and
    eviction_epoch. Struct-literal construction and exhaustive destructuring break;
    Cache::default() is unaffected, which is how every consumer builds it.
  • JobId gains Anchor — exhaustive patterns break.
  • SpkJob::advance takes &mut Cache and no longer takes a &CheckPoint.

Additive: Observed, AnchorStep, resolve_anchor, EVICTION_HORIZON. State::new, Update,
ReqCoord and ChainJob are unchanged
git diff on their declarations against 2250918 is
empty.

Known limitation, stated rather than discovered

Anchoring necessarily plants a checkpoint at every confirmation height it establishes:
LocalChain::is_block_in_chain answers unknown — not false — for a height the chain does not hold,
and canonical_iter marks a transaction canonical only on Some(true), so without those checkpoints
confirmed transactions read as unconfirmed.

Each of those checkpoints is a single unverified server answer, and nothing revisits it. The
chain pass fetches a fixed window near the tip, so a gap filled below that window is never checked
again, and a fork deeper than the window is never noticed at all — leaving an anchor pointing at a
block no longer in the best chain while the transaction still reads confirmed. Anchoring cannot
compensate for that, and this PR does not claim to: how deep a reorg can be and still be seen is
decided by the chain pass alone. It is documented on the crate root rather than left to be
rediscovered.


https://claude.ai/code/session_017kyDF7XatJ5fQE14v3ydeq

…ht reorg is recoverable

Fixes evanlinjin#12. A reorg that moves a tx into a different block at the same height leaves the
Electrum script status untouched — it is a hash over `txid:height:` pairs — so the
server has no reason to notify, and no subscription can be arranged that would tell us.
The protocol carries no signal; the chain dropping the block is the only one there is.

The old code keyed pending anchors by `(height, txid)` and re-resolved that height
through the chain on every pass. A height is a mutable key and a block hash is not, so a
reorg silently retargeted a proof onto the wrong block. An anchor is now established
from the header fetched for its height and nothing else — header first, then proof,
checked against whatever header is held at check time — and keyed by the block's hash.
That makes it a fact rather than a claim about the chain: true forever once established,
and merely inert when its block leaves, because bdk scans every anchor a tx has and
takes whichever one is in the chain. `failed_anchors` goes, because a non-match cannot
distinguish 'not in that block' from 'that header is from a chain the server has left',
so it is a retry and never a verdict.

Eviction is detected by diffing the tip across a chain update rather than by asking the
chain job, so `chain_job.rs` is untouched and the trigger works whatever the chain job
is. The height-to-txids record survives a re-ask that finds nothing, because a tx can be
reorged out and back in at the same height with the status identical either side, and a
client that misses the middle is never told again.

Answers are filed against the chain they were asked about. `ReqQueuer::enqueue` merges a
re-ask into an identical request already in flight, so a burst of reorgs is answered
once — from whichever chain the server held when it read the first request — and the
anchor settles there while every later re-ask is absorbed. Anchoring the first block of
a burst was never wrong; settling there is, because that anchor is the only one no
longer in the chain. An eviction counter stamps outstanding requests, and an answer
older than the counter is dropped rather than stored, so the next ask reaches the wire.
The counter moves on evictions rather than on chain updates, so an ordinary new block
costs nothing.

Known limit, now stated in the crate docs rather than discovered: anchoring puts a
checkpoint at every confirmation height, those checkpoints are single unverified server
answers, and nothing revisits them — a reorg deeper than the chain pass's window is
never noticed, and anchoring cannot compensate for that.

Both stale-answer guards are pinned rather than merely present, after LLFourn's
review of evanlinjin#13 flagged the same class there: an error answer predating a reorg
must not be recorded as a refusal, and a header predating one must not be
spliced into the chain. The second needs a sparse chain forked below the
anchor's height and deeper than the chain pass's window to reach at all, which
is why deleting its guard passed every other test.

Two caches are bounded. `anchored_at` is read only by an eviction, and an
eviction cannot reach below the window the chain pass rewrites — the gap fill
never conflicts, because it only writes where the chain holds nothing — so
everything below that was ballast. `headers_at` was worse: a tip notification
records a header for every announced block, so it gained an entry per block for
the life of the process. Both prune to `EVICTION_HORIZON`, leaving alone any
slot an outstanding request owns.

Breaking, so 0.6.0: `Cache` swaps `failed_anchors`/`headers` for `headers_at`,
`proofs`, `anchored_at` and `eviction_epoch`; `JobId` gains `Anchor`; and
`SpkJob::advance` takes `&mut Cache` and no longer takes a `CheckPoint`.
`Cache::default()`, `State::new` and `Update` are unaffected. Additive:
`Observed`, `AnchorStep`, `resolve_anchor`, `EVICTION_HORIZON`.

Claude-Session: https://claude.ai/code/session_017kyDF7XatJ5fQE14v3ydeq
@evanlinjin

Copy link
Copy Markdown
Owner

Two reproducible liveness bugs on this branch. In both a transaction stays permanently unconfirmed after a one-block reorg that the chain pass itself resolves correctly — so neither is covered by the "Known limitation" section, which is about forks deeper than the chain pass window. Both repros are 1–2 blocks deep and the chain ends up holding the right block at the reorged height; only the anchor never catches up.

Both use the existing AnchorFixture and fail against 4b86e45. The other 15 tests in tests/state.rs stay green.

Bug 1 — an anchor whose proof is in flight when a reorg lands is abandoned

Cache::anchored_at is written only on the success path (spk_job.rs:362), but it is the sole input to evicted_anchors. While an anchor is unresolved the map has no entry for its height, so an eviction at that height:

  • does not remove the now-stale headers_at[height], and
  • does not add the pair to reanchor.

The job then re-asks for the proof, gets a correct one for the new block, checks it against the old cached header, and takes the mismatch branch at spk_job.rs:368-380 — which returns AnchorStep::Abandoned and discards both observations. Nothing holds a reason to try again, so this is an absorbing state.

Observed: anchors held: {}, and the only request after the reorg is one blockchain.transaction.get_merkle.

Bug 2 — an anchor established below the tip is never re-asked

evicted_anchors skips any height the chain did not hold before the update:

match before.get(height) {
    Some(was) => now.get(height).map(|cp| cp.hash()) != Some(was.hash()),
    None => false,
}

an_anchor_resolves_without_the_chain_catching_up establishes that an anchor can be made at a height the chain has never reached. Any such anchor is permanently outside the trigger's reach: before.get(height) is None, so no eviction is ever reported for it, even once the chain grows past that height and holds a different block there.

In the repro the chain ends up holding header_2b at height 2, while the only anchor held is against the evicted header_2. The single request issued after the reorg is blockchain.block.headers. The tx reads unconfirmed from then on.

Is this architectural?

I think so, yes — and worth saying plainly, because I tried the obvious local patch and it is not sufficient.

Keying anchors by block hash gives correctness: an anchor can never become false, and a stale one is inert. But it supplies no liveness, and liveness is what issue #12 is actually about. Liveness here is carried by a separate mechanism bolted alongside the anchors — anchored_at + eviction_epoch + the evicted_anchors checkpoint diff — and the two bugs are one defect in that mechanism seen from two sides:

The re-ask trigger's domain is strictly narrower than the anchor's domain. An anchor may be established at any height, at any time, without reading the chain. A re-ask fires only when (a) the anchor already resolved successfully, and (b) the chain held that exact height before the update and holds a different block there now. Anything outside that intersection is unrecoverable. Bug 1 is the (a) gap; bug 2 is the (b) gap. Both funnel into AnchorStep::Abandoned, which throws away the evidence and leaves no one responsible for retrying.

The root of it is that the trigger is edge-triggered: recovery depends on observing a transition. Miss the edge — the answer was in flight, or the chain had not reached that height yet — and the state is never revisited. eviction_epoch exists to date-stamp answers against those edges, and the EVICTION_HORIZON pruning is a second place the edge can be lost.

I tried the minimal fix for bug 1 — move the anchored_at insert from the success path to the top of resolve_anchor, so a height is recorded when it is asked about rather than when it resolves. That is a real improvement and fixes several in-flight scenarios, with all 15 existing tests still passing. But it fixes neither repro above: bug 2 is untouched by it, and bug 1 still fails because the proof slot and the epoch stamp interact with the released ReqCoord entry. The defect is not in where one insert lives.

What removes the class is making the trigger level-triggered — a function of current state rather than of observed transitions. On every chain update, for each (txid, height) the client believes confirmed, if it holds no anchor whose block is in the current chain, ask again. That needs no eviction counter, no before/after checkpoint diff, no pruning horizon, and has no in-flight window to miss: a late answer is either still useful or simply superseded by the next pass. It also subsumes a_re_ask_survives_the_chain_shrinking_below_it without that case being special.

Concretely, the trigger currently asks "did a block leave the chain?" when the question that matters is "does every transaction I believe confirmed have an anchor in the chain I now hold?". The second question is answerable from state you already keep, and answering it is what makes the fact-based anchor model complete rather than only sound.

Repros

Append to tests/state.rs:

fn on_top_of(prev: block::Header, time: u32) -> block::Header {
    block::Header {
        merkle_root: TxMerkleNode::all_zeros(),
        prev_blockhash: prev.block_hash(),
        time,
        ..prev
    }
}

/// BUG 1: an anchor whose proof is still in flight when a reorg lands is never asked for again.
#[test]
fn repro_1_in_flight_anchor_is_orphaned_by_a_reorg() -> anyhow::Result<()> {
    let mut f = AnchorFixture::new(|_, _| {})?;
    f.sync();

    // A second tx, under a script the client does not track, so it changes what block 2 contains
    // without changing the tracked script's history. The replacement block therefore needs a
    // different merkle proof for our tx.
    let other = tx_paying(ScriptBuf::from_hex("0014000000000000000000000000000000000000dead")?, 7);
    let other_txid = other.compute_txid();
    let mut both = vec![f.txid, other_txid];
    both.sort();
    let header_2c = block::Header {
        merkle_root: merkle_root_of(&both),
        time: 260,
        ..f.header_2
    };
    let header_3c = on_top_of(header_2c, 360);
    let header_4c = on_top_of(header_3c, 460);

    // The chain reaches height 3 via the bulk header fetch, so height 2 is a height the chain
    // holds and nothing has cached a `headers_at` entry for it.
    f.server.headers.push(f.header_2);
    f.server.headers.push(f.header_3);
    f.notify_tip(f.header_3, 3)?;
    f.drain()?;

    // The tx confirms at height 2. Stop once its header has landed, leaving the proof in flight.
    f.notify_confirmed_at(2)?;
    loop {
        match f.answer_next()? {
            Some(m) if m == "blockchain.block.header" => break,
            Some(_) => continue,
            None => panic!("header never asked for"),
        }
    }
    // The server reads the proof request now, against the chain it has now.
    let held = f.capture_in_flight();
    assert!(!held.is_empty(), "the proof must be in flight");
    assert!(
        f.state.cache().anchored_at.is_empty(),
        "nothing is recorded at the height while the anchor is unresolved",
    );

    // Height 2 is replaced by a block that also holds the tx, and the chain grows.
    f.server.wallet.push((ElectrumScriptHash::new(&other.output[0].script_pubkey), vec![(other, 2)]));
    f.server.headers[2] = header_2c;
    f.server.headers[3] = header_3c;
    f.server.headers.push(header_4c);
    f.notify_tip(header_4c, 4)?;
    f.drain()?;

    // The held answer, describing the chain the server has since left.
    let mut updates = f.deliver(held)?;
    let (asked, more) = f.drain()?;
    updates.extend(more);

    assert!(
        updates
            .iter()
            .any(|u| u.tx_update.anchors.contains(&(f.anchor_in(header_2c, 2), f.txid))),
        "the replacement block must be anchored, got: {asked:?}; anchors held: {:?}",
        f.state.cache().anchors,
    );
    Ok(())
}

/// BUG 2: an anchor established while the chain was behind is never asked for again, even
/// though the chain later holds the replacement block at that very height.
#[test]
fn repro_2_anchor_made_below_the_tip_is_never_re_asked() -> anyhow::Result<()> {
    let mut f = AnchorFixture::new(|_, _| {})?;
    f.sync();
    let header_3b = on_top_of(f.header_2b, 350);

    // The tip stays at 1; the anchor resolves from the header fetched for height 2 alone.
    f.server.headers.push(f.header_2);
    f.notify_confirmed_at(2)?;
    f.drain()?;
    assert!(
        f.state
            .cache()
            .anchors
            .contains_key(&(f.txid, f.header_2.block_hash())),
        "the anchor must resolve before the reorg",
    );
    assert!(
        f.state.cache().anchored_at.get(&2).is_some(),
        "and is recorded at its height",
    );

    // Height 2 is replaced and the chain grows over it.
    f.server.headers[2] = f.header_2b;
    f.server.headers.push(header_3b);
    f.notify_tip(header_3b, 3)?;
    let (asked, updates) = f.drain()?;

    assert!(
        updates
            .iter()
            .any(|u| u.tx_update.anchors.contains(&(f.anchor_in(f.header_2b, 2), f.txid))),
        "the replacement block must be anchored, got: {asked:?}",
    );
    Ok(())
}

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.

We may not refetch anchor for a transaction that moves to different block of the same height during reorg.

2 participants