Skip to content

chore: forward-port v5 node fixes to next - #25247

Open
spalladino wants to merge 9 commits into
merge-train/spartanfrom
spl/forward-port-v5-fixes
Open

chore: forward-port v5 node fixes to next#25247
spalladino wants to merge 9 commits into
merge-train/spartanfrom
spl/forward-port-v5-fixes

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

Forward-ports the fixes merged into merge-train/spartan-v5 over the last two weeks onto the next line. One commit per original PR, each carrying a cherry picked from trailer.

Ported

Commits are ordered as they merged into the v5 line, since several build on each other (#25177#25183#25185 and #25207#25222#25229).

Not ported

Conflict resolutions worth a look

Four commits conflicted; all others applied clean.

Verification

yarn build produces a byte-identical error set to origin/merge-train/spartan built in the same working tree (46 errors, all from stale cross-line generated artifacts locally — Noir circuit artifacts, verification-key lengths, withWallet on contract types). No new compile errors from the port.

Unit tests for every package with a hand-resolved conflict pass: stdlib (52), ethereum (61), foundation (47), slasher (140), validator-client (39). Two suites (archiver/data_store_updater.test.ts, sequencer-client/checkpoint_proposal_job.test.ts) fail to load in this working tree on stale cross-line artifacts; the archiver one was confirmed to fail identically with the base version of the file, so CI is the first real run for those two.

Labeled ci-no-squash to preserve one commit per ported PR.

spalladino and others added 9 commits August 17, 2026 11:47
When the p2p client starts while there are blocks to sync, the libp2p
service start is deferred to
`startServiceIfSynched`, which runs inside the block stream event
handler. That handler catches and
only logs errors, so a TCP bind failure (e.g. `ERR_NO_VALID_ADDRESSES`
after a restart) was swallowed.
On top of that, the sync promise awaited by node creation was resolved
before the service was started,
so startup succeeded regardless and the node kept running with a dead
p2p stack and zero peers.

The service is now started before the sync promise settles, and a
failure logs at fatal level and
rejects the stored sync promise (rather than throwing, which the block
stream would eat) so node
creation fails and the process exits, letting the orchestrator restart
it.

Part of A-1701.

---

Related PRs from the same incident (all independent, all targeting
`merge-train/spartan-v5`): #25177 (fail startup when the p2p service
fails to start), #25185 (connectivity signal +
slasher/proposer/health/sendTx gates), #25183 (periodic zero-peers
warning).

(cherry picked from commit c22df34)
)

A validator could run for hours with a dead libp2p stack - zero peers
and zero
gossip - without anything in its logs flagging the condition. The
failure was
only reconstructable after the fact from the absence of activity, rather
than
from any explicit signal.

The peer manager heartbeat now tracks consecutive zero-peer heartbeats.
After 3
of them (so startup and transient dips stay quiet) it logs a warning
that the
node has no connected peers and can neither gossip nor propagate txs,
with the
zero-peer heartbeat count, the heartbeat interval, time since a peer was
last
connected, and the cached peer count. The warning then repeats about
once a
minute, derived from peerCheckIntervalMS, instead of on every heartbeat.
When
peers come back, a single info log reports connectivity restored and the
new
peer count.

Connected peers are counted via libp2p getPeers(), matching what
getP2PConnectivity reports. A peer count gauge already existed
(PEER_MANAGER_PEER_COUNT, recorded in discover()), so no metric was
added -
this change is logs only.

Part of A-1701.

---

Related PRs from the same incident (all independent, all targeting
`merge-train/spartan-v5`): #25177 (fail startup when the p2p service
fails to start), #25185 (connectivity signal +
slasher/proposer/health/sendTx gates), #25183 (periodic zero-peers
warning).

(cherry picked from commit e9ee6ea)
…25202)

Validators were aborting checkpoint proposals on eth-mainnet with a
generic `L1RpcError: L1 RPC request failed` during pre-broadcast header
validation, e.g. `insufficient funds for gas * price + value: have
169461140054989709 want 432138191814787072`.

With this PR we now skip any balance checks during simulate calls for
header validation by removing fee-per-gas settings, and also just in
case we fake a lot of ETH as balance. Created A-1712 to gate
block-building on publisher balance.

`L1TxUtils.simulate` attached production fee fields (competitive P75
priority fee, ~26 gwei during the incident) together with the worst-case
`MAX_L1_TX_LIMIT` gas cap (16.7M) to every `eth_simulateV1` call, and
`validateBlockHeader` overrode the multicall3 sender's balance with the
validator's real balance. Supplying fee fields makes the node enforce
the EIP-1559 upfront funds check (balance >= gasLimit x maxFeePerGas,
~0.43 ETH at those numbers) before executing anything, so any validator
holding less than that failed header validation and skipped its proposal
— even though the actual publish tx costs a small fraction of that. With
fee fields omitted, the node defaults them to zero, so the check only
fired because we supplied them.

- Omit `maxFeePerGas`/`maxPriorityFeePerGas` from simulated calls
entirely, making the upfront check vacuous for all `simulate()` callers.
- `validateBlockHeader` now always uses the ample 10 ETH multicall3
balance override (previously fisherman-mode only) instead of the real
sender balance, as compatibility with providers that still apply an
upfront check.
- While at it, the third commit renames
`SequencerPublisher.validateBlockHeader` to `validateCheckpointHeader`:
it takes a `CheckpointHeader` and simulates the rollup's
`validateHeaderWithAttestations`, so it never validated a *block*
header. Mechanical rename of the method, its `trackSpan` label, and all
call sites and doc references; contract-side names, the
`header-validation-failed` event key, and metric labels are untouched.
- An insufficient-funds rejection of the simulation request (code
-38014, with a message-match fallback for clients and gateways that
report it differently) now surfaces as a descriptive error carrying the
sender and the provider's have/want message, instead of a generic RPC
error.
- `_simulate` is restructured so only the `simulateBlocks` transport
call sits inside the classifying try/catch; decoding of failed calls and
the success path happen after it.

- **The core semantic claim**, verified against client and library
sources: viem 2.38.2's `simulateBlocks` forwards call objects verbatim
(no fee filling, no type inference — unlike
`simulateCalls`/`sendTransaction`, so re-verify on a viem bump), and
geth, reth, anvil (>= v1.1.0), and nethermind all default omitted fee
fields to a zero gas price and zero the block base fee under
`validation: false`. The upfront funds check still runs in all four but
degenerates to `balance >= value`; every call we simulate is zero-value,
so it passes regardless of sender balance. Nothing in the simulated
paths reads `tx.gasprice`. All four production `simulate()` callers were
audited (`validateCheckpointHeader`, `simulateInvalidateCheckpoint`, the
multicall aggregate helper, L1 contract deploy simulations): they
consume only `gasUsed`/return data, both unaffected by gas price.
- **The insufficient-funds classifier keeps a message fallback
deliberately**: geth reports the rejection as spec code -38014, but reth
uses -32003 (TransactionRejected) and anvil did too before its
spec-conformance fixes, so matching on the code alone would miss those
clients.
- **The catch boundary in `readonly_l1_tx_utils.ts`** (second commit):
only the RPC request is inside the try, so an execution revert whose
message mentions "insufficient funds" (e.g. a plain `Error("insufficient
funds")` revert string) can no longer be misclassified as an RPC
rejection — this was a real bug in the first commit, caught in review
and pinned by the new regression test. Also check the
`MethodNotFound`/`fallbackGasEstimate` early-return path survived the
restructure unchanged.
- **Loss of signal**: dropping the real-balance override removes an
(unintentional) affordability check. It never carried a real signal —
wrong sender (multicall3), worst-case gas cap, inflated fees — and no
other affordability check exists today: publisher selection only
requires balance > 0, with the optional funding loop and balance metrics
covering the rest operationally. An explicit post-simulation
cost-vs-balance warning is a candidate follow-up.
- **Tests are correct by inspection only** — this branch has not been
built or run locally; CI is the first real execution.

A port to `next` will follow; it needs the `getGasPrice` ->
`getFeesPerGas` rename accounted for.

Fixes A-1706

(cherry picked from commit 6289a9a)
…osal type (#25222)

Follow-up to #25207. That PR removed the duplicate downstream
re-validation of inbound block and checkpoint proposals, leaving p2p
ingress (gossipsub topic validation) as the single place proposals are
validated. The downstream handlers document that precondition in prose;
this PR enforces it with the type system.

- Adds branded `ValidatedBlockProposal` and
`ValidatedCheckpointProposalCore` types (plus their minting functions)
in `stdlib/src/p2p/validated_proposal.ts`, following the existing
`Branded<T, Brand>` convention used by `BlockNumber` and friends.
- The p2p received-proposal callbacks (`P2PBlockReceivedCallback`,
`P2PCheckpointReceivedCallback`) and the downstream consumers
(`ValidatorClient.validateBlockProposal` / `attestToCheckpointProposal`,
`ProposalHandler.handleBlockProposal` / `handleCheckpointProposal`, and
the `Validator` interface) now take the branded types, so a raw inbound
`BlockProposal` / `CheckpointProposalCore` cannot reach them.
- The brands are minted only in `libp2p_service.ts`, at the three points
where the topic validator has already returned `Accept`: the
block-proposal topic path, the checkpoint-embedded block path
(`processBlock` is only set after Accept), and the checkpoint path.
- Purely a compile-time marker: no runtime validation is added and there
is no behavior change. The only non-type edits are in tests, which mint
validated proposals from constructed ones.

Related to A-1703.

(cherry picked from commit 9118206)
Fixes two independent validation gaps around checkpoint shape.

There is no circuit for proving a non-first block with zero
transactions. The zero-tx block-root circuit
(`rollup_block_root_first_empty_tx`) exists only so that an epoch can be
proven when there are no transactions at all, and it is pinned to index
0: it starts a fresh sponge blob and carries a non-zero `in_hash`, both
of which the block-merge continuity checks forbid at any later index.

Validation methods had upper bounds on block contents but no lower
bound, so a checkpoint carrying an empty block past the first would pass
every node-side check and then be unprovable.

**Fix**: `validateCheckpointStructure` now requires every block after
the first to carry at least one tx, and the sequencer floors
`minValidTxs` at 1 for any block past index 0 so it never builds one.
The archiver passes `allowEmptyNonFirstBlocks` on the L1-ingest path,
since such a checkpoint is already final on L1 and refusing to ingest it
would only stall sync.

Network configuration allowed `maxBlocksPerCheckpoint` to be set higher
than the attestable limit, which would produce block indices that p2p
and checkpoint validation reject. It is now capped at
`MAX_ATTESTABLE_BLOCKS_PER_CHECKPOINT`.

Fixes A-1614

(cherry picked from commit dd95801)
…25206)

Fixes a race where `worldState.syncImmediate(N)` failed with
`block_not_available` even though the archiver had block N, alerting
`Eviction rule FeePayerBalanceEviction failed` on production nodes.

## Context

`EventDrivenL2BlockStream.sync()` goes through
`RunningPromise.trigger()`, whose pending-request slot was only cleared
after the pass serving it completed. Since the world-state stream is
event-driven, nearly every block-processing pass serves a request, so a
`sync()` arriving mid-pass attached to a pass that started before the
archiver committed the target block and resolved at N-1. Additionally, a
trigger coalescing onto a pending request silently dropped its event
payload, so the next pass could run armed with an older event's
hot-block cache, which capped `getL2Tips()` below the archiver's real
tip. Both mechanisms produce the observed error; the 100ms fallback poll
self-healed right after.

## Approach

- `RunningPromise` now captures and clears the request slot when a pass
starts, so `trigger()` resolves only after a full run that started after
the call; unserved requests are rejected when the loop exits instead of
hanging; the per-trigger argument is removed entirely.
- `EventDrivenL2BlockStream` is reduced to a doorbell: the archiver's
aggregate event just triggers an immediate reconciliation pass, and
every pass reads tips and blocks authoritatively from the source. The
`HotBlockSourceAdapter` fast path and the event's `blocksAdded` payload
are deleted — their arming conditions were the source of the race, and
their only real saving was block-body re-hydration on event-triggered
passes.
- The latency win that motivated the event-driven stream (#24317) is
preserved; if the removed body-read saving ever shows up in profiles, it
should be recovered inside the archiver with a content-addressed block
cache instead (tracked separately).

Fixes A-1659

(cherry picked from commit 6f01032)
…tivity (#25185)

Gates p2p-dependent operations on actual gossip connectivity, so a node
running with a dead libp2p stack (zero peers) can no longer file false
slashing accusations, burn its proposer slots, or silently blackhole
txs. Follows an incident where a validator whose TCP listener failed to
bind kept running peerless and voted `DATA_WITHHOLDING` against entire
committees.

One commit per concern (squashed on merge):

1. **`feat(p2p): expose p2p connectivity`** — new `getP2PConnectivity():
{ enabled, connectedPeers }` on the p2p service, client, and node RPC
API. The dummy (p2p-disabled) implementation reports `enabled: false`,
so sandbox and single-node setups are vacuously healthy for every gate
below.
2. **`fix(slasher): do not file data-withholding offenses while
peerless`** — the data-withholding watcher infers offenses from the
*absence* of txs in the local pool, which is invalid evidence without
gossip connectivity. It now permanently skips (never backfills) slots
probed while p2p is enabled with zero peers — after reconnecting, mined
txs may already be evicted from the pool, so a late probe would still
false-positive.
3. **`fix(sequencer): skip proposing when node has no connected peers`**
— a peerless proposer broadcasts into the void (burns the slot) or, with
enough own committee seats, publishes a checkpoint whose data nobody
has. It now skips building below `minPeersToPropose` (default 1,
`SEQ_MIN_PEERS_TO_PROPOSE`, 0 disables) while keeping L1 duties (votes,
prune, invalidation) via the existing fallback.
4. **`feat(node): report per-component health on GET /status`** —
`/status` now returns `{ ok, components: { p2p: { healthy, enabled,
connectedPeers }, ... } }`. The `p2pHealthMinPeers` floor
(`P2P_HEALTH_MIN_PEERS`) defaults to 0 (report-only) so fresh-network
bootstrap nodes aren't failed by default; operators opt in.
5. **`fix(node): reject sendTx when node has no peers`** — instead of
accepting a tx and gossiping it to nobody, the node rejects immediately
so callers can retry against a healthy node.
6. **`test: keep single-node mock-gossip e2e working`** —
`registerPhantomGossipPeer` test helper so the one single-node e2e test
on the in-memory mock gossip bus presents one connected peer to the
gates.
7. **`fix(slasher): do not file data-withholding offenses when p2p is
disabled`** — a node with no p2p stack at all sees strictly less than a
peerless one, so it must not slash either. The runtime gate drops
`enabled` (any node with zero peers skips), and the watcher isn't
constructed when `p2pEnabled` is false. Unlike the other gates, this one
infers guilt from *absence*, which is never valid without gossip; the
propose/`/status`/`sendTx` gates keep treating `enabled: false` as
vacuously fine because they gate the ability to *act*, which a
deliberately p2p-less node has by design.

Strongly suggest to **review commit by commit**.

Related PRs (independent, same incident): #25177 (fail node startup when
the p2p service fails to start), #25183 (periodic zero-peers warning).

Part of A-1701.

(cherry picked from commit a6ce449)
)

The node's five L1 event watchers (slasher rotation, slashing
votes/rounds, checkpoint invalidation, slash events) used viem's
`watchContractEvent`, which installs a server-side filter and polls
`eth_getFilterChanges`. Server-side filters die in the field: load
balancers route polls to backends that never saw the filter, and
providers report purged filters with error codes viem does not treat as
"filter gone" (reth -32602, Alchemy -32600; erigon returns an empty
result with no error). Watchers then either churn recreating filters or
hammer a dead filter id at the poll rate — operators reported 30-40
req/s of spam — while silently missing slashing and governance events.

Replace the viem watchers with a custom poller, `watchContractEvent` in
`@aztec/ethereum`. An earlier revision of this PR instead excluded the
filter RPC methods at the transport level to force viem into its
built-in getLogs fallback, but that fallback retries filter creation on
every tick and issues a single unbounded `eth_getLogs` when catching up
after an outage, so we now own the loop:

- Each tick polls `eth_blockNumber` (viem caches it per client at the
client's polling interval, so concurrent watchers share one request) and
fetches new logs with `eth_getLogs` via `client.getContractEvents`,
never touching filter RPCs. Requests never span more than a configurable
`maxBlockRange` (default 100) blocks: catch-up after downtime is chunked
into multiple bounded requests, and the cursor advances per successful
chunk so a mid-catch-up failure retries from where it left off instead
of losing blocks.
- The helper is strongly typed on the event via viem's
`ContractEventName` / `GetContractEventsReturnType` helpers (including
`strict` decoding), and is built on foundation's `RunningPromise`, so
ticks never overlap and unwatching interrupts a catch-up loop.
- Per-log callback isolation (a throwing callback or a rejected returned
promise is logged without dropping the rest of the batch), and every
failed poll logs a warning.
- Poll intervals per event: `SlasherUpdated` polls every 60s since
slasher rotations are rare governance operations; the other four
watchers poll at the client's polling interval as before, and only issue
`eth_getLogs` when the head actually advances. All `listen*` methods
accept an options override.
- Tests cover baseline semantics (only events mined after subscription),
chunk boundaries, failed-chunk retry from the same cursor, callback
isolation, stop-on-unwatch, and an anvil integration test behind a
request-recording proxy asserting events are delivered with zero filter
RPCs on the wire.

Known trade-offs, documented in the helper's jsdoc: delivery is
best-effort with possible duplicates on reorgs (no `removed`
notifications; production consumers treat events as triggers and re-read
chain state), and events mined within ~1 polling interval of subscribing
may be missed (filter mode had nearly the same window).

Fixes A-1695

(cherry picked from commit 81fc589)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants