Skip to content

core, eth, miner: make block witnesses reproducible between producer and importer - #2405

Closed
lucca30 wants to merge 13 commits into
v2.10.2-candidatefrom
lmartins/witness-interrupt-drop
Closed

lucca30 wants to merge 13 commits into
v2.10.2-candidatefrom
lmartins/witness-interrupt-drop

Conversation

@lucca30

@lucca30 lucca30 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

⛔ Blocked on pos-workflows#53 — merge that first. pipeline-e2e-tests fails here by design until it lands; all other 18 checks pass. See Dependency — merge order below.

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. RevertToSnapshot undoes 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, ProcessBlock hands the same *stateless.Witness to 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.Enable defaults to true, 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, and CommitSnapshot finishes by draining the shared readerWithCache into 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. TestPipelinedSRCDiffCarriesBlockPrefetcherReads pins 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. Witness gains BeginTx / CommitTx / DiscardTx. While a scope is open, code blobs and state nodes are staged rather than added, and the header extension AddBlockHash performs is recorded so it can be rolled back — AddBlockHash only ever appends, so rolling back is a truncation. StateDB wraps this and additionally defers the read-driven bookkeeping that ends up in the witness indirectly: read-prefetch scheduling, whose resolved trie paths IntermediateRoot harvests, and non-existent-account reads, which drive proof-of-absence walks.

DiscardWitnessTx also 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. commitTransaction opens 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 around ValidateKnownAccounts.

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 InsertChain witness generation both hand a witness to a node that is otherwise entitled to run Block STM.

This half restores 0e88032c2, which landed on v2.9.2-candidate and was never forward-ported. No commit ever reverted it on develop; it simply never arrived. Combined with ParallelEVM.Enable defaulting to true, every witness-recording node cut from develop has 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. BeginWitnessTx returns immediately without a witness, and the refactored read paths fall through to the identical prefetch call. benchstat, 6 runs each, against develop:

develop this PR
InsertChain_ring1000_memdb 3.879 ms 3.881 ms ~ (p=0.394)
BlockChain_1x1000Executions 8.618 ms 8.633 ms ~ (p=0.699)
allocs 76.64k 76.65k ~ (p=0.738)

The 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* 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. Shapes come from mainnet producers, where worker_txApplyDuration reports p50 ~155-210 µs and p75 ~600-850 µs.

accounts slots tx work vs immediate
4 6 20 µs ~ (p=0.065)
4 6 180 µs −3.4%
4 26 20 µs +7.8%
4 26 180 µs −3.8%
4 26 600 µs −2.9%
2 78 180 µs −2.7%

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-workflows that the kurtosis-pipeline-e2e leg runs: "on a non-mining full-sync witness producer, every witness must come from the pipelined SRC completion path." That assertion is why pipeline-e2e-tests is 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 two src checks commented rather than inverted — asserting src == 0 before this guard is on develop would 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, enforceParallelProcessor on and off. Proven load-bearing: dropping the witness == nil condition 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.go and core/state/witness_tx_scope_test.go — scope semantics from both sides: header truncation on discard, read-cache eviction, the dirty-object exemption, and the IntermediateRoot fail-safe.
  • witness_drop_determinism_test.go and witness_drop_fixture_test.go — the interrupt, nonce-race and invalidity drop paths, asserting producer and importer agree.
  • TestPipelinedProducerFlatDiffIgnoresDroppedTransaction stays 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.isPipelineEligible is hard-wired to false, so nothing is sealed through miner/pipeline.go — while the import path is live and leaks through the speculative block prefetcher instead, because importers drop nothing.
  • core + miner 813 passed; core/state + core/stateless + eth 941 passed; go build ./... clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBppHd4UqVqBBkEf7uDT7h

@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 20 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v2.10.2-candidate@4b07a05). Learn more about missing BASE report.

Files with missing lines Patch % Lines
core/state/statedb.go 84.94% 9 Missing and 5 partials ⚠️
miner/worker.go 88.57% 3 Missing and 1 partial ⚠️
eth/backend.go 92.30% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                 Coverage Diff                  @@
##             v2.10.2-candidate    #2405   +/-   ##
====================================================
  Coverage                     ?   55.62%           
====================================================
  Files                        ?      918           
  Lines                        ?   167312           
  Branches                     ?        0           
====================================================
  Hits                         ?    93067           
  Misses                       ?    68757           
  Partials                     ?     5488           
Files with missing lines Coverage Δ
core/blockchain.go 72.08% <100.00%> (ø)
core/state/state_object.go 88.48% <100.00%> (ø)
core/stateless/witness.go 76.19% <100.00%> (ø)
eth/backend.go 55.09% <92.30%> (ø)
miner/worker.go 84.90% <88.57%> (ø)
core/state/statedb.go 79.12% <84.94%> (ø)
Files with missing lines Coverage Δ
core/blockchain.go 72.08% <100.00%> (ø)
core/state/state_object.go 88.48% <100.00%> (ø)
core/stateless/witness.go 76.19% <100.00%> (ø)
eth/backend.go 55.09% <92.30%> (ø)
miner/worker.go 84.90% <88.57%> (ø)
core/state/statedb.go 79.12% <84.94%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lucca30
lucca30 marked this pull request as ready for review September 15, 2026 05:55

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

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.

@lucca30

lucca30 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

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

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…

Comment thread miner/worker.go Outdated
Comment on lines +1841 to +1846
env.state.BeginWitnessTx()
err := env.state.ValidateKnownAccounts(options.KnownAccounts)
if err != nil {
env.state.DiscardWitnessTx()
} else {
env.state.CommitWitnessTx()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 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…

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.

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.

@lucca30

lucca30 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Both review findings are addressed.

eth/backend.go live tracer — already fixed in cb2b5fcf7, which landed one commit after the reviewed SHA (e195e8256), so the review couldn't see it. The diagnosis matches exactly: the tracer block read the raw config.ParallelEVM.Enable while witnessSafeAccelerators ran later, so a witness-recording node lost live tracing and was told parallel EVM was in the way — untrue by the time the blockchain was built. Resolved by hoisting witnessSafeAccelerators above the tracer block and branching on the effective value, which is the suggested fix.

PIP-15 known-accounts leak — fixed in ae82af178; see the inline thread for why the scope is now discarded unconditionally rather than widened, and why I don't think "pre-existing" is the right classification.

Also addressed from CI since the review ran:

  • Quality metrics now passes. Function size was over threshold because the gating logic sat inline in eth.New and BlockChain.ProcessBlock; it's extracted into witnessSafeAccelerators and processorsFor, both now unit-testable. The mutation leg then flagged surviving mutants on the scope's guard conditions — three were genuinely untested branches (the Witness nil-receiver guards, CommitWitnessTx's prefetcher nil-guard, and the immediate no-scope read path), each now covered by a test verified to fail under the mutation and pass without it.
  • Coverage on core/state/statedb.go was low because the scope's tests lived in core, and codecov attributes per package. Added core/state/witness_tx_scope_test.go.

pipeline-e2e-tests is expected to stay red until a companion change merges. This PR disables pipelined import SRC on witness-producing nodes, which contradicts an assertion in 0xPolygon/pos-workflows that witnesses on such a node must come from the pipelined SRC path. That assertion is deliberate (POS-3697), so it needs changing there rather than being worked around here — see the "Dependency — merge order" section in the description.

@lucca30

lucca30 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

⛔ Blocked on 0xPolygon/pos-workflows#53 — do not merge this first

0xPolygon/pos-workflows#53 must merge before this PR.

pipeline-e2e-tests is the only failing check here, and it fails by design. This PR stops witness-producing nodes from running pipelined import SRC; the pipeline leg asserts the opposite — that on a witness producer every witness comes from the pipelined SRC completion path. That assertion is deliberate (POS-3697), so it is being changed at the source rather than worked around here.

Why the order matters

bor's kurtosis-pipeline-e2e.yml checks out pos-workflows at ref: main. Merging this PR first leaves the old assertion live against the new behaviour, so the leg stays red on develop and on every open bor PR — not just this one. #53 is deliberately written to pass against both old and new bor, so it can land first with no red window:

witness validators l2-el-7 (witness rpc) l2-el-6 (plain rpc)
bor develop today src>0 src=602, witness=602 src>0
bor with this PR src=0, witness=620 src=0, witness=601 src=590
#53 verdict passes both passes both strict src>0, passes both

Sequence

  1. Merge pos-workflows#53
  2. Merge this PR
  3. One-line follow-up on pos-workflows tightening the two TIGHTEN AFTER bor #2405 checks to -eq 0

The latest pipeline run failed for exactly the three predicted assertions, nothing else

l2-el-1: src=0 mismatch=0 witness=607
l2-el-2: src=0 mismatch=0 witness=597
l2-el-3: src=0 mismatch=0 witness=8
❌ witness validators: pipeline not active on any node (aggregate src=0)
l2-el-6: src=590 mismatch=0 witness=0      ← pipelined SRC still works where witnesses are off
l2-el-7: src=0 mismatch=0 witness=601
❌ l2-el-7: witness/src divergence (601 vs 0)
❌ l2-el-7: pipeline not active

Everything substantive in that run is healthy: all 8 nodes reached block 127 with the same hash and continued past the veblop HF, mismatch=0 on every node, and witnesses are still produced. The ❌ stopped service / ❌ errors in container logs lines below those come from the on-failure diagnostics step, not from independent failures.

All other 18 checks pass.

lucca30 and others added 13 commits September 15, 2026 08:28
…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
@lucca30
lucca30 force-pushed the lmartins/witness-interrupt-drop branch from 43bc6e2 to 6d8fdd5 Compare September 15, 2026 12:32
@lucca30
lucca30 changed the base branch from develop to v2.10.2-candidate September 15, 2026 12:32
@lucca30

lucca30 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

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 BP-signed commitment becomes a size-range oracle — accept/relay a witness if its size is within 3× the BP-signed size, with the pre-existing absolute ceiling retained underneath as a hard cap + starvation fallback;
  • a per-node witness self-diagnosis (bytes actually touched during execution vs bytes received) detects oversized-but-valid witnesses locally, immune to cross-peer variance;
  • stateless execution against the block state root becomes the sole correctness arbiter, so honest divergence is never punished and blame for a bad-but-signed witness attaches to the BP signer, not honest relayers. This preserves WIT/2's core property: re-announce/serve a trusted signed witness before self-validating it.

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, ProcessBlock waits for both, and the BlockSTM-v2 winner collects flat-served reads via CollectStateWitness + the read-set prewalk. Verified 2026-09-16 (a 20k-account probe could not produce an incomplete witness; TestCollectStateWitnessIncludesFlatServedReads + TestV2WitnessRegeneration* pass). So the processorsFor guard in this PR is belt-and-suspenders, not a fix.

One piece worth keeping — the validateConditionalOptions extraction in miner/worker.go (a plain PIP-15 admission-check refactor, no witness dependency) — will be lifted as a standalone cleanup.

@lucca30 lucca30 closed this Sep 16, 2026
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.

1 participant