fix(p2p): treat unverifiable tx proofs as unknown, not invalid - #383
Open
spalladino wants to merge 27 commits into
Open
spalladino wants to merge 27 commits into
spalladino wants to merge 27 commits into
Conversation
A pooled bb instance whose process died was returned to the pool and handed out again, so every later borrow of it failed. The pool now checks BBJsApi.isAlive(): a dead instance is destroyed when it is returned or found idle, and a replacement is spawned when a borrower finds no idle instance while the pool is below its size. A failed spawn fails that borrow and is retried by the next one, and a failed pool initialization is retried too. Requires a bb.js release with Barretenberg.isAlive().
…nvalid proof
BBCircuitVerifier.verifyProof turned every error into { valid: false }, so a
bb process that died was reported as an invalid transaction proof and counted
as a verification failure. A call that fails because its bb died is now
retried once on another pooled instance; if that bb dies too, or no instance
can be started, verifyProof throws ProofVerifierUnavailableError. RPC
admission then fails with that error instead of rejecting the tx as invalid,
and QueuedIVCVerifier does not record it as a failed verification. A call
that fails while bb is alive still means bb rejected the proof.
…p a partly started pool A borrower waiting on a full pool was stranded when a borrowed instance died, because the eviction freed room without spawning anything. Returning a dead instance now spawns its replacement, which goes straight to a waiting borrower. A borrow whose replacement spawn fails waits for a borrowed instance instead of failing, and only throws when the pool holds none. Pool initialization keeps the instances that started and spawns the rest on demand, rather than destroying them all when one fails.
…e checked ClientProtocolCircuitVerifier.verifyProof documents that it rejects, rather than reporting the proof invalid, when the proof could not be checked. BBCircuitVerifier's docs no longer claim that every failure while bb is alive is a rejection of the proof, and its failure log is structured.
…not strand them A borrower waiting on an empty pool counted on an in-flight spawn or a borrowed instance to come back, and was never woken when the spawn failed or the instance died without a replacement. Waiting borrowers now re-check every second: they spawn an instance themselves when the pool is below its size, and throw when that fails while no instance exists or is being spawned. A dead instance's replacement is spawned in the background, so returning it does not hold up the verification that found it dead.
…royed A borrower re-checking after destroy(), or an eviction just before it, spawned a bb only to destroy it. The pool test that waits for a borrowed instance also plans enough failed spawns for re-checks that land before that instance returns.
…tenance run The pool no longer spawns from the borrow path. Every second a maintenance run destroys the pooled instances whose bb died and spawns instances until the pool is back at poolSize, retrying failed spawns on the next run. A borrower skips dead instances and waits for a live one however long that takes, as it does while every instance is busy. destroy() does not wait for a spawn in flight; the spawn destroys its instance when it completes. QueuedIVCVerifier stops its verifier before draining its queue, so a verification waiting for a bb instance fails instead of blocking shutdown.
…n the factory is destroyed destroy() retires every dead member as well as the idle ones, so a dead instance a borrow dropped between maintenance runs is destroyed. Dispose returns an instance to the pool as before; the borrow path skips it if it is dead. QueuedIVCVerifier.stop() drains its queue even if stopping the verifier fails.
…r instead of a member list The idle queue is the only record of idle instances. Pool maintenance sweeps it, destroying dead instances and putting the live ones back, and spawns until idle, borrowed and spawning instances reach poolSize. A borrow destroys a dead instance it takes. Each instance is idle, borrowed or destroyed, so destroy() tears down the idle queue as on main.
The count covers idle, borrowed and spawning instances: a spawn adds one, and a failed spawn or a destroyed dead instance removes one. Pool maintenance spawns up to poolSize from it, and the dispose code is main's again.
… the pool Keeps the verifier half of this branch and replaces the pool half with one option. A pooled instance now spawns with bb.js's `respawn`, so an instance whose bb process dies replaces it and the next borrower gets a working one. The pool needs no liveness check, no eviction and no maintenance loop: returning an instance unconditionally is correct again, exactly as it already is for the AVM simulator pool. The verification retry keys off the failure rather than a liveness query. bb.js marks an environmental failure with `retry: true`, feature-detected rather than imported, so the same check works across the bb.js and ipc-runtime package boundaries. Asking an instance whether it is still alive can only ever be a guess, since the process can die between the answer and the next call, and it would not distinguish a dead helper from a bad proof, which is what makes the node report one as the other. The double models the same thing: a death fails the call in flight, retryably, and leaves the instance usable, because a replacement process serves the next call. Needs a bb.js release carrying aztec-packages#25548, which adds `respawn` and the `retry` property. Until the pin moves this does not build, as the branch it adjusts did not. The bb.js side is tested there, including against a real bb. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
`ProvingError` was the only retryable failure the prover recognised, so a bb process that died mid-call surfaced as permanent and the job was not retried. Both the prover and the verifier now ask the same question of the error, and the classifier lives next to the backend that raises it rather than once per caller. `ProvingError.retry` still answers true, since the check is on the property rather than the class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
Reverting the pool to main's brought back main's permanent wedge, which #351 had also been fixing: initPromise caches its rejection, so one failed spawn made every later borrow fail for the life of the process. It is cleared on failure now, as the bb.js singleton's is, so the next borrow starts the pool again. A borrow also raced destruction, so a pool whose bb never comes up no longer holds up shutdown: the wait ends when the factory is destroyed and the borrow says so. The verifier spends its attempt budget on both halves. A bb that could not be started is worth another go, on the same terms a bb that died under the call gets; anything else, such as the factory being destroyed, will not improve by asking again. Either way no proof was checked, so neither is ever reported as the proof's fault. The double now fails a spawn retryably, as BBJsInstance.create does, rather than showing a test a permanent failure where production has a transient one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
… test A failed spawn is now retried, so failing it once let the second attempt succeed and the proof verify. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
…into respawn The bb-prover and AVM pool copies of the retry check become one type guard in foundation, without a cast. Respawn is now an explicit BBJsFactory option the verifier sets, rather than implied by pooling, so a future pooled owner of stateful sessions does not get it by accident. The retry wording says attempts, since with respawn a retry usually lands on the same instance's new process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
A dead batch verifier latched a fatal error and stayed dead for the life of the node. Every gossiped transaction then failed proof verification, and because verifyProof turned any failure into `valid: false`, and that result feeds gossip validation at LowToleranceError, the node worked through its peer set penalising honest nodes for its own dead helper process. Two changes, and the second matters even if the first never fires. The verifier rebuilds itself on the next verification after a fatal error. Everything the session holds comes from configuration — the protocol circuit verification keys, the batch size, the core count — so nothing is lost by replacing the bb process and starting a fresh session on it; only the proofs already in flight were lost, and those were rejected when it failed. Concurrent callers share one rebuild, and attempts are paced at one a second, indefinitely rather than a bounded number of times and flat rather than backing off, which is the convention the AVM simulator pool settled on so a node recovers as soon as bb is healthy rather than waiting out a backoff it has already outlived. A verifier that could not check a proof no longer reports it as invalid. It has not judged the proof, and saying otherwise is what turns a dead helper into a punished peer. That is the same distinction the RPC verifier draws. Not run: the labs tests need stdlib, which needs constants generated from the Noir sources in aztec-packages. The behaviour wants an integration test that kills the bb the verifier spawned and verifies again, which needs the pid exposed; happy to add it if that is wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
Awaiting the rebuild check before queueing let a stop() issued after the proof was submitted close the queue first, so the proof was rejected instead of drained. Only defer when a rebuild is actually needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
…thout penalty Propagating ProofVerifierUnavailableError was not enough: gossip validation turns any thrown error into a rejection with a penalty, so a dead local bb still had the node penalising honest peers. Stage 2 now ignores the tx instead. The error moves to stdlib so p2p can recognise it. In the batch verifier, proofs in flight when the session fails, and proofs bb fails to take, now fail as unavailable rather than invalid. stop() waits out a rebuild in progress so it cannot leave a bb process running after teardown, and each session gets its own FIFO reader, so a replaced reader's late 'end' cannot fail the session that replaced it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4
A tx validation that could not run (for example, because the proof verifier is unavailable) now has its own result, distinct from a verdict that the tx is invalid. Adds the non-slashable UnverifiableBlockProposalTxsError for block proposal txs that could not be checked, and LruMap.peek for lookups that must not refresh recency.
…ted it BBCircuitVerifier and BatchChonkVerifier now return valid: false only for a completed verification that answered false, or for the empty placeholder proof, which they reject up front. Every other failure, including any error bb throws while alive and a batch result whose message says bb threw, rejects with ProofVerifierUnavailableError. Adds an unavailable-verification metric, and lets TestCircuitVerifier answer invalid or unavailable.
…ache it TxProofValidator maps a rejected proof to invalid and any verifier failure to unverifiable. AggregateTxValidator stops at, and returns, the first result that is not valid, so a cheap deterministic invalid still wins. TxValidationCache evicts a validation that ends unverifiable or rejects, once it settles and only if it is still the cached entry, so one transient failure no longer sticks to the tx; invalid verdicts stay cached.
Gossip ignores an unverifiable tx without a penalty. The batch tx requester neither penalises nor redeems a peer for one, and leaves the tx missing so it is requested again, giving up on it for the run after a bounded number of attempts. Block proposal txs that could not be checked, with none invalid, throw UnverifiableBlockProposalTxsError and are not added to the pool. The tx collection sources report unverifiable txs apart from invalid ones, and the tx pool ignores rather than rejects them.
…be verified A block proposal whose carried txs could not be checked now fails with the non-slashable txs_unverifiable, counted as a node issue, instead of escaping as an error or being classified as invalid_embedded_txs. It never reaches slashInvalidBlock or markInvalidProposalSlot.
sendTx refuses a tx whose proof could not be checked with a retry-later error rather than as an invalid tx; isValidTx returns the unverifiable result. The public processor skips such a tx instead of failing it, so it is not dropped from the pool.
spalladino
added this pull request to stack #384
October 2, 2026 20:19
spalladino
commented
Oct 2, 2026
spalladino
commented
Oct 2, 2026
spalladino
marked this pull request as ready for review
October 2, 2026 20:28
spalladino
requested review from
IlyasRidhuan,
alexghr and
fcarreiro
as code owners
October 2, 2026 20:28
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #359 (which is stacked on #354). Merge those first.
Problem
A client proof that the node could not check (bb crashed, OOM, timed out, its FIFO broke, the verifier was stopped) has been reported as an invalid proof. Every consumer then acts on that verdict:
LowToleranceError(enough on its own to disconnect), and the reqresp batch requester penalises every peer that serves it. A node whose bb is down penalises its way off the network.invalid_embedded_txs, which is slashable. The node votes to slash the proposer and marks the slot invalid, so the attesters of that slot getATTESTED_TO_INVALID_CHECKPOINT_PROPOSALvotes. One node alone can't reach quorum, but a correlated failure (load-induced OOM, a bad bb release) can.TxValidationCachekept the result, rejections included, so one transient failure stuck to the tx until LRU eviction, for both reqresp and proposal validation.#354/#359 make the verifiers throw
ProofVerifierUnavailableErrorinstead. Throwing alone doesn't fix this, because gossip and reqresp treat a thrown validation as a failure too.The rule here: a proof is invalid only when a verification ran to completion and answered false. Anything else is unverifiable.
Changes
TxValidationResult(and its zod schema) gains{ result: 'unverifiable'; reason }, with the error textTX_ERROR_PROOF_UNVERIFIABLE. Adds the non-slashableUnverifiableBlockProposalTxsError.BBCircuitVerifierreturnsvalid: falseonly for a completedverified === false. Any thrown error fromverifyChonkProofis nowProofVerifierUnavailableError, not justretry: trueones. The TS side already rejects malformed proofs before bb sees them:ChonkProofdeserialization enforces the field count. The one gap is the empty placeholder proof, which both verifiers now reject as invalid up front, without asking bb.BatchChonkVerifiertakes the same approach. Every rejection path (stopped, dead or rebuilding verifier, send failure, per-request timeout, missing VK index) surfaces asProofVerifierUnavailableError. A verdict only comes from a FIFO result. A FAILED result whose message says bbthrewis also unavailable.aztec.ivc_verifier.unavailable_countmetric, recorded byQueuedIVCVerifierandBatchChonkVerifier.TestCircuitVerifiergets anoutcome(valid,invalid,unavailable). The default is stillvalid.TxProofValidator:valid: false→invalid. Any thrown error →unverifiable.AggregateTxValidator: stops at the firstinvalidand returns it. Anunverifiableresult does not stop the run, so aninvalidfrom any validator wins regardless of order. The firstunverifiableis returned only when no validator found the tx invalid.TxValidationCache: stores one wrapped promise and evicts it when it resolvesunverifiableor rejects, but only while the map still holds that exact promise. Callers already waiting on it still get its result. Genuineinvalidverdicts are still cached. This covers both the proof key and the aggregate integrity-check key.unverifiablestage result isIgnorewith no penalty. fix(bb-prover): rebuild the batch verifier after its bb dies #359's catch forProofVerifierUnavailableErroris removed:TxProofValidatoralready turns every verifier throw intounverifiable, so the catch could never be reached. Within a stage, aninvalidfrom another validator still wins.unverifiabletx (or a thrown validation) is not penalised, not marked fetched, and does not count as the peer redeeming itself. It stays missing and is re-requested. To bound rework against inputs that deterministically break bb, a tx that fails to verifyMAX_UNVERIFIABLE_ATTEMPTS_PER_TX(3) times in one run stops being requested in that run, still without penalty.validateTxsReceivedInBlockProposalthrowsInvalidBlockProposalTxsErrorwhen any tx is invalid. Otherwise, if any tx is unverifiable, it throwsUnverifiableBlockProposalTxsError. Unverifiable txs are never added to the pool as protected.ProposalHandlermaps the new error totxs_unverifiable. That reason is classified non-slashable and counted as a node-issue metric, so it never reachesslashInvalidBlockormarkInvalidProposalSlot. It fails fast with no retry until the deadline, for three reasons: that deadline is the attestation deadline, a single batch attempt can take up to 5 minutes, and the tx provider'sPromise.allkeeps sibling work running.isValidTxreturns theunverifiablevariant as its contract.sendTxrefuses such a tx with a retry-later error, notInvalid tx.LruMap.peek, a lookup that doesn't refresh recency, used by the cache eviction.Known limitation
bb exposes only OK/FAILED per proof. Its batch verifier catches a batch-check exception and returns false, and bisection then reports it as a per-proof failure ("batch check failed (bisected to individual)"). So some internal bb errors still look like invalid proofs. This PR classifies the
threwmessages visible in the bb binary (reduce_to_triple_ipa_opening threw: …,ChonkBatchVerifier: result callback threw: …). The rest need an upstreamVerifyStatus::ERRORin barretenberg.Testing
The whole stack builds locally against the pinned bb.js (
6.0.0-nightly.20261001):make yarn-project, thenyarn build.yarn lintis clean on the touched packages.New and updated unit tests, all passing:
bb_verifier,batch_chonk_verifier(17)libp2p_service, batch tx requester, tx collection, tx provider, tx pool v2 (923)proposal_handlerandvalidator(183)server(115)public_processor(17)lru_map(15)The new tests cover:
TxProofValidatortxs_unverifiablebeing non-slashable, with no slash event and the slot not marked invalidsendTxandisValidTxRed/green: the new p2p, validator-client and bb-prover tests fail against the code from before this PR (11 p2p, 2 validator-client, 2 bb-prover). The gossip "verifier throws" case already passed thanks to #359.
ivc-integrationbatch_verifier_queue.test.ts(26 tests) passes with real bb against the changedBatchChonkVerifier. Corrupted proofs come back asreduction failed, so they stay invalid and are not classified as bb errors.Not run: e2e and multi-node tests, and a live run where bb is killed mid-verification.
Fixes A-2276