Skip to content

feat(bdk_electrum_streaming)!: Verify proof-of-work against trusted headers - #22

Draft
evanlinjin wants to merge 9 commits into
mainfrom
claude/verify-pow-rebased
Draft

feat(bdk_electrum_streaming)!: Verify proof-of-work against trusted headers#22
evanlinjin wants to merge 9 commits into
mainfrom
claude/verify-pow-rebased

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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.

HeaderChain

A CheckPoint<Header> anchored to a set of user-provided trusted headers. Syncing starts at highest_trusted + 1, so the first header's prev_blockhash pins 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 via prev_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.

ProvenAnchor

Transactions 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> becomes FullScanResponse<K, ProvenAnchor, Header>. No block time on the anchor — the header it was proved against travels with every update in chain_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::headers is gone — only the verified chain may hold a header.

Do not replan a run that can never be fetched

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. The fix distinguishes 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.

Cache no longer persists

Everything in it can be rebuilt from what a wallet already knows, so Cache doesn't need to round-trip through serde any more. 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 — no bdk_chain dependency, just a TxConfirmationStatus enum (Confirmed(ProvenAnchor) or Mempool { confirmed_inputs }). It rebuilds all four fields: subscriptions and spk_txids from computing the same status hash a server would, txs from the transaction itself, and anchors for 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 onto Cache, and the serde-only persist module (HistoryTx, the anchors-as-seq codec, Subscriptions' hand-written Serialize/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_position covers 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_refused covers the refusal; anchor_is_refetched_when_tx_moves_to_another_block_of_same_height covers 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-gen

Trusting a header means asserting it's canonical, which has to come from somewhere the user actually checked — ideally their own node. trusted-headers-gen is a new workspace crate: cargo run -p trusted-headers-gen -- --url ... --network bitcoin fetches 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::new requires 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.

--network is checked against what the node reports for chain: 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 deliberately uses the v17 client surface to stay version-agnostic (the typed version does in fact fail against v28).

bdk_electrum_streaming ships 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.

Header can't be built in a const context (its component types have private fields and non-const constructors), so the generated module keeps each network's data as a const array of (height, hex) — the auditable source of truth — decoded once into pub static ..._TRUSTED_HEADERS: LazyLock<[(u32, Header); N]>, with a trusted_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't bdk_electrum_streaming-specific either, only touching bdk_core's Header.

Testing

cargo test — 62 tests. bdk_electrum_streaming: 23 unit (15 of them header_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 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. 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_start mines 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_backfilled is 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::apply could 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 through ConfirmationJob (its 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.
  • A lagging server was silently accommodated. For a target more than REORG_WINDOW below 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. The REORG_WINDOW doc already claimed the connection errors out here; now it does.

Also: FetchHeaders re-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 tracking master; and the Subscriptions doc 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() == None reporting Done should be an error. Two attempts at a guard each broke a legitimate flow — anchor_below_the_trusted_block_is_backfilled passes 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::canonicalize is still LocalChain<BlockHash>-only upstream, so tests/env.rs converts 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 for CheckPoint<Header> consumers.

These crates depend on bdk_core/bdk_chain from git (pinned) because CheckPoint<D> and FullScanResponse<K, A, D> are unreleased. cargo publish rejects git dependencies, so a release has to wait for them upstream.

🤖 Generated with Claude Code

https://claude.ai/code/session_013Ga32WdTURWnpMDxAckM2K

@evanlinjin
evanlinjin force-pushed the claude/verify-pow-rebased branch from 3fcbc8b to cb7ac58 Compare August 31, 2026 15:01
@evanlinjin
evanlinjin force-pushed the claude/verify-pow-rebased branch 2 times, most recently from f51b269 to e045060 Compare September 1, 2026 02:38
@evanlinjin
evanlinjin force-pushed the claude/issue-12-verify-fix-68dq8f branch from c477f1e to d636eb8 Compare September 1, 2026 03:02
@evanlinjin
evanlinjin force-pushed the claude/verify-pow-rebased branch from e045060 to 084fcbf Compare September 1, 2026 03:05
@evanlinjin
evanlinjin force-pushed the claude/issue-12-verify-fix-68dq8f branch 8 times, most recently from d3ff1b1 to 0a756eb Compare September 1, 2026 11:37
…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
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
evanlinjin force-pushed the claude/verify-pow-rebased branch from 084fcbf to f2aad11 Compare September 2, 2026 02:38
@evanlinjin
evanlinjin changed the base branch from claude/issue-12-verify-fix-68dq8f to main September 2, 2026 02:41
Comment thread trusted-headers-gen/src/main.rs Outdated
@evanlinjin
evanlinjin force-pushed the claude/verify-pow-rebased branch from fa9b6ea to 56a4aae Compare September 2, 2026 03:38
evanlinjin and others added 2 commits September 2, 2026 03:40
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
evanlinjin force-pushed the claude/verify-pow-rebased branch from 56a4aae to 66d31fa Compare September 2, 2026 03:50
evanlinjin and others added 5 commits September 2, 2026 04:17
`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
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.

1 participant