Skip to content

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

Draft
evanlinjin wants to merge 1 commit into
mainfrom
feat/verify-pow
Draft

feat(bdk_electrum_streaming): Verify proof-of-work against trusted headers#10
evanlinjin wants to merge 1 commit into
mainfrom
feat/verify-pow

Conversation

@evanlinjin

@evanlinjin evanlinjin commented Aug 17, 2026

Copy link
Copy Markdown
Owner

The client took the server's word for chain data. ChainJob fetched a 21-block suffix around the tip purely to move the CheckPoint forward, single headers were pulled on demand into a HashMap<BlockHash, Header>, and nothing was ever checked. A merkle proof was validated against whatever header the server returned for that height, so ConfirmationBlockTime 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

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:

  1. match the trusted header at its height, if one was given,
  2. link to the block below it via prev_blockhash,
  3. claim the difficulty consensus requires at its height (fixed within a retarget period, recomputed at each boundary via CompactTarget::from_header_difficulty_adjustment), and
  4. hash below that difficulty's target, and below the network's PoW limit.

A reorg on top of that is accepted only if it brings more work than the blocks it replaces. Since everything below the run's start height is untouched, comparing the two chains from there is the same as comparing their totals — so it's an O(reorg depth) sum, not a full-chain one. Re-applying blocks we already have (which the reorg window does on every new tip) is not a reorg and is exempt.

Anything that fails leaves the chain untouched and errors out of State::advance, which tears down the run loop. That covers a header conflicting with a trusted block directly, and — via a post-apply check — a reorg that would evict a trusted block without naming it (a shorter-but-harder fork at height 4 that silently drops the trusted block at 8).

Genesis comes from the network params rather than being downloaded, and every trusted block goes into the checkpoint, so chain_update connects to a LocalChain. Between the trusted blocks and the sync start there are gaps.

Difficulty, and why trusted blocks are headers

Rule 3 is what makes rule 4 mean anything: without it a server can claim a trivial target and mine a fake chain for nothing. Recomputing a retarget at boundary H needs time at H - 2016 and bits/time at H - 1, so a bare blockhash at the anchor is not enough — hence Header, which carries both and self-verifies against its own hash (anyone holding only a hash can fetch the header from anywhere and check block_hash()).

On networks where difficulty actually moves (!allow_min_difficulty_blocks && !no_pow_retargeting), HeaderChain::new requires the highest trusted block to sit on a difficulty-adjustment boundary. Every retarget above it is then recomputed from a header already in hand — the anchor itself for the first one, downloaded headers after that — so there is no adjustment 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, so its difficulty needs no checking at all.

ProvenAnchor

Transactions were already merkle-proved, but against an unverified header. The proof is now checked against a header in the verified chain, and is kept rather than discarded: ProvenAnchor { block_id, pos, merkle }, with impl Anchor. 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.

Backfill

A transaction confirmed below the sync start can't be verified from the synced range. HeaderJob::backfill extends the chain downwards from just above the highest trusted block at or below the transaction's height, up to where the chain already begins, and the rebuilt checkpoint is trusted blocks ++ run ++ whatever was above. State::ensure_backfill starts one when any spk job's lowest pending anchor has no verified header.

ChainJob and the old blockchain.block.header requests collapse into a single HeaderJob: fetch a contiguous range, re-request until the server has handed over all of it (they cap batches at ~2016), apply in one go. Two constructors, to_tip and backfill, and one process.

Dependencies

CheckPoint<Header> means bdk_core and bdk_chain now come from bitcoindevkit/bdk master. miniscript bumped to 13 to match, rust-version to 1.85.

Deliberate limit

Marked with a ponytail: comment in the source: the re-download window is 20 blocks, and a reorg deeper than that errors the connection rather than walking further back. Same depth as the old CHAIN_SUFFIX_LENGTH.

Testing

18 unit tests. The header_chain ones mine real headers and grind nonces, over three parameter sets so each rule actually runs: regtest with allow_min_difficulty_blocks off (difficulty pinned within a period), regtest with retargeting on over a 10-block period (retargets are cheap to mine), and plain regtest (so a fork can be harder than the chain it replaces). Covered: valid chain, bad PoW, broken link, difficulty change mid-period, run that doesn't link to the trusted block, direct trusted conflict, reorg with more work, reorg with less work, re-applying the same headers, reorg that displaces a trusted block, backfill, backfill that doesn't reach the base, trusted anchor off the retarget boundary, and every retarget above a boundary anchor being recomputed (a faked bits at height 20 is caught from the trusted header at 10).

A new backfills_history_below_the_sync_start integration test mines 101 blocks to the wallet's spk before the client connects and trusts only the tip, so the entire history sits below the sync start. It takes the trusted header from the server but holds it to env.get_block_hash. Log confirms the path: sync starts at 102, an spk job needs height 2, backfill runs, base drops to 1.

cargo test (21 tests, including the env integration tests), cargo fmt --check, and cargo clippy all pass; the only clippy warnings are the three pre-existing ones in tests/env.rs.

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. Nothing to fix in this crate — flagging it as the friction point for CheckPoint<Header> consumers.

🤖 Generated with Claude Code

…aders

The client took the server's word for chain data. Headers were fetched
only as a 21-block suffix around the tip, kept in a `HashMap` keyed by
blockhash, and never checked for anything: a merkle proof was validated
against whatever header the server happened to return for that height,
so the proof only ever said "this server says so".

Introduce `HeaderChain`: a `CheckPoint<Header>` anchored to a set of
user-provided trusted headers. Syncing starts one block above the
highest trusted block, so the first header's `prev_blockhash` pins the
run to a block the user vouched for, and every header from there up must
link to the block below it, claim the difficulty consensus requires at
its height, and hash below that difficulty's target. A reorg is accepted
only if it brings more work than the blocks it replaces. A header that
conflicts with a trusted block -- or a reorg that would evict one
without naming it -- errors out of `State::advance` and takes the
connection down with it. Genesis is derived from the network params
rather than downloaded, and every trusted block goes into the
checkpoint, so the `chain_update` still connects to a `LocalChain`.

Difficulty is what makes proof-of-work mean anything -- without it a
server can claim a trivial target and mine a fake chain cheaply -- and
recomputing a retarget needs the header that opened the previous
period. Trusted blocks are therefore headers rather than blockhashes
(the timestamp and bits are both needed, and a header self-verifies
against its hash), and on networks where difficulty moves the highest
trusted block must sit on a difficulty-adjustment boundary. Every
retarget above it is then recomputed from a header we already hold,
with no gap 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, so its difficulty needs no checking.

Transactions were already merkle-proved; the proof is now checked
against a verified header and kept, as `ProvenAnchor { block_id, pos,
merkle }`. `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 in `chain_update`.

A transaction confirmed below the sync start cannot be verified from the
synced range, so `HeaderJob::backfill` extends the chain downwards from
just above the highest trusted block at or below it, up to where the
chain already begins. `ChainJob` and the old single-header requests
collapse into one `HeaderJob` that fetches a contiguous range,
re-requesting until the server has handed over all of it, and applies it
in one go.

This needs `CheckPoint<Header>`, so `bdk_core` and `bdk_chain` now come
from `bitcoindevkit/bdk` master.

One limit is deliberate and marked in the source: a reorg deeper than 20
blocks errors the connection instead of walking further back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@evanlinjin evanlinjin changed the title feat(bdk_electrum_streaming): Verify proof-of-work against trusted blockhashes feat(bdk_electrum_streaming): Verify proof-of-work against trusted headers Aug 17, 2026
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