Skip to content

fix(levm): charge the EIP-8250 first-use surcharge on the default-code approval - #7085

Open
AnkushinDaniil wants to merge 3 commits into
lambdaclass:hegota-devnetfrom
AnkushinDaniil:daniil/eip8250-first-use-default-code
Open

fix(levm): charge the EIP-8250 first-use surcharge on the default-code approval#7085
AnkushinDaniil wants to merge 3 commits into
lambdaclass:hegota-devnetfrom
AnkushinDaniil:daniil/eip8250-first-use-default-code

Conversation

@AnkushinDaniil

Copy link
Copy Markdown

consume_keyed_nonces deducted the 20k first-use surcharge from the current call frame. The default-code approval path runs outside an ordinary frame, so the deduction landed nowhere and the surcharge was silently free.

The function now returns the surcharge instead of deducting it, and the default-code path charges it explicitly before applying the approval. Observed cross-client as a 16688117199824 wei balance divergence on a keyed-nonce transaction whose sender has no code.

@AnkushinDaniil
AnkushinDaniil requested a review from a team as a code owner August 2, 2026 04:33
@github-actions github-actions Bot added the external-contributor PR opened by a contributor outside the team label Aug 2, 2026
@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

Greptile Summary

The PR moves EIP-8250 first-use surcharge accounting out of nonce consumption so ordinary APPROVE execution and default-code verification can charge their respective frame budgets explicitly.

  • consume_keyed_nonces now returns the incurred surcharge, and a read-only helper predicts it for default verification.
  • The APPROVE opcode charges the returned amount after applying approval effects.
  • Default verification checks its declared frame limit and reports the surcharge as frame gas used.
  • The ordinary APPROVE path currently leaves transaction-level approval metadata behind if this new post-mutation charge runs out of gas.

Confidence Score: 4/5

The PR should not merge until an out-of-gas surcharge rolls back the transaction-level approval metadata along with the approval’s database changes.

Charging the surcharge after apply_approve creates a reachable failure path where database rollback removes the payer debit but leaves payer_address set, allowing final accounting to treat a failed approval as valid and credit an unmatched refund.

Files Needing Attention: crates/vm/levm/src/opcode_handlers/frame_tx.rs

Important Files Changed

Filename Overview
crates/vm/levm/src/opcode_handlers/frame_tx.rs Adds explicit surcharge charging to APPROVE and default verification, but ordinary APPROVE can retain payer metadata when the post-mutation charge fails.
crates/vm/levm/src/vm.rs Refactors keyed-nonce consumption to return its first-use surcharge and adds a matching read-only surcharge calculation.

Sequence Diagram

sequenceDiagram
  participant Frame
  participant Approve as apply_approve
  participant Context as FrameTxContext
  participant Gas as Gas accounting
  participant Rollback
  Frame->>Approve: APPROVE scope
  Approve->>Context: Record payer/approval
  Approve->>Approve: Debit balance and consume nonce
  Approve-->>Frame: Return first-use surcharge
  Frame->>Gas: Charge surcharge
  Gas-->>Frame: OutOfGas
  Frame->>Rollback: Restore call-frame database state
  Note over Context,Rollback: Approval metadata is not restored
Loading
Prompt To Fix All With AI
### Issue 1
crates/vm/levm/src/opcode_handlers/frame_tx.rs:257-258
**Out-of-gas leaves stale payer state**

When a first-use surcharge exceeds the frame's remaining gas, `apply_approve` records the payer and mutates database state before `increase_consumed_gas` returns `OutOfGas`. Frame rollback restores the database-backed debit and nonce changes but not `FrameTxContext.payer_address`, so final accounting accepts the failed approval and credits an unmatched refund to the payer.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(levm): charge the EIP-8250 first-use..." | Re-trigger Greptile

Comment on lines +257 to +258
let surcharge = apply_approve(vm, scope_val, frame_target)?;
vm.current_call_frame.increase_consumed_gas(surcharge)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Out-of-gas leaves stale payer state

When a first-use surcharge exceeds the frame's remaining gas, apply_approve records the payer and mutates database state before increase_consumed_gas returns OutOfGas. Frame rollback restores the database-backed debit and nonce changes but not FrameTxContext.payer_address, so final accounting accepts the failed approval and credits an unmatched refund to the payer.

Knowledge Base Used: VM: LEVM Execution and the Database Glue

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/opcode_handlers/frame_tx.rs
Line: 257-258

Comment:
**Out-of-gas leaves stale payer state**

When a first-use surcharge exceeds the frame's remaining gas, `apply_approve` records the payer and mutates database state before `increase_consumed_gas` returns `OutOfGas`. Frame rollback restores the database-backed debit and nonce changes but not `FrameTxContext.payer_address`, so final accounting accepts the failed approval and credits an unmatched refund to the payer.

**Knowledge Base Used:** [VM: LEVM Execution and the Database Glue](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/vm-levm.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Valid. apply_approve records the payer and mutates database state, and the surcharge charge that follows can return OutOfGas; the frame rollback restores the database-backed debit and nonce consumption but not FrameTxContext, which is not database-backed, so the failed approval survived and the payer was refunded at end of transaction. The approval context is now snapshotted before apply_approve and restored when the surcharge charge fails.

Comment thread crates/vm/levm/src/vm.rs
}

/// The surcharge `consume_keyed_nonces` will charge, without consuming anything.
pub(crate) fn keyed_nonce_first_use_surcharge(&mut self) -> Result<u64, VMError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reimplements the slot derivation from consume_keyed_nonces character-for-character - same [0u8; 64] preimage, same [12..32]/[32..64] layout, same keccak_hash. Two copies of a consensus-critical derivation that must agree: if either drifts, the pre-check prices a different slot than the one actually consumed, and the default-code path charges a surcharge unrelated to what it did.

Worth collapsing to one private helper, something like fn keyed_nonce_slot(sender: Address, key: &U256) -> H256, called from both. Cheap now, and it makes the invariant structural instead of something a future edit has to remember.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Collapsed into keyed_nonce_slot(sender, key), called from both consume_keyed_nonces and keyed_nonce_first_use_surcharge.


// The surcharge comes out of this frame's budget like any other gas, so a frame that cannot
// afford it approves nothing.
let surcharge = vm.keyed_nonce_first_use_surcharge()?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

apply_approve now returns the surcharge it actually consumed, but this path discards it and reports the pre-computed estimate instead (Ok((true, surcharge, ...)) below). The two agree today, though only because of an invariant that lives in another crate: validate_static_constraints rejects nonce_keys unless strictly ascending (transaction.rs:2445, w[0] >= w[1]), so no two keys can hash to the same slot and the pre-check cannot count a first-use that consumption then finds already set.

That is a real guarantee, not a gap - I verified it before writing this. But it is load-bearing for gas accounting and nothing here says so. Relax that ordering rule to allow duplicates and the estimate over-charges by 20k per repeated key, silently.

The pre-check has to stay where it is, since the affordability gate must run before any state is touched. But the charge could come from apply_approve's return value rather than the estimate, which makes this locally correct regardless of what the ordering rule does later:

let surcharge = vm.keyed_nonce_first_use_surcharge()?;   // affordability gate only
if surcharge > frame.gas_limit {
    return Ok((false, frame.gas_limit, Vec::new()));
}
let charged = apply_approve(vm, allowed_scope, target)?; // what was actually consumed
...
Ok((true, charged, Vec::new()))

At minimum, a comment naming the strict-ordering rule as the reason the estimate is trustworthy.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Changed as you wrote it. The pre-check stays where it is as the affordability gate, and the value reported is now what apply_approve returned rather than the estimate, so gas accounting here no longer rests on the strict-ascending rule in another crate.

@AnkushinDaniil
AnkushinDaniil force-pushed the daniil/eip8250-first-use-default-code branch from 2658e7e to 68a8461 Compare August 4, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contributor PR opened by a contributor outside the team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants