Skip to content

fix(p2p): fall back to archiver in BLOCK_TXS response validation - #23624

Merged
PhilWindle merged 1 commit into
merge-train/spartanfrom
mr/fix-block-txs-validation-archiver-fallback
May 29, 2026
Merged

fix(p2p): fall back to archiver in BLOCK_TXS response validation#23624
PhilWindle merged 1 commit into
merge-train/spartanfrom
mr/fix-block-txs-validation-archiver-fallback

Conversation

@mrzeszutko

Copy link
Copy Markdown
Contributor

Summary

Libp2pService.validateRequestedBlockTxsConsistency rejects every BLOCK_TXS reqresp response for any block whose proposal is not in the local attestation pool. The responder handler already falls back to the archiver in this case, the validator did not. So any node that doesn't have the proposal locally — but does know the block from the archiver — cannot collect its missing txs, and instead storms its peers until it is rate-limited and disconnected.

This PR teaches the validator the same proposal-or-archiver fallback the handler already uses, breaking the storm at its source.

The defect: validator/handler asymmetry

To verify that a peer's BLOCK_TXS response matches the block, the validator needs the canonical tx-hash list for the block. Until this PR it consulted only the attestation pool:

// yarn-project/p2p/src/services/libp2p/libp2p_service.ts (pre-fix)
const proposal = await this.mempools.attestationPool.getBlockProposalByArchive(...);
if (proposal) { /* check membership/order */ } else { return false; }

The responder handler (p2p/src/services/reqresp/protocols/block_txs/block_txs_handler.ts:40-45) already serves from either source:

let txHashes = (await attestationPool.getBlockProposalByArchive(...))?.txHashes;
if (!txHashes) {
  txHashes = (await archiver.getBlock({ archive: request.archiveRoot }))?.body.txEffects.map(e => e.txHash);
}

So peers can produce valid responses for blocks that are only known via the archiver, but the validator at the other end rejects them.

When this fires

Any p2p-enabled node subscribes to block_proposal gossip (libp2p_service.ts:575) and stores received proposals into its attestation pool (validateAndStoreBlockProposal at libp2p_service.ts:1236, calling tryAddBlockProposal at line 1252). Neither subscription nor storage is gated by disableValidator — that flag only controls the validator-client (attestation signing). So in the steady state, a node that was online when a proposal was gossiped does have it locally.

The validator's lookup fails whenever the node lacks the proposal in its local attestation pool, yet still needs to collect the block's txs over reqresp. The real-world triggers we've seen and can describe:

  • A node joins the mesh late and misses the proposal gossip for blocks that were proposed before it arrived. This was originally noticed during an e2e run where mesh formation was slower than usual, and it's the scenario the e2e test in this PR reproduces.
  • A prover-node calling ProverNode.gatherTxs (prover-node/src/prover-node.ts:330) → TxProvider.getTxsForBlockTxCollection.collectFastFor({type:'block', ...})BatchTxRequester for any block whose proposal it doesn't happen to hold: prover restart, gossip drop, mesh churn during the epoch, etc.

In every case the prover (or any node) has the mined block in its archiver but no proposal in its attestation pool. Until this PR the validator only consulted the attestation pool, so every otherwise-valid response was rejected.

The self-inflicted ban-storm

In BatchTxRequester (p2p/src/services/reqresp/batch-tx-requester/batch_tx_requester.ts):

// line 432-438: every response gets rejected because validation has no way to validate it
const isValid = await this.p2pService.validateRequestedBlockTxsConsistency(...);
if (!isValid) {
  this.handleFailResponseFromPeer(peerId, ReqRespStatus.INTERNAL_ERROR);
  return;
}

// line 461-481: INTERNAL_ERROR correctly does not penalize the peer, but
// also does not back off — the dumb worker loop just rotates to the next peer:
if (responseStatus === ReqRespStatus.NOT_FOUND || responseStatus === ReqRespStatus.INTERNAL_ERROR) {
  this.peers.markPeerDumb(peerId);
  this.txsMetadata.clearPeerData(peerId);
  return;
}

The dumb worker loop (batch_tx_requester.ts:261-304) has no inter-iteration sleep and ten parallel workers (dumbParallelWorkerCount: 10) round-robin the peers. Per-peer in-flight de-duplication caps it at one request in flight per peer, but the steady-state hit-rate per peer easily exceeds the responder's per-peer GCRA cap.

The penalty arrives from the responder side, on the requester:

// rate_limiter.ts:214-225
if (rateLimitStatus === RateLimitStatus.DeniedPeer) {
  this.peerScoring.penalizePeer(peerId, PeerErrorSeverity.HighToleranceError);
}
BLOCK_TXS per-peer cap : 10 req / 1000 ms   (rate_limits.ts:55-65)
HighToleranceError     : -2 score points    (peer_scoring.ts:34-36)
disconnect threshold   : -50                (peer_scoring.ts:57)
ban threshold          : -100               (peer_scoring.ts:56)

With ~1 GCRA denial per second per peer, the prover loses 2 points/sec at each responder. The first responder hits -50 in ~25 s and goodbyes the prover via peer_manager.ts:601-603 pruneUnhealthyPeers (GoodByeReason.LOW_SCORE); -100 in ~50 s would ban.

Fix

A four-line change in yarn-project/p2p/src/services/libp2p/libp2p_service.ts: fall back to the archiver after the attestation-pool lookup, mirroring the responder handler. If neither source has the block we still return falsewithout penalising the peer (it really is unverifiable locally).

const proposal = await this.mempools.attestationPool.getBlockProposalByArchive(...);
const blockTxHashes =
  proposal?.txHashes ??
  (await this.archiver.getBlock({ archive: request.archiveRoot }))?.body.txEffects.map(e => e.txHash);

if (blockTxHashes) { /* existing membership/order check, against blockTxHashes */ }
else               { /* unchanged: log warn, return false, no penalty */ }

The validator's other checks (archive-root match, bitvector length, no dupes, size bounds, subset-membership, ordering) are unchanged.

Tests

Unit anchorp2p/src/services/libp2p/libp2p_service.test.ts

  • New test: "should accept when the proposal is missing but the block is known via the archiver" — verified red before the fix (Expected: true, Received: false) and green after.
  • Existing test renamed and tightened to "should reject without penalising when the block is unknown (no proposal and not in the archiver)" — covers the still-correct rejection path.
  • All 46 tests in the file pass.

End-to-endend-to-end/src/e2e_p2p/late_prover_tx_collection.test.ts

Validators form a mesh and mine a block carrying real txs; a prover joins after the block is mined, so it has the block in its archiver but never received the proposal or txs over gossip. The test then drives the exact production path the prover would take to gather txs for proving:

const txCollection = (proverNode as ...).p2pClient.txCollection;
const collected = await txCollection.collectFastForBlock(minedBlock, blockTxHashes, { deadline });
expect(collected.map(t => t.getTxHash().toString()).sort())
  .toEqual(blockTxHashes.map(h => h.toString()).sort());
  • Red on the unfixed source: collected.length === 0 (validation rejects every response, dumb loop runs until the deadline), assertion fails.
  • Green with the fix: all of block.body.txEffects's txs are collected.

@PhilWindle
PhilWindle merged commit 11fd9f9 into merge-train/spartan May 29, 2026
14 checks passed
@PhilWindle
PhilWindle deleted the mr/fix-block-txs-validation-archiver-fallback branch May 29, 2026 08:57
danielntmd pushed a commit to danielntmd/aztec-packages that referenced this pull request Jun 4, 2026
BEGIN_COMMIT_OVERRIDE
test(e2e): unskip pipelining related e2e tests (AztecProtocol#23642)
fix(archiver): prune blocks without proposed checkpoint by end of build
slot (AztecProtocol#23606)
test: migrate benchmarks to pipelining setup (AztecProtocol#23647)
fix(p2p): fall back to archiver in BLOCK_TXS response validation
(AztecProtocol#23624)
docs(slashing): align operator and slasher docs with AZIP-7 (AztecProtocol#23494)
fix(p2p): do not penalize peers that signal a missing block with Fr.ZERO
(AztecProtocol#23672)
chore: adjust metrics deployment (AztecProtocol#23676)
fix(cheat-codes): warpL2TimeAtLeastBy advances relative to leading clock
(AztecProtocol#23675)
chore: tighten node pool sizes (AztecProtocol#23678)
chore: remove archival nodes (AztecProtocol#23630)
chore: merge blob sink duties into RPC node (AztecProtocol#23631)
fix: sync avm-transpiler Cargo.lock with noir submodule (AztecProtocol#23683)
fix(spartan): set validator lag env vars in tps-scenario (AztecProtocol#23684)
fix: make world-state hash queries reorg-aware to close getWorldState
race (AztecProtocol#23677)
fix: pin noir submodule to next's version on merge-train/spartan
(AztecProtocol#23690)
fix: ensure image ref is used by bench runner (AztecProtocol#23682)
fix(ci): retry aztec-nr nargo dependency clone on transient network
flake (AztecProtocol#23653)
chore: run one-off jobs on network nodes (AztecProtocol#23701)
fix: simulate proposals inside target slot (AztecProtocol#23692)
chore: smaller eth-devnet (AztecProtocol#23704)
chore: enable testnet autoscaling (AztecProtocol#23705)
feat(api)!: redesign node log retrieval API around tag-based queries
(AztecProtocol#23625)
fix(sequencer): set own proposed checkpoint locally instead of via p2p
loopback (AztecProtocol#23659)
END_COMMIT_OVERRIDE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants