Skip to content

feat(proof-gen): confirm requested heights against the attested set, not the source tip - #1365

Open
DylanVerstraete wants to merge 2 commits into
feat/hash-pinned-source-readsfrom
feat/prover-attested-boundary
Open

DylanVerstraete wants to merge 2 commits into
feat/hash-pinned-source-readsfrom
feat/prover-attested-boundary

Conversation

@DylanVerstraete

@DylanVerstraete DylanVerstraete commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Stack (bottom → top; each PR's diff is only its own commits, review in this order)

PR Phase What Base
   #1330 Phase 0 RpcSafe / RpcFinalized maturity strategies usc-dev
   #1362 Phase 1a receipts must carry the fetched block's hash #1330
#1365 Phase 2b prover confirms against the attestation cache #1362
   #1366 Phase 2a archiver follows the latest attested height #1365
   #1367 Phase 1c block tags resolved by cross-provider agreement #1366

Plan: https://claude.ai/code/artifact/400f8668-7b43-4b11-86c8-b8922d8c85ee · Live run against Sepolia safe via Chainstack (2026-09-15): attested heights tracked safe within one interval, archiver ≤ attested ≤ safe at every sample, prover 200 at attested / 422 one above; survived a 20-min RPC outage without restarts.

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_blocks read the source-chain tip on every proof request and applied its own reorg window to it: tip − block_confirmation_depth, or the node's safe/finalized tag for RpcSafe/RpcFinalized chains. Startup parsed the on-chain MaturityStrategy to 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_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. I checked the "eager" predicted-upper-bound path referenced in an old doc comment: the service never calls it, the builder only reaches it with current_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.

  • above it → 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 first
  • empty cache → AttestationsMissing
  • genesis check unchanged
  • merkle-cache backfill tip is the attested height, no longer min(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_block and its tests, the startup reconciliation block, and the prover's supported-chains-primitives dependency (nothing in the prover needs the strategy any more).

Kept, deprecated

The YAML key block_confirmation_depth and the --block-confirmation-depth / BLOCK_CONFIRMATION_DEPTH flag still parse and log a warning. The creditcoin-proof-gen-api chart 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 with BlockNotReady (422). Both are retriable(), 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_tests in continuity_service/helpers.rs. The ETH provider's get_last_block and get_block_number_by_tag panic, 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-server green, clippy -D warnings across continuity, proof-gen, archiver, eth, attestor, fmt, taplo 0.10.0, machete.

@DylanVerstraete
DylanVerstraete requested review from a team, BradleyOlson64, beqaabu, creditcoinprotoclaw, didac-gluwa, jakerumbles and mdbig1 and removed request for a team September 15, 2026 11:37
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes the core gate for which block heights can receive proofs and alters HTTP error codes for some in-flight heights; behavior is intentionally aligned with attestors but operators should expect BlockNotReady where BlockNotOnSourceChain appeared before.

Overview
The proof-gen API stops treating the source-chain tip (tip − depth, safe/finalized tags, or on-chain MaturityStrategy reconciliation at startup) as the serving boundary. Whether a height may be served is decided only against the attested set kept in CC3-driven attestation/checkpoint caches.

validate_blocks now rejects blocks above max(latest attestation, latest checkpoint) with BlockNotReady (same as the builder), returns AttestationsMissing when caches are empty, and does not call the ETH RPC on that path. Merkle backfill uses the attested height as cache_tip instead of min(attested, confirmed).

Removed from continuity: block_confirmation_depth, confirmation_tag, and ContinuityBuilder::get_confirmed_last_block. The prover drops supported-chains-primitives and the startup maturity-strategy wiring.

Deprecated but still accepted: YAML block_confirmation_depth and CLI/env --block-confirmation-depth log warnings and are ignored so existing charts keep parsing.

Client-visible change: heights between the latest attestation and the old “confirmed tip” used to surface BlockNotOnSourceChain; they now surface BlockNotReady (422, retriable). New attested_boundary_tests panic if validation reads the source tip again.

Reviewed by Cursor Bugbot for commit cc11f5f. Bugbot is set up for automated code reviews on this repo. Configure here.

@DylanVerstraete
DylanVerstraete added this pull request to stack #1368 September 15, 2026 13:40
DylanVerstraete added a commit that referenced this pull request Sep 15, 2026
…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.
DylanVerstraete added a commit that referenced this pull request Sep 16, 2026
…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.
@DylanVerstraete
DylanVerstraete force-pushed the feat/prover-attested-boundary branch from 8806bde to 01379b8 Compare September 16, 2026 08:57

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread proof-gen-api-server/src/services/continuity_service/mod.rs
… 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.
DylanVerstraete added a commit that referenced this pull request Sep 16, 2026
…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.
@gluwa-bot

Copy link
Copy Markdown
Contributor

Overview

Image reference gluwa/creditcoin3:latest gluwa/creditcoin3:latest
- digest dfb918c3c546 508637fd1249
- tag latest latest
- provenance 7e92a94 2590431
- vulnerabilities critical: 3 high: 16 medium: 21 low: 4 unspecified: 13 critical: 2 high: 13 medium: 11 low: 3 unspecified: 11
- platform linux/amd64 linux/amd64
- size 430 MB 431 MB (+615 kB)
- packages 405 404 (-1)
Base Image ubuntu:26.04
also known as:
latest
resolute
ubuntu:26.04
also known as:
latest
resolute
rolling
- vulnerabilities critical: 1 high: 7 medium: 12 low: 1 unspecified: 2 critical: 1 high: 5 medium: 2 low: 0
Labels (1 changes)
  • ± 1 changed
  • 3 unchanged
-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.04
Policies (2 improved, 0 worsened)
Policy Name gluwa/creditcoin3:latest gluwa/creditcoin3:latest Change Standing
Default non-root user No Change
No copyleft licenses ⚠️ 373 ⚠️ 361 -12 Improved
No fixable critical or high vulnerabilities ⚠️ 19 ⚠️ 15 -4 Improved
No high-profile vulnerabilities No Change
No outdated base images ⚠️ ⚠️ No Change
No unapproved base images No Change
Supply chain attestations No Change
Packages and Vulnerabilities (47 package changes and 15 vulnerability changes)
  • ➖ 1 packages removed
  • ♾️ 46 packages changed
  • 337 packages unchanged
  • ✔️ 15 vulnerabilities removed
Changes for packages of type deb (35 changes)
Package Version
gluwa/creditcoin3:latest
Version
gluwa/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)
Package Version
gluwa/creditcoin3:latest
Version
gluwa/creditcoin3:latest
♾️ github.com/canonical/pebble 1.31.1-0.20260528050051-33f10658d3fd 1.32.2-0.20260721212935-faa1696b477d
♾️ github.com/gorilla/websocket 1.5.1 1.5.3
critical: 0 high: 0 medium: 1 low: 0
Removed vulnerabilities (1):
  • medium : GHSA--w67g--5rqw--f597
golang.org/x/net 0.40.0
critical: 1 high: 2 medium: 7 low: 0
Removed vulnerabilities (10):
  • critical : CVE--2026--39821
  • high : CVE--2026--46600
  • high : CVE--2026--33814
  • medium : CVE--2026--25680
  • medium : CVE--2026--42506
  • medium : CVE--2026--42502
  • medium : CVE--2026--27136
  • medium : CVE--2026--25681
  • medium : CVE--2025--58190
  • medium : CVE--2025--47911
♾️ golang.org/x/sys 0.33.0 0.46.0
critical: 0 high: 0 medium: 0 low: 1
Removed vulnerabilities (1):
  • low : CVE--2026--39824
♾️ golang.org/x/term 0.32.0 0.44.0
♾️ stdlib 1.26.3 1.26.5
critical: 1 high: 6 medium: 4 low: 0 unspecified: 2 critical: 1 high: 5 medium: 2 low: 0
Removed vulnerabilities (5):
  • high : CVE--2026--42504
  • medium : CVE--2026--27145
  • medium : CVE--2026--42507
  • unspecified : CVE--2026--42505
  • unspecified : CVE--2026--39822
Changes for packages of type npm (6 changes)
Package Version
gluwa/creditcoin3:latest
Version
gluwa/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

DylanVerstraete added a commit that referenced this pull request Sep 16, 2026
…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.
DylanVerstraete added a commit that referenced this pull request Sep 16, 2026
…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.
DylanVerstraete added a commit that referenced this pull request Sep 16, 2026
…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.
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.

4 participants