feat(bdk_electrum_streaming): Verify proof-of-work against trusted headers - #10
Draft
evanlinjin wants to merge 1 commit into
Draft
feat(bdk_electrum_streaming): Verify proof-of-work against trusted headers#10evanlinjin wants to merge 1 commit into
evanlinjin wants to merge 1 commit into
Conversation
…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
force-pushed
the
feat/verify-pow
branch
from
August 17, 2026 10:02
aeae223 to
5706e8c
Compare
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.
ChainJobfetched a 21-block suffix around the tip purely to move theCheckPointforward, single headers were pulled on demand into aHashMap<BlockHash, Header>, and nothing was ever checked. A merkle proof was validated against whatever header the server returned for that height, soConfirmationBlockTimeonly ever meant "this server says so" — a server could invent a height, hand back a matching fake header, and the proof would pass.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:prev_blockhash,CompactTarget::from_header_difficulty_adjustment), andA 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_updateconnects to aLocalChain. 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
HneedstimeatH - 2016andbits/timeatH - 1, so a bare blockhash at the anchor is not enough — henceHeader, which carries both and self-verifies against its own hash (anyone holding only a hash can fetch the header from anywhere and checkblock_hash()).On networks where difficulty actually moves (
!allow_min_difficulty_blocks && !no_pow_retargeting),HeaderChain::newrequires 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.ProvenAnchorTransactions 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 }, withimpl Anchor.Update<K>becomesFullScanResponse<K, ProvenAnchor, Header>. No block time on the anchor — the header it was proved against travels with every update inchain_update.Backfill
A transaction confirmed below the sync start can't be verified from the synced range.
HeaderJob::backfillextends 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 istrusted blocks ++ run ++ whatever was above.State::ensure_backfillstarts one when any spk job's lowest pending anchor has no verified header.ChainJoband the oldblockchain.block.headerrequests collapse into a singleHeaderJob: 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_tipandbackfill, and oneprocess.Dependencies
CheckPoint<Header>meansbdk_coreandbdk_chainnow come frombitcoindevkit/bdkmaster.miniscriptbumped to 13 to match,rust-versionto 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 oldCHAIN_SUFFIX_LENGTH.Testing
18 unit tests. The
header_chainones mine real headers and grind nonces, over three parameter sets so each rule actually runs: regtest withallow_min_difficulty_blocksoff (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 fakedbitsat height 20 is caught from the trusted header at 10).A new
backfills_history_below_the_sync_startintegration 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 toenv.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 theenvintegration tests),cargo fmt --check, andcargo clippyall pass; the only clippy warnings are the three pre-existing ones intests/env.rs.Note for reviewers
LocalChain::canonicalizeis stillLocalChain<BlockHash>-only upstream, sotests/env.rsconverts the header chain to a blockhash one to compute balance. Nothing to fix in this crate — flagging it as the friction point forCheckPoint<Header>consumers.🤖 Generated with Claude Code