feat(proof-gen): confirm requested heights against the attested set, not the source tip - #1365
DylanVerstraete wants to merge 2 commits into
Conversation
PR SummaryMedium Risk Overview
Removed from Deprecated but still accepted: YAML Client-visible change: heights between the latest attestation and the old “confirmed tip” used to surface Reviewed by Cursor Bugbot for commit cc11f5f. Bugbot is set up for automated code reviews on this repo. Configure here. |
…he block hash
Phase 1c, and the second half of 1a, of making the attestor's maturity
decision worth following.
get_block_number_by_tag walked the provider list and returned the first
block any provider served, and it received the full block and returned only
its number. For a safety boundary that is the wrong shape twice over: a
single load-balanced peer on a stale fork could set the boundary for the
whole attestor, and the one datum that pins block identity was thrown away
at the one place it was free.
New get_block_by_tag returns TaggedBlock { number, hash } and asks every
configured provider at once. The lowest reported height is the candidate,
because a provider further along has by definition already passed it. Every
other responder must then have the candidate block under the same hash:
same-height responders already said so, further-along responders are asked
for the block at that height. A different hash is
Error::BlockTagDisagreement, and the lookup refuses to pick a side; the tip
and roots streams already log and retry on the next head. A provider that
reports the tag past a height it cannot serve is broken, not forked, and
fails the lookup outright. One that errors on the confirmation read has
already voted the candidate mature and is warned about, as tag-call errors
were before.
Providers that error or answer null still do not take part, so with one
provider this degenerates to that provider's answer: per-replica provider
diversity in the deployment is what gives the check its teeth, and this is
the code side of that. get_block_number_by_tag stays as a thin wrapper.
The hash stops at the boundary decision on purpose. Fetching the blocks
themselves by hash would serialise get_block and get_block_receipts and
roughly halve archiver throughput; #1362's receipts-to-block hash check
already pins each fetched block's integrity at no cost.
Also removed: EthRpcProvider::get_block_number_by_tag in continuity, dead
since #1365 took the prover's maturity opinion away.
Tests: agreement takes the lowest height and confirms it against the further
provider (both are asked every time); a forked provider is a disagreement at
different heights and at the same height; the disagreement surfaces through
Maturity as a per-head error. The mock chain now salts block hashes so two
mocks can be on one chain or on different forks.
…not the source tip
Phase 2b of making the attested set the single maturity boundary.
validate_blocks used to read the source-chain tip on every proof request
and apply its own reorg window to it: tip - block_confirmation_depth, or the
node's safe/finalized tag for RpcSafe/RpcFinalized chains. That was a second,
independent opinion on maturity, formed against this process's own RPC, that
could only ever disagree with the attestors, in either direction.
It was also redundant. A proof needs an attestation at or above the
requested height to exist, and build_continuity already refuses anything the
attestation and checkpoint caches cannot bracket, returning BlockNotReady.
So the tip check could never admit a height the caches would then serve; it
could only reject one they would have served, or spend an RPC round trip
agreeing with them.
The boundary is now the latest attested height from the caches the cc3 event
subscription keeps warm: above it is BlockNotReady (the same code the
builder raises, so clients see one code for not-yet-attested whichever check
fires first), an empty cache is AttestationsMissing, and the genesis check
is unchanged. The merkle-cache backfill uses the same tip instead of
min(attested, confirmed). No source-chain read remains on the confirmation
path; merkle proofs still read the source for transaction bytes.
Removed: ContinuityConfig::{block_confirmation_depth, confirmation_tag},
ContinuityBuilder::get_confirmed_last_block, and the startup block that
parsed the on-chain MaturityStrategy to reconcile them (the prover no longer
needs the strategy at all; supported-chains-primitives is dropped from its
dependencies). The YAML key and the --block-confirmation-depth flag are kept
as deprecated no-ops that log a warning, so the IaC chart that still renders
the key can be cleaned up on its own schedule instead of taking a fleet down
on the image bump.
Tests: a provider whose tip and tag reads panic drives validate_blocks
through the at-or-below, above, empty-cache and genesis cases, so any
reintroduction of a source read fails at the read.
…he block hash
Phase 1c, and the second half of 1a, of making the attestor's maturity
decision worth following.
get_block_number_by_tag walked the provider list and returned the first
block any provider served, and it received the full block and returned only
its number. For a safety boundary that is the wrong shape twice over: a
single load-balanced peer on a stale fork could set the boundary for the
whole attestor, and the one datum that pins block identity was thrown away
at the one place it was free.
New get_block_by_tag returns TaggedBlock { number, hash } and asks every
configured provider at once. The lowest reported height is the candidate,
because a provider further along has by definition already passed it. Every
other responder must then have the candidate block under the same hash:
same-height responders already said so, further-along responders are asked
for the block at that height. A different hash is
Error::BlockTagDisagreement, and the lookup refuses to pick a side; the tip
and roots streams already log and retry on the next head. A provider that
reports the tag past a height it cannot serve is broken, not forked, and
fails the lookup outright. One that errors on the confirmation read has
already voted the candidate mature and is warned about, as tag-call errors
were before.
Providers that error or answer null still do not take part, so with one
provider this degenerates to that provider's answer: per-replica provider
diversity in the deployment is what gives the check its teeth, and this is
the code side of that. get_block_number_by_tag stays as a thin wrapper.
The hash stops at the boundary decision on purpose. Fetching the blocks
themselves by hash would serialise get_block and get_block_receipts and
roughly halve archiver throughput; #1362's receipts-to-block hash check
already pins each fetched block's integrity at no cost.
Also removed: EthRpcProvider::get_block_number_by_tag in continuity, dead
since #1365 took the prover's maturity opinion away.
Tests: agreement takes the lowest height and confirms it against the further
provider (both are asked every time); a forked provider is a disagreement at
different heights and at the same height; the disagreement surfaces through
Maturity as a per-head error. The mock chain now salts block hashes so two
mocks can be on one chain or on different forks.
8806bde to
01379b8
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 01379b8. Configure here.
… and checkpoint caches Bugbot: cached_attested_height read the checkpoint cache only when the attestation cache was empty, while build_continuity brackets against either. With a pruned attestation cache trailing a newer checkpoint, validate_blocks returned BlockNotReady for heights the proof builder could still serve. Both caches are attested state, so the boundary is the max of the two. Test added for a checkpoint ahead of the attestation cache.
…he block hash
Phase 1c, and the second half of 1a, of making the attestor's maturity
decision worth following.
get_block_number_by_tag walked the provider list and returned the first
block any provider served, and it received the full block and returned only
its number. For a safety boundary that is the wrong shape twice over: a
single load-balanced peer on a stale fork could set the boundary for the
whole attestor, and the one datum that pins block identity was thrown away
at the one place it was free.
New get_block_by_tag returns TaggedBlock { number, hash } and asks every
configured provider at once. The lowest reported height is the candidate,
because a provider further along has by definition already passed it. Every
other responder must then have the candidate block under the same hash:
same-height responders already said so, further-along responders are asked
for the block at that height. A different hash is
Error::BlockTagDisagreement, and the lookup refuses to pick a side; the tip
and roots streams already log and retry on the next head. A provider that
reports the tag past a height it cannot serve is broken, not forked, and
fails the lookup outright. One that errors on the confirmation read has
already voted the candidate mature and is warned about, as tag-call errors
were before.
Providers that error or answer null still do not take part, so with one
provider this degenerates to that provider's answer: per-replica provider
diversity in the deployment is what gives the check its teeth, and this is
the code side of that. get_block_number_by_tag stays as a thin wrapper.
The hash stops at the boundary decision on purpose. Fetching the blocks
themselves by hash would serialise get_block and get_block_receipts and
roughly halve archiver throughput; #1362's receipts-to-block hash check
already pins each fetched block's integrity at no cost.
Also removed: EthRpcProvider::get_block_number_by_tag in continuity, dead
since #1365 took the prover's maturity opinion away.
Tests: agreement takes the lowest height and confirms it against the further
provider (both are asked every time); a forked provider is a disagreement at
different heights and at the same height; the disagreement surfaces through
Maturity as a per-head error. The mock chain now salts block hashes so two
mocks can be on one chain or on different forks.
Overview
Labels (1 changes)
-org.opencontainers.image.created=2026-06-27T04:19:04.617438+00:00
+org.opencontainers.image.created=2026-08-17T09:02:45.677319+00:00
org.opencontainers.image.description=The Ubuntu container image maintained by Canonical
Ubuntu is a Debian-based Linux operating system that runs from the desktop to the cloud, to all your internet connected things.
It is the world's most popular operating system across public clouds and OpenStack clouds.
It is the number one platform for containers; from Docker to Kubernetes to LXD, Ubuntu can run your containers at scale.
Fast, secure and simple, Ubuntu powers millions of PCs worldwide.
org.opencontainers.image.title=ubuntu
org.opencontainers.image.version=26.04Policies (2 improved, 0 worsened)
Packages and Vulnerabilities (47 package changes and 15 vulnerability changes)
Changes for packages of type
|
| Package | Versiongluwa/creditcoin3:latest |
Versiongluwa/creditcoin3:latest |
|
|---|---|---|---|
| ♾️ | base-files | 14ubuntu6.1 |
14ubuntu6.2 |
| ♾️ | bsdutils | 1:2.41.3-3ubuntu2 |
1:2.41.3-3ubuntu2.2 |
| ♾️ | curl | 8.18.0-1ubuntu2.3 |
8.18.0-1ubuntu2.5 |
| ♾️ | diffutils | 1:3.12-1 |
1:3.12-1ubuntu0.1 |
| ♾️ | gnu-coreutils | 9.7-3ubuntu2 |
9.7-3ubuntu2.1 |
| ♾️ | gpgv | 2.4.8-4ubuntu3 |
2.4.8-4ubuntu3.1 |
| ♾️ | libattr1 | 1:2.5.2-4 |
1:2.5.2-4ubuntu0.1 |
| ♾️ | libaudit-common | 1:4.1.2-1build1 |
1:4.1.2-1ubuntu0.1 |
| ♾️ | libaudit1 | 1:4.1.2-1build1 |
1:4.1.2-1ubuntu0.1 |
| ♾️ | libblkid1 | 2.41.3-3ubuntu2 |
2.41.3-3ubuntu2.2 |
| ♾️ | libbz2-1.0 | 1.0.8-6build2 |
1.0.8-6ubuntu0.1 |
| ♾️ | libc-bin | 2.43-2ubuntu2.3 |
2.43-2ubuntu2.4 |
| ♾️ | libc-gconv-modules-extra | 2.43-2ubuntu2.3 |
2.43-2ubuntu2.4 |
| ♾️ | libc6 | 2.43-2ubuntu2.3 |
2.43-2ubuntu2.4 |
| ♾️ | libcurl4t64 | 8.18.0-1ubuntu2.3 |
8.18.0-1ubuntu2.5 |
| ♾️ | libgcrypt20 | 1.12.0-2ubuntu1 |
1.12.0-2ubuntu1.1 |
| ♾️ | libmount1 | 2.41.3-3ubuntu2 |
2.41.3-3ubuntu2.2 |
| ♾️ | libpam-modules | 1.7.0-5ubuntu3.1 |
1.7.0-5ubuntu3.2 |
| ♾️ | libpam-modules-bin | 1.7.0-5ubuntu3.1 |
1.7.0-5ubuntu3.2 |
| ♾️ | libpam-runtime | 1.7.0-5ubuntu3.1 |
1.7.0-5ubuntu3.2 |
| ♾️ | libpam0g | 1.7.0-5ubuntu3.1 |
1.7.0-5ubuntu3.2 |
| ♾️ | libpq5 | 18.4-0ubuntu0.26.04.1 |
18.6-0ubuntu0.26.04.1 |
| ♾️ | libsmartcols1 | 2.41.3-3ubuntu2 |
2.41.3-3ubuntu2.2 |
| ♾️ | libssh2-1t64 | 1.11.1-1ubuntu0.26.04.3 |
1.11.1-1ubuntu0.26.04.4 |
| ♾️ | libssl3t64 | 3.5.5-1ubuntu3.3 |
3.5.5-1ubuntu3.5 |
| ♾️ | libsystemd0 | 259.5-0ubuntu3.3 |
259.5-0ubuntu3.4 |
| ♾️ | libudev1 | 259.5-0ubuntu3.3 |
259.5-0ubuntu3.4 |
| ♾️ | libuuid1 | 2.41.3-3ubuntu2 |
2.41.3-3ubuntu2.2 |
| ♾️ | login | 1:4.16.0-2+really2.41.3-3ubuntu2 |
1:4.16.0-2+really2.41.3-3ubuntu2.2 |
| ♾️ | mount | 2.41.3-3ubuntu2 |
2.41.3-3ubuntu2.2 |
| ♾️ | openssl | 3.5.5-1ubuntu3.3 |
3.5.5-1ubuntu3.5 |
| ♾️ | openssl-provider-legacy | 3.5.5-1ubuntu3.3 |
3.5.5-1ubuntu3.5 |
| ♾️ | perl-base | 5.40.1-7ubuntu0.1 |
5.40.1-7ubuntu0.3 |
| ♾️ | util-linux | 2.41.3-3ubuntu2 |
2.41.3-3ubuntu2.2 |
| ♾️ | zlib1g | 1:1.3.dfsg+really1.3.1-1ubuntu3 |
1:1.3.dfsg+really1.3.1-1ubuntu3.1 |
Changes for packages of type golang (6 changes)
Changes for packages of type npm (6 changes)
| Package | Versiongluwa/creditcoin3:latest |
Versiongluwa/creditcoin3:latest |
|
|---|---|---|---|
| ♾️ | @types/node | 26.1.2 |
22.7.5 |
| ♾️ | node-gyp | 13.0.1 |
13.0.2 |
| ♾️ | picomatch | 4.0.5 |
4.0.7 |
| ♾️ | undici | 8.10.0 |
8.10.2 |
| ♾️ | undici-types | 8.3.0 |
6.21.0 |
| ♾️ | ws | 8.21.2 |
8.21.3 |
…he block hash
Phase 1c, and the second half of 1a, of making the attestor's maturity
decision worth following.
get_block_number_by_tag walked the provider list and returned the first
block any provider served, and it received the full block and returned only
its number. For a safety boundary that is the wrong shape twice over: a
single load-balanced peer on a stale fork could set the boundary for the
whole attestor, and the one datum that pins block identity was thrown away
at the one place it was free.
New get_block_by_tag returns TaggedBlock { number, hash } and asks every
configured provider at once. The lowest reported height is the candidate,
because a provider further along has by definition already passed it. Every
other responder must then have the candidate block under the same hash:
same-height responders already said so, further-along responders are asked
for the block at that height. A different hash is
Error::BlockTagDisagreement, and the lookup refuses to pick a side; the tip
and roots streams already log and retry on the next head. A provider that
reports the tag past a height it cannot serve is broken, not forked, and
fails the lookup outright. One that errors on the confirmation read has
already voted the candidate mature and is warned about, as tag-call errors
were before.
Providers that error or answer null still do not take part, so with one
provider this degenerates to that provider's answer: per-replica provider
diversity in the deployment is what gives the check its teeth, and this is
the code side of that. get_block_number_by_tag stays as a thin wrapper.
The hash stops at the boundary decision on purpose. Fetching the blocks
themselves by hash would serialise get_block and get_block_receipts and
roughly halve archiver throughput; #1362's receipts-to-block hash check
already pins each fetched block's integrity at no cost.
Also removed: EthRpcProvider::get_block_number_by_tag in continuity, dead
since #1365 took the prover's maturity opinion away.
Tests: agreement takes the lowest height and confirms it against the further
provider (both are asked every time); a forked provider is a disagreement at
different heights and at the same height; the disagreement surfaces through
Maturity as a per-head error. The mock chain now salts block hashes so two
mocks can be on one chain or on different forks.
…he block hash
Phase 1c, and the second half of 1a, of making the attestor's maturity
decision worth following.
get_block_number_by_tag walked the provider list and returned the first
block any provider served, and it received the full block and returned only
its number. For a safety boundary that is the wrong shape twice over: a
single load-balanced peer on a stale fork could set the boundary for the
whole attestor, and the one datum that pins block identity was thrown away
at the one place it was free.
New get_block_by_tag returns TaggedBlock { number, hash } and asks every
configured provider at once. The lowest reported height is the candidate,
because a provider further along has by definition already passed it. Every
other responder must then have the candidate block under the same hash:
same-height responders already said so, further-along responders are asked
for the block at that height. A different hash is
Error::BlockTagDisagreement, and the lookup refuses to pick a side; the tip
and roots streams already log and retry on the next head. A provider that
reports the tag past a height it cannot serve is broken, not forked, and
fails the lookup outright. One that errors on the confirmation read has
already voted the candidate mature and is warned about, as tag-call errors
were before.
Providers that error or answer null still do not take part, so with one
provider this degenerates to that provider's answer: per-replica provider
diversity in the deployment is what gives the check its teeth, and this is
the code side of that. get_block_number_by_tag stays as a thin wrapper.
The hash stops at the boundary decision on purpose. Fetching the blocks
themselves by hash would serialise get_block and get_block_receipts and
roughly halve archiver throughput; #1362's receipts-to-block hash check
already pins each fetched block's integrity at no cost.
Also removed: EthRpcProvider::get_block_number_by_tag in continuity, dead
since #1365 took the prover's maturity opinion away.
Tests: agreement takes the lowest height and confirms it against the further
provider (both are asked every time); a forked provider is a disagreement at
different heights and at the same height; the disagreement surfaces through
Maturity as a per-head error. The mock chain now salts block hashes so two
mocks can be on one chain or on different forks.
…he block hash
Phase 1c, and the second half of 1a, of making the attestor's maturity
decision worth following.
get_block_number_by_tag walked the provider list and returned the first
block any provider served, and it received the full block and returned only
its number. For a safety boundary that is the wrong shape twice over: a
single load-balanced peer on a stale fork could set the boundary for the
whole attestor, and the one datum that pins block identity was thrown away
at the one place it was free.
New get_block_by_tag returns TaggedBlock { number, hash } and asks every
configured provider at once. The lowest reported height is the candidate,
because a provider further along has by definition already passed it. Every
other responder must then have the candidate block under the same hash:
same-height responders already said so, further-along responders are asked
for the block at that height. A different hash is
Error::BlockTagDisagreement, and the lookup refuses to pick a side; the tip
and roots streams already log and retry on the next head. A provider that
reports the tag past a height it cannot serve is broken, not forked, and
fails the lookup outright. One that errors on the confirmation read has
already voted the candidate mature and is warned about, as tag-call errors
were before.
Providers that error or answer null still do not take part, so with one
provider this degenerates to that provider's answer: per-replica provider
diversity in the deployment is what gives the check its teeth, and this is
the code side of that. get_block_number_by_tag stays as a thin wrapper.
The hash stops at the boundary decision on purpose. Fetching the blocks
themselves by hash would serialise get_block and get_block_receipts and
roughly halve archiver throughput; #1362's receipts-to-block hash check
already pins each fetched block's integrity at no cost.
Also removed: EthRpcProvider::get_block_number_by_tag in continuity, dead
since #1365 took the prover's maturity opinion away.
Tests: agreement takes the lowest height and confirms it against the further
provider (both are asked every time); a forked provider is a disagreement at
different heights and at the same height; the disagreement surfaces through
Maturity as a per-head error. The mock chain now salts block hashes so two
mocks can be on one chain or on different forks.

Phase 2b of the plan to make the attested set the single maturity boundary: https://claude.ai/code/artifact/400f8668-7b43-4b11-86c8-b8922d8c85ee
What was there
validate_blocksread the source-chain tip on every proof request and applied its own reorg window to it:tip − block_confirmation_depth, or the node'ssafe/finalizedtag forRpcSafe/RpcFinalizedchains. Startup parsed the on-chainMaturityStrategyto reconcile that window with the attestors, refused to start on some combinations and warned on others.That is a second, independent opinion on maturity, formed against this process's own RPC. It can only ever disagree with the attestors, in either direction.
Why it was safe to delete rather than harden
A proof needs an attestation at or above the requested height to exist.
build_continuityalready refuses anything the attestation and checkpoint caches cannot bracket, returningBlockNotReady. So the tip check could never admit a height the caches would then serve. It could only reject one they would have served, or spend an RPC round trip agreeing with them. I checked the "eager" predicted-upper-bound path referenced in an old doc comment: the service never calls it, the builder only reaches it withcurrent_block: None, so it was dead in this process too.What is there now
The boundary is the latest attested height from the caches the cc3 event subscription keeps warm.
BlockNotReady { block_number, last_attested_block }, the same code the builder raises, so a client sees one code for not-yet-attested whichever check fires firstAttestationsMissingmin(attested, confirmed)No source-chain read remains on the confirmation path. Merkle proofs still read the source for transaction bytes; only the boundary decision moved.
Removed
ContinuityConfig::{block_confirmation_depth, confirmation_tag},ContinuityBuilder::get_confirmed_last_blockand its tests, the startup reconciliation block, and the prover'ssupported-chains-primitivesdependency (nothing in the prover needs the strategy any more).Kept, deprecated
The YAML key
block_confirmation_depthand the--block-confirmation-depth/BLOCK_CONFIRMATION_DEPTHflag still parse and log a warning. Thecreditcoin-proof-gen-apichart in cc-networks-iac renders that key today; a hard removal would fail every pod on the image bump. Clean-up of the chart is a separate IaC change.Behaviour change worth knowing
A height between the latest attestation and the old confirmed tip used to fail with
BlockNotOnSourceChain(404). It now fails withBlockNotReady(422). Both areretriable(), and 422 is the honest code: the block exists, it is just not attested yet. The 404 variant still exists for the builder's upper-bound mapping and keeps its wire shape.Tests
attested_boundary_testsincontinuity_service/helpers.rs. The ETH provider'sget_last_blockandget_block_number_by_tagpanic, so any reintroduction of a source read fails at the read, not at an assertion. Covers at-or-below, above, empty cache, genesis-first.cargo test -p continuity -p proof-gen-api-servergreen, clippy-D warningsacross continuity, proof-gen, archiver, eth, attestor, fmt, taplo 0.10.0, machete.