diff --git a/specs/opcode/F1CALL.md b/specs/opcode/F1CALL.md new file mode 100644 index 000000000..6197f22cb --- /dev/null +++ b/specs/opcode/F1CALL.md @@ -0,0 +1,96 @@ +# CALL opcode + +## Procedure + +### EVM behavior + +The `CALL` opcode transfer specified amount of ether to callee and creates a new call context and switch to it. This is done by popping serveral words from stack: + +1. `gas` - The amount of gas caller want to give to callee (capped by rule in EIP150) +2. `callee_address` - The ether recipient whose code is to be executed (by taking the 20 LSB of popped word) +3. `value` - The amount of ether to be transfered +4. `call_data_offset` - The offset of call_data chunk in caller's memory as call_data for callee +5. `call_data_length` - The length of call_data chunk +6. `return_data_offset` - The offset of return_data chunk in caller's memory, which will be set to return_data from callee after call +7. `return_data_length` - The length of return_data chunk + +Before switching call context to the new one, it does several things: + +1. Expand memory +2. Add `callee_address` into access list +3. Calculate `gas_cost` and check `gas_left` is enough +4. Calculate `callee_gas_left` for new context by rule in EIP150 +5. Check `depth` is less than `1024` +6. Check `value` could be transfer + +The memory size is calculated as follows: + +``` +calc_memory_size(offset, length) := ceil((offset + length) / 32) if length != 0 else 0 +``` + +The memory expansion gas cost is calculated like this: + +``` +MEMORY_EXPANSION_QUAD_DENOMINATOR := 512 +MEMORY_EXPANSION_LINEAR_COEFF := 3 +calc_memory_cost(memory_size) := MEMORY_EXPANSION_LINEAR_COEFF * memory_size + floor(memory_size * memory_size / MEMORY_EXPANSION_QUAD_DENOMINATOR) +``` + +The gas cost charged for the op is the additional `memory_size` needed: + +``` +next.memory_size := max( + curr.memory_size, + memory_size(call_data_offset, call_data_length), + memory_size(return_data_offset, return_data_length), +) +memory_expansion_gas_cost := calc_memory_cost(next.memory_size) - memory_cost(curr.memory_size) +``` + +The `gas_cost` is calculated like this: + +``` +GAS_COST_WARM_ACCESS := 100 +GAS_COST_ACCOUNT_COLD_ACCESS := 2600 +GAS_COST_CALL_EMPTY_ACCOUNT := 25000 +GAS_COST_CALL_WITH_VALUE := 9000 +gas_cost = ( + GAS_COST_WARM_ACCESS + + GAS_COST_WARM_ACCESS if is_warm_access else GAS_COST_ACCOUNT_COLD_ACCESS + + has_value * (GAS_COST_CALL_WITH_VALUE + is_account_empty * GAS_COST_CALL_EMPTY_ACCOUNT) + + memory_expansion_gas_cost +) +``` + +The `callee_gas_left` for new context by rule in EIP150 is calculated like this: + +``` +gas_available := curr.gas_left - gas_cost +callee_gas_left := min(gas_available - floor(gas_available / 64), gas) +``` + +After switching call context, it does: + +1. Transfer `value` +2. Execution + 1. If `callee_address` is a precompiled, it runs the pre-defined handler + 2. Otherwise, it takes callee's code for execution +3. Copy `return_data` of execution to caller specified memory chunk +4. Push `1` to stack if it succeeds, otherwise push `0` and revert everything done after switching call context + +### Circuit behavior + +The circuit takes current `rw_counter` as next call's `call_id` to make sure each call has a unique `call_id`. + +It pops the 7 words from stack, and take `result` of execution from prover to and push it to stack instantly. The reason it pushes the `result` before execution is to avoid the redundancy that every terminating `ExecutionState` needs to do the push. And it can do this because the `result` will also be in call context and checked in terminating `ExecutionState`s. + +It then checks the new call `is_persistent` only if current `is_persistent` and `result` of execution is success. If the new call is not persistent is due to current's call is not persistent, we need to propagate the `rw_counter_end_of_reversion` to make sure every state update has a corresponding reversion. + +Finally it stores current call context by writting to `rw_table` and checks the new call context is setup correctly by reading to `rw_table`, then does step state transition to a initialized one and begin the execution. + +In the end of execution, the terminating `ExecutionState` like `RETURN`, `REVERT` will copy the `return_data` to caller specified chunk. + +## Code + +Please refer to `src/zkevm_specs/evm/execution/call.py`. diff --git a/src/zkevm_specs/evm/execution/__init__.py b/src/zkevm_specs/evm/execution/__init__.py index 3967a3030..ef5606449 100644 --- a/src/zkevm_specs/evm/execution/__init__.py +++ b/src/zkevm_specs/evm/execution/__init__.py @@ -13,6 +13,7 @@ from .block_coinbase import * from .block_timestamp import * from .block_number import * +from .call import * from .calldatasize import * from .caller import * from .callvalue import * @@ -59,4 +60,5 @@ ExecutionState.EXTCODEHASH: extcodehash, ExecutionState.CopyToLog: copy_to_log, ExecutionState.LOG: log, + ExecutionState.CALL: call, } diff --git a/src/zkevm_specs/evm/execution/begin_tx.py b/src/zkevm_specs/evm/execution/begin_tx.py index 4fc956810..f2f392220 100644 --- a/src/zkevm_specs/evm/execution/begin_tx.py +++ b/src/zkevm_specs/evm/execution/begin_tx.py @@ -83,13 +83,16 @@ def begin_tx(instruction: Instruction): (CallContextFieldTag.LastCalleeId, FQ(0)), (CallContextFieldTag.LastCalleeReturnDataOffset, FQ(0)), (CallContextFieldTag.LastCalleeReturnDataLength, FQ(0)), + (CallContextFieldTag.IsRoot, FQ(True)), + (CallContextFieldTag.IsCreate, FQ(False)), + (CallContextFieldTag.CodeSource, code_hash), ]: instruction.constrain_equal( instruction.call_context_lookup(tag, call_id=call_id), value ) instruction.step_state_transition_to_new_context( - rw_counter=Transition.delta(19), + rw_counter=Transition.delta(22), call_id=Transition.to(call_id), is_root=Transition.to(True), is_create=Transition.to(False), diff --git a/src/zkevm_specs/evm/execution/call.py b/src/zkevm_specs/evm/execution/call.py new file mode 100644 index 000000000..e8b279b36 --- /dev/null +++ b/src/zkevm_specs/evm/execution/call.py @@ -0,0 +1,199 @@ +from ...util import ( + FQ, + N_BYTES_ACCOUNT_ADDRESS, + N_BYTES_GAS, + EMPTY_CODE_HASH, + GAS_COST_WARM_ACCESS, + GAS_COST_ACCOUNT_COLD_ACCESS, + GAS_COST_NEW_ACCOUNT, + GAS_COST_CALL_WITH_VALUE, + GAS_STIPEND_CALL_WITH_VALUE, +) +from ..instruction import Instruction, Transition +from ..table import RW, CallContextFieldTag, AccountFieldTag +from ..precompiled import PrecompiledAddress + + +def call(instruction: Instruction): + instruction.responsible_opcode_lookup(instruction.opcode_lookup(True)) + + callee_call_id = instruction.curr.rw_counter + + tx_id = instruction.call_context_lookup(CallContextFieldTag.TxId) + reversion_info = instruction.reversion_info() + caller_address = instruction.call_context_lookup(CallContextFieldTag.CalleeAddress) + is_static = instruction.call_context_lookup(CallContextFieldTag.IsStatic) + depth = instruction.call_context_lookup(CallContextFieldTag.Depth) + + # Verify depth is less than 1024 + instruction.range_lookup(depth, 1024) + + # Lookup values from stack + gas_rlc = instruction.stack_pop() + callee_address_rlc = instruction.stack_pop() + value = instruction.stack_pop() + cd_offset_rlc = instruction.stack_pop() + cd_length_rlc = instruction.stack_pop() + rd_offset_rlc = instruction.stack_pop() + rd_length_rlc = instruction.stack_pop() + is_success = instruction.stack_push() + + # Verify is_success is a bool + instruction.constrain_bool(is_success) + + # Recomposition of random linear combination to integer + callee_address = instruction.rlc_to_fq_unchecked(callee_address_rlc, N_BYTES_ACCOUNT_ADDRESS) + gas = instruction.rlc_to_fq_unchecked(gas_rlc, N_BYTES_GAS) + gas_is_u64 = instruction.is_zero(instruction.sum(gas_rlc.le_bytes[N_BYTES_GAS:])) + cd_offset, cd_length = instruction.memory_offset_and_length(cd_offset_rlc, cd_length_rlc) + rd_offset, rd_length = instruction.memory_offset_and_length(rd_offset_rlc, rd_length_rlc) + + # Verify memory expansion + next_memory_size, memory_expansion_gas_cost = instruction.memory_expansion_dynamic_length( + cd_offset, + cd_length, + rd_offset, + rd_length, + ) + + # Add callee to access list + is_warm_access = instruction.add_account_to_access_list(tx_id, callee_address, reversion_info) + + # Propagate rw_counter_end_of_reversion and is_persistent + callee_reversion_info = instruction.reversion_info(call_id=callee_call_id) + instruction.constrain_equal( + callee_reversion_info.is_persistent, reversion_info.is_persistent * is_success.expr() + ) + is_reverted_by_caller = is_success.expr() == FQ(1) and reversion_info.is_persistent == FQ(0) + if is_reverted_by_caller: + # Propagate rw_counter_end_of_reversion when callee succeeds but one of callers revert at some point. + # Note that we subtract it with current caller's state_write_counter as callee's endpoint, where caller's + # state_write_counter here is added by 1 due to adding callee to access list + instruction.constrain_equal( + callee_reversion_info.rw_counter_end_of_reversion, + reversion_info.rw_counter_of_reversion(), + ) + + # Check not is_static if call has value + has_value = 1 - instruction.is_zero(value) + instruction.constrain_zero(has_value * is_static) + + # Verify transfer + _, (_, callee_balance_prev) = instruction.transfer( + caller_address, callee_address, value, callee_reversion_info + ) + + # Verify gas cost + callee_nonce = instruction.account_read(callee_address, AccountFieldTag.Nonce) + callee_code_hash = instruction.account_read(callee_address, AccountFieldTag.CodeHash) + is_empty_code_hash = instruction.is_equal( + callee_code_hash, instruction.rlc_encode(EMPTY_CODE_HASH) + ) + is_account_empty = ( + instruction.is_zero(callee_nonce) + * instruction.is_zero(callee_balance_prev) + * is_empty_code_hash + ) + gas_cost = ( + instruction.select( + is_warm_access, FQ(GAS_COST_WARM_ACCESS), FQ(GAS_COST_ACCOUNT_COLD_ACCESS) + ) + + has_value * (GAS_COST_CALL_WITH_VALUE + is_account_empty * GAS_COST_NEW_ACCOUNT) + + memory_expansion_gas_cost + ) + + # Apply EIP 150. + # Note that sufficient gas_left is checked implicitly by constant_divmod. + gas_available = instruction.curr.gas_left - gas_cost + one_64th_gas, _ = instruction.constant_divmod(gas_available, FQ(64), N_BYTES_GAS) + all_but_one_64th_gas = gas_available - one_64th_gas + callee_gas_left = instruction.select( + gas_is_u64, + instruction.min(all_but_one_64th_gas, gas, N_BYTES_GAS), + all_but_one_64th_gas, + ) + + if callee_address in list(PrecompiledAddress): + # TODO: Handle precompile + raise NotImplementedError + elif is_empty_code_hash == FQ(1): + # Make sure call is successful + instruction.constrain_equal(is_success, FQ(1)) + + # Empty return_data + for (field_tag, expected_value) in [ + (CallContextFieldTag.LastCalleeId, FQ(0)), + (CallContextFieldTag.LastCalleeReturnDataOffset, FQ(0)), + (CallContextFieldTag.LastCalleeReturnDataLength, FQ(0)), + ]: + instruction.constrain_equal( + instruction.call_context_lookup(field_tag, RW.Write), + expected_value, + ) + + instruction.constrain_step_state_transition( + rw_counter=Transition.delta(24), + program_counter=Transition.delta(1), + stack_pointer=Transition.delta(6), + gas_left=Transition.delta(has_value * GAS_STIPEND_CALL_WITH_VALUE - gas_cost), + memory_size=Transition.to(next_memory_size), + state_write_counter=Transition.delta(3), + # Always stay same + call_id=Transition.same(), + is_root=Transition.same(), + is_create=Transition.same(), + code_source=Transition.same(), + ) + else: + # Save caller's call state + for (field_tag, expected_value) in [ + (CallContextFieldTag.ProgramCounter, instruction.curr.program_counter + 1), + (CallContextFieldTag.StackPointer, instruction.curr.stack_pointer + 6), + (CallContextFieldTag.GasLeft, instruction.curr.gas_left - gas_cost - callee_gas_left), + (CallContextFieldTag.MemorySize, next_memory_size), + (CallContextFieldTag.StateWriteCounter, instruction.curr.state_write_counter + 1), + ]: + instruction.constrain_equal( + instruction.call_context_lookup(field_tag, RW.Write), + expected_value, + ) + + # Setup next call's context. Note that RwCounterEndOfReversion, IsPersistent + # have been checked above. + for (field_tag, expected_value) in [ + (CallContextFieldTag.CallerId, instruction.curr.call_id), + (CallContextFieldTag.TxId, tx_id.expr()), + (CallContextFieldTag.Depth, depth.expr() + 1), + (CallContextFieldTag.CallerAddress, caller_address.expr()), + (CallContextFieldTag.CalleeAddress, callee_address), + (CallContextFieldTag.CallDataOffset, cd_offset), + (CallContextFieldTag.CallDataLength, cd_length), + (CallContextFieldTag.ReturnDataOffset, rd_offset), + (CallContextFieldTag.ReturnDataLength, rd_length), + (CallContextFieldTag.Value, value.expr()), + (CallContextFieldTag.IsSuccess, is_success.expr()), + (CallContextFieldTag.IsStatic, is_static.expr()), + (CallContextFieldTag.LastCalleeId, FQ(0)), + (CallContextFieldTag.LastCalleeReturnDataOffset, FQ(0)), + (CallContextFieldTag.LastCalleeReturnDataLength, FQ(0)), + (CallContextFieldTag.IsRoot, FQ(False)), + (CallContextFieldTag.IsCreate, FQ(False)), + (CallContextFieldTag.CodeSource, callee_code_hash.expr()), + ]: + instruction.constrain_equal( + instruction.call_context_lookup(field_tag, call_id=callee_call_id), + expected_value, + ) + + # Give gas stipend if value is not zero + callee_gas_left += has_value * GAS_STIPEND_CALL_WITH_VALUE + + instruction.step_state_transition_to_new_context( + rw_counter=Transition.delta(44), + call_id=Transition.to(callee_call_id), + is_root=Transition.to(False), + is_create=Transition.to(False), + code_source=Transition.to(callee_code_hash), + gas_left=Transition.to(callee_gas_left), + state_write_counter=Transition.to(2), + ) diff --git a/src/zkevm_specs/evm/execution/extcodehash.py b/src/zkevm_specs/evm/execution/extcodehash.py index ced422074..fbfcbe01f 100644 --- a/src/zkevm_specs/evm/execution/extcodehash.py +++ b/src/zkevm_specs/evm/execution/extcodehash.py @@ -21,9 +21,7 @@ def extcodehash(instruction: Instruction): is_empty = ( instruction.is_zero(nonce) * instruction.is_zero(balance) - * instruction.is_equal( - code_hash, instruction.rlc_encode(EMPTY_CODE_HASH.to_bytes(64, "little")) - ) + * instruction.is_equal(code_hash, instruction.rlc_encode(EMPTY_CODE_HASH)) ) instruction.constrain_equal( diff --git a/src/zkevm_specs/evm/instruction.py b/src/zkevm_specs/evm/instruction.py index 4d85e3d80..ea6b21b5b 100644 --- a/src/zkevm_specs/evm/instruction.py +++ b/src/zkevm_specs/evm/instruction.py @@ -82,10 +82,10 @@ def __init__( self.is_persistent = is_persistent.expr() self.state_write_counter = state_write_counter.expr() - def rw_counter(self) -> FQ: - rw_counter = self.rw_counter_end_of_reversion - self.state_write_counter + def rw_counter_of_reversion(self) -> FQ: + rw_counter_of_reversion = self.rw_counter_end_of_reversion - self.state_write_counter self.state_write_counter += 1 - return rw_counter + return rw_counter_of_reversion class Instruction: @@ -190,7 +190,7 @@ def constrain_step_state_transition(self, **kwargs: Transition): if isinstance(transition.value, int): transition.value = FQ(transition.value) assert next.expr() == curr.expr() + transition.value.expr(), ConstraintUnsatFailure( - f"State {key} should transit to {curr} + {transition.value}, but got {next}" + f"State {key} should transit to {curr} + {transition.value} ({curr + transition.value}), but got {next}" ) elif transition.kind == TransitionKind.To: if isinstance(transition.value, int): @@ -333,10 +333,8 @@ def mul_word_by_u64(self, multiplicand: RLC, multiplier: Expression) -> Tuple[RL return self.rlc_encode(product_bytes), FQ(quotient_hi) - def rlc_to_fq_unchecked(self, word: RLC, n_bytes: int) -> Tuple[FQ, FQ]: - return self.bytes_to_fq(word.le_bytes[:n_bytes]), self.is_zero( - self.sum(word.le_bytes[n_bytes:]) - ) + def rlc_to_fq_unchecked(self, word: RLC, n_bytes: int) -> FQ: + return self.bytes_to_fq(word.le_bytes[:n_bytes]) def rlc_to_fq_exact(self, word: RLC, n_bytes: int) -> FQ: if any(word.le_bytes[n_bytes:]): @@ -352,8 +350,8 @@ def bytes_to_fq(self, value: bytes) -> FQ: assert len(value) <= MAX_N_BYTES, "Too many bytes to composite an integer in field" return FQ(int.from_bytes(value, "little")) - def rlc_encode(self, value: bytes) -> RLC: - return RLC(value, self.randomness) + def rlc_encode(self, value: Union[int, bytes], n_bytes: int = 32) -> RLC: + return RLC(value, self.randomness, n_bytes) def range_lookup(self, value: Expression, range: int): self.fixed_lookup(FixedTableTag.range_table_tag(range), value) @@ -372,8 +370,8 @@ def fixed_lookup( self, tag: FixedTableTag, value0: Expression, - value1: Expression = None, - value2: Expression = None, + value1: Expression = FQ(0), + value2: Expression = FQ(0), ) -> FixedTableRow: return self.tables.fixed_lookup(FQ(tag), value0, value1, value2) @@ -466,9 +464,9 @@ def state_write( row = self.rw_lookup(RW.Write, tag, key1, key2, key3, value, value_prev, aux0, aux1) - if reversion_info is not None and reversion_info.is_persistent == 0: + if reversion_info is not None and reversion_info.is_persistent == FQ(0): self.tables.rw_lookup( - rw_counter=reversion_info.rw_counter(), + rw_counter=reversion_info.rw_counter_of_reversion(), rw=FQ(RW.Write), tag=FQ(tag), key1=row.key1, @@ -491,12 +489,17 @@ def call_context_lookup( return self.rw_lookup(rw, RWTableTag.CallContext, call_id, FQ(field_tag)).value def reversion_info(self, call_id: Expression = None) -> ReversionInfo: - rw_counter_end_of_reversion = self.call_context_lookup( - CallContextFieldTag.RwCounterEndOfReversion, call_id=call_id - ) - is_persistent = self.call_context_lookup(CallContextFieldTag.IsPersistent, call_id=call_id) + [rw_counter_end_of_reversion, is_persistent] = [ + self.call_context_lookup(tag, call_id=call_id) + for tag in [ + CallContextFieldTag.RwCounterEndOfReversion, + CallContextFieldTag.IsPersistent, + ] + ] return ReversionInfo( - rw_counter_end_of_reversion, is_persistent, self.curr.state_write_counter + rw_counter_end_of_reversion, + is_persistent, + self.curr.state_write_counter if call_id is None else FQ(0), ) def stack_pop(self) -> RLC: diff --git a/src/zkevm_specs/evm/table.py b/src/zkevm_specs/evm/table.py index 172bae02b..22cd620bb 100644 --- a/src/zkevm_specs/evm/table.py +++ b/src/zkevm_specs/evm/table.py @@ -199,6 +199,9 @@ class CallContextFieldTag(IntEnum): IsSuccess = auto() # to peek result in the future IsPersistent = auto() # to know if current call is within reverted call or not IsStatic = auto() # to know if state modification is within static call or not + IsRoot = auto() + IsCreate = auto() + CodeSource = auto() # The following are read-only data inside a call like previous section for # opcode RETURNDATASIZE and RETURNDATACOPY, except they will be updated when @@ -213,9 +216,6 @@ class CallContextFieldTag(IntEnum): # Note that stack and memory could also be included here, but since they # need extra constraints on their data format, so we separate them to be # different kinds of RWTableTag. - IsRoot = auto() - IsCreate = auto() - CodeSource = auto() ProgramCounter = auto() StackPointer = auto() GasLeft = auto() @@ -342,8 +342,8 @@ def fixed_lookup( self, tag: Expression, value0: Expression, - value1: Expression = None, - value2: Expression = None, + value1: Expression = FQ(0), + value2: Expression = FQ(0), ) -> FixedTableRow: query = { "tag": tag, @@ -351,7 +351,10 @@ def fixed_lookup( "value1": value1, "value2": value2, } - return _lookup(FixedTableRow, self.fixed_table, query) + row = FixedTableRow(tag, value0, value1, value2) + if row not in self.fixed_table: + raise LookupUnsatFailure(FixedTableRow.__name__, query) + return row def block_lookup( self, field_tag: Expression, block_number: Expression = FQ(0) diff --git a/src/zkevm_specs/evm/typing.py b/src/zkevm_specs/evm/typing.py index 7ffbde178..eda5e814c 100644 --- a/src/zkevm_specs/evm/typing.py +++ b/src/zkevm_specs/evm/typing.py @@ -14,6 +14,7 @@ keccak256, GAS_COST_TX_CALL_DATA_PER_NON_ZERO_BYTE, GAS_COST_TX_CALL_DATA_PER_ZERO_BYTE, + EMPTY_CODE_HASH, ) from .table import ( RW, @@ -301,6 +302,9 @@ def code_hash(self) -> U256: def storage_trie_hash(self) -> U256: raise NotImplementedError("Trie has not been implemented") + def is_empty(self) -> bool: + return self.nonce == 0 and self.balance == 0 and self.code_hash() == EMPTY_CODE_HASH + class RWDictionary: rw_counter: int @@ -341,6 +345,15 @@ def call_context_read( RW.Read, RWTableTag.CallContext, key1=FQ(call_id), key2=FQ(field_tag), value=value ) + def call_context_write( + self, call_id: IntOrFQ, field_tag: CallContextFieldTag, value: Union[int, FQ, RLC] + ) -> RWDictionary: + if isinstance(value, int): + value = FQ(value) + return self._append( + RW.Write, RWTableTag.CallContext, key1=FQ(call_id), key2=FQ(field_tag), value=value + ) + def tx_refund_read(self, tx_id: IntOrFQ, refund: IntOrFQ) -> RWDictionary: return self._append( RW.Read, RWTableTag.TxRefund, key1=FQ(tx_id), value=FQ(refund), value_prev=FQ(refund) diff --git a/src/zkevm_specs/util/arithmetic.py b/src/zkevm_specs/util/arithmetic.py index 3bd5325c2..88f9f0c03 100644 --- a/src/zkevm_specs/util/arithmetic.py +++ b/src/zkevm_specs/util/arithmetic.py @@ -35,16 +35,13 @@ class RLC: value: FQ le_bytes: bytes - def __init__( - self, value: Union[int, bytes], randomness: FQ = FQ(0), n_bytes: int = None - ) -> None: + def __init__(self, value: Union[int, bytes], randomness: FQ = FQ(0), n_bytes: int = 32) -> None: if isinstance(value, int): - value = value.to_bytes(32, "little") + value = value.to_bytes(n_bytes, "little") - if n_bytes is not None: - if len(value) > n_bytes: - raise ValueError(f"RLC expects to have {n_bytes} bytes, but got {len(value)} bytes") - value = value.ljust(n_bytes, b"\x00") + if len(value) > n_bytes: + raise ValueError(f"RLC expects to have {n_bytes} bytes, but got {len(value)} bytes") + value = value.ljust(n_bytes, b"\x00") self.value = FQ.linear_combine(value, randomness) self.le_bytes = value diff --git a/src/zkevm_specs/util/param.py b/src/zkevm_specs/util/param.py index 1bd8299af..ad7ecbb05 100644 --- a/src/zkevm_specs/util/param.py +++ b/src/zkevm_specs/util/param.py @@ -53,14 +53,14 @@ GAS_COST_TX_CALL_DATA_PER_ZERO_BYTE = 4 # Gas cost of accessing account or storage slot, EIP 2929 GAS_COST_WARM_ACCESS = 100 -# Extra gas cost of not-yet-accessed account -EXTRA_GAS_COST_ACCOUNT_COLD_ACCESS = 2500 -# Extra gas cost of not-yet-accessed storage slot -EXTRA_GAS_COST_STORAGE_SLOT_COLD_ACCESS = 2000 +# Gas cost of accessing not-yet-accessed account +GAS_COST_ACCOUNT_COLD_ACCESS = 2600 +# Extra gas cost of accessing not-yet-accessed account +EXTRA_GAS_COST_ACCOUNT_COLD_ACCESS = GAS_COST_ACCOUNT_COLD_ACCESS - GAS_COST_WARM_ACCESS # Gas cost of calling with non-zero value GAS_COST_CALL_WITH_VALUE = 9000 -# Gas cost of calling empty account -GAS_COST_CALL_EMPTY_ACCOUNT = 25000 +# Gas cost of turning empty account into non-empty account +GAS_COST_NEW_ACCOUNT = 25000 # Gas stipend given if call with non-zero value GAS_STIPEND_CALL_WITH_VALUE = 2300 diff --git a/tests/evm/test_begin_tx.py b/tests/evm/test_begin_tx.py index 591adf2a3..20f842fff 100644 --- a/tests/evm/test_begin_tx.py +++ b/tests/evm/test_begin_tx.py @@ -130,6 +130,9 @@ def test_begin_tx(tx: Transaction, callee: Account, is_success: bool): .call_context_read(1, CallContextFieldTag.LastCalleeId, 0) .call_context_read(1, CallContextFieldTag.LastCalleeReturnDataOffset, 0) .call_context_read(1, CallContextFieldTag.LastCalleeReturnDataLength, 0) + .call_context_read(1, CallContextFieldTag.IsRoot, True) + .call_context_read(1, CallContextFieldTag.IsCreate, False) + .call_context_read(1, CallContextFieldTag.CodeSource, bytecode_hash) .rws, # fmt: on ), @@ -147,7 +150,7 @@ def test_begin_tx(tx: Transaction, callee: Account, is_success: bool): execution_state=ExecutionState.EndTx if callee.code_hash() == EMPTY_CODE_HASH else ExecutionState.PUSH, - rw_counter=20, + rw_counter=23, call_id=1, is_root=True, is_create=False, diff --git a/tests/evm/test_call.py b/tests/evm/test_call.py new file mode 100644 index 000000000..fbc1aef7d --- /dev/null +++ b/tests/evm/test_call.py @@ -0,0 +1,304 @@ +import itertools +import pytest +from collections import namedtuple +from itertools import chain + +from zkevm_specs.evm import ( + ExecutionState, + StepState, + verify_steps, + Tables, + AccountFieldTag, + CallContextFieldTag, + Block, + Account, + Bytecode, + RWDictionary, +) +from zkevm_specs.util import rand_fq, RLC, EMPTY_CODE_HASH +from zkevm_specs.util.param import ( + GAS_COST_NEW_ACCOUNT, + GAS_COST_CALL_WITH_VALUE, + GAS_COST_WARM_ACCESS, + GAS_COST_ACCOUNT_COLD_ACCESS, + GAS_STIPEND_CALL_WITH_VALUE, +) + +CallContext = namedtuple( + "CallContext", + [ + "rw_counter_end_of_reversion", + "is_persistent", + "gas_left", + "memory_size", + "state_write_counter", + ], + defaults=[0, True, 0, 0, 2], +) +Stack = namedtuple( + "Stack", + ["gas", "value", "cd_offset", "cd_length", "rd_offset", "rd_length"], + defaults=[0, 0, 0, 0, 0, 0], +) +Expected = namedtuple( + "Expected", + ["caller_gas_left", "callee_gas_left", "next_memory_size"], +) + +STOP_BYTECODE = Bytecode().stop() +RETURN_BYTECODE = Bytecode().return_(0, 0) +REVERT_BYTECODE = Bytecode().revert(0, 0) + +CALLER = Account(address=0xFE, balance=int(1e20)) +CALLEE_WITH_NOTHING = Account(address=0xFF) +CALLEE_WITH_STOP_BYTECODE_AND_BALANCE = Account(address=0xFF, code=STOP_BYTECODE, balance=int(1e18)) +CALLEE_WITH_RETURN_BYTECODE = Account(address=0xFF, code=RETURN_BYTECODE) +CALLEE_WITH_REVERT_BYTECODE = Account(address=0xFF, code=REVERT_BYTECODE) + + +def expected(callee: Account, caller_ctx: CallContext, stack: Stack, is_warm_access: bool): + def memory_size(offset: int, length: int) -> int: + if length == 0: + return 0 + return (offset + length + 31) // 32 + + is_account_empty = callee.is_empty() + has_value = stack.value != 0 + next_memory_size = max( + memory_size(stack.cd_offset, stack.cd_length), + memory_size(stack.rd_offset, stack.rd_length), + caller_ctx.memory_size, + ) + memory_expansion_gas_cost = ( + next_memory_size * next_memory_size - caller_ctx.memory_size * caller_ctx.memory_size + ) // 512 + 3 * (next_memory_size - caller_ctx.memory_size) + gas_cost = ( + (GAS_COST_WARM_ACCESS if is_warm_access else GAS_COST_ACCOUNT_COLD_ACCESS) + + has_value * (GAS_COST_CALL_WITH_VALUE + is_account_empty * GAS_COST_NEW_ACCOUNT) + + memory_expansion_gas_cost + ) + gas_available = caller_ctx.gas_left - gas_cost + all_but_one_64th_gas = gas_available - gas_available // 64 + callee_gas_left = min(all_but_one_64th_gas, stack.gas) + caller_gas_left = caller_ctx.gas_left - ( + gas_cost - has_value * GAS_STIPEND_CALL_WITH_VALUE + if callee.code_hash() == EMPTY_CODE_HASH + else gas_cost + callee_gas_left + ) + + return Expected( + caller_gas_left=caller_gas_left, + callee_gas_left=callee_gas_left + has_value * GAS_STIPEND_CALL_WITH_VALUE, + next_memory_size=next_memory_size, + ) + + +def gen_testing_data(): + callees = [ + CALLEE_WITH_NOTHING, + CALLEE_WITH_STOP_BYTECODE_AND_BALANCE, + CALLEE_WITH_RETURN_BYTECODE, + CALLEE_WITH_REVERT_BYTECODE, + ] + call_contexts = [ + CallContext(gas_left=100000, is_persistent=True), + CallContext(gas_left=100000, is_persistent=True, memory_size=8, state_write_counter=5), + CallContext(gas_left=100000, is_persistent=False, rw_counter_end_of_reversion=88), + ] + stacks = [ + Stack(), + Stack(value=int(1e18)), + Stack(gas=100), + Stack(gas=100000), + Stack(cd_offset=64, cd_length=320, rd_offset=0, rd_length=32), + Stack(cd_offset=0, cd_length=32, rd_offset=64, rd_length=320), + Stack(cd_offset=0xFFFFFF, cd_length=0, rd_offset=0xFFFFFF, rd_length=0), + ] + is_warm_accesss = [True, False] + + return [ + ( + CALLER, + callee, + call_context, + stack, + is_warm_access, + expected(callee, call_context, stack, is_warm_access), + ) + for callee, call_context, stack, is_warm_access in itertools.product( + callees, call_contexts, stacks, is_warm_accesss + ) + ] + + +TESTING_DATA = gen_testing_data() + + +@pytest.mark.parametrize( + "caller, callee, caller_ctx, stack, is_warm_access, expected", TESTING_DATA +) +def test_call( + caller: Account, + callee: Account, + caller_ctx: CallContext, + stack: Stack, + is_warm_access: bool, + expected: Expected, +): + randomness = rand_fq() + + caller_balance_prev = RLC(caller.balance, randomness) + callee_balance_prev = RLC(callee.balance, randomness) + caller_balance = RLC(caller.balance - stack.value, randomness) + callee_balance = RLC(callee.balance + stack.value, randomness) + caller_bytecode = ( + Bytecode() + .call( + stack.gas, + callee.address, + stack.value, + stack.cd_offset, + stack.cd_length, + stack.rd_offset, + stack.rd_length, + ) + .stop() + ) + caller_bytecode_hash = RLC(caller_bytecode.hash(), randomness) + callee_bytecode_hash = RLC(callee.code_hash(), randomness) + + is_success = False if callee is CALLEE_WITH_REVERT_BYTECODE else True + is_reverted_by_caller = not caller_ctx.is_persistent and is_success + is_reverted_by_callee = not is_success + callee_is_persistent = caller_ctx.is_persistent and is_success + callee_rw_counter_end_of_reversion = ( + 80 + if is_reverted_by_callee + else ( + caller_ctx.rw_counter_end_of_reversion - (caller_ctx.state_write_counter + 1) + if is_reverted_by_caller + else 0 + ) + ) + + # fmt: off + rw_dictionary = ( + RWDictionary(24) + .call_context_read(1, CallContextFieldTag.TxId, 1) + .call_context_read(1, CallContextFieldTag.RwCounterEndOfReversion, caller_ctx.rw_counter_end_of_reversion) + .call_context_read(1, CallContextFieldTag.IsPersistent, caller_ctx.is_persistent) + .call_context_read(1, CallContextFieldTag.CalleeAddress, caller.address) + .call_context_read(1, CallContextFieldTag.IsStatic, False) + .call_context_read(1, CallContextFieldTag.Depth, 1) + .stack_read(1, 1017, RLC(stack.gas, randomness)) + .stack_read(1, 1018, RLC(callee.address, randomness)) + .stack_read(1, 1019, RLC(stack.value, randomness)) + .stack_read(1, 1020, RLC(stack.cd_offset, randomness)) + .stack_read(1, 1021, RLC(stack.cd_length, randomness)) + .stack_read(1, 1022, RLC(stack.rd_offset, randomness)) + .stack_read(1, 1023, RLC(stack.rd_length, randomness)) + .stack_write(1, 1023, RLC(is_success, randomness)) + .tx_access_list_account_write(1, callee.address, True, is_warm_access, rw_counter_of_reversion=None if caller_ctx.is_persistent else caller_ctx.rw_counter_end_of_reversion - caller_ctx.state_write_counter) + .call_context_read(24, CallContextFieldTag.RwCounterEndOfReversion, callee_rw_counter_end_of_reversion) + .call_context_read(24, CallContextFieldTag.IsPersistent, callee_is_persistent) + .account_write(caller.address, AccountFieldTag.Balance, caller_balance, caller_balance_prev, rw_counter_of_reversion=None if callee_is_persistent else callee_rw_counter_end_of_reversion) + .account_write(callee.address, AccountFieldTag.Balance, callee_balance, callee_balance_prev, rw_counter_of_reversion=None if callee_is_persistent else callee_rw_counter_end_of_reversion - 1) + .account_read(callee.address, AccountFieldTag.Nonce, RLC(callee.nonce, randomness)) + .account_read(callee.address, AccountFieldTag.CodeHash, callee_bytecode_hash) + ) + # fmt: on + + # fmt: off + if callee.code_hash() == EMPTY_CODE_HASH: + rw_dictionary \ + .call_context_write(1, CallContextFieldTag.LastCalleeId, 0) \ + .call_context_write(1, CallContextFieldTag.LastCalleeReturnDataOffset, 0) \ + .call_context_write(1, CallContextFieldTag.LastCalleeReturnDataLength, 0) + else: + rw_dictionary \ + .call_context_write(1, CallContextFieldTag.ProgramCounter, 232) \ + .call_context_write(1, CallContextFieldTag.StackPointer, 1023) \ + .call_context_write(1, CallContextFieldTag.GasLeft, expected.caller_gas_left) \ + .call_context_write(1, CallContextFieldTag.MemorySize, expected.next_memory_size) \ + .call_context_write(1, CallContextFieldTag.StateWriteCounter, caller_ctx.state_write_counter + 1) \ + .call_context_read(24, CallContextFieldTag.CallerId, 1) \ + .call_context_read(24, CallContextFieldTag.TxId, 1) \ + .call_context_read(24, CallContextFieldTag.Depth, 2) \ + .call_context_read(24, CallContextFieldTag.CallerAddress, caller.address) \ + .call_context_read(24, CallContextFieldTag.CalleeAddress, callee.address) \ + .call_context_read(24, CallContextFieldTag.CallDataOffset, stack.cd_offset if stack.cd_length != 0 else 0) \ + .call_context_read(24, CallContextFieldTag.CallDataLength, stack.cd_length) \ + .call_context_read(24, CallContextFieldTag.ReturnDataOffset, stack.rd_offset if stack.rd_length != 0 else 0) \ + .call_context_read(24, CallContextFieldTag.ReturnDataLength, stack.rd_length) \ + .call_context_read(24, CallContextFieldTag.Value, RLC(stack.value, randomness)) \ + .call_context_read(24, CallContextFieldTag.IsSuccess, is_success) \ + .call_context_read(24, CallContextFieldTag.IsStatic, False) \ + .call_context_read(24, CallContextFieldTag.LastCalleeId, 0) \ + .call_context_read(24, CallContextFieldTag.LastCalleeReturnDataOffset, 0) \ + .call_context_read(24, CallContextFieldTag.LastCalleeReturnDataLength, 0) \ + .call_context_read(24, CallContextFieldTag.IsRoot, False) \ + .call_context_read(24, CallContextFieldTag.IsCreate, False) \ + .call_context_read(24, CallContextFieldTag.CodeSource, callee_bytecode_hash) + # fmt: on + + tables = Tables( + block_table=set(Block().table_assignments(randomness)), + tx_table=set(), + bytecode_table=set( + chain( + caller_bytecode.table_assignments(randomness), + callee.code.table_assignments(randomness), + ) + ), + rw_table=set(rw_dictionary.rws), + ) + + verify_steps( + randomness=randomness, + tables=tables, + steps=[ + StepState( + execution_state=ExecutionState.CALL, + rw_counter=24, + call_id=1, + is_root=True, + is_create=False, + code_source=caller_bytecode_hash, + program_counter=231, + stack_pointer=1017, + gas_left=caller_ctx.gas_left, + memory_size=caller_ctx.memory_size, + state_write_counter=caller_ctx.state_write_counter, + ), + ( + StepState( + execution_state=ExecutionState.STOP, + rw_counter=rw_dictionary.rw_counter, + call_id=1, + is_root=True, + is_create=False, + code_source=caller_bytecode_hash, + program_counter=232, + stack_pointer=1023, + gas_left=expected.caller_gas_left, + memory_size=expected.next_memory_size, + state_write_counter=caller_ctx.state_write_counter + 3, + ) + if callee.code_hash() == EMPTY_CODE_HASH + else StepState( + execution_state=ExecutionState.STOP + if callee.code == STOP_BYTECODE + else ExecutionState.PUSH, + rw_counter=rw_dictionary.rw_counter, + call_id=24, + is_root=False, + is_create=False, + code_source=callee_bytecode_hash, + program_counter=0, + stack_pointer=1024, + gas_left=expected.callee_gas_left, + state_write_counter=2, + ) + ), + ], + ) diff --git a/tests/evm/test_push.py b/tests/evm/test_push.py index f73e9c7c9..0de6612e5 100644 --- a/tests/evm/test_push.py +++ b/tests/evm/test_push.py @@ -27,7 +27,7 @@ def test_push(value_be_bytes: bytes): randomness = rand_fq() - value = RLC(bytes(reversed(value_be_bytes)), randomness, 32) + value = RLC(bytes(reversed(value_be_bytes)), randomness) bytecode = Bytecode().push(value_be_bytes, n_bytes=len(value_be_bytes)) bytecode_hash = RLC(bytecode.hash(), randomness)