Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## v2.10.2-candidate #2405 +/- ##
====================================================
Coverage ? 55.62%
====================================================
Files ? 918
Lines ? 167312
Branches ? 0
====================================================
Hits ? 93067
Misses ? 68757
Partials ? 5488
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
|
@claude review |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
eth/backend.go— Operators running a witness-producing node with --vmtrace lose live tracing even though ParallelEVM ends up disabled for them. The check at eth/backend.go:315 reads the raw config.ParallelEVM.Enable and warns/skips tracer setup, but witnessSafeAccelerators (line 366), which forces parallelEVM off when WitnessProtocol or SyncAndProduceWitnesses is set, runs after this check, so its adjusted value never reaches it. Fix: compute witnessSafeAccelerators before the vmCfg/tracer block and gate the VMTrace check on that effective parallelEVM value instead of config.ParallelEVM.Enable, so tracing is enabled whenever BlockSTM is actually off.Extended reasoning...
Operator sets --parallelevm.enable=true (common/default), --witness.enable (or --witness.producewitnesses), and --vmtrace=. eth.New() builds vmCfg at lines 306-334. Line 315 checks config.ParallelEVM.Enable, which is still true (raw config, untouched). It takes the true branch, logs 'Live tracing requested but not supported with ParallelEVM enabled', and never sets vmCfg.Tracer. Line 366 then calls witnessSafeAccelerators(config), which would have returned parallelEVM=false and logged its own warning disabling BlockSTM specifically because witness generation is enabled. The blockchain is built via core.NewBlockChain (serial only, no parallelProcessor) using the adjusted parallelEVM=false, so ParallelEVM is in fact off for this node. But the tracer decision already happened using the stale true value, so the operator's live tracer is silently never installed even though nothing prevents it from running.
Verification: severity: nit. The candidate accurately reads the code. eth/backend.go:315 gates live-tracer setup on the raw config.ParallelEVM.Enable ("if config.VMTrace != "" && config.ParallelEVM.Enable { log.Warn(...) } else if config.VMTrace != "" { ...; vmCfg.Tracer = t }"), while this diff changed processor selection to use the adjusted value: parallelEVM, pipelinedImportSRC :=… | nit. The…
| env.state.BeginWitnessTx() | ||
| err := env.state.ValidateKnownAccounts(options.KnownAccounts) | ||
| if err != nil { | ||
| env.state.DiscardWitnessTx() | ||
| } else { | ||
| env.state.CommitWitnessTx() |
There was a problem hiding this comment.
🟣 A PIP-15 conditional transaction (KnownAccounts) that passes validation but is then dropped by commitTransaction leaves its known-accounts state reads permanently in the block's witness. env.state.CommitWitnessTx() at miner/worker.go:1846 closes and commits that scope as soon as ValidateKnownAccounts succeeds, before w.commitTransaction (line 1872) decides whether the tx is actually included; the drop branches at miner/worker.go:1879-1946 (ErrNonceTooLow, vm.ErrInterrupt, invalidity) only discard commitTransaction's own later scope, never the earlier committed one. Fix: keep the known-accounts reads staged (or otherwise tie their fate) until commitTransaction's inclusion outcome is known, discarding both together on every non-inclusion path, not just committing early on validation success.
Extended reasoning...
commitTransactions reaches a tx with options.KnownAccounts set (~worker.go:1821). BeginWitnessTx (1841) opens a scope; ValidateKnownAccounts (1842) reads account/storage tries for the check. It succeeds, so CommitWitnessTx (1846) folds those staged reads into the witness's Codes/State sets permanently -- no later undo path exists for this scope. Code proceeds to w.commitTransaction(env, tx) (1872), which opens its own separate scope (line 1656) around real EVM execution. Execution hits ErrNonceTooLow, vm.ErrInterrupt, or other invalidity; commitTransaction discards only its own scope (line 1663) and returns an error. The switch (1878-1947) pops/shifts the tx, excluding it from the block; nothing revisits the already-committed known-accounts scope. The witness now carries trie nodes for a tx absent from the block -- the exact producer/importer mismatch this PR exists to eliminate, but for conditional txs. The PR's own data (1,199-2,098 mid-execution interrupts/day) shows drops are routine, so any conditional tx unlucky enough to be interrupted after passing its known-accounts check…
Verification: pre-existing. The leak is real and reachable but the base already fails the same way by the same route, so merging is not worse than base. Real gap: For a conditional (PIP-15) tx, miner/worker.go:1841 opens a witness scope, ValidateKnownAccounts (statedb.go:3228-3259, reading s.StorageTrie(k).Hash() at :3237-3239 and s.GetState(k,slot) at :3248) stages reads, and on success CommitWitnessTx() at…
There was a problem hiding this comment.
Confirmed and fixed in ae82af1 — thank you, this was a real hole.
One correction to the classification: this is not pre-existing. The base has no witness scoping at all, so it leaks everything everywhere. What this PR did was introduce a scope around ValidateKnownAccounts that looked like it covered the conditional path and didn't — the PR body even claimed it did. That's a regression in the claim, not in the behaviour, and it's worth fixing before merge rather than after.
I didn't take the suggested fix, though. Keeping the reads staged until commitTransaction's outcome is known would require nesting, which the scope design explicitly forbids (BeginTx replaces any open scope, so the inner BeginTx at worker.go:1656 would silently discard the outer staged set — a witness that is too small, which is the worse failure mode).
The deeper point is that these reads don't belong in the witness even when the transaction is included. PIP-15 conditional options arrive with the submission and never travel in the block, so no importing node re-runs ValidateKnownAccounts. Committing its reads makes the producer's witness a strict superset of every importer's — exactly the divergence WIT/2's page-count check treats as misbehaviour.
So the scope is now discarded unconditionally, on both branches. The discard also evicts the read caches those lookups populated, so a later genuinely-included read of the same slot still resolves its trie path instead of returning a silent cache hit.
TestKnownAccountsValidationIsWitnessNeutral pins it with a non-vacuity control: committing the same scope adds 2 nodes, discarding leaves the witness at the baseline.
|
Both review findings are addressed.
PIP-15 known-accounts leak — fixed in Also addressed from CI since the review ran:
|
⛔ Blocked on 0xPolygon/pos-workflows#53 — do not merge this first0xPolygon/pos-workflows#53 must merge before this PR.
Why the order mattersbor's
Sequence
The latest pipeline run failed for exactly the three predicted assertions, nothing elseEverything substantive in that run is healthy: all 8 nodes reached block 127 with the same hash and continued past the veblop HF, All other 18 checks pass. |
…saction
A block's witness has to be a function of the transactions the block
contains. It is not, because the witness has no journal while the state
does: a caller can snapshot the state, run a transaction, and roll it
back, but everything that transaction contributed to the witness stays.
Add a transaction scope that both layers honour.
stateless.Witness gains BeginTx/CommitTx/DiscardTx. Between Begin and
Commit, AddCode and AddState land in a scratch set instead of the
witness, and AddBlockHash's extension of the ancestor chain is recorded
by length so Discard can truncate it back. Headers are part of the
canonical BorWitness encoding, so a discarded transaction that read a
block hash would otherwise move the witness commitment.
StateDB gains BeginWitnessTx/CommitWitnessTx/DiscardWitnessTx, which
drive that scope and additionally buffer the read-driven bookkeeping
that is not a witness call but ends up in the witness anyway:
- account and storage read-prefetch scheduling, whose resolved trie
paths IntermediateRoot harvests through the prefetched tries
- non-existent-account reads, which drive proof-of-absence walks in
the pipelined SRC goroutine
Discard also evicts the read caches the abandoned transaction populated.
Both getStateObject and GetCommittedState return early on a cache hit,
ahead of the prefetch call that puts the path into the witness, so
leaving those entries behind would turn a later genuinely-included read
into a silent cache hit whose node never reaches the witness. That
direction is the dangerous one: a witness carrying too much is merely
large, one missing a node fails stateless execution outright.
IntermediateRoot closes a scope left open by mistake rather than
dropping it, erring the same way, and logs loudly.
All of this is inert without a witness, so importing and non-producing
paths keep their current behaviour byte for byte: regenerating the
mainnet witness fixture still yields commit a1ee70e8604516248068c940
across every worker count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
commitTransactions attempts a transaction and drops it on several paths: the build deadline firing mid-EVM (vm.ErrInterrupt, worker.go:1905), a nonce race with the pool, plain invalidity, and a conditional transaction failing its known-accounts check. commitTransaction restores the state with RevertToSnapshot, but nothing restored the witness, so whatever that transaction read stayed in the witness of a block that does not contain it. For the interrupt path the amount left behind depends on where the wall clock cut the transaction, which made the produced witness non-deterministic: sweeping the deadline across one dropped transaction yielded nine different witnesses for the same block. The interrupt is not a rare path -- worker/opcodeCommitInterrupt fires on the order of a thousand times a day on each mainnet block producer. The consequence reaches the network. The BP signs the witness the miner produced (writeTaskBlock stores it; handler_wit2 signs over the stored bytes), while every importing node computes the witness from the block's own transactions. When the two differ, no importer can reproduce the BP-signed hash, and an importer serving its own correct bytes is struck for a byte mismatch. Wrap each attempt in the StateDB witness scope: begin alongside the state snapshot, commit on success, discard after the revert on failure. The same wrapping guards ValidateKnownAccounts, which reads state for a transaction that may be dropped on the next line. The suite in core/ drives the production sequence exactly -- the interrupt flag the interpreter checks on every opcode, vm.ErrInterrupt surfacing through ApplyTransaction, the revert, the drop -- over a fixture block that exercises read-only loads, writes, a revert, absence proofs, CREATE, SELFDESTRUCT, EIP-158 deletion and 24 concurrent storage writers. It covers the deadline landing at ten points inside a transaction, the drop at six positions in the block, several drops in one build, pre-check failures, a drop sharing state with an included transaction, and a drop reading new slots of an already-loaded account. The acceptance test asserts what the network depends on: producer and importer agree on the witness whatever the producer discarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
Three additions, all about the same question: is the serial path -- the one that will produce witnesses once BlockSTM v2 is kept out of witness production -- blind to everything non-deterministic around it? TestSerialWitnessIsDeterministicAcrossRuns replays one block 30 times across GOMAXPROCS 1..16. IntermediateRoot updates each mutated account's storage trie in its own goroutine and every one calls witness.AddState concurrently, so this is where a racy collection would surface. The trie prefetcher is live throughout, which is the only configuration that exists: --cache.noprefetch is plumbed as far as BlockChainConfig and never read by core, and read-only prefetching is enabled precisely when a witness is being collected (newTriePrefetcher's noreads is witness == nil), because it is what makes the witness complete. TestSerialWitnessIgnoresBlockPrefetcher guards the property that keeps serial clean where v2 is not. ProcessBlock hands the speculative block prefetcher a throwaway StateDB from the same reader triple as the processor, so all three wrap one readerWithCache over one trieReader, and that prefetcher executes transactions in parallel against the parent state -- a transaction branching on a slot an earlier one writes takes a path the ordered execution never takes. Serial collects from its own tries and never drains the shared reader, so those reads cannot reach it. The test carries its own non-vacuity control: draining the shared reader explicitly must change the witness, proving the speculative reads really did land there. TestPipelinedProducerFlatDiffIgnoresDroppedTransaction is skipped, and documents a gap the per-transaction scope does NOT close. Under pipelined sealing the witness is not the one execution accumulated: miner/pipeline.go spawns SRC with makeWitness and allowOwnWitness, and SRC builds a witness by walking the FlatDiff's read set. Most of that diff comes from structures the scope cleans -- ReadSet from s.stateObjects, ReadStorage from originStorage, NonExistentReads from s.nonExistentReads -- but CommitSnapshot ends with drainExternalReadsIntoDiff, which pours the whole readerWithCache read set in. That record is shared and attribution-free, so it carries both dropped-transaction reads and the speculative prefetcher's, and no StateDB-level scope can separate them; closing it needs read attribution on the reader itself. The gap is latent: --pipeline.enable-import-src defaults to false and no chain_pipelined_src_* metric exists on any mainnet or Amoy node. Un-skip and fix before enabling pipelined SRC on a witness producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
Witness recording is only sound on the serial, non-pipelined execution path, but nothing stopped a node from enabling it alongside either accelerator. ProcessBlock hands the same *stateless.Witness to the serial and the parallel processor and keeps whichever finishes first. Both have already written into it by then, so the witness is the union of the two engines' reads, and its size depends on how far the loser got before cancellation — on wall clock. ParallelEVM.Enable defaults to true, so this was the default configuration for any witness-producing node. Pipelined import SRC has a separate leak: CommitSnapshot finishes by draining the shared readerWithCache into the FlatDiff, and that read set carries no attribution, so reads from the speculative block prefetcher and from attempted-then-dropped transactions land in it alongside the block's own. Both produce witnesses that differ between nodes for the same block, which WIT/2's cross-peer page-count check treats as a misbehaving peer. Disable both with a warning rather than refusing to boot: witness generation is the feature the operator asked for, and a node that keeps running without the accelerators is better than one that does not start. ProcessBlock also skips the parallel processor for any block carrying a witness, covering the per-block cases configuration cannot see — stateless self-validation and single-block InsertChain witness generation. The blockchain-side guard and its test are ported from 0e88032, which landed on v2.9.2-candidate but never reached develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
Determinism here is bought on the block producer's hot path, so the cost belongs in the repository next to the change rather than in a review comment. Two benchmarks. The first measures the witness bookkeeping itself: a block's worth of staged additions against today's unscoped ones. The scope costs roughly 8-9% there and about five allocations per transaction -- some 60-75us against a block import measured in tens of milliseconds, which is why it does not move BenchmarkBlockChain_1x1000- Executions or BenchmarkInsertChain_ring1000_memdb. The second measures the part that could plausibly hurt: the scope moves read-prefetch scheduling from the moment the EVM touches a slot to the end of the transaction. It mirrors CommitWitnessTx call for call -- accounts coalesced into one call, storage slots still one call each -- and returns only once the prefetcher has drained, since that is what the state root computation waits on. Transaction execution is spun rather than slept so the sealing thread holds a core the way it does in production, and shapes come from mainnet producers, where worker_txApplyDuration reports p50 ~155-210us and p75 ~600-850us. At those shapes the deferral is a small win, roughly 3%, from batching the handful of account reads. One shape regresses: many storage reads with almost no execution (acct4/slot26/work20us, +7.8%), where the prefetcher is nearest the critical path and there is nothing to batch. That sits below the p25 execution time these producers report, but it is the shape to watch if the transaction mix moves that way. Numbers are memdb-backed and understate absolute trie-walk cost on a real disk. The lock and wake savings do not depend on storage speed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
The gating logic sat inline in eth.New and BlockChain.ProcessBlock, both already far over the 80-line function threshold the Quality metrics CI gate enforces, and neither was reachable from a unit test. witnessSafeAccelerators takes the ethconfig and reports which of the two accelerators a node may run; BlockChain.processorsFor takes the block's witness and reports which engines execute it. No behaviour change -- same conditions, same warnings, same defaults. The extraction also makes the boot-time rule assertable, which TestWitnessSafeAccelerators now does across both witness flags independently: either one alone disables both accelerators, and a node recording no witnesses keeps whatever the operator configured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
TestPipelinedProducerFlatDiffIgnoresDroppedTransaction shipped skipped on a skip message that read the code rather than running it. Running it shows all four drop modes leak, so the message now says so -- and corrects two claims in it. It called the gap "latent because the flag defaults off". The producer side it models is not latent but unreachable: miner.isPipelineEligible is hard-wired to return false, so no block is sealed through miner/pipeline.go at all. The import side, meanwhile, is live and has its own CI leg -- and it leaks too, but through the speculative block prefetcher rather than through dropped transactions, because importers drop nothing. TestPipelinedSRCDiffCarriesBlockPrefetcherReads pins that second one. It is a tripwire, not a property test: it asserts the defect is still present, and starts failing the day the shared reader gains read attribution. That is the signal to drop the pipelined-SRC guard in eth.witnessSafeAccelerators and delete the test with it. The core/state tests cover the scope from inside its own package, where the per-transaction buffering, the read-cache eviction on discard, the dirty-object exemption and the IntermediateRoot fail-safe were reachable only indirectly from core. The dirty-object case documents a sharp edge: the exemption keys off s.mutations, which fills at Finalise, so it protects accounts an INCLUDED transaction touched and correctly leaves a still-in-flight transaction's accounts evictable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
The live-tracer setup reads config.ParallelEVM.Enable directly, so on a witness-recording node it saw the configured value rather than the effective one: parallel EVM is disabled a few lines later, but the tracer block had already refused to install the tracer and warned that parallel EVM was in the way. The operator lost live tracing for a reason that was no longer true by the time the blockchain was built. Resolve the accelerators above the tracer instead and branch on the effective value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
The Quality metrics mutation leg flagged surviving mutants on the scope's
guard conditions. Three were genuinely untested branches rather than
sampling noise, and each is now covered by a test proven to fail under
the mutation:
- Witness.BeginTx/CommitTx/DiscardTx nil-receiver guards. StateDB calls
these through s.witness, which is nil on every node that does not
record witnesses -- all of mainnet today. Without the guards each call
is a nil dereference on the hot path, so they are the reason the
feature is inert rather than fatal when off.
- CommitWitnessTx's prefetcher nil-guard. The buffer is filled while the
prefetcher is live and drained after it is gone, the ordering a
StopPrefetcher between attempt and commit produces. Negating the guard
dereferences a nil prefetcher; the deferred non-existent reads must
still land.
- The immediate path through recordWitnessAccountRead and friends. Every
non-producing caller runs it, and nothing asserted that a read outside
a scope takes effect at once instead of being buffered.
Verified by mutating each condition locally and confirming the new test
fails, then reverting.
Dropped a no-scope test that duplicated
TestWitnessTxWithoutScopeIsPassThrough.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
The conditional-transaction path opened a witness scope around ValidateKnownAccounts and committed it whenever validation passed. That left a hole exactly where this PR claims to close one: a conditional transaction that passes its known-accounts check and is then dropped during execution -- interrupt, nonce race, invalidity -- keeps its validation reads in the witness, because commitTransaction's later scope is a separate one and discarding it cannot reach them. The fix is not to widen the scope across both steps. Scopes deliberately do not nest, and more importantly the reads do not belong in the witness even when the transaction IS included. PIP-15 options arrive with the submission and never travel in the block, so no importing node re-runs the check; committing its reads makes the producer's witness a strict superset of every importer's, which is the divergence WIT/2's cross-peer page-count check punishes. So the scope is now discarded unconditionally. The discard also evicts the read caches the lookups populated, so a later genuinely-included read of the same slot still resolves its trie path into the witness rather than returning a silent cache hit. TestKnownAccountsValidationIsWitnessNeutral pins the property with a non-vacuity control: committing the same scope adds 2 nodes, discarding leaves the witness at the baseline. Reported by the PR review bot, which classified it as pre-existing. It is not: the base has no scoping at all, while this PR introduced the scope that looked like it covered the conditional path and did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
…teness commitTransactions was 298 lines and over the Quality metrics function-size gate. The PIP-15 admission checks move into validateConditionalOptions, which takes the whole nested conditional block with them: the function is now 265 lines, 17 below the base rather than 16 above it, and the //nolint:nestif goes away with the nesting. TestCommitWitnessTxKeepsWitnessComplete pins what the deferral design actually promises and nothing yet asserted at this level: holding a transaction's read-prefetches back until it commits must not cost the witness a single node. It builds the same reads twice, once inside a scope and once not, and requires the scoped witness to contain everything the unscoped one does. Losing a node is the dangerous direction -- too much witness is merely large, too little fails stateless execution outright. Not chasing the remaining mutation survivors. The `true` literals at the deferred prefetch calls are equivalent mutants: prefetch consults `read` only when p.noreads is set (trie_prefetcher.go:257), and witness mode constructs the prefetcher with noreads=false, so flipping the flag is unobservable -- confirmed by mutating it locally and watching the completeness test still pass. The rest are error-log branches reachable only by injecting a failing prefetcher, which would mean adding a test seam to production code to satisfy a sampled metric. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
…er call site TestDiscardWitnessTxEvictsReadCaches did not prove what it claimed. It re-fetched the state object AFTER the discard and guarded the storage assertion behind a nil check -- but the account-eviction loop removes that object from s.stateObjects, so the guard was always false and the slot check never ran. Inverting the eviction loop's nil test left the test green. It now captures the object before the discard, asserts the slot was actually cached first, and checks both evictions: the slot leaves originStorage and the account leaves stateObjects. Verified by inverting the condition and watching it fail. TestValidateConditionalOptionsIsWitnessNeutral covers the producer call site itself, which the state-level test cannot reach -- extracting validateConditionalOptions is what made it addressable. Deleting the DiscardWitnessTx call leaves the scope open, IntermediateRoot's fail-safe then folds the staged validation reads into the witness, and the test sees 4 nodes where the baseline has 2. Also verified by mutation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
…il-guards
Three surviving mutants pointed at genuinely untested behaviour rather
than at equivalent code.
The PIP-15 block-number and timestamp range checks decide whether a
conditional transaction is eligible for this block at all, and nothing
asserted them. Swallowing either error includes a transaction whose own
stated preconditions do not hold.
Writing that test surfaced a real inefficiency: a conditional transaction
constraining only block number or timestamp still opened and discarded a
witness scope, because ValidateKnownAccounts returns early on a nil map
but the scope was taken unconditionally. It now returns before touching
the state when there are no known accounts to check, so range-only
conditionals never reach the witness machinery.
The read-recording helpers' prefetcher nil-guards were also uncovered.
With no scope open they fall straight through to s.prefetcher.prefetch, so
without the guards a read taken after StopPrefetcher -- during shutdown,
or between builds -- is a nil dereference on the state read path.
Verified: removing the guard panics the new test.
Still not chasing two survivors, both confirmed equivalent by mutation
rather than argued:
- statedb.go:839 remove-if-body, the deferred account batch. Account
nodes reach the witness through the read itself (s.reader), not the
prefetcher, so dropping that batch is a performance change and the
completeness test correctly sees no lost nodes.
- the `true` read flags, unobservable while noreads is false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h
43bc6e2 to
6d8fdd5
Compare
|
Closing. This PR forces producer/importer witnesses to be byte-reproducible, but we've decided not to continue treating witnesses as deterministic — BlockSTM speculative reads make them legitimately vary node-to-node (observed ±5–9% node-set spread on mainnet). A non-determinism-tolerant WIT/2 design supersedes this approach:
The per-tx witness scope here is also not needed for witness completeness: that is already guaranteed on develop by #2333 — both processors share one witness object, One piece worth keeping — the |
Problem
A witness is only useful if every node derives the same bytes for the same block. WIT/2 relies on that: the cross-peer page-count check treats a witness that disagrees with its peers as a misbehaving peer and drops and jails it. There are three ways a node currently produces a witness nobody else can reproduce.
A dropped transaction leaves its reads behind. The producer attempts a transaction and then abandons it — the build deadline fires mid-execution, the nonce turns out to be taken, the transaction is simply invalid.
RevertToSnapshotundoes the state, but the witness has no journal of its own, so whatever that transaction read stays in the witness of a block that does not contain it. The producer then signs a witness hash no importing node can reproduce.Two engines write into one witness. On import,
ProcessBlockhands the same*stateless.Witnessto the serial processor and to Block STM, runs them concurrently, and keeps whichever finishes first. Both have already written into it by then, so the witness is the union of the two engines' reads and its size depends on how far the loser got before cancellation — that is, on wall clock.ParallelEVM.Enabledefaults totrue, so this is the default configuration, not a corner case.Pipelined import SRC drains an unattributed reader. The SRC witness is derived from a
FlatDiff, andCommitSnapshotfinishes by draining the sharedreaderWithCacheinto it. That read set carries no attribution, so the speculative block prefetcher's reads land in it alongside the block's own — and how far the prefetcher got before the block finished is wall-clock dependent.TestPipelinedSRCDiffCarriesBlockPrefetcherReadspins this; it is a tripwire that starts failing the day the shared reader gains read attribution, which is the signal to drop the guard.Changes
Per-transaction witness scoping.
WitnessgainsBeginTx/CommitTx/DiscardTx. While a scope is open, code blobs and state nodes are staged rather than added, and the header extensionAddBlockHashperforms is recorded so it can be rolled back —AddBlockHashonly ever appends, so rolling back is a truncation.StateDBwraps this and additionally defers the read-driven bookkeeping that ends up in the witness indirectly: read-prefetch scheduling, whose resolved trie pathsIntermediateRootharvests, and non-existent-account reads, which drive proof-of-absence walks.DiscardWitnessTxalso evicts the read caches the abandoned transaction populated. This is not optional. A cache hit returns ahead of the read-prefetch call, so leaving those entries behind would turn a later, genuinely included read into a silent cache hit whose trie path is never resolved — a witness that is too small, which fails stateless execution outright rather than merely carrying too much.The producer uses the scope.
commitTransactionopens a scope per attempt and discards it on every error path, after the state revert so the cache eviction cannot strand journalled changes. The conditional-transaction path does the same aroundValidateKnownAccounts.Witness recording forces the serial, non-pipelined path. A node with witness production enabled now logs a warning and disables Block STM and pipelined import SRC, rather than refusing to boot — witness generation is the feature the operator asked for, and a node that keeps running without the accelerators is strictly better than one that does not start. Independently, any block carrying a witness runs the serial processor regardless of configuration. That second layer covers what configuration cannot see: stateless self-validation and single-block
InsertChainwitness generation both hand a witness to a node that is otherwise entitled to run Block STM.This half restores
0e88032c2, which landed onv2.9.2-candidateand was never forward-ported. No commit ever reverted it ondevelop; it simply never arrived. Combined withParallelEVM.Enabledefaulting to true, every witness-recording node cut fromdevelophas been running the broken configuration.Performance
Determinism here is bought on the block producer's hot path, so the cost is measured rather than asserted, and the benchmarks ship with the change.
Nodes that do not record witnesses — every node on mainnet today — are unaffected.
BeginWitnessTxreturns immediately without a witness, and the refactored read paths fall through to the identical prefetch call.benchstat, 6 runs each, againstdevelop:InsertChain_ring1000_memdbBlockChain_1x1000ExecutionsThe witness bookkeeping itself costs ~8-9% — 782 µs to 856 µs for a block's worth of staged additions — and about five allocations per transaction. That is some 60-75 µs against a block import measured in tens of milliseconds, which is why it does not surface in either chain benchmark above.
The scheduling change is the part that could plausibly hurt block building. The scope moves read-prefetch scheduling from the moment the EVM touches a slot to the end of the transaction.
BenchmarkPrefetchSchedule*mirrorsCommitWitnessTxcall for call — accounts coalesced into one call, storage slots still one call each — and returns only once the prefetcher has drained, since that is what the state root computation waits on. Transaction execution is spun rather than slept, so the sealing thread holds a core the way it does in production. Shapes come from mainnet producers, whereworker_txApplyDurationreports p50 ~155-210 µs and p75 ~600-850 µs.Geomean −1.0%. At realistic shapes the deferral is a small win, from batching the handful of account reads. One shape regresses: many storage reads with almost no execution, where the prefetcher is nearest the critical path and there is nothing to batch. That sits below the p25 execution time these producers report, but it is the shape to watch if the transaction mix moves that way.
Two scoping notes. The EVM never blocks on the trie prefetcher — scheduling appends to a queue and sends a non-blocking wake — so this change cannot stall execution; it only shifts when background trie work starts. And the pool prefetcher is not involved: it runs on a separate throwaway state that never has a trie prefetcher, warming a shared reader cache instead.
Numbers are memdb-backed and understate absolute trie-walk cost on a real disk. The lock and wake savings do not depend on storage speed. An end-to-end producer run on real storage would close the remaining gap.
Drop-path cost. A discarded transaction forces its cached reads to be re-read. Mainnet producers report 1,199-2,098 mid-execution interrupts per day against 4.5-5.6M successful transaction applies. The other discard paths — nonce races, invalidity, conditional-transaction failures — are not currently instrumented, so their frequency is unmeasured.
Dependency — merge order
This PR turns pipelined import SRC off on witness-producing nodes, which contradicts an assertion in
0xPolygon/pos-workflowsthat thekurtosis-pipeline-e2eleg runs: "on a non-mining full-sync witness producer, every witness must come from the pipelined SRC completion path." That assertion is whypipeline-e2e-testsis red here.pos-workflows PR: 0xPolygon/pos-workflows#53 moves witness producers into the same self-gating class as stateless-sync nodes. bor's workflow pins
pos-workflows@main, so that PR deliberately leaves the twosrcchecks commented rather than inverted — assertingsrc == 0before this guard is ondevelopwould fail every open bor PR. It must merge before this one. Once both are in, the two marked lines there tighten to-eq 0.Worth a reviewer's attention: after this change participant 6, the plain pipelined rpc node, is the only node exercising pipelined SRC end to end. That is thinner than before. Restoring it means adding a second non-witness pipelined rpc node, which changes the topology the stateless suite asserts on, so it is left for a follow-up.
Testing
TestWitnessBlockRunsSerialProcessor— hash and path schemes,enforceParallelProcessoron and off. Proven load-bearing: dropping thewitness == nilcondition fails it with "parallel processor ran for a witness-recording block".TestWitnessSafeAccelerators— the boot-time rule, both witness flags independently: either alone disables both accelerators; a node recording no witnesses keeps what the operator configured.witness_tx_test.goandcore/state/witness_tx_scope_test.go— scope semantics from both sides: header truncation on discard, read-cache eviction, the dirty-object exemption, and theIntermediateRootfail-safe.witness_drop_determinism_test.goandwitness_drop_fixture_test.go— the interrupt, nonce-race and invalidity drop paths, asserting producer and importer agree.TestPipelinedProducerFlatDiffIgnoresDroppedTransactionstays skipped, and the skip message now reports a test that has been run rather than one that reads the code. All four drop modes leak. Two corrections went with it: the producer path it models is not latent but unreachable —miner.isPipelineEligibleis hard-wired tofalse, so nothing is sealed throughminer/pipeline.go— while the import path is live and leaks through the speculative block prefetcher instead, because importers drop nothing.core+miner813 passed;core/state+core/stateless+eth941 passed;go build ./...clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h