Improvements possible for testing out-of-gas on MCOPY
Description
The EVM circuit has some tests to test out-of-gas errors being handled by the ErrorOOGMemoryCopyGadget
on MCOPY
execution, given by the following test function:
#[test]
fn test_oog_memory_copy_for_mcopy() {
for (src_offset, dest_offset, copy_size) in TESTING_MCOPY_PARIS {
let testing_data =
TestingData::new_for_mcopy(*src_offset, *dest_offset, *copy_size, None);
test_root(&testing_data);
test_internal(&testing_data);
}
}
This function first uses TestingData::new_for_mcopy
to obtain a TestingData
struct, containing bytecode executing an MCOPY
instruction as well as the calculated gas cost. Then test_root
and test_internal
are called, which perform the actual test. In the former case, the bytecode including the MCOPY
will be executed directly (at the top level of the transaction), and in the latter case, it will be executed one level lower, with a wrapper contract calling the bytecode to be tested. In both cases, the amount of gas is adjusted so that it should be just one gas too little to execute the MCOPY
instruction:
fn test_root(testing_data: &TestingData) {
let gas_cost = GasCost::TX
.0
! // Decrease expected gas cost (by 1) to trigger out of gas error.
! .checked_add(testing_data.gas_cost - 1)
.unwrap_or(MOCK_BLOCK_GAS_LIMIT);
let gas_cost = if gas_cost > MOCK_BLOCK_GAS_LIMIT {
MOCK_BLOCK_GAS_LIMIT
} else {
gas_cost
};
let ctx = TestContext::<2, 1>::new(
None,
account_0_code_account_1_no_code(testing_data.bytecode.clone()),
|mut txs, accs| {
txs[0]
.from(accs[1].address)
.to(accs[0].address)
.gas(gas_cost.into());
},
|block, _tx| block.number(0xcafe_u64),
)
.unwrap();
CircuitTestBuilder::new_from_test_ctx(ctx)
.params(CircuitsParams {
max_copy_rows: 1750,
..Default::default()
})
.run();
}
fn test_internal(testing_data: &TestingData) {
// ...
// Code A calls code B.
let code_a = bytecode! {
// populate memory in A's context.
PUSH8(U256::from_big_endian(&rand_bytes(8)))
PUSH1(0x00) // offset
MSTORE
// call ADDR_B.
PUSH1(0x00) // retLength
PUSH1(0x00) // retOffset
PUSH32(0x00) // argsLength
PUSH32(0x20) // argsOffset
PUSH1(0x00) // value
PUSH32(addr_b.to_word()) // addr
! // Decrease expected gas cost (by 1) to trigger out of gas error.
! PUSH32(gas_cost_b - 1) // gas
CALL
STOP
};
// ...
}
If we run this test with RUST_BACKTRACE=1 RUST_LOG=trace cargo test test_oog_memory_copy_for_mcopy -- --nocapture
, we can also see in the debug output that an out-of-memory error happens, as seen here for the very last test case:
[2024-06-04T18:40:55Z TRACE zkevm_circuits::evm_circuit::execution] assign_exec_step offset: 48 state ErrorOutOfGasMemoryCopy step: ExecStep { call_index: 1, rw_indices: [(Stack, 22), (Stack, 23), (Stack, 24), (CallContext, 49), (CallContext, 50), (CallContext, 51), (CallContext, 52), (CallContext, 53), (CallContext, 54), (CallContext, 55), (CallContext, 56), (CallContext, 57), (CallContext, 58), (CallContext, 59), (CallContext, 60), (CallContext, 61), (CallContext, 62)], copy_rw_counter_delta: 0, execution_state: ErrorOutOfGasMemoryCopy, rw_counter: 95, program_counter: 99, stack_pointer: 1021, gas_left: 14, gas_cost: 42, memory_size: 0, reversible_write_counter: 0, reversible_write_counter_delta: 0, log_id: 0, opcode: Some(MCOPY), block_num: 0, aux_data: None } call: Call { id: 50, is_root: false, is_create: false, code_hash: 69088479414407603174779605424463024523545942567839557216382057906686480814179, rw_counter_end_of_reversion: 111, caller_id: 1, depth: 2, caller_address: 0x000000000000000000000000000000000cafe111, callee_address: 0x000000000000000000000000000000000cafe222, code_address: Some(0x000000000000000000000000000000000cafe222), call_data_offset: 0, call_data_length: 0, return_data_offset: 0, return_data_length: 0, last_callee_id: 0, last_callee_return_data_offset: 0, last_callee_return_data_length: 0, value: 0, is_success: false, is_persistent: false, is_static: false }
[2024-06-04T18:40:55Z DEBUG zkevm_circuits::evm_circuit::execution::error_oog_memory_copy] ErrorOutOfGasMemoryCopy: opcode = MCOPY, gas_left = 14, gas_cost = 42
However, the way these tests are run does not check that an out-of-gas error actually happens on the MCOPY
.
We verified this by changing the amount of gas available to be sufficient:
fn test_root(testing_data: &TestingData) {
let gas_cost = GasCost::TX
.0
// Decrease expected gas cost (by 1) to trigger out of gas error.
- .checked_add(testing_data.gas_cost - 1)
+ .checked_add(testing_data.gas_cost + 100000)
.unwrap_or(MOCK_BLOCK_GAS_LIMIT);
let gas_cost = if gas_cost > MOCK_BLOCK_GAS_LIMIT {
MOCK_BLOCK_GAS_LIMIT
} else {
gas_cost
};
// ...
}
fn test_internal(testing_data: &TestingData) {
// ...
// Code A calls code B.
let code_a = bytecode! {
// populate memory in A's context.
PUSH8(U256::from_big_endian(&rand_bytes(8)))
PUSH1(0x00) // offset
MSTORE
// call ADDR_B.
PUSH1(0x00) // retLength
PUSH1(0x00) // retOffset
PUSH32(0x00) // argsLength
PUSH32(0x20) // argsOffset
PUSH1(0x00) // value
PUSH32(addr_b.to_word()) // addr
// Decrease expected gas cost (by 1) to trigger out of gas error.
- PUSH32(gas_cost_b - 1) // gas
+ PUSH32(gas_cost_b + 100000) // gas
CALL
STOP
};
// ...
}
The tests still pass. However, no out-of-gas error occurs:
[2024-06-04T18:46:28Z TRACE zkevm_circuits::evm_circuit::execution] assign_exec_step offset: 48 state MCOPY step: ExecStep { call_index: 1, rw_indices: [(Stack, 22), (Stack, 23), (Stack, 24), (Memory, 2), (Memory, 3), (Memory, 4), (Memory, 5), (Memory, 6), (Memory, 7), (Memory, 8), (Memory, 9)], copy_rw_counter_delta: 8, execution_state: MCOPY, rw_counter: 95, program_counter: 99, stack_pointer: 1021, gas_left: 100015, gas_cost: 42, memory_size: 0, reversible_write_counter: 0, reversible_write_counter_delta: 0, log_id: 0, opcode: Some(MCOPY), block_num: 0, aux_data: None } call: Call { id: 50, is_root: false, is_create: false, code_hash: 69088479414407603174779605424463024523545942567839557216382057906686480814179, rw_counter_end_of_reversion: 0, caller_id: 1, depth: 2, caller_address: 0x000000000000000000000000000000000cafe111, callee_address: 0x000000000000000000000000000000000cafe222, code_address: Some(0x000000000000000000000000000000000cafe222), call_data_offset: 0, call_data_length: 0, return_data_offset: 0, return_data_length: 0, last_callee_id: 0, last_callee_return_data_offset: 0, last_callee_return_data_length: 0, value: 0, is_success: true, is_persistent: true, is_static: false }
[2024-06-04T18:46:28Z TRACE zkevm_circuits::evm_circuit::execution] assign_exec_step offset: 50 state STOP step: ExecStep { call_index: 1, rw_indices: [(CallContext, 49), (CallContext, 50), (CallContext, 51), (CallContext, 52), (CallContext, 53), (CallContext, 54), (CallContext, 55), (CallContext, 56), (CallContext, 57), (CallContext, 58), (CallContext, 59), (CallContext, 60), (CallContext, 61)], copy_rw_counter_delta: 0, execution_state: STOP, rw_counter: 106, program_counter: 100, stack_pointer: 1024, gas_left: 99973, gas_cost: 0, memory_size: 288, reversible_write_counter: 0, reversible_write_counter_delta: 0, log_id: 0, opcode: Some(STOP), block_num: 0, aux_data: None } call: Call { id: 50, is_root: false, is_create: false, code_hash: 69088479414407603174779605424463024523545942567839557216382057906686480814179, rw_counter_end_of_reversion: 0, caller_id: 1, depth: 2, caller_address: 0x000000000000000000000000000000000cafe111, callee_address: 0x000000000000000000000000000000000cafe222, code_address: Some(0x000000000000000000000000000000000cafe222), call_data_offset: 0, call_data_length: 0, return_data_offset: 0, return_data_length: 0, last_callee_id: 0, last_callee_return_data_offset: 0, last_callee_return_data_length: 0, value: 0, is_success: true, is_persistent: true, is_static: false }
Impact
Should the gas-cost calculation done by TestingData::new_for_mcopy
be correct, these tests will test that the assignments to the ErrorOOGMemoryCopyGadget
satisfy the constraints. However, should TestingData::new_for_mcopy
return an incorrect gas cost that is too high, then these tests will not actually use the ErrorOOGMemoryCopyGadget
. Those constraints will thus not be tested. Furthermore, the current tests do not ensure that the gas costs calculated inside the circuit match those calculated by TestingData::new_for_mcopy
.
Note that Finding refā precisely discusses a bug in the gas-cost calculation by TestingData::new_for_mcopy
.
Recommendations
We recommend to enhance the testing code in the following way. Firstly, the bytecode is run twice. Once with available gas set to testing_data.gas_cost - 1
and once with testing_data.gas_cost
. Secondly, the testing code checks whether the test actually did reach an out-of-gas error on the MCOPY
instruction. The test should then only succeed if all of the following are true:
Both runs succeeded, as in the constraints were satisfied.
The first run reached an out-of-gas error on the
MCOPY
instruction.The second run did not reach an out-of-gas error on the
MCOPY
instruction.
This will ensure that the gas-cost calculation done by TestingData::new_for_mcopy
matches the calculation done in circuit in the cases tested and that the ErrorOOGMemoryCopyGadget
is indeed tested.
Remediation
Scroll informed us that they are considering to add the discussed tests in the future.