Skip to content

Derive the block.headers cp_height proof from the last returned header. - #955

Merged
evoskuil merged 3 commits into
libbitcoin:masterfrom
echennells:fix-cp-height-proof-target
Sep 20, 2026
Merged

evoskuil merged 3 commits into
libbitcoin:masterfrom
echennells:fix-cp-height-proof-target

Conversation

@echennells

Copy link
Copy Markdown
Contributor

blockchain.block.headers computes the cp_height branch from the requested last header, while the returned set is clamped to maximum_headers and may shorten further under a reorg. The branch then proves a header absent from the response.

The protocol defines branch as the merkle branch of the last returned header, present only when the returned count is non-zero. Both now derive from links.size(). The request bounds check is unchanged: the protocol states start_height + (count - 1) <= cp_height in requested terms.

Test: a request over maximum_headers proves the last returned header, not the requested one. Fulcrum returns the same branch for both.

@evoskuil
evoskuil force-pushed the fix-cp-height-proof-target branch from e3ebdb5 to 2e57c67 Compare September 20, 2026 15:50
@evoskuil

Copy link
Copy Markdown
Member

The documented precondition start_height + (count - 1) <= cp_height is stated over the requested count, while the same method's Result section defines branch as the merkle branch of "the last returned header" and explicitly contemplates the server returning fewer than requested. The precondition is stricter than the proof needs — a vestige of a model in which count is never clamped.

Both reference implementations apply it to the returned count. ElectrumX clamps to max_size and to the available chain, then validates start_height + count - 1 <= cp_height <= chain_height inside _merkle_proof, over the clamped count. Fulcrum does the same explicitly: count = std::min(std::min(unsigned(tip+1) - height, count), MAX_COUNT); and only then if (count && cp_height) { if (!(height + (count - 1) <= cp_height && cp_height <= unsigned(tip))) throw RPCError(...); }, rejecting with Code_App_BadRequest. Neither tests the requested count. electrs never implemented cp_height at all.

The effect of the stricter reading is that a request whose served range lies at or below the checkpoint is rejected when the unserved remainder would have exceeded it, and that a request extending past the chain top is rejected rather than clamped. Deriving the proof from the last returned header without moving the guard leaves two different notions of "last" in the same function.

This applies the requirement to the height actually proven. The check follows the header index query, because the returned count can be reduced by both the configured maximum and the chain top, so no pre-clamp test is equivalent; it precedes all header serialization, as it does in Fulcrum. The guard cannot be dropped in favor of the query's own target > waypoint test, which reports database::error::invalid_argument and translates to daemon_error rather than bad_request. Unlike Fulcrum, which derives lastHeight from its pre-fetch count, the proven height here comes from the headers actually returned.

Coverage of the chain-top case surfaced libbitcoin/libbitcoin-database#941: merge_merkle resolved a padding sibling from the last leaf rather than the duplicated own node, so proofs at an odd cp_height did not fold to the root they were served with. That is fixed and merged; the test here requires it.

@evoskuil
evoskuil merged commit 0054e2e into libbitcoin:master Sep 20, 2026
7 of 15 checks passed
@echennells
echennells deleted the fix-cp-height-proof-target branch September 21, 2026 02:03
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.

2 participants