Skip to main content

ethrex_levm/opcode_handlers/
frame_tx.rs

1//! # EIP-8141 Frame Transaction opcodes
2//!
3//! Includes:
4//!   - `APPROVE` (0xAA)
5//!   - `TXPARAM` (0xB0)
6//!   - `FRAMEDATALOAD` (0xB1)
7//!   - `FRAMEDATACOPY` (0xB2)
8//!   - `FRAMEPARAM` (0xB3)
9//!   - `SIGPARAM` (0xB4)
10//!   - Default code for EOAs: `VERIFY` has the signature-check behavior;
11//!     `SENDER` and `DEFAULT` return successfully as if calling empty code
12//!     (pinned EIP-8141 spec §"Default code" lines 412-413).
13
14use crate::{
15    errors::{ExceptionalHalt, InternalError, OpcodeResult, VMError},
16    gas_cost,
17    memory::calculate_memory_size,
18    opcode_handlers::OpcodeHandler,
19    utils::size_offset_to_usize,
20    vm::VM,
21};
22use ethrex_common::{Address, U256, types::FrameMode, types::Log};
23
24/// Convert a u64 index to usize, returning InvalidOpcode on overflow.
25fn index_to_usize(val: u64) -> Result<usize, VMError> {
26    usize::try_from(val).map_err(|_| ExceptionalHalt::InvalidOpcode.into())
27}
28
29/// Convert a U256 offset to usize, returning None when the value does not fit
30/// in usize on the current target. Used by FRAMEDATALOAD and FRAMEDATACOPY so
31/// out-of-range offsets are treated as past-the-end rather than as an
32/// exceptional halt (per the EIP-8141 spec the load returns zero and the copy
33/// writes zero bytes).
34pub fn u256_to_offset(value: U256) -> Option<usize> {
35    if value.0[1] != 0 || value.0[2] != 0 || value.0[3] != 0 {
36        return None;
37    }
38    usize::try_from(value.0[0]).ok()
39}
40
41/// Compute the transaction's MAXIMUM cost (spec line 387: APPROVE must
42/// "collect the transaction's maximum cost from payer"):
43/// `max_cost = max_fee_per_gas * total_gas_limit
44///           + len(blob_hashes) * 131072 * max_fee_per_blob_gas`.
45/// This is the single definition of "maximum cost": APPROVE (scopes 0x1/0x3)
46/// debits it from the payer, TXPARAM(0x06) reports it (spec line 455), and the
47/// mempool paymaster reservation reserves it. The end-of-tx refund returns
48/// `max_cost - effective_gas_price * total_gas_used - base-rate blob burn`, so
49/// the payer nets the effective-rate cost of the gas actually used plus the
50/// EIP-4844 blob burn (intrinsic gas is inside `total_gas_used`, so it stays
51/// non-refundable).
52pub(crate) fn compute_tx_max_cost(ctx: &crate::vm::FrameTxContext) -> Result<U256, VMError> {
53    let gas_cost = U256::from(ctx.tx.max_fee_per_gas)
54        .checked_mul(U256::from(ctx.total_gas_limit))
55        .ok_or(ExceptionalHalt::InvalidOpcode)?;
56    let blob_cost = U256::from(ctx.tx.blob_versioned_hashes.len())
57        .checked_mul(U256::from(131072u64))
58        .ok_or(ExceptionalHalt::InvalidOpcode)?
59        .checked_mul(ctx.tx.max_fee_per_blob_gas)
60        .ok_or(ExceptionalHalt::InvalidOpcode)?;
61    gas_cost
62        .checked_add(blob_cost)
63        .ok_or(ExceptionalHalt::InvalidOpcode.into())
64}
65
66/// Apply APPROVE side effects for the given scope.
67/// This is shared between OpApproveHandler and (future) default code.
68pub fn apply_approve(
69    vm: &mut VM<'_>,
70    scope: u64,
71    frame_target: ethrex_common::Address,
72) -> Result<(), VMError> {
73    match scope {
74        0x1 => {
75            // APPROVE_PAYMENT: increment nonce, deduct max cost, record payer.
76            // Per spec, the single transaction-scoped variable `payer` is
77            // set on success; `payer.is_some()` is the source of truth for
78            // "payment has been approved".
79            let ctx = vm
80                .frame_tx_context
81                .as_ref()
82                .ok_or(ExceptionalHalt::InvalidOpcode)?;
83            if ctx.payer_address.is_some() {
84                return Err(ExceptionalHalt::InvalidOpcode.into());
85            }
86            // No sender_approved precondition: the spec allows APPROVE_PAYMENT
87            // in any order relative to APPROVE_EXECUTION.
88            let tx_cost = compute_tx_max_cost(ctx)?;
89            let sender = ctx.tx.sender;
90
91            vm.increment_account_nonce(sender)?;
92            // Payer balance underflow is a frame-level revert, not a consensus
93            // fault: the outer restore_cache_state() path rolls back the nonce
94            // increment above when RevertOpcode propagates.
95            match vm.decrease_account_balance(frame_target, tx_cost) {
96                Ok(()) => {}
97                Err(InternalError::Underflow) => return Err(VMError::RevertOpcode),
98                Err(e) => return Err(VMError::Internal(e)),
99            }
100
101            let ctx = vm
102                .frame_tx_context
103                .as_mut()
104                .ok_or(ExceptionalHalt::InvalidOpcode)?;
105            ctx.payer_address = Some(frame_target);
106        }
107        0x2 => {
108            // APPROVE_EXECUTION: set sender_approved (requires frame_target == tx.sender)
109            let ctx = vm
110                .frame_tx_context
111                .as_ref()
112                .ok_or(ExceptionalHalt::InvalidOpcode)?;
113            if ctx.sender_approved {
114                return Err(ExceptionalHalt::InvalidOpcode.into());
115            }
116            if frame_target != ctx.tx.sender {
117                return Err(VMError::RevertOpcode);
118            }
119            let ctx = vm
120                .frame_tx_context
121                .as_mut()
122                .ok_or(ExceptionalHalt::InvalidOpcode)?;
123            ctx.sender_approved = true;
124        }
125        0x3 => {
126            // APPROVE_EXECUTION_AND_PAYMENT: both, in one atomic step.
127            let ctx = vm
128                .frame_tx_context
129                .as_ref()
130                .ok_or(ExceptionalHalt::InvalidOpcode)?;
131            if ctx.sender_approved || ctx.payer_address.is_some() {
132                return Err(ExceptionalHalt::InvalidOpcode.into());
133            }
134            if frame_target != ctx.tx.sender {
135                return Err(VMError::RevertOpcode);
136            }
137            let tx_cost = compute_tx_max_cost(ctx)?;
138            let sender = ctx.tx.sender;
139
140            vm.increment_account_nonce(sender)?;
141            // See scope 0x1 above for the Underflow → RevertOpcode rationale.
142            match vm.decrease_account_balance(frame_target, tx_cost) {
143                Ok(()) => {}
144                Err(InternalError::Underflow) => return Err(VMError::RevertOpcode),
145                Err(e) => return Err(VMError::Internal(e)),
146            }
147
148            let ctx = vm
149                .frame_tx_context
150                .as_mut()
151                .ok_or(ExceptionalHalt::InvalidOpcode)?;
152            ctx.sender_approved = true;
153            ctx.payer_address = Some(frame_target);
154        }
155        _ => {
156            // scope 0 and any other value are invalid
157            return Err(ExceptionalHalt::InvalidOpcode.into());
158        }
159    }
160    Ok(())
161}
162
163/// APPROVE (0xAA) -- Frame transaction approval opcode.
164///
165/// Pops [offset, length, scope] from the stack.
166/// - scope 0x1 (APPROVE_PAYMENT): increment nonce, deduct tx cost, record payer
167/// - scope 0x2 (APPROVE_EXECUTION): set sender_approved (requires resolved_target == tx.sender)
168/// - scope 0x3 (APPROVE_EXECUTION_AND_PAYMENT): both, in one atomic step
169/// - scope 0x0 (APPROVE_NONE) and any value > 3: invalid (exceptional halt)
170///
171/// The requested scope must also be a subset of the frame's allowed scope, taken
172/// from flags bits 0-1 (`frame.scope_restriction()`). When the allowed scope is 0
173/// (APPROVE_SCOPE_NONE) no approval may be granted in the frame at all, so APPROVE
174/// halts (consistent with `execute_default_verify`).
175///
176/// On success, copies memory[offset..offset+length] to output and halts the frame.
177pub struct OpApproveHandler;
178impl OpcodeHandler for OpApproveHandler {
179    #[inline(always)]
180    fn eval(vm: &mut VM<'_>) -> Result<OpcodeResult, VMError> {
181        let [offset, length, scope] = *vm.current_call_frame.stack.pop()?;
182        let (length, offset) = size_offset_to_usize(length, offset)?;
183
184        // Must be in a frame transaction context
185        let ctx = vm
186            .frame_tx_context
187            .as_ref()
188            .ok_or(ExceptionalHalt::InvalidOpcode)?;
189
190        // The executing contract must be the frame's target
191        let current_frame = ctx
192            .tx
193            .frames
194            .get(ctx.current_frame_index)
195            .ok_or(ExceptionalHalt::InvalidOpcode)?;
196        let frame_target = current_frame.target.unwrap_or(ctx.tx.sender);
197        if vm.current_call_frame.to != frame_target {
198            return Err(VMError::RevertOpcode);
199        }
200
201        // Enforce scope restriction from flags bits 0-1.
202        // allowed_scope == 0 is APPROVE_SCOPE_NONE: no approval may be granted
203        // in this frame at all (consistent with execute_default_verify).
204        let allowed_scope = current_frame.scope_restriction();
205        let scope_val = u64::try_from(scope).unwrap_or(u64::MAX);
206        // requested scope must be a non-zero subset of a (necessarily non-zero) allowed_scope
207        if scope_val == 0 || scope_val > 3 || (scope_val & u64::from(allowed_scope)) != scope_val {
208            return Err(ExceptionalHalt::InvalidOpcode.into());
209        }
210
211        // Charge gas (memory expansion, same as RETURN)
212        vm.current_call_frame
213            .increase_consumed_gas(gas_cost::exit_opcode(
214                calculate_memory_size(offset, length)?,
215                vm.current_call_frame.memory.len(),
216            )?)?;
217
218        apply_approve(vm, scope_val, frame_target)?;
219
220        let ctx = vm
221            .frame_tx_context
222            .as_mut()
223            .ok_or(ExceptionalHalt::InvalidOpcode)?;
224        ctx.approve_called_in_current_frame = true;
225
226        // Copy memory to output (like RETURN)
227        if length != 0 {
228            vm.current_call_frame.output =
229                vm.current_call_frame.memory.load_range(offset, length)?;
230        }
231
232        Ok(OpcodeResult::Halt)
233    }
234}
235
236/// TXPARAM (0xB0) -- Load a transaction parameter as a 32-byte word.
237/// Gas cost: 2
238pub struct OpTxParamHandler;
239impl OpcodeHandler for OpTxParamHandler {
240    #[inline(always)]
241    fn eval(vm: &mut VM<'_>) -> Result<OpcodeResult, VMError> {
242        let [param_id] = *vm.current_call_frame.stack.pop()?;
243
244        vm.current_call_frame
245            .increase_consumed_gas(gas_cost::TXPARAM)?;
246
247        let ctx = vm
248            .frame_tx_context
249            .as_ref()
250            .ok_or(ExceptionalHalt::InvalidOpcode)?;
251
252        let param_id = u64::try_from(param_id).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
253        let result = load_tx_param(ctx, param_id)?;
254        vm.current_call_frame.stack.push(result)?;
255
256        Ok(OpcodeResult::Continue)
257    }
258}
259
260/// FRAMEDATALOAD (0xB1) -- Load one 32-byte word from a frame's data.
261/// Stack: [offset, frameIndex] with offset on top (popped first); frameIndex is
262/// the deeper operand. Gas cost: 3.
263pub struct OpFrameDataLoadHandler;
264impl OpcodeHandler for OpFrameDataLoadHandler {
265    #[inline(always)]
266    fn eval(vm: &mut VM<'_>) -> Result<OpcodeResult, VMError> {
267        let [offset, frame_index] = *vm.current_call_frame.stack.pop()?;
268
269        vm.current_call_frame
270            .increase_consumed_gas(gas_cost::FRAMEDATALOAD)?;
271
272        let ctx = vm
273            .frame_tx_context
274            .as_ref()
275            .ok_or(ExceptionalHalt::InvalidOpcode)?;
276
277        let frame_index = u64::try_from(frame_index).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
278        let idx = index_to_usize(frame_index)?;
279        let frame = ctx
280            .tx
281            .frames
282            .get(idx)
283            .ok_or(ExceptionalHalt::InvalidOpcode)?;
284
285        // Out-of-usize offsets are past-the-end: the word stays zero-filled.
286        let mut word = [0u8; 32];
287        if let Some(byte_offset) = u256_to_offset(offset) {
288            let data = &frame.data;
289            let available = data.len().saturating_sub(byte_offset);
290            let copy_len = available.min(32);
291            if copy_len > 0
292                && let Some(src) = data.get(byte_offset..byte_offset.saturating_add(copy_len))
293            {
294                // copy_len <= 32 == word.len(), so this slice is in bounds.
295                if let Some(dst) = word.get_mut(..copy_len) {
296                    dst.copy_from_slice(src);
297                }
298            }
299        }
300
301        vm.current_call_frame
302            .stack
303            .push(U256::from_big_endian(&word))?;
304
305        Ok(OpcodeResult::Continue)
306    }
307}
308
309/// FRAMEDATACOPY (0xB2) -- Copy frame data into memory.
310/// Takes [memOffset, dataOffset, length, frameIndex] from the stack.
311/// Gas cost matches CALLDATACOPY.
312pub struct OpFrameDataCopyHandler;
313impl OpcodeHandler for OpFrameDataCopyHandler {
314    #[inline(always)]
315    fn eval(vm: &mut VM<'_>) -> Result<OpcodeResult, VMError> {
316        let [mem_offset, data_offset, length, frame_index] = *vm.current_call_frame.stack.pop()?;
317        let (length, mem_offset) = size_offset_to_usize(length, mem_offset)?;
318        // Out-of-usize data_offset is past-the-end: destination stays zero-filled.
319        let data_offset_opt = u256_to_offset(data_offset);
320
321        let new_memory_size = calculate_memory_size(mem_offset, length)?;
322        let current_memory_size = vm.current_call_frame.memory.len();
323        // Charging memory-expansion gas before the frame-context guard below is
324        // intentional: the caller pays for the memory growth it requested even
325        // when the opcode then halts for running outside a frame tx.
326        vm.current_call_frame
327            .increase_consumed_gas(gas_cost::framedatacopy(
328                new_memory_size,
329                current_memory_size,
330                length,
331            )?)?;
332
333        // Frame-context and frame_index checks precede the zero-length early
334        // return: an out-of-bounds frameIndex halts exceptionally even when
335        // length == 0 (EIP-8141 §FRAMEDATACOPY, consensus parity with FRAMEDATALOAD).
336        let ctx = vm
337            .frame_tx_context
338            .as_ref()
339            .ok_or(ExceptionalHalt::InvalidOpcode)?;
340
341        let frame_index = u64::try_from(frame_index).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
342        let idx = index_to_usize(frame_index)?;
343        let frame = ctx
344            .tx
345            .frames
346            .get(idx)
347            .ok_or(ExceptionalHalt::InvalidOpcode)?;
348
349        if length == 0 {
350            return Ok(OpcodeResult::Continue);
351        }
352
353        let data = &frame.data;
354        let mut buf = vec![0u8; length];
355        if let Some(data_offset) = data_offset_opt {
356            let available = data.len().saturating_sub(data_offset);
357            let copy_len = length.min(available);
358            if let (Some(dst), Some(src)) = (
359                buf.get_mut(..copy_len),
360                data.get(data_offset..data_offset.saturating_add(copy_len)),
361            ) {
362                dst.copy_from_slice(src);
363            }
364        }
365
366        vm.current_call_frame.memory.store_data(mem_offset, &buf)?;
367
368        Ok(OpcodeResult::Continue)
369    }
370}
371
372/// FRAMEPARAM (0xB3) -- Load a frame parameter as a 32-byte word.
373/// Stack: [param, frameIndex] with frameIndex on top (matches SIGPARAM). Gas cost: 2.
374pub struct OpFrameParamHandler;
375impl OpcodeHandler for OpFrameParamHandler {
376    #[inline(always)]
377    fn eval(vm: &mut VM<'_>) -> Result<OpcodeResult, VMError> {
378        let [frame_index, param_id] = *vm.current_call_frame.stack.pop()?;
379
380        vm.current_call_frame
381            .increase_consumed_gas(gas_cost::FRAMEPARAM)?;
382
383        let ctx = vm
384            .frame_tx_context
385            .as_ref()
386            .ok_or(ExceptionalHalt::InvalidOpcode)?;
387
388        let frame_index = u64::try_from(frame_index).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
389        let idx = index_to_usize(frame_index)?;
390        let frame = ctx
391            .tx
392            .frames
393            .get(idx)
394            .ok_or(ExceptionalHalt::InvalidOpcode)?;
395
396        let param_id = u64::try_from(param_id).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
397        let result: U256 = match param_id {
398            0x00 => {
399                // target
400                address_to_u256(frame.target.unwrap_or(ctx.tx.sender))
401            }
402            0x01 => {
403                // gas_limit
404                U256::from(frame.gas_limit)
405            }
406            0x02 => {
407                // mode
408                U256::from(frame.mode)
409            }
410            0x03 => {
411                // flags
412                U256::from(frame.flags)
413            }
414            0x04 => {
415                // len(data)
416                U256::from(frame.data.len())
417            }
418            0x05 => {
419                // status -- exceptional halt if current/future frame.
420                // Returns the EIP-8141 status code: 0 = failure, 1 = success,
421                // 3 = skipped (atomic-batch failure).
422                if idx >= ctx.current_frame_index {
423                    return Err(ExceptionalHalt::InvalidOpcode.into());
424                }
425                let (status, _, _) = ctx
426                    .frame_results
427                    .get(idx)
428                    .ok_or(ExceptionalHalt::InvalidOpcode)?;
429                U256::from(*status)
430            }
431            0x06 => {
432                // allowed_scope (flags & 0x03)
433                U256::from(frame.scope_restriction())
434            }
435            0x07 => {
436                // atomic_batch ((flags >> 2) & 1, returns 0 or 1)
437                U256::from(u8::from(frame.is_atomic_batch()))
438            }
439            0x08 => {
440                // value -- EIP-8141 FRAMEPARAM table (spec line 287)
441                frame.value
442            }
443            _ => return Err(ExceptionalHalt::InvalidOpcode.into()),
444        };
445
446        vm.current_call_frame.stack.push(result)?;
447
448        Ok(OpcodeResult::Continue)
449    }
450}
451
452/// SIGPARAM (0xB4) -- signature-scoped metadata (EIP-8141, spec commit fe0940cae2).
453/// Stack: [param, signatureIndex] with signatureIndex on top. Gas cost: 2.
454/// Raw `signature` bytes are intentionally NOT exposed.
455pub struct OpSigParamHandler;
456impl OpcodeHandler for OpSigParamHandler {
457    #[inline(always)]
458    fn eval(vm: &mut VM<'_>) -> Result<OpcodeResult, VMError> {
459        let [signature_index, param] = *vm.current_call_frame.stack.pop()?;
460
461        vm.current_call_frame
462            .increase_consumed_gas(gas_cost::SIGPARAM)?;
463
464        let ctx = vm
465            .frame_tx_context
466            .as_ref()
467            .ok_or(ExceptionalHalt::InvalidOpcode)?;
468
469        let signature_index =
470            u64::try_from(signature_index).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
471        let idx = index_to_usize(signature_index)?;
472        let sig = ctx
473            .tx
474            .signatures
475            .get(idx)
476            .ok_or(ExceptionalHalt::InvalidOpcode)?;
477
478        let param = u64::try_from(param).map_err(|_| ExceptionalHalt::InvalidOpcode)?;
479        let result = match param {
480            0x00 => address_to_u256(sig.signer), // effective signer
481            0x01 => U256::from(sig.scheme),
482            0x02 => {
483                // msg: 0 when empty (canonical sig_hash case), else the 32-byte digest.
484                if sig.msg.is_empty() {
485                    U256::zero()
486                } else {
487                    U256::from_big_endian(&sig.msg)
488                }
489            }
490            0x03 => U256::from(sig.signature.len()),
491            _ => return Err(ExceptionalHalt::InvalidOpcode.into()),
492        };
493        vm.current_call_frame.stack.push(result)?;
494        Ok(OpcodeResult::Continue)
495    }
496}
497
498// -- Helper functions --
499
500pub fn load_tx_param(ctx: &crate::vm::FrameTxContext, param_id: u64) -> Result<U256, VMError> {
501    match param_id {
502        0x00 => Ok(U256::from(0x06u8)), // tx_type (EIP-8141 = type 6)
503        0x01 => Ok(U256::from(ctx.tx.nonce)),
504        0x02 => Ok(address_to_u256(ctx.tx.sender)),
505        0x03 => Ok(U256::from(ctx.tx.max_priority_fee_per_gas)),
506        0x04 => Ok(U256::from(ctx.tx.max_fee_per_gas)),
507        0x05 => Ok(ctx.tx.max_fee_per_blob_gas),
508        0x06 => compute_tx_max_cost(ctx),
509        0x07 => Ok(U256::from(ctx.tx.blob_versioned_hashes.len())),
510        0x08 => {
511            let mut bytes = [0u8; 32];
512            bytes.copy_from_slice(ctx.sig_hash.as_bytes());
513            Ok(U256::from_big_endian(&bytes))
514        }
515        0x09 => Ok(U256::from(ctx.tx.frames.len())),
516        0x0A => Ok(U256::from(ctx.current_frame_index)),
517        0x0B => Ok(U256::from(ctx.tx.signatures.len())),
518        _ => Err(ExceptionalHalt::InvalidOpcode.into()),
519    }
520}
521
522pub fn address_to_u256(addr: ethrex_common::Address) -> U256 {
523    let mut bytes = [0u8; 32];
524    bytes[12..].copy_from_slice(addr.as_bytes());
525    U256::from_big_endian(&bytes)
526}
527
528// -- Default code for EOAs (EIP-8141) --
529
530/// Execute default code for an EOA target in a frame transaction.
531///
532/// When a frame targets an address with no deployed code (an EOA), the protocol
533/// runs built-in "default code" instead of executing a normal CALL. `VERIFY`
534/// runs the signature-check logic; `SENDER` and `DEFAULT` return successfully
535/// as if calling empty code (pinned EIP-8141 spec §"Default code" lines 412-413).
536///
537/// Returns `(success, gas_used, logs)`.
538pub fn execute_default_code(
539    vm: &mut VM<'_>,
540    frame: &ethrex_common::types::Frame,
541    target: Address,
542) -> Result<(bool, u64, Vec<Log>), VMError> {
543    match frame.execution_mode() {
544        FrameMode::Verify => execute_default_verify(vm, frame, target),
545        // Pinned EIP-8141 spec (fe0940cae2) §"Default code" lines 412-413:
546        // a SENDER or DEFAULT frame whose target has no code "returns
547        // successfully as if calling empty code" — this is what makes a plain
548        // ETH transfer to an EOA work (spec §EOA support / Example 1).
549        // Consumes no execution gas (the frame's value transfer is handled by
550        // the caller's deferred transfer).
551        FrameMode::Sender | FrameMode::Default => Ok((true, 0, Vec::new())),
552    }
553}
554
555fn execute_default_verify(
556    vm: &mut VM<'_>,
557    frame: &ethrex_common::types::Frame,
558    target: Address,
559) -> Result<(bool, u64, Vec<Log>), VMError> {
560    let ctx = vm
561        .frame_tx_context
562        .as_ref()
563        .ok_or(ExceptionalHalt::InvalidOpcode)?;
564
565    // Read allowed scope from flags bits 0-1
566    let allowed_scope = u64::from(frame.scope_restriction());
567    if allowed_scope == 0 {
568        return Ok((false, 0, Vec::new()));
569    }
570
571    // If scope includes APPROVE_EXECUTION and resolved_target != tx.sender, revert
572    if (allowed_scope & 0x02) != 0 && target != ctx.tx.sender {
573        return Ok((false, 0, Vec::new()));
574    }
575
576    // EIP-8141 (spec commit fe0940cae2): the default account approves only if
577    // the outer signature list contains a SECP256K1 signature over the
578    // canonical sig_hash (empty msg) whose signer is the resolved target.
579    // Signatures were already validated in execute_frame_tx, so a match here is
580    // sufficient — no in-frame crypto.
581    let has_sender_sig = ctx.tx.signatures.iter().any(|s| {
582        s.scheme == ethrex_common::types::FRAME_SIG_SCHEME_SECP256K1
583            && s.msg.is_empty()
584            && s.signer == target
585    });
586    if !has_sender_sig {
587        return Ok((false, 0, Vec::new()));
588    }
589
590    apply_approve(vm, allowed_scope, target)?;
591
592    let ctx = vm
593        .frame_tx_context
594        .as_mut()
595        .ok_or(ExceptionalHalt::InvalidOpcode)?;
596    ctx.approve_called_in_current_frame = true;
597
598    Ok((true, 0, Vec::new()))
599}
600
601#[cfg(test)]
602mod max_cost_tests {
603    use super::{compute_tx_max_cost, load_tx_param};
604    use crate::vm::FrameTxContext;
605    use ethrex_common::{H256, U256, types::FrameTransaction};
606
607    fn ctx(max_fee: u64, blobs: usize, max_blob_fee: u64, total_gas_limit: u64) -> FrameTxContext {
608        let tx = FrameTransaction {
609            max_fee_per_gas: max_fee,
610            max_fee_per_blob_gas: U256::from(max_blob_fee),
611            blob_versioned_hashes: vec![H256::zero(); blobs],
612            ..Default::default()
613        };
614        FrameTxContext {
615            sender_approved: false,
616            payer_address: None,
617            frame_results: Vec::new(),
618            current_frame_index: 0,
619            sig_hash: H256::zero(),
620            tx,
621            approve_called_in_current_frame: false,
622            total_gas_limit,
623        }
624    }
625
626    #[test]
627    fn max_cost_is_max_fee_times_limit_plus_max_blob_cost() {
628        // 10 * 100_000 + 2 * 131072 * 5 = 1_000_000 + 1_310_720
629        let c = ctx(10, 2, 5, 100_000);
630        assert_eq!(compute_tx_max_cost(&c).unwrap(), U256::from(2_310_720u64));
631        // No blobs: just max_fee * total_gas_limit.
632        let c = ctx(7, 0, 999, 21_000);
633        assert_eq!(compute_tx_max_cost(&c).unwrap(), U256::from(147_000u64));
634    }
635
636    #[test]
637    fn txparam_0x06_reports_the_same_maximum_cost_approve_debits() {
638        // TXPARAM(0x06) and the APPROVE debit must stay one definition of
639        // "maximum cost"; a split between them is a consensus bug.
640        let c = ctx(10, 2, 5, 100_000);
641        assert_eq!(
642            load_tx_param(&c, 0x06).unwrap(),
643            compute_tx_max_cost(&c).unwrap()
644        );
645    }
646}