Skip to content

feat(eth): resolve block tags by cross-provider agreement, keyed on the block hash - #1367

Open
DylanVerstraete wants to merge 3 commits into
feat/archiver-follows-attestedfrom
feat/tag-lookup-agreement
Open

DylanVerstraete wants to merge 3 commits into
feat/archiver-follows-attestedfrom
feat/tag-lookup-agreement

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 1c, plus the second half of 1a, 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

get_block_number_by_tag walked the provider list and returned the first block any provider served. It also received the full block from the RPC and returned only its number. For the one call that decides the attestor's maturity boundary that is the wrong shape twice: 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 discarded at the one place it was free.

What is there now

get_block_by_tag returns TaggedBlock { number, hash } and asks every configured provider at once.

  1. The lowest reported height is the candidate, because a provider further along has by definition already passed it.
  2. Every other responder must 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.
  3. 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, so a transient fork view costs one head, not an attestation.

Two edge cases are decided explicitly. A provider that reports the tag past a height it cannot serve is broken rather than forked, and fails the lookup. A provider that errors on the confirmation read has already voted the candidate mature by reporting a higher tag, so it is warned about and skipped, the same treatment a tag-call error got before.

Providers that error or answer null still do not take part. With one provider this degenerates to that provider's answer, so this PR is the code half of a two-part change: per-replica provider diversity in the AttestorSet (1b, cc-networks-iac) is what gives it teeth. Today every replica in a set reads the boundary from one ethUrlSecretRef.

get_block_number_by_tag stays as a thin wrapper, so Maturity and the streams are untouched.

Why the hash stops here

The plan said "carry the hash through Maturity and fetch by BlockId::Hash". I stopped short deliberately, as in #1362: fetching by hash serialises get_block and get_block_receipts and roughly halves archiver throughput, and #1362's receipts-to-block hash check already pins each fetched block's integrity for free. The hash is used where it decides something, the boundary identity across providers, and not threaded through code that has no use for it.

Also removed

EthRpcProvider::get_block_number_by_tag in the continuity crate, its three impls and its test. Dead since #1365 took the prover's maturity opinion away.

Tests

common/eth/tests/block_tag.rs mock now salts block hashes, so two mocks can be on one chain or on different forks. New: agreement takes the lowest height and confirms it against the further provider, and 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. Existing fall-through and clamping tests unchanged. Clippy -D warnings across eth, continuity, proof-gen, archiver, attestor, stream_eth; fmt; 189 tests green across those crates.

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

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes how safe/finalized maturity boundaries are chosen across multiple RPC peers; misconfiguration or disagreement now fails the lookup rather than trusting the first response, which directly affects attestor and stream progress.

Overview
Block tag maturity no longer uses the first RPC that answers. get_block_by_tag returns TaggedBlock { number, hash }, polls all configured providers in parallel with per-call timeouts (with_call_timeout / DEFAULT_CALL_TIMEOUT), picks the primary (or the highest fallback if the primary has no tag), and requires other providers that have reached that height to agree on the block hash. A fork view yields Error::BlockTagDisagreement instead of silently picking a peer. Lagging, missing, errored, or timed-out providers are warned and skipped so a stale fallback cannot pin the boundary.

get_block_number_by_tag is a thin wrapper, so existing Maturity::mature_height call sites keep the same API while inheriting the stricter semantics.

The archiver tracks when the HTTP chain head was last seen (head_seen_at + known_head / fetchable_bound). An unread or stale head is treated as unknown and does not clamp the attested bound, so stream timeouts err toward reconnect rather than treating silence as “nothing to fetch.”

Continuity drops EthRpcProvider::get_block_number_by_tag (archiver provider, mocks, reconnecting client, and related tests) as dead after maturity moved off that path.

Tests in common/eth/tests/block_tag.rs add fork-salted mocks, black-hole timeout coverage, stale-fallback behavior, and disagreement propagation through Maturity.

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

@DylanVerstraete

Copy link
Copy Markdown
Contributor Author

pre-commit.ci run

@gluwa-bot

Copy link
Copy Markdown
Contributor

Overview

Image reference gluwa/creditcoin3:latest gluwa/creditcoin3:latest
- digest dfb918c3c546 9087f08b7e96
- tag latest latest
- provenance 7e92a94 9aef42b
- 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 (+720 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

Comment thread common/eth/src/lib.rs Outdated
for (err_label, err) in errors.drain(..) {
if let Some((candidate_label, candidate)) = reported
.iter()
.min_by_key(|(_, block)| block.number)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A stale fallback provider permanently freezes maturity for the whole pipeline. common/eth/src/lib.rs:1044 picks the candidate via min_by_key(block.number) across all responders. A backup RPC that stops syncing but keeps serving answers safe = N forever; its block at N hashes identically to canonical, so the agreement check passes and returns Ok(N). common/streams/eth/src/tip.rs:81 only advances on tip_new > tip, so the tip pins at N indefinitely even though the primary is healthy. Verified both ends. This inverts what a fallback means — before this PR a fallback was only consulted when the primary errored, so a stale backup was harmless; now it's a hard brake. There's no staleness bound, no warning (only debug!), and no metric.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and it's the more serious of the two: lowest-height turned every fallback into a brake. Fixed in e87dee4. The primary's answer is the candidate (the freshest fallback's when the primary has none), and the others confirm its identity: same height, compare hashes; further along, fetch the block at the candidate height and compare. A provider that hasn't reached the candidate cannot confirm and cannot veto; it is logged at warn with how far behind it is, so a persistently stale fallback is visible. Hash mismatch at the candidate height stays a hard BlockTagDisagreement. Resolution without full agreement now logs at warn, not debug. Tests: fallback ahead confirms, fallback behind steps aside, stale fallback cannot pin with and without a primary answer.

Comment thread common/eth/src/lib.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No timeout anywhere — one black-holed provider hangs the tip stream forever. get_block_by_tag does join_all over all providers, and grep -E 'timeout|Timeout' common/eth/src/lib.rs returns nothing; init_rpc builds providers with a default reqwest client. A fallback that accepts TCP but never responds makes the lookup never return, and tip.rs awaits it inline in the stream loop — no reconnect, no error, no log. A per-provider tokio::time::timeout treated like the tolerated Err branch at lib.rs:1106 fixes it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e87dee4: every provider call in the lookup goes through a timed wrapper bounded by Client::call_timeout (15 s default, one Ethereum slot; with_call_timeout to override), and a timeout is handled exactly like the tolerated Err branch. Test uses a mock that answers eth_chainId and black-holes everything else; the lookup returns the primary's answer in under the timeout. Worth saying: init_rpc still has no timeout, so a black-holed URL hangs client construction; that is pre-existing and not on the tag path, I'd take it as a separate fix if you want it.

@DylanVerstraete
DylanVerstraete force-pushed the feat/tag-lookup-agreement branch from 37971a6 to e87dee4 Compare September 16, 2026 15:47

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

Stale Bugbot comment from a previous run.

Comment thread common/streams/eth/src/roots.rs
beqaabu
beqaabu previously approved these changes Sep 16, 2026
@DylanVerstraete
DylanVerstraete dismissed beqaabu’s stale review September 16, 2026 16:06

The merge-base changed after approval.

@DylanVerstraete
DylanVerstraete force-pushed the feat/tag-lookup-agreement branch from e87dee4 to 9009fc6 Compare September 16, 2026 16:08
beqaabu
beqaabu previously approved these changes Sep 16, 2026
@DylanVerstraete
DylanVerstraete dismissed beqaabu’s stale review September 16, 2026 16:14

The merge-base changed after approval.

@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 9009fc6. Configure here.

Comment thread archiver/src/main.rs
…ment

Bugbot: the stall check and flush-at-tip capped the attested bound with the
HTTP head tracker, which is 0 until its first successful read and is never
repaired on later failures. min(bound, 0) put the fetchable bound below the
next wanted height, so a hung fetch read as "nothing new to fetch" and the
stream was never rebuilt; a lagging rpc_http node did the same. The tracker
now records when it last succeeded, and a head is trusted only if read at
least once and refreshed within three polls (36 s). Otherwise it does not
clamp and the stream is judged against the raw bound, which errs towards a
reconnect, never towards hiding a stall. Pure helpers, tested.
…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.
… aside; every call is bounded

Review (beqaabu):

1. Taking the lowest reported height made any fallback a brake on the whole
   pipeline: a backup that stopped syncing keeps answering safe = N forever,
   its block at N hashes identically to the canonical one, agreement passes,
   and the tip stream, which only ever advances, pins at N while the primary
   is healthy. That inverts what a fallback is for. The primary's answer is
   now the candidate (the freshest fallback's when the primary has none), and
   the other providers confirm its identity: same height, compare hashes;
   further along, ask for the block at that height. A provider that has not
   reached the candidate cannot confirm and cannot veto; it is logged at warn
   and skipped, as is one that claims the tag past a height it cannot serve.
   A different hash at the candidate height is still a hard
   BlockTagDisagreement. Resolution without full agreement is now a warn, not
   a debug line.

2. No provider call had a timeout, so one black-holed provider hung the
   lookup and the tip stream awaiting it inline, with no error and no log.
   Every provider call in the lookup is now bounded by Client::call_timeout
   (default 15 s, one Ethereum slot; Client::with_call_timeout overrides) and
   a timeout is handled like any other transport error.

Tests: primary leads with a fallback ahead (confirms) and behind (steps
aside); a stale fallback cannot pin maturity, with and without a primary
answer; a black-holed fallback times out instead of hanging. The mock gained
a black-hole mode that answers eth_chainId only.
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.

3 participants