feat(bdk_electrum_streaming)!: Verify proof-of-work against trusted headers - #22
Draft
evanlinjin wants to merge 9 commits into
Draft
feat(bdk_electrum_streaming)!: Verify proof-of-work against trusted headers#22evanlinjin wants to merge 9 commits into
evanlinjin wants to merge 9 commits into
Conversation
evanlinjin
force-pushed
the
claude/verify-pow-rebased
branch
from
August 31, 2026 15:01
3fcbc8b to
cb7ac58
Compare
evanlinjin
force-pushed
the
claude/verify-pow-rebased
branch
2 times, most recently
from
September 1, 2026 02:38
f51b269 to
e045060
Compare
evanlinjin
force-pushed
the
claude/issue-12-verify-fix-68dq8f
branch
from
September 1, 2026 03:02
c477f1e to
d636eb8
Compare
evanlinjin
force-pushed
the
claude/verify-pow-rebased
branch
from
September 1, 2026 03:05
e045060 to
084fcbf
Compare
evanlinjin
force-pushed
the
claude/issue-12-verify-fix-68dq8f
branch
8 times, most recently
from
September 1, 2026 11:37
d3ff1b1 to
0a756eb
Compare
…eaders
The client took the server's word for chain data. Headers were spliced into a
checkpoint chain unchecked, and a merkle proof was validated against whatever
header the server returned for that height, so an anchor only ever meant "this
server says so" — a server could invent a height, hand back a matching fake
header, and the proof would pass.
`HeaderChain` is a `CheckPoint<Header>` anchored to user-provided trusted
headers. Syncing starts above the highest one, so the first header's
`prev_blockhash` pins the run to a block the user vouched for. From there every
header must match a trusted header at its height, link to the block below it,
claim the difficulty consensus requires, and hash below that difficulty's
target. A reorg is accepted only if it brings more work than the blocks it
replaces, and never if it would displace a trusted block.
Anchors become `ProvenAnchor { block_id, pos, merkle }`, keeping the proof
rather than discarding it, and `Update<K>` becomes
`FullScanResponse<K, ProvenAnchor, Header>`. There is no block time on the
anchor: the header it was proved against travels with every update.
`UpdateJob` now fetches contiguous runs rather than scattered heights, since a
header is only verifiable as part of a chain reaching a block we trust, and
gained a backfill: a transaction confirmed below where the chain starts grows
it downwards to the nearest trusted block. `Cache::headers` is gone — only the
verified chain may hold a header.
Rebased from #10, which was written before `ChainJob` was replaced by
`UpdateJob`; `header_chain.rs` and `anchor.rs` carry over unchanged.
BREAKING CHANGE: anchors are `ProvenAnchor` rather than `ConfirmationBlockTime`
and `Update<K>` carries `Header` data, so `chain_update` applies to a
`LocalChain<Header>`. `State::new` takes a `HeaderChain` in place of a
`CheckPoint`, and `Cache::headers` is removed. `bdk_core`/`bdk_chain` now come
from git master, `miniscript` is 13, and `rust-version` is 1.85.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
evanlinjin
marked this pull request as draft
September 2, 2026 02:37
…tched `FetchAnchors` read a missing header as history the chain had not backfilled yet and went back to `Init` to plan a run for it. But a run only ever reaches the tip the server has announced, so an anchor *above* that tip has no run to plan: `Init` produced the same runs, `FetchHeaders` completed with nothing to do, and the anchor stage asked again — a loop with no exit inside a single `State::poll`. A history can name a height above the announced tip in the ordinary course of things: electrs notifies the script hash before the header. Distinguish the two misses. Below the chain base is a backfill, which `Init` can plan; above the verified tip is simply pending, and the announcement that carries the tip is what re-polls it.
evanlinjin
force-pushed
the
claude/verify-pow-rebased
branch
from
September 2, 2026 02:38
084fcbf to
f2aad11
Compare
evanlinjin
changed the base branch from
claude/issue-12-verify-fix-68dq8f
to
main
September 2, 2026 02:41
evanlinjin
commented
Sep 2, 2026
evanlinjin
force-pushed
the
claude/verify-pow-rebased
branch
from
September 2, 2026 03:38
fa9b6ea to
56a4aae
Compare
Everything in `Cache` can be rebuilt from what a wallet already knows, so it does not need to round-trip through serde. `Cache::from_wallet_txs` takes `(tx, status, relevant_scripts)` — one entry per transaction, not one per script it pays, since that is how a wallet holds them — and rebuilds all four fields: `subscriptions` and `spk_txids` by computing the same status hash a server would, `txs` from the transaction itself, and `anchors` from the `ProvenAnchor` a confirmed status carries, so nothing has to be redownloaded or reproved on reconnect. With nothing left to persist, `TxCache`'s fields (`spk_txids`, `txs`, `anchors`) move directly onto `Cache`, and the serde-only `persist` module (`HistoryTx`, the anchors-as-seq codec, `Subscriptions`' hand-written `Serialize`/`Deserialize`) is gone. The status hash needs care. Electrum mixes in only `txid:height:`, but orders a history by height *and then block position*, so two transactions confirmed in the same block hash to a status the server would not recognise unless their order matches the block's — `ProvenAnchor::pos`, which a wallet already has from the merkle proof, is what orders them. Mempool entries follow the protocol's `(-height, tx_hash)` rule rather than being left in whatever order the caller supplied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K
HeaderChain has to be handed trusted headers, and trusting one means asserting it is canonical — which has to come from somewhere the operator actually checked, ideally their own node. `cargo run -p trusted-headers-gen -- --url ... --network bitcoin` fetches a header from Bitcoin Core and writes a Rust module exposing a `..._TRUSTED_HEADERS` per network plus a `trusted_headers()` map over them. A node serves one network, so each run targets one and only touches that network's section of the output, merging into whatever heights are already recorded for the others. Nothing is shipped by this repo: the output lands in the current directory, and placing it takes an explicit `--out`, so the data lives in the tree of whoever reviewed it as the diff it is. The default height is the second highest difficulty-adjustment boundary at or below the tip: a boundary, because a header-verifying client needs its highest trusted header on one to recompute every retarget above it, and the second one because the highest can be the tip itself — a block a handful of confirmations deep is no basis for trust. `--network` is checked against what the node reports for `chain`, since a header filed under the wrong network is a poisoned anchor and the node is the one thing that knows which chain it serves. That field is read out of the raw response rather than a typed one: the rest of `getblockchaininfo` has changed shape across Core releases, and this uses the v17 client surface precisely to stay version-agnostic. Tested against Bitcoin Core v28.1: a test spins up a real bitcoind and checks the header fetched is the one that node has at that height, that a height above the tip is refused, that a regtest node cannot be talked into answering for mainnet, and that the result survives the round trip out into generated source and back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K
evanlinjin
force-pushed
the
claude/verify-pow-rebased
branch
from
September 2, 2026 03:50
56a4aae to
66d31fa
Compare
`HeaderChain::apply` took the backfill path for any run starting below the base, requiring only that it reach *at least* the base. A run that also carried on past the tip took that path while replacing verified blocks — and the backfill path rebuilds the chain rather than comparing work, so an equal-work fork was accepted there, which the extend path exists to refuse. Not reachable through `ConfirmationJob`, whose backfills always stop at `base - 1`, but `apply` is public and its contract read as a lower bound. A backfill must now fill the gap exactly. `ConfirmationJob` planned no run at all for a target more than `REORG_WINDOW` below the verified tip, then found its remaining runs complete, applied nothing, and handed back the chain it already had as though the server had confirmed it — while going on to ask that server for proofs at heights it does not have. The `REORG_WINDOW` doc claimed the connection errors out in this case; now it does. Checked only once there is a verified tip: before that, planning nothing is an ordinary waypoint, since a backfill is planned from heights the histories have yet to name. Also re-queue the gaps when a `FetchHeaders` run is still incomplete. A server may answer `blockchain.block.headers` with fewer headers than asked for, and nothing else would ask again — `Init` is only re-entered when the target or the statuses move, which on a settled chain may be never. Requests in flight are deduplicated, so this cannot pile up. Both chain fixes come with a regression test that fails without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K
`branch = "master"` re-resolves on every fresh checkout, so an upstream change could break or, worse, quietly alter this crate between builds — not what you want underneath proof-of-work verification. Pin the rev the lock file already resolved to. These stay git dependencies until `CheckPoint<D>` and `FullScanResponse<K, A, D>` are released; `cargo publish` rejects git dependencies outright, so that release has to wait for them regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K
…tches `from_wallet_txs` reproduces a server's status exactly for confirmed history, which is ordered by facts about the chain every server agrees on. Unconfirmed history is weaker: the protocol specifies an ordering, but a server is free to hash its mempool entries in whatever order they come out of its own index, so a script with several unconfirmed transactions may still hash to something it does not recognise — costing a refetch that would have happened anyway. Claiming it computes statuses "the same way Electrum does" oversold that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K
Nothing caught a trusted set built for the wrong network. Handed another chain's real headers, a network with the difficulty rules off and a high target limit accepts them in full: they link to each other, they hash below their claimed targets, and the retarget check that would notice never runs. The set is silently adopted as the anchor for everything above it. Genesis is the one block whose hash `params` fixes, so it is the only thing a set can be held against. Require it in any non-empty set, which makes the set say which chain it came from and turns the existing height-0 agreement check into one that actually fires. An empty set still means "sync from genesis" — it has nothing to mismatch. A generated headers file had no genesis entry, so the rule would have rejected the one file anyone passes. `trusted-headers-gen` now writes its network's genesis into every section it touches, derived from the network rather than fetched: it is a constant, and the node has already been held to that network by then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTtvv9UuTfnZXuMr2kL9bq
`anyhow::Error` appears throughout this crate's public signatures, but the crate was not re-exported, so a caller wanting to inspect an error had to add their own `anyhow` dependency and keep its version in step with ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTtvv9UuTfnZXuMr2kL9bq
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.
The client took the server's word for chain data. Headers were spliced into the checkpoint chain unchecked, and a merkle proof was validated against whatever header the server returned for that height — so an anchor only ever meant "this server says so". A server could invent a height, hand back a matching fake header, and the proof would pass.
Rebased directly onto current
main(already includes #13, #17, #21, #24) — the branch's own prior commits for the cache/anchor/chain-ownership refactors turned out to already be superseded by more-evolved equivalents merged there independently, so this PR now carries only what's new on top: PoW verification, a follow-up fix, and a small cache cleanup.HeaderChainA
CheckPoint<Header>anchored to a set of user-provided trusted headers. Syncing starts athighest_trusted + 1, so the first header'sprev_blockhashpins the run to a block the user vouched for. From there up, every header must match the trusted header at its height if one was given, link to the block below it viaprev_blockhash, claim the difficulty consensus requires (recomputed at each retarget boundary), and hash below that difficulty's target.A reorg on top of that is accepted only if it brings more work than the blocks it replaces. Everything below the run's start is untouched, so comparing the two chains from there is the same as comparing their totals — an O(reorg depth) sum. Re-applying blocks we already have is not a reorg and is exempt. An eviction that would displace a trusted block is refused outright.
On networks where difficulty moves, the highest trusted block must sit on a difficulty-adjustment boundary, so every retarget above it is recomputed from a header already in hand rather than taken on faith. Trusted blocks below the sync start are exempt: a backfilled run is pinned by a trusted block at the bottom and the verified chain at the top.
ProvenAnchorTransactions were already merkle-proved, but against an unverified header. The proof is now checked against a header in the verified chain, and kept rather than discarded:
ProvenAnchor { block_id, pos, merkle }.Update<K>becomesFullScanResponse<K, ProvenAnchor, Header>. No block time on the anchor — the header it was proved against travels with every update inchain_update.ConfirmationJob(the job that moves the chain and anchors what the scripts found) now fetches contiguous runs of headers rather than scattered heights, since a header is only verifiable as part of a chain reaching a block we trust, and gained a backfill: a transaction confirmed below where the chain starts grows it downwards to the nearest trusted block.Cache::headersis gone — only the verified chain may hold a header.Do not replan a run that can never be fetched
FetchAnchorsread a missing header as history the chain had not backfilled yet and went back toInitto plan a run for it. But a run only ever reaches the tip the server has announced, so an anchor above that tip has no run to plan:Initproduced the same runs,FetchHeaderscompleted with nothing to do, and the anchor stage asked again — a loop with no exit inside a singleState::poll.A history can name a height above the announced tip in the ordinary course of things: electrs notifies the script hash before the header. The fix distinguishes the two misses — below the chain base is a backfill, which
Initcan plan; above the verified tip is simply pending, and the announcement that carries the tip is what re-polls it.Cacheno longer persistsEverything in it can be rebuilt from what a wallet already knows, so
Cachedoesn't need to round-trip through serde any more.Cache::from_wallet_txstakes(tx, status, relevant_scripts)— one entry per transaction, not one per script it pays, since that is how a wallet holds them — nobdk_chaindependency, just aTxConfirmationStatusenum (Confirmed(ProvenAnchor)orMempool { confirmed_inputs }). It rebuilds all four fields:subscriptionsandspk_txidsfrom computing the same status hash a server would,txsfrom the transaction itself, andanchorsfor anything already confirmed. A reconnect need not redownload a script's history, refetch its transactions, or reprove its anchors — only whatever actually changed.With nothing left to persist,
TxCache's fields (spk_txids,txs,anchors) move directly ontoCache, and the serde-onlypersistmodule (HistoryTx, the anchors-as-seq codec,Subscriptions' hand-writtenSerialize/Deserialize) is gone.The status computation needs care: the Electrum status hash mixes in just
txid:height:, but the protocol orders entries by height and then block position — so two transactions confirmed in the same block hash to a status a server would not recognise unless their order matches the block's.ProvenAnchor::pos(a wallet already has it from the merkle proof) is what orders them correctly, and mempool entries are ordered by the protocol's(-height, tx_hash)rule rather than left arbitrary.same_block_transactions_are_ordered_by_block_positioncovers it.Behaviour changes worth reviewing
An equal-work fork is refused. A same-height reorg carries the same work as what it replaces, so the verified chain keeps what it has — which is what a full node does. A server that genuinely reorged will also have extended, and that tip announcement is what triggers the switch.
a_fork_without_more_work_is_refusedcovers the refusal;anchor_is_refetched_when_tx_moves_to_another_block_of_same_heightcovers the switch when the fork does out-work us.A trusted block the server disagrees with stops the connection. Every header above it descends from a block we do not accept, so there is nothing to reconcile. The old client asked again forever.
A reorg deeper than the re-download window errors rather than walking further back. Marked with a
ponytail:comment.trusted-headers-genTrusting a header means asserting it's canonical, which has to come from somewhere the user actually checked — ideally their own node.
trusted-headers-genis a new workspace crate:cargo run -p trusted-headers-gen -- --url ... --network bitcoinfetches a header from a Bitcoin Core node and writes it out as reviewable, diffable Rust source rather than anything fetched at runtime. Each run only touches the network it's pointed at (a node only ever serves one), merging into whatever heights are already recorded for the others.The default height is the second highest difficulty-adjustment boundary at or below the tip: a boundary because that's what
HeaderChain::newrequires of the highest trusted header, and the second one because the highest can be the tip itself — a block a few confirmations deep is no basis for trust. That puts the anchor 2016..=4032 blocks back.--networkis checked against what the node reports forchain: a header filed under the wrong network is a poisoned anchor, and the node is the one thing that knows which chain it serves. That field is read out of the raw response rather than a typed one — the rest ofgetblockchaininfohas changed shape across Core releases, and this deliberately uses the v17 client surface to stay version-agnostic (the typed version does in fact fail against v28).bdk_electrum_streamingships none of this data, and gains no API from it. The output lands in the current directory and placing it takes an explicit--out, so trusted headers live in the tree of whoever reviewed them as the diff they are. Shipping them from a library would put its release process in the trust path instead of the operator's own node, which is a strictly larger trust surface.Headercan't be built in aconstcontext (its component types have private fields and non-const constructors), so the generated module keeps each network's data as aconstarray of(height, hex)— the auditable source of truth — decoded once intopub static ..._TRUSTED_HEADERS: LazyLock<[(u32, Header); N]>, with atrusted_headers()map over whichever networks are present.It's a separate crate rather than an example because it needs
corepc-client, which has nothing to do with the library and shouldn't reach downstream consumers' dependency trees; it isn'tbdk_electrum_streaming-specific either, only touchingbdk_core'sHeader.Testing
cargo test— 62 tests.bdk_electrum_streaming: 23 unit (15 of themheader_chain, mining real headers and grinding nonces over three parameter sets so each rule actually runs), 29 deterministic (tests/state.rs), 5 live (tests/env.rs).trusted-headers-gen: 5, including one that spins up a realbitcoindand checks the header fetched is the one that node has at that height, that a height above the tip is refused, that a regtest node cannot be talked into answering for mainnet, and that the result survives the round trip out into generated source and back. Verified against Bitcoin Core v28.1, and the generated module was confirmed to compile.The deterministic tests grind nonces: regtest's target is easy but not free, and a little under half of all nonces miss it.
backfills_history_below_the_sync_startmines 101 blocks to the wallet's spk before the client connects and trusts only the tip, so the whole history sits below the sync start.anchor_below_the_trusted_block_is_backfilledis the deterministic version.reorg_to_same_height_block_refetches_anchor_live(issue #12, end to end) mines one extra block, so the fork out-works the chain it replaces — which is what makes a real node switch too.From review
Two holes worth naming, both now fixed with a regression test that fails without the fix:
HeaderChain::applycould accept an equal-work fork. It took the backfill path for any run starting below the base, requiring only that the run reach at least the base. A run that also carried on past the tip took that path while replacing verified blocks — and the backfill path rebuilds rather than comparing work, so the check the extend path exists for was bypassed. Not reachable throughConfirmationJob(its backfills always stop atbase - 1), butapplyis public and its contract read as a lower bound. A backfill must now fill the gap exactly.REORG_WINDOWbelow the verified tip, no run was planned to it, so the job found its remaining runs complete, applied nothing, and handed back the chain it already had as though the server had confirmed it — then asked that server for proofs at heights it does not have. TheREORG_WINDOWdoc already claimed the connection errors out here; now it does.Also:
FetchHeadersre-queues its gaps when a run is incomplete, since a server may return fewer headers than asked for and nothing else would ask again; git dependencies are pinned to a rev rather than trackingmaster; and theSubscriptionsdoc no longer claims statuses are computed "the same way Electrum does" — that holds for confirmed history, but a server may hash its mempool entries in its own index's order.One raised point I did not act on: that
chain.tip() == NonereportingDoneshould be an error. Two attempts at a guard each broke a legitimate flow —anchor_below_the_trusted_block_is_backfilledpasses through exactly that state, because a backfill is planned from heights the histories have yet to name. The condition isn't cleanly separable from healthy operation, and I'd rather leave it than ship a guard I've already seen reject valid syncs twice.Note for reviewers
LocalChain::canonicalizeis stillLocalChain<BlockHash>-only upstream, sotests/env.rsconverts the header chain to a blockhash one to compute balance and chain positions. Nothing to fix in this crate — flagging it as the friction point forCheckPoint<Header>consumers.These crates depend on
bdk_core/bdk_chainfrom git (pinned) becauseCheckPoint<D>andFullScanResponse<K, A, D>are unreleased.cargo publishrejects git dependencies, so a release has to wait for them upstream.🤖 Generated with Claude Code
https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K