Skip to content

Commit f4727ce

Browse files
fix(evm): meter each RPC transact against a fresh zk gas budget (#232)
* fix(evm): meter each RPC transact against a fresh zk gas budget eth_estimateGas reuses one EVM for its repeated simulations (full-gas run, optimistic run, binary search), but the zk gas meter lives on the EVM instance and transact_raw never reset it, so in-flight usage accumulated across runs and falsely tripped the 100M Unzen block limit (observed live: ~28M zk gas fulfillments failing with "zk gas limit exceeded" while eth_call succeeded). eth_createAccessList and eth_simulateV1 leaked the same way. TaikoEvmWrapper now discards in-flight zk gas at the start of every transact_raw. The block executor opts out via the new set_per_transact_zk_gas_reset_enabled switch because it owns the per-transaction bracket (reset, intrinsic charge, commit), keeping consensus metering byte-for-byte unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(ci): refresh pinned apt.llvm.org installer hash Upstream llvm.sh moved from opencollab/llvm-jenkins.debian.net@eeed6742 (the previously pinned 9474ecd7) to HEAD 6dc0d1ad, and apt.llvm.org now serves the new script byte-for-byte, so the stale pin failed every LLVM setup step (clippy, test, docker llvm install) at checksum verification. Reviewed the full delta before repinning: a usage exit-code tweak, long-option parsing (--help/--version/--), and the LLVM 23/24 version patterns from upstream's "prepare 23". Nothing affects our "llvm.sh 22 all" invocation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(evm): clear inspector step bookkeeping in the zk gas reset Review round 1: reset_transaction_zk_gas only reset the meter, leaving the inspector path's deferred/pending step state behind. The unwind of a failed transaction drains deferred steps through call_end/create_end on current revm — two adversarial nested-call tests pin that — but the invariant lived in revm's frame handling; clearing everything in the reset makes the transact boundary locally provable. Also corrects the flag documentation: eth_createAccessList uses a fresh EVM per run and eth_simulateV1 runs through the block executor, so the raw-EVM reuse set is estimateGas, eth_callBundle, and the intra-block replay helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent c0bf140 commit f4727ce

5 files changed

Lines changed: 327 additions & 7 deletions

File tree

.github/scripts/install_llvm_ubuntu.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ for attempt in $(seq "$attempts"); do
3939
done
4040
# Pin the upstream installer so CI and Docker builds fail loudly when it changes; review the new
4141
# script before updating this hash.
42-
echo "9474ecd78b52aba6e923976b1e9773f5613027cc7e237b9956986cb536e02a36 $llvm_installer" | sha256sum -c -
42+
echo "03878e08f47b66cc95bc4b544b0db3c6d9ce8d60e6cf2492ae357984330a9eae $llvm_installer" | sha256sum -c -
4343
chmod +x "$llvm_installer"
4444
"$llvm_installer" "$version" all
4545
rm -f "$llvm_installer"

crates/block/src/executor.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,16 @@ where
145145
receipt_builder: R,
146146
) -> Self
147147
where
148-
Evm: TaikoAnchorEvm,
148+
Evm: TaikoAnchorEvm + TaikoZkGasEvm,
149149
{
150150
// The executor installs the authoritative anchor context through the anchor system
151151
// call in `apply_pre_execution_changes`; replay-only derivation must stay off so a
152152
// missing initialization keeps failing loudly.
153153
evm.set_anchor_ctx_derivation_enabled(false);
154+
// The executor owns the per-transaction zk gas bracket (reset, intrinsic charge,
155+
// commit) in `execute_transaction_without_commit`; the wrapper's per-transact entry
156+
// reset must stay off or it would wipe the intrinsic charged before `transact` runs.
157+
evm.set_per_transact_zk_gas_reset_enabled(false);
154158
Self {
155159
evm,
156160
ctx,

crates/evm/src/alloy.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ pub struct TaikoEvmWrapper<DB: Database, I, P> {
6060
/// because it installs the authoritative context through the anchor system call, and a
6161
/// missing pre-execution initialization must keep failing loudly there.
6262
derive_anchor_ctx: bool,
63+
/// Whether [`Evm::transact_raw`] discards in-flight zk gas before executing. Enabled by
64+
/// default so RPC-style consumers that reuse one EVM never accumulate zk gas across
65+
/// transacts: `eth_estimateGas`'s repeated runs of the same transaction meter from zero,
66+
/// and multi-transaction simulations over one raw EVM (`eth_callBundle`, the intra-block
67+
/// replay helpers) get a fresh per-transaction budget rather than a consensus-shaped
68+
/// cumulative one. Consensus-shaped execution (including `eth_simulateV1`) runs through
69+
/// the block executor, which turns this off because it owns the per-transaction bracket
70+
/// (reset, intrinsic charge, commit) and an entry reset here would wipe the intrinsic zk
71+
/// gas it charges before execution.
72+
reset_zk_gas_per_transact: bool,
6373
}
6474

6575
impl<DB: Database, I, P> TaikoEvmWrapper<DB, I, P> {
@@ -69,18 +79,23 @@ impl<DB: Database, I, P> TaikoEvmWrapper<DB, I, P> {
6979
where
7080
P: PrecompileProvider<TaikoEvmContext<DB>, Output = InterpreterResult>,
7181
{
72-
Self { inner: revmc::revm_evm::JitEvm::disabled(evm), inspect, derive_anchor_ctx: true }
82+
Self {
83+
inner: revmc::revm_evm::JitEvm::disabled(evm),
84+
inspect,
85+
derive_anchor_ctx: true,
86+
reset_zk_gas_per_transact: true,
87+
}
7388
}
7489

7590
/// Creates an interpreter-backed [`TaikoEvmWrapper`] instance.
7691
#[cfg(not(feature = "jit"))]
7792
pub const fn new(evm: BaseTaikoEvm<DB, I, P>, inspect: bool) -> Self {
78-
Self { inner: evm, inspect, derive_anchor_ctx: true }
93+
Self { inner: evm, inspect, derive_anchor_ctx: true, reset_zk_gas_per_transact: true }
7994
}
8095

8196
/// Creates a wrapper around an already configured optional JIT dispatcher.
8297
pub(crate) fn new_with_inner(evm: InnerTaikoEvm<DB, I, P>, inspect: bool) -> Self {
83-
Self { inner: evm, inspect, derive_anchor_ctx: true }
98+
Self { inner: evm, inspect, derive_anchor_ctx: true, reset_zk_gas_per_transact: true }
8499
}
85100

86101
/// Consumes self and return the inner EVM instance.
@@ -210,6 +225,16 @@ impl<DB: Database, I, P> TaikoAnchorEvm for TaikoEvmWrapper<DB, I, P> {
210225

211226
/// EVM extension trait for reading and mutating the zk gas meter state.
212227
pub trait TaikoZkGasEvm {
228+
/// Enables or disables the automatic in-flight zk gas reset at the start of every transact.
229+
///
230+
/// Enabled by default: RPC-style consumers reuse one EVM across transacts
231+
/// (`eth_estimateGas` re-runs the same transaction; `eth_callBundle` and the intra-block
232+
/// replay helpers execute a transaction sequence), and each run must meter from zero
233+
/// instead of inheriting earlier runs' usage. The block executor disables it because it
234+
/// drives the meter through its own reset/intrinsic-charge/commit bracket, which an entry
235+
/// reset would corrupt by wiping the intrinsic charged before execution.
236+
fn set_per_transact_zk_gas_reset_enabled(&mut self, enabled: bool);
237+
213238
/// Discards any in-flight zk gas recorded for the current transaction.
214239
fn reset_transaction_zk_gas(&mut self);
215240

@@ -236,11 +261,21 @@ where
236261
I: Inspector<TaikoEvmContext<DB>>,
237262
P: PrecompileProvider<TaikoEvmContext<DB>, Output = InterpreterResult>,
238263
{
264+
/// Enables or disables the automatic in-flight zk gas reset at the start of every transact.
265+
fn set_per_transact_zk_gas_reset_enabled(&mut self, enabled: bool) {
266+
self.reset_zk_gas_per_transact = enabled;
267+
}
268+
239269
/// Discards any in-flight zk gas recorded for the current transaction.
270+
///
271+
/// Covers both metering homes — the production meter on the base EVM and the inspector's
272+
/// meter plus its step bookkeeping (only one of the two is ever installed) — so nothing an
273+
/// aborted run recorded can charge into the next transaction.
240274
fn reset_transaction_zk_gas(&mut self) {
241-
if let Some(meter) = self.meter_mut() {
275+
if let Some(meter) = self.base_evm_mut().zk_gas_meter_mut() {
242276
meter.reset_transaction();
243277
}
278+
self.base_evm_mut().inner.inspector.reset_transaction();
244279
}
245280

246281
/// Commits the current transaction's zk gas into the block total and returns the new total.
@@ -363,6 +398,15 @@ where
363398
&mut self,
364399
tx: Self::Tx,
365400
) -> Result<ResultAndState<Self::HaltReason>, Self::Error> {
401+
// RPC callers that reuse one EVM (`eth_estimateGas`'s repeated runs of one transaction,
402+
// `eth_callBundle`'s and the intra-block replay helpers' transaction sequences) never
403+
// commit the meter, so without a reset the in-flight zk gas of previous runs
404+
// accumulates and eventually trips the block budget. The block executor disables this
405+
// and manages the per-transaction bracket itself; `eth_simulateV1` and consensus paths
406+
// reach the meter through the block executor.
407+
if self.reset_zk_gas_per_transact {
408+
self.reset_transaction_zk_gas();
409+
}
366410
self.maybe_derive_anchor_execution_ctx(&tx)?;
367411
self.ctx_mut().set_tx(tx);
368412
// Run [`TaikoEvmHandler`] against the (possibly JIT-dispatching) inner EVM directly:

crates/evm/src/zk_gas/adapter.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ impl<I> ZkGasInspector<I> {
7575
pub(crate) fn meter_mut(&mut self) -> Option<&mut ZkGasMeter<'static>> {
7676
self.metering.as_mut().map(|state| &mut state.meter)
7777
}
78+
79+
/// Discards in-flight transaction metering state, if metering is enabled.
80+
///
81+
/// Clears both the meter's per-transaction usage and the inspector's step bookkeeping so
82+
/// nothing recorded by an aborted transaction can charge into the next one.
83+
pub(crate) fn reset_transaction(&mut self) {
84+
if let Some(metering) = &mut self.metering {
85+
metering.reset_transaction();
86+
}
87+
}
7888
}
7989

8090
impl<DB, I> Inspector<TaikoEvmContext<DB>, EthInterpreter> for ZkGasInspector<I>
@@ -302,6 +312,21 @@ impl ZkGasMeteringState {
302312
}
303313
}
304314

315+
/// Discards the in-flight transaction zk gas together with all per-frame step bookkeeping.
316+
///
317+
/// The unwind of a failed transaction drains deferred steps through `call_end`/`create_end`
318+
/// today, but that invariant lives in revm's frame handling; resetting everything here keeps
319+
/// the transaction boundary self-contained regardless of how execution aborted.
320+
fn reset_transaction(&mut self) {
321+
self.meter.reset_transaction();
322+
for index in 0..=self.max_active_depth {
323+
self.pending_steps[index] = PendingStep::EMPTY;
324+
self.deferred_steps[index] = None;
325+
}
326+
self.has_deferred_steps = false;
327+
self.max_active_depth = 0;
328+
}
329+
305330
/// Records the opcode and gas snapshot for the current frame depth.
306331
#[inline(always)]
307332
fn begin_step(&mut self, depth: usize, opcode: u8, gas_remaining: u64) {
@@ -504,6 +529,24 @@ mod tests {
504529
assert!(metering.deferred_steps[1].is_some());
505530
}
506531

532+
#[test]
533+
fn reset_transaction_clears_meter_and_step_bookkeeping() {
534+
let mut metering = ZkGasMeteringState::new(&UNZEN_ZK_GAS_SCHEDULE);
535+
metering.begin_step(1, 0x01, 10);
536+
metering.defer_step(0, FinishedStep { opcode: 0xf1, step_gas: 5, spawned: true });
537+
metering.defer_step(1, FinishedStep { opcode: 0x01, step_gas: 3, spawned: false });
538+
metering.meter.charge_opcode(0x01, 3).expect("charge fits");
539+
540+
metering.reset_transaction();
541+
542+
assert_eq!(metering.meter.tx_zk_gas_used(), 0);
543+
assert!(!metering.has_deferred_steps);
544+
assert!(metering.deferred_steps[..2].iter().all(Option::is_none));
545+
assert_eq!(metering.pending_steps[1].opcode, 0);
546+
assert_eq!(metering.pending_steps[1].gas_remaining, 0);
547+
assert_eq!(metering.max_active_depth, 0);
548+
}
549+
507550
#[test]
508551
fn charge_finished_step_uses_active_meter_schedule_for_spawn_estimate() {
509552
let mut metering = ZkGasMeteringState::new(&UNZEN_ZK_GAS_SCHEDULE);

0 commit comments

Comments
 (0)