From fb4f55964a0b3a1592122633b94f755dc262333a Mon Sep 17 00:00:00 2001 From: Remo Date: Tue, 24 Feb 2026 19:41:20 +0200 Subject: [PATCH] 1. Support for ISRC6 (__validate__ & __execute__ ) 2. Refactor test and test utils. 3. Generate test.cairo signature via python script (directly). 4. send_tx.py utility to prepare (EFO) for starkly and send tx (normal tx) via rpc. Signed-off-by: Remo --- eth_712_account/scripts/eip712.py | 229 ++++++ eth_712_account/scripts/example_call.json | 11 + .../scripts/generate_test_signatures.py | 737 +++++++++--------- eth_712_account/scripts/requirements.txt | 2 + eth_712_account/scripts/send_tx.py | 461 +++++++++++ eth_712_account/src/eth_712_account.cairo | 90 ++- eth_712_account/src/eth_712_utils.cairo | 125 ++- eth_712_account/src/test.cairo | 371 ++++++++- eth_712_account/src/test_utils.cairo | 339 ++++---- 9 files changed, 1785 insertions(+), 580 deletions(-) create mode 100644 eth_712_account/scripts/eip712.py create mode 100644 eth_712_account/scripts/example_call.json mode change 100644 => 100755 eth_712_account/scripts/generate_test_signatures.py create mode 100755 eth_712_account/scripts/send_tx.py diff --git a/eth_712_account/scripts/eip712.py b/eth_712_account/scripts/eip712.py new file mode 100644 index 0000000..71246dd --- /dev/null +++ b/eth_712_account/scripts/eip712.py @@ -0,0 +1,229 @@ +""" +Shared EIP-712 hashing logic for eth_712_account scripts. + +Mirrors the hashing functions in eth_712_utils.cairo. Both generate_test_signatures.py +and send_tx.py import from this module to avoid duplicating the hashing core. + +Call dict format (canonical, matching Cairo's Call struct): + {"to": int, "selector": int, "calldata": list[int]} +""" + +from eth_account import Account +from web3 import Web3 + +# ============================================================================ +# Bit masks +# ============================================================================ + +MASK_128 = (1 << 128) - 1 +MASK_250 = (1 << 250) - 1 + +# ============================================================================ +# EIP-712 type hashes (must match eth_712_utils.cairo) +# ============================================================================ + +EIP712_DOMAIN_TYPE_HASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f +CALL_TYPE_HASH = 0x7793b9bed3b87c6119fe923f0da4e85e1f97a03272a446514622ee7bd62ad25f +OUTSIDE_EXECUTION_TYPE_HASH = 0x57fbef2abe14202f3651b3935a8feddd357b8f83a862e046239d196ec76f281e +TRANSACTION_METADATA_TYPE_HASH = 0x3e1a84b9a25a2ffe216927b61cc91a10921dabd3305985281d0bb9707b0d8310 +TRANSACTION_TYPE_HASH = 0x1dc45489b8d4418703686ca441c4ea8ead534ff02815a47b9059490edf3a0c68 +VERSION_HASH = 0xad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a5 + +# ============================================================================ +# Resource identifier constants (matching Cairo felt252 short strings) +# ============================================================================ + +L1_GAS_ID = 0x4c315f474153 # 'L1_GAS' +L2_GAS_ID = 0x4c325f474153 # 'L2_GAS' +L1_DATA_ID = 0x4c315f44415441 # 'L1_DATA' + +# ============================================================================ +# SNIP-9 constants +# ============================================================================ + +ANY_CALLER = int.from_bytes(b"ANY_CALLER", "big") + +# ============================================================================ +# Keccak primitives +# ============================================================================ + + +def keccak256(data: bytes) -> bytes: + """Compute keccak256 hash.""" + return Web3.keccak(data) + + +def to_bytes32(val: int) -> bytes: + """Convert int to 32-byte big-endian representation.""" + return val.to_bytes(32, "big") + + +def keccak_ints(*values: int) -> int: + """Compute keccak256 of concatenated 32-byte representations, returning int.""" + data = b"".join(to_bytes32(v) for v in values) + return int.from_bytes(keccak256(data), "big") + + +def hash_felt_array(felts: list[int]) -> int: + """Hash an array of felts (keccak of concatenated 32-byte representations).""" + if not felts: + return int.from_bytes(keccak256(b""), "big") + data = b"".join(to_bytes32(f) for f in felts) + return int.from_bytes(keccak256(data), "big") + + +def selector(name: str) -> int: + """Compute Starknet selector (sn_keccak): keccak256 masked to 250 bits.""" + h = keccak256(name.encode("utf-8")) + return int.from_bytes(h, "big") & MASK_250 + + +# ============================================================================ +# Domain separator +# ============================================================================ + + +def domain_separator(sn_chain_name: str, contract_address: int, evm_chain_id: int) -> int: + """ + Compute the EIP-712 domain separator matching push_domain_separator in eth_712_utils.cairo. + + Fields: + - name: keccak256(sn_chain_name) -- e.g. keccak("SN_MAIN") + - version: VERSION_HASH (keccak("2")) + - chainId: evm_chain_id + - verifyingContract: lower 128 bits of contract_address + """ + name_hash = int.from_bytes(keccak256(sn_chain_name.encode("utf-8")), "big") + verifying_contract = contract_address & MASK_128 + return keccak_ints( + EIP712_DOMAIN_TYPE_HASH, name_hash, VERSION_HASH, evm_chain_id, verifying_contract, + ) + + +# ============================================================================ +# Call hashing +# ============================================================================ + + +def hash_call(call: dict) -> int: + """ + Hash a Call struct matching push_call in eth_712_utils.cairo. + + call = {"to": int, "selector": int, "calldata": list[int]} + """ + calldata_hash = hash_felt_array(call["calldata"]) + return keccak_ints(CALL_TYPE_HASH, call["to"], call["selector"], calldata_hash) + + +def hash_call_array(calls: list[dict]) -> int: + """Hash an array of Calls (keccak of concatenated call hashes).""" + if not calls: + return int.from_bytes(keccak256(b""), "big") + data = b"".join(to_bytes32(hash_call(c)) for c in calls) + return int.from_bytes(keccak256(data), "big") + + +# ============================================================================ +# OutsideExecution hashing +# ============================================================================ + + +def hash_outside_execution(oe: dict) -> int: + """ + Hash OutsideExecution struct matching push_outside_execution in eth_712_utils.cairo. + + Field order: type_hash, hash(calls), caller, nonce, execute_after, execute_before. + """ + calls_hash = hash_call_array(oe.get("calls", [])) + return keccak_ints( + OUTSIDE_EXECUTION_TYPE_HASH, + calls_hash, + oe["caller"], + oe["nonce"], + oe["execute_after"], + oe["execute_before"], + ) + + +def outside_execution_msg_hash( + oe: dict, sn_chain_name: str, contract_address: int, evm_chain_id: int, +) -> int: + """Compute the full EIP-712 message hash for an OutsideExecution.""" + ds = domain_separator(sn_chain_name, contract_address, evm_chain_id) + sh = hash_outside_execution(oe) + data = b"\x19\x01" + to_bytes32(ds) + to_bytes32(sh) + return int.from_bytes(keccak256(data), "big") + + +# ============================================================================ +# Transaction hashing (__validate__) +# ============================================================================ + + +def hash_transaction_metadata(metadata: dict) -> int: + """Hash TransactionMetadata struct matching push_metadata in eth_712_utils.cairo.""" + exec_hash = hash_felt_array(metadata["execution_resources"]) + return keccak_ints( + TRANSACTION_METADATA_TYPE_HASH, + metadata["version"], + metadata["chain_id"], + exec_hash, + metadata["tip"], + metadata["nonce"], + ) + + +def hash_transaction(calls: list[dict], metadata: dict) -> int: + """Hash Transaction struct matching push_transaction in eth_712_utils.cairo.""" + calls_hash = hash_call_array(calls) + metadata_hash = hash_transaction_metadata(metadata) + return keccak_ints(TRANSACTION_TYPE_HASH, calls_hash, metadata_hash) + + +def transaction_msg_hash( + calls: list[dict], + metadata: dict, + sn_chain_name: str, + contract_address: int, + evm_chain_id: int, +) -> int: + """Compute the full EIP-712 message hash for a Transaction (__validate__).""" + ds = domain_separator(sn_chain_name, contract_address, evm_chain_id) + sh = hash_transaction(calls, metadata) + data = b"\x19\x01" + to_bytes32(ds) + to_bytes32(sh) + return int.from_bytes(keccak256(data), "big") + + +# ============================================================================ +# Signature utilities +# ============================================================================ + + +def sign_and_split(msg_hash: int, private_key, evm_chain_id: int) -> dict: + """ + Sign a pre-computed hash and split into 6-felt dict. + + Returns: {"r_high", "r_low", "s_high", "s_low", "v", "chain_id"} + """ + signed = Account.unsafe_sign_hash(msg_hash.to_bytes(32, "big"), private_key) + return { + "r_high": signed.r >> 128, + "r_low": signed.r & MASK_128, + "s_high": signed.s >> 128, + "s_low": signed.s & MASK_128, + "v": signed.v, + "chain_id": evm_chain_id, + } + + +def resource_bounds_to_felts( + l1_gas_amount: int, l1_gas_price: int, + l2_gas_amount: int, l2_gas_price: int, + l1_data_amount: int, l1_data_price: int, +) -> list[int]: + """Convert resource bounds to 9-felt array matching resource_bounds_as_felts in Cairo.""" + return [ + L1_GAS_ID, l1_gas_amount, l1_gas_price, + L2_GAS_ID, l2_gas_amount, l2_gas_price, + L1_DATA_ID, l1_data_amount, l1_data_price, + ] diff --git a/eth_712_account/scripts/example_call.json b/eth_712_account/scripts/example_call.json new file mode 100644 index 0000000..be88114 --- /dev/null +++ b/eth_712_account/scripts/example_call.json @@ -0,0 +1,11 @@ +[ + { + "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "selector": "0x219209e083275171774dab1df80982e9df2096516f06319c5c6d71ae0a8480c", + "data": [ + "0x6a9125a67b0c35f1e760421f7699d82e600d6ddf3f5503a629f389d94b704ba", + "0x0", + "0x1" + ] + } +] diff --git a/eth_712_account/scripts/generate_test_signatures.py b/eth_712_account/scripts/generate_test_signatures.py old mode 100644 new mode 100755 index aa4b178..c509426 --- a/eth_712_account/scripts/generate_test_signatures.py +++ b/eth_712_account/scripts/generate_test_signatures.py @@ -2,8 +2,15 @@ """ Generate EIP-712 signatures for eth_712_account test cases. -This script generates signatures that match the hashing logic in eth_712_utils.cairo. -The signatures are used in test_execute_from_outside tests with actual calls. +This script generates signatures that match the hashing logic in eth_712_utils.cairo +and writes them directly into ../src/test_utils.cairo between marker comments: + + // GENERATED-SIGNATURES-START (by scripts/generate_test_signatures.py -- do not edit manually) + ... generated functions ... + // GENERATED-SIGNATURES-END + +The script is idempotent: running it again on an already-correct file produces no changes. +If the signing logic or test parameters change, re-running the script updates test_utils.cairo. Setup: cd eth_712_account/scripts @@ -17,464 +24,440 @@ Dependencies: eth-account, web3 (see requirements.txt) """ -from eth_account import Account -from eth_account.messages import encode_typed_data -from web3 import Web3 +import pathlib +import re + +from eip712 import ( + ANY_CALLER, + MASK_128, + outside_execution_msg_hash, + resource_bounds_to_felts, + selector, + sign_and_split, + transaction_msg_hash, +) + +# ============================================================================ +# Test constants (must match test_utils.cairo) +# ============================================================================ -# Test private key (same as used for existing test signatures) -# Address: 0xbF60187c5dFfA627249f1C3000A4168dbB9D7A1A PRIVATE_KEY = "0xa6d86467b6ec9e161649b27edfd8519e75a2e1cf5f4c309c628706e6999780e8" -# Expected deployed contract address (deterministic from snforge) -# This is the lower 128 bits used in verifyingContract -EXPECTED_CONTRACT_ADDRESS = 0x651b6cc1595bcd7edddc42163b57e066956b8fba487dd781cd7e4b3a671ffe4 +EXPECTED_CONTRACT_ADDRESS = 0x07120acc07120acc07120acc07120acc +ERC20_MOCK_ADDRESS = 0x0e2c200e2c200e2c200e2c200e2c2000 + +# Re-run this script after recompiling if RegisterInterfacesEIC changes. +FIXED_UPGRADE_TARGET_CLASS_HASH = 0x4775e80641f54baffdce08e82de59d491bc9cbef8d674c7f76f94c2b80b1035 -# Test constants matching test_utils.cairo EXECUTE_AFTER = 1000 EXECUTE_BEFORE = 3000 TEST_NONCE = 1 ETH_CHAIN_ID = 1 -ANY_CALLER = int.from_bytes(b"ANY_CALLER", "big") - -MASK_128 = (1 << 128) - 1 -MASK_250 = (1 << 250) - 1 -# ERC20 Mock address - deterministic based on snforge deployment -ERC20_MOCK_ADDRESS = 0x405ea0439568d265140400aa7b31e896604406bdfa7e73e18dec06303c31c6c +SN_CHAIN_ID = "SN_MAIN" -# Test addresses for spender/recipient (matching test.cairo) TEST_SPENDER = 0x1234 TEST_RECIPIENT = 0x5678 - -# Specific caller address for testing non-ANY_CALLER scenarios SPECIFIC_CALLER = 0xCAFE +WRONG_CONTRACT_ADDRESS = 0xDEAD -# Starknet chain ID for domain name (keccak of this string) -SN_CHAIN_ID = "SN_MAIN" +# ============================================================================ +# Signing helpers +# ============================================================================ -def keccak256(data: bytes) -> bytes: - """Compute keccak256 hash.""" - return Web3.keccak(data) +def sign_outside_execution( + oe: dict, + contract_address: int, + evm_chain_id: int = ETH_CHAIN_ID, + sn_chain_name: str = SN_CHAIN_ID, +) -> dict: + """Sign an OutsideExecution and return 6-felt signature dict.""" + msg_hash = outside_execution_msg_hash(oe, sn_chain_name, contract_address, evm_chain_id) + return sign_and_split(msg_hash, PRIVATE_KEY, evm_chain_id) -def keccak256_str(s: str) -> bytes: - """Compute keccak256 of a string.""" - return keccak256(s.encode("utf-8")) +def sign_transaction( + calls: list[dict], + metadata: dict, + contract_address: int, + evm_chain_id: int = ETH_CHAIN_ID, + sn_chain_name: str = SN_CHAIN_ID, +) -> dict: + """Sign a Transaction (__validate__) and return 6-felt signature dict.""" + msg_hash = transaction_msg_hash(calls, metadata, sn_chain_name, contract_address, evm_chain_id) + return sign_and_split(msg_hash, PRIVATE_KEY, evm_chain_id) -def to_bytes32(val: int) -> bytes: - """Convert u256 to 32 bytes (big-endian).""" - return val.to_bytes(32, "big") +# ============================================================================ +# __validate__ metadata +# ============================================================================ +VALIDATE_TX_VERSION = 3 +VALIDATE_NONCE = 0 +VALIDATE_TIP = 0 +VALIDATE_SN_CHAIN_ID = int.from_bytes(SN_CHAIN_ID.encode("ascii"), "big") +VALIDATE_RESOURCE_BOUNDS = resource_bounds_to_felts( + l1_gas_amount=100, l1_gas_price=1000, + l2_gas_amount=200, l2_gas_price=2000, + l1_data_amount=300, l1_data_price=3000, +) -def hash_felt_array(felts: list[int]) -> bytes: - """Hash an array of felts (keccak of concatenated 32-byte representations).""" - data = b"".join(to_bytes32(f) for f in felts) - return keccak256(data) +def build_validate_metadata(nonce: int = VALIDATE_NONCE) -> dict: + """Build TransactionMetadata dict for __validate__ tests.""" + return { + "version": VALIDATE_TX_VERSION, + "chain_id": VALIDATE_SN_CHAIN_ID, + "execution_resources": VALIDATE_RESOURCE_BOUNDS, + "tip": VALIDATE_TIP, + "nonce": nonce, + } -# Type hashes (must match eth_712_utils.cairo) -EIP712_DOMAIN_TYPE_HASH = 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f -CALL_TYPE_HASH = 0x7793b9bed3b87c6119fe923f0da4e85e1f97a03272a446514622ee7bd62ad25f -OUTSIDE_EXECUTION_TYPE_HASH = 0x57fbef2abe14202f3651b3935a8feddd357b8f83a862e046239d196ec76f281e -VERSION_HASH = 0xad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a5 +# ============================================================================ +# EFO test case generators +# ============================================================================ -def hash_call(call: dict) -> bytes: - """ - Hash a Call struct matching push_call in eth_712_utils.cairo. - call = {"to": int, "selector": int, "calldata": list[int]} - """ - # Hash: keccak(CALL_TYPE_HASH || to || selector || hash(calldata)) - data = ( - to_bytes32(CALL_TYPE_HASH) - + to_bytes32(call["to"]) - + to_bytes32(call["selector"]) - + hash_felt_array(call["calldata"]) - ) - return keccak256(data) +def _build_oe(calls: list[dict], nonce: int, caller: int = ANY_CALLER) -> dict: + """Build an OutsideExecution dict with default timestamps.""" + return { + "caller": caller, + "nonce": nonce, + "execute_after": EXECUTE_AFTER, + "execute_before": EXECUTE_BEFORE, + "calls": calls, + } -def hash_call_array(calls: list[dict]) -> bytes: - """Hash an array of Calls (keccak of concatenated call hashes).""" - data = b"".join(hash_call(c) for c in calls) - return keccak256(data) +def _approve_call(token: int, spender: int, amount: int) -> dict: + return { + "to": token, + "selector": selector("approve"), + "calldata": [spender, amount & MASK_128, amount >> 128], + } -def hash_outside_execution(outside_execution: dict) -> bytes: - """ - Hash OutsideExecution struct matching push_outside_execution in eth_712_utils.cairo. - - NOTE: The Cairo code hashes fields in this order: - 1. OUTSIDE_EXECUTION_TYPE_HASH - 2. hash(calls) - 3. caller - 4. nonce - 5. execute_after - 6. execute_before - """ - calls_hash = hash_call_array(outside_execution["calls"]) - - data = ( - to_bytes32(OUTSIDE_EXECUTION_TYPE_HASH) - + calls_hash - + to_bytes32(outside_execution["caller"]) - + to_bytes32(outside_execution["nonce"]) - + to_bytes32(outside_execution["execute_after"]) - + to_bytes32(outside_execution["execute_before"]) - ) - return keccak256(data) +def _transfer_call(token: int, recipient: int, amount: int) -> dict: + return { + "to": token, + "selector": selector("transfer"), + "calldata": [recipient, amount & MASK_128, amount >> 128], + } -def get_domain_separator(contract_address: int, evm_chain_id: int, sn_chain_name: str) -> bytes: - """ - Compute EIP-712 domain separator matching push_domain_separator in eth_712_utils.cairo. +def _upgrade_call(contract_address: int, class_hash: int) -> dict: + return { + "to": contract_address, + "selector": selector("upgrade"), + "calldata": [class_hash, 1], # 1 = Option::None in Cairo + } - Domain fields: - - name: keccak(sn_chain_name) - - version: VERSION_HASH (keccak("2")) - - chainId: evm_chain_id - - verifyingContract: lower 128 bits of contract_address - """ - name_hash = keccak256_str(sn_chain_name) - # verifyingContract is the lower 128 bits of the contract address - verifying_contract = contract_address & MASK_128 +def generate_basic_outside_execution( + contract_address: int, + nonce: int = TEST_NONCE, + evm_chain_id: int = ETH_CHAIN_ID, + sn_chain_name: str = SN_CHAIN_ID, +) -> dict: + """EFO signature for basic OutsideExecution with empty calls.""" + oe = _build_oe([], nonce) + return sign_outside_execution(oe, contract_address, evm_chain_id, sn_chain_name) - data = ( - to_bytes32(EIP712_DOMAIN_TYPE_HASH) - + name_hash - + to_bytes32(VERSION_HASH) - + to_bytes32(evm_chain_id) - + to_bytes32(verifying_contract) - ) - return keccak256(data) +def generate_wrong_sn_chain_name(contract_address: int) -> dict: + """EFO signature with SN_SEPOLIA (will fail against SN_MAIN domain).""" + return generate_basic_outside_execution(contract_address, sn_chain_name="SN_SEPOLIA") -def get_message_hash( - outside_execution: dict, - contract_address: int, - evm_chain_id: int, - sn_chain_name: str = SN_CHAIN_ID, -) -> bytes: - """ - Compute the full EIP-712 message hash matching get_outside_execution_hash in eth_712_utils.cairo. - Format: keccak(0x19 || 0x01 || domain_separator || struct_hash) - """ - domain_separator = get_domain_separator(contract_address, evm_chain_id, sn_chain_name) - struct_hash = hash_outside_execution(outside_execution) +def generate_wrong_contract_address() -> dict: + """EFO signature with a wrong contract address.""" + return generate_basic_outside_execution(WRONG_CONTRACT_ADDRESS) - data = b"\x19\x01" + domain_separator + struct_hash - return keccak256(data) +def generate_single_call_approve_test( + contract_address: int, token: int, spender: int, amount: int, nonce: int, +) -> dict: + """EFO signature for single approve call.""" + oe = _build_oe([_approve_call(token, spender, amount)], nonce) + return sign_outside_execution(oe, contract_address) -def sign_outside_execution( - outside_execution: dict, - contract_address: int, - evm_chain_id: int = ETH_CHAIN_ID, - sn_chain_name: str = SN_CHAIN_ID, - private_key: str = PRIVATE_KEY, + +def generate_multi_call_test( + contract_address: int, token: int, spender: int, recipient: int, + approve_amount: int, transfer_amount: int, nonce: int, ) -> dict: - """ - Sign an OutsideExecution and return signature components. + """EFO signature for approve + transfer.""" + calls = [ + _approve_call(token, spender, approve_amount), + _transfer_call(token, recipient, transfer_amount), + ] + oe = _build_oe(calls, nonce) + return sign_outside_execution(oe, contract_address) - Returns: {"r_high", "r_low", "s_high", "s_low", "v", "chain_id"} - """ - msg_hash = get_message_hash(outside_execution, contract_address, evm_chain_id, sn_chain_name) - # Sign using eth_account - account = Account.from_key(private_key) - signed = account.unsafe_sign_hash(msg_hash) +def generate_atomicity_test( + contract_address: int, token: int, spender: int, recipient: int, + approve_amount: int, transfer_amount: int, nonce: int, +) -> dict: + """EFO signature for atomicity test (approve succeeds, transfer fails).""" + calls = [ + _approve_call(token, spender, approve_amount), + _transfer_call(token, recipient, transfer_amount), + ] + oe = _build_oe(calls, nonce) + return sign_outside_execution(oe, contract_address) - r = signed.r - s = signed.s - v = signed.v - # Split r and s into high/low 128-bit parts (for Cairo felt252) - r_high = r >> 128 - r_low = r & ((1 << 128) - 1) - s_high = s >> 128 - s_low = s & ((1 << 128) - 1) +def generate_specific_caller_test(contract_address: int, caller: int, nonce: int) -> dict: + """EFO signature with a specific caller (not ANY_CALLER), empty calls.""" + oe = _build_oe([], nonce, caller=caller) + return sign_outside_execution(oe, contract_address) - return { - "r_high": r_high, - "r_low": r_low, - "s_high": s_high, - "s_low": s_low, - "v": v, - "chain_id": evm_chain_id, - } +def generate_efo_upgrade_test( + contract_address: int, class_hash: int, nonce: int, +) -> dict: + """EFO signature for upgrade(class_hash, Option::None).""" + oe = _build_oe([_upgrade_call(contract_address, class_hash)], nonce) + return sign_outside_execution(oe, contract_address) -def format_signature_cairo(sig: dict, name: str) -> str: - """Format signature as Cairo code.""" - return f"""/// Signature for {name} -pub fn get_{name}_signature() -> Array {{ - array![ - 0x{sig['r_high']:032x}, // r_high - 0x{sig['r_low']:032x}, // r_low - 0x{sig['s_high']:032x}, // s_high - 0x{sig['s_low']:032x}, // s_low - {sig['v']}, // v - {sig['chain_id']} // chain_id (EVM) - ] -}} -""" +# ============================================================================ +# __validate__ test case generators +# ============================================================================ -def selector(name: str) -> int: - """Compute Starknet selector (sn_keccak of function name).""" - # sn_keccak is keccak256 with the top 250 bits - h = keccak256_str(name) - val = int.from_bytes(h, "big") - # Mask to 250 bits (felt252 constraint) - return val & MASK_250 + +def generate_validate_empty_calls(contract_address: int, nonce: int = VALIDATE_NONCE) -> dict: + """__validate__ signature with empty calls.""" + return sign_transaction([], build_validate_metadata(nonce), contract_address) + + +def generate_validate_with_approve( + contract_address: int, token: int, spender: int, amount: int, nonce: int = VALIDATE_NONCE, +) -> dict: + """__validate__ signature with a single approve call.""" + call = _approve_call(token, spender, amount) + return sign_transaction([call], build_validate_metadata(nonce), contract_address) + + +def generate_validate_wrong_chain(contract_address: int, nonce: int = VALIDATE_NONCE) -> dict: + """__validate__ signature with SN_SEPOLIA (will fail against SN_MAIN domain).""" + return sign_transaction( + [], build_validate_metadata(nonce), contract_address, sn_chain_name="SN_SEPOLIA", + ) + + +def generate_validate_upgrade(contract_address: int, class_hash: int, nonce: int) -> dict: + """__validate__ signature for upgrade(class_hash, Option::None).""" + call = _upgrade_call(contract_address, class_hash) + return sign_transaction([call], build_validate_metadata(nonce), contract_address) # ============================================================================ -# Test Case Definitions +# Cairo code generation # ============================================================================ -def generate_single_call_approve_test( - contract_address: int, - token_address: int, - spender: int, - amount: int, -) -> tuple[dict, dict]: - """ - Generate OutsideExecution for single approve call test. +MARKER_START = "// GENERATED-SIGNATURES-START (by scripts/generate_test_signatures.py -- do not edit manually)" +MARKER_END = "// GENERATED-SIGNATURES-END" + + +def format_signature_cairo(sig: dict, fn_name: str, doc: str) -> str: + """Format a single signature as a Cairo function (scarb fmt compatible).""" + r_high = f"0x{sig['r_high']:032x}" + r_low = f"0x{sig['r_low']:032x}" + s_high = f"0x{sig['s_high']:032x}" + s_low = f"0x{sig['s_low']:032x}" + v = str(sig["v"]) + chain_id = str(sig["chain_id"]) + return ( + f"/// {doc}\n" + f"pub fn {fn_name}() -> Array {{\n" + f" array![\n" + f" {r_high}, {r_low},\n" + f" {s_high}, {s_low}, {v}, {chain_id},\n" + f" ]\n" + f"}}" + ) - Returns: (outside_execution, signature) - """ - # approve(spender: ContractAddress, amount: u256) - # calldata: [spender, amount_low, amount_high] - amount_low = amount & MASK_128 - amount_high = amount >> 128 - call = { - "to": token_address, - "selector": selector("approve"), - "calldata": [spender, amount_low, amount_high], - } +def generate_all_signatures() -> list[str]: + """Generate all signature functions as Cairo code strings.""" + addr = EXPECTED_CONTRACT_ADDRESS + token = ERC20_MOCK_ADDRESS + spender = TEST_SPENDER + recipient = TEST_RECIPIENT - outside_execution = { - "caller": ANY_CALLER, - "nonce": TEST_NONCE, - "execute_after": EXECUTE_AFTER, - "execute_before": EXECUTE_BEFORE, - "calls": [call], - } + NONCE_SINGLE_CALL = 100 + NONCE_MULTI_CALL = 101 + NONCE_ATOMICITY = 102 + NONCE_SPECIFIC_CALLER = 103 + NONCE_EFO_UPGRADE = 200 + VALIDATE_NONCE_WITH_CALLS = 1 + VALIDATE_NONCE_UPGRADE = 2 + APPROVE_AMOUNT = 500 + TRANSFER_AMOUNT = 100 + INITIAL_SUPPLY = 1000 - sig = sign_outside_execution(outside_execution, contract_address) - return outside_execution, sig + blocks: list[str] = [] + # --- EFO: basic (empty calls) --- -def generate_multi_call_test( - contract_address: int, - token_address: int, - spender: int, - recipient: int, - approve_amount: int, - transfer_amount: int, -) -> tuple[dict, dict]: - """ - Generate OutsideExecution for multi-call test (approve + transfer). + sig = generate_basic_outside_execution(addr) + blocks.append(format_signature_cairo( + sig, "get_outside_execution_signature", + "EFO signature: empty calls, nonce=1, chain_id=1.", + )) - Returns: (outside_execution, signature) - """ - approve_low = approve_amount & MASK_128 - approve_high = approve_amount >> 128 - transfer_low = transfer_amount & MASK_128 - transfer_high = transfer_amount >> 128 + sig = generate_basic_outside_execution(addr, evm_chain_id=2) + blocks.append(format_signature_cairo( + sig, "get_signature_evm_chain_id_2", + "EFO signature: empty calls, nonce=1, chain_id=2.", + )) - calls = [ - { - "to": token_address, - "selector": selector("approve"), - "calldata": [spender, approve_low, approve_high], - }, - { - "to": token_address, - "selector": selector("transfer"), - "calldata": [recipient, transfer_low, transfer_high], - }, - ] + sig = generate_wrong_sn_chain_name(addr) + blocks.append(format_signature_cairo( + sig, "get_signature_wrong_sn_chain_name", + "EFO signature signed with SN_SEPOLIA domain (fails against SN_MAIN).", + )) - outside_execution = { - "caller": ANY_CALLER, - "nonce": 2, # Different nonce for this test - "execute_after": EXECUTE_AFTER, - "execute_before": EXECUTE_BEFORE, - "calls": calls, - } + sig = generate_wrong_contract_address() + blocks.append(format_signature_cairo( + sig, "get_signature_wrong_contract_address", + "EFO signature signed with wrong contract address (domain mismatch).", + )) - sig = sign_outside_execution(outside_execution, contract_address) - return outside_execution, sig + # --- EFO: with ERC20 calls --- + sig = generate_single_call_approve_test(addr, token, spender, APPROVE_AMOUNT, NONCE_SINGLE_CALL) + blocks.append(format_signature_cairo( + sig, "get_single_call_approve_signature", + f"EFO signature: approve(0x1234, 500), nonce={NONCE_SINGLE_CALL}.", + )) -def generate_atomicity_test( - contract_address: int, - token_address: int, - spender: int, - recipient: int, - approve_amount: int, - transfer_amount: int, # Should be > balance to cause revert -) -> tuple[dict, dict]: - """ - Generate OutsideExecution for atomicity test (approve succeeds, transfer fails). + sig = generate_multi_call_test( + addr, token, spender, recipient, APPROVE_AMOUNT, TRANSFER_AMOUNT, NONCE_MULTI_CALL, + ) + blocks.append(format_signature_cairo( + sig, "get_multi_call_signature", + f"EFO signature: approve(500) + transfer(100), nonce={NONCE_MULTI_CALL}.", + )) - Returns: (outside_execution, signature) - """ - approve_low = approve_amount & MASK_128 - approve_high = approve_amount >> 128 - transfer_low = transfer_amount & MASK_128 - transfer_high = transfer_amount >> 128 + sig = generate_atomicity_test( + addr, token, spender, recipient, APPROVE_AMOUNT, INITIAL_SUPPLY + 1, NONCE_ATOMICITY, + ) + blocks.append(format_signature_cairo( + sig, "get_atomicity_test_signature", + f"EFO signature: approve(500) + transfer(1001, fails), nonce={NONCE_ATOMICITY}.", + )) + + sig = generate_specific_caller_test(addr, SPECIFIC_CALLER, NONCE_SPECIFIC_CALLER) + blocks.append(format_signature_cairo( + sig, "get_specific_caller_signature", + f"EFO signature: specific caller=0xCAFE, nonce={NONCE_SPECIFIC_CALLER}, empty calls.", + )) + + # --- __validate__ --- + + sig = generate_validate_empty_calls(addr) + blocks.append(format_signature_cairo( + sig, "get_validate_empty_calls_signature", + "__validate__ signature: empty calls, nonce=0.", + )) + + sig = generate_validate_with_approve(addr, token, spender, APPROVE_AMOUNT, VALIDATE_NONCE_WITH_CALLS) + blocks.append(format_signature_cairo( + sig, "get_validate_with_approve_signature", + "__validate__ signature: approve(0x1234, 500), nonce=1.", + )) + + sig = generate_validate_wrong_chain(addr) + blocks.append(format_signature_cairo( + sig, "get_validate_wrong_chain_signature", + "__validate__ signature signed with SN_SEPOLIA domain (fails against SN_MAIN).", + )) + + # --- Upgrade --- + + sig = generate_efo_upgrade_test(addr, FIXED_UPGRADE_TARGET_CLASS_HASH, NONCE_EFO_UPGRADE) + blocks.append(format_signature_cairo( + sig, "get_efo_upgrade_signature", + f"EFO signature: upgrade(FIXED_UPGRADE_TARGET_CLASS_HASH, None), nonce={NONCE_EFO_UPGRADE}.", + )) + + sig = generate_validate_upgrade(addr, FIXED_UPGRADE_TARGET_CLASS_HASH, VALIDATE_NONCE_UPGRADE) + blocks.append(format_signature_cairo( + sig, "get_validate_upgrade_signature", + f"__validate__ signature: upgrade(FIXED_UPGRADE_TARGET_CLASS_HASH, None), nonce={VALIDATE_NONCE_UPGRADE}.", + )) + + return blocks - calls = [ - { - "to": token_address, - "selector": selector("approve"), - "calldata": [spender, approve_low, approve_high], - }, - { - "to": token_address, - "selector": selector("transfer"), - "calldata": [recipient, transfer_low, transfer_high], - }, - ] - outside_execution = { - "caller": ANY_CALLER, - "nonce": 3, # Different nonce for this test - "execute_after": EXECUTE_AFTER, - "execute_before": EXECUTE_BEFORE, - "calls": calls, - } +# ============================================================================ +# File I/O +# ============================================================================ - sig = sign_outside_execution(outside_execution, contract_address) - return outside_execution, sig +def build_generated_block(blocks: list[str]) -> str: + """Wrap signature functions in marker comments.""" + result = MARKER_START + "\n" + for block in blocks: + result += "\n" + block + "\n" + result += MARKER_END + "\n" + return result -def generate_specific_caller_test( - contract_address: int, - caller: int, - nonce: int, -) -> tuple[dict, dict]: - """ - Generate OutsideExecution with a specific caller (not ANY_CALLER). - Uses empty calls for simplicity. - Returns: (outside_execution, signature) +def write_signatures_to_file(cairo_path: str, generated_block: str) -> bool: """ - outside_execution = { - "caller": caller, - "nonce": nonce, - "execute_after": EXECUTE_AFTER, - "execute_before": EXECUTE_BEFORE, - "calls": [], - } + Replace the marker-delimited section in the Cairo file with the generated block. + Returns True if the file was changed. + """ + with open(cairo_path, "r") as f: + content = f.read() + + pattern = re.compile( + re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END) + r"\n?", + re.DOTALL, + ) - sig = sign_outside_execution(outside_execution, contract_address) - return outside_execution, sig + if not pattern.search(content): + raise ValueError( + f"Markers not found in {cairo_path}. " + f"Expected '{MARKER_START}' and '{MARKER_END}'." + ) + + new_content = pattern.sub(generated_block, content) + + if new_content == content: + return False + + with open(cairo_path, "w") as f: + f.write(new_content) + return True + + +# ============================================================================ +# Main +# ============================================================================ def main(): - """Generate and print all test signatures.""" - print("=" * 80) - print("EIP-712 Test Signature Generator for eth_712_account") - print("=" * 80) - print() - - contract_address = EXPECTED_CONTRACT_ADDRESS - token_address = ERC20_MOCK_ADDRESS - spender = TEST_SPENDER - recipient = TEST_RECIPIENT + """Generate all test signatures and write them into test_utils.cairo.""" + script_dir = pathlib.Path(__file__).resolve().parent + cairo_path = (script_dir / ".." / "src" / "test_utils.cairo").resolve() - # Nonces matching test_utils.cairo - NONCE_SINGLE_CALL = 100 - NONCE_MULTI_CALL = 101 - NONCE_ATOMICITY = 102 - NONCE_SPECIFIC_CALLER = 103 + print("Generating signatures...") + blocks = generate_all_signatures() + generated_block = build_generated_block(blocks) - # Test amounts (matching test.cairo). - APPROVE_AMOUNT = 500 - TRANSFER_AMOUNT = 100 - INITIAL_SUPPLY = 1000 + print(f"Writing to {cairo_path}") + changed = write_signatures_to_file(str(cairo_path), generated_block) - print(f"Contract address: 0x{contract_address:064x}") - print(f"Token address: 0x{token_address:064x}") - print(f"Spender: 0x{spender:x}") - print(f"Recipient: 0x{recipient:x}") - print() - - print("Test 1: Single Call (approve 500 tokens)") - print("-" * 40) - - # OSE: OutsideExecution. - ose, sig = generate_single_call_approve_test( - contract_address=contract_address, - token_address=token_address, - spender=spender, - amount=APPROVE_AMOUNT, - ) - # Override nonce to match test. - ose["nonce"] = NONCE_SINGLE_CALL - sig = sign_outside_execution(ose, contract_address) - print(f"Nonce: {NONCE_SINGLE_CALL}") - print(format_signature_cairo(sig, "single_call_approve")) - - print() - print("Test 2: Multi Call (approve 500 + transfer 100)") - print("-" * 40) - ose, sig = generate_multi_call_test( - contract_address=contract_address, - token_address=token_address, - spender=spender, - recipient=recipient, - approve_amount=APPROVE_AMOUNT, - transfer_amount=TRANSFER_AMOUNT, - ) - # Override nonce to match test. - ose["nonce"] = NONCE_MULTI_CALL - sig = sign_outside_execution(ose, contract_address) - print(f"Nonce: {NONCE_MULTI_CALL}") - print(format_signature_cairo(sig, "multi_call")) - - print() - print("Test 3: Atomicity (approve 500 + transfer 1001 - fails)") - print("-" * 40) - ose, sig = generate_atomicity_test( - contract_address=contract_address, - token_address=token_address, - spender=spender, - recipient=recipient, - approve_amount=APPROVE_AMOUNT, - transfer_amount=INITIAL_SUPPLY + 1, # More than balance. - ) - # Override nonce to match test - ose["nonce"] = NONCE_ATOMICITY - sig = sign_outside_execution(ose, contract_address) - print(f"Nonce: {NONCE_ATOMICITY}") - print(format_signature_cairo(sig, "atomicity_test")) - - print() - print("Test 4: Specific Caller (non-ANY_CALLER)") - print("-" * 40) - ose, sig = generate_specific_caller_test( - contract_address=contract_address, - caller=SPECIFIC_CALLER, - nonce=NONCE_SPECIFIC_CALLER, - ) - print(f"Nonce: {NONCE_SPECIFIC_CALLER}") - print(f"Caller: 0x{SPECIFIC_CALLER:x}") - print(format_signature_cairo(sig, "specific_caller")) - - print() - print("=" * 80) - print("Copy the signatures above into test_utils.cairo") - print("=" * 80) + if changed: + print("File updated.") + else: + print("No changes (file already up to date).") if __name__ == "__main__": diff --git a/eth_712_account/scripts/requirements.txt b/eth_712_account/scripts/requirements.txt index 66dfe68..f5454b7 100644 --- a/eth_712_account/scripts/requirements.txt +++ b/eth_712_account/scripts/requirements.txt @@ -1,2 +1,4 @@ eth-account>=0.13.0 web3>=7.0.0 +aiohttp>=3.9.0 +starknet-py>=0.24.0 diff --git a/eth_712_account/scripts/send_tx.py b/eth_712_account/scripts/send_tx.py new file mode 100755 index 0000000..a3384a0 --- /dev/null +++ b/eth_712_account/scripts/send_tx.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +""" +Send InvokeV3 transactions or build EFO (Execute From Outside) calldata +for the eth_712_account contract on Starknet. + +Modes: + Regular TX: Signs and sends an InvokeV3 transaction (__validate__ -> __execute__). + EFO (--efo): Signs an OutsideExecution and prints the calldata for execute_from_outside_v2. + +Both modes use the same dynamic EIP-712 domain separator (matching eth_712_utils.cairo): +- name: keccak(SN_CHAIN_ID) +- verifyingContract: contract_address_low (lower 128 bits) +- Signature is 6 felts: [r_high, r_low, s_high, s_low, v, evm_chain_id] + +Setup: + cd eth_712_account/scripts + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + +Examples: + # Regular transaction (STRK approval) + ./send_tx.py -a 0x1234... + + # Dry run + ./send_tx.py -a 0x1234... --dry-run + + # EFO with ANY_CALLER (default) + ./send_tx.py -a 0x1234... --efo + + # EFO with specific caller + ./send_tx.py -a 0x1234... --efo --sender 0xCAFE + + # EFO with custom calls + ./send_tx.py -a 0x1234... --efo --calls calls.json --nonce 42 +""" + +import argparse +import asyncio +import json +import os +import time +import warnings + +import aiohttp +from eth_account import Account +from starknet_py.net.client_models import ResourceBounds, ResourceBoundsMapping +from starknet_py.net.full_node_client import FullNodeClient +from starknet_py.net.models.transaction import InvokeV3 + +from eip712 import ( + ANY_CALLER, + L1_DATA_ID, + L1_GAS_ID, + L2_GAS_ID, + MASK_128, + domain_separator, + hash_outside_execution, + hash_transaction, + outside_execution_msg_hash, + sign_and_split, + to_bytes32, + transaction_msg_hash, +) + +warnings.filterwarnings("ignore", category=UserWarning, module="starknet_py") + +# ============================================================================ +# Configuration defaults +# ============================================================================ + +DEFAULT_RPC_URL = os.environ.get("STARKNET_RPC") +DEFAULT_ETH_PRIVATE_KEY = "0xa6d86467b6ec9e161649b27edfd8519e75a2e1cf5f4c309c628706e6999780e8" +DEFAULT_SN_CHAIN_ID = "SN_SEPOLIA" +DEFAULT_EVM_CHAIN_ID = 1 + +DEFAULT_CALL = { + "address": "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d", + "selector": "0x219209e083275171774dab1df80982e9df2096516f06319c5c6d71ae0a8480c", + "data": [ + "0x6a9125a67b0c35f1e760421f7699d82e600d6ddf3f5503a629f389d94b704ba", + "0x0", + "0x0", + ], +} + +# ============================================================================ +# Input parsing helpers +# ============================================================================ + + +def to_int(v) -> int: + """Convert hex string or int to int.""" + return int(str(v), 0) + + +def parse_chain_id(value: str) -> str: + """Parse chain ID text (e.g. 'SN_SEPOLIA'). Returned as-is for domain hashing.""" + return value + + +def sn_chain_name_to_felt(name: str) -> int: + """Convert chain name string to felt252 (for metadata.chain_id).""" + return int.from_bytes(name.encode("ascii"), "big") + + +def normalize_call(call: dict) -> dict: + """Convert JSON call format (address/data) to canonical format (to/calldata).""" + return { + "to": to_int(call["address"]), + "selector": to_int(call["selector"]), + "calldata": [to_int(x) for x in call["data"]], + } + + +def load_calls_from_json(filepath: str) -> list: + """Load calls array from a JSON file.""" + calls = json.load(open(filepath)) + if not isinstance(calls, list): + calls = [calls] + return calls + + +# ============================================================================ +# Resource bounds +# ============================================================================ + + +def build_resource_bounds() -> ResourceBoundsMapping: + """ + Build ResourceBoundsMapping for the transaction. + The values are good for all typical transactions. + """ + return ResourceBoundsMapping( + l1_gas=ResourceBounds(max_amount=0x0, max_price_per_unit=0x1000000000000000), + l1_data_gas=ResourceBounds(max_amount=0x1000, max_price_per_unit=0x10000000000000), + l2_gas=ResourceBounds(max_amount=0x5F5E100, max_price_per_unit=0x2540BE400), + ) + + +def rb_mapping_to_felts(rb: ResourceBoundsMapping) -> list[int]: + """Convert ResourceBoundsMapping to 9-felt array for EIP-712 signing.""" + return [ + L1_GAS_ID, rb.l1_gas.max_amount, rb.l1_gas.max_price_per_unit, + L2_GAS_ID, rb.l2_gas.max_amount, rb.l2_gas.max_price_per_unit, + L1_DATA_ID, rb.l1_data_gas.max_amount, rb.l1_data_gas.max_price_per_unit, + ] + + +# ============================================================================ +# Call serialization (for Starknet calldata) +# ============================================================================ + + +def serialize_calls_to_felts(calls: list) -> list: + """Serialize a list of calls to felts for calldata.""" + n_calls = len(calls) + rc_calls = [n_calls] + + for call in calls: + call_felts = [call["address"], call["selector"]] + data = call["data"] + call_felts.append(len(data)) + call_felts.extend(data) + + call_felts = [to_int(r) for r in call_felts] + rc_calls.extend(call_felts) + return rc_calls + + +def serialize_efo_calldata(oe: dict, calls: list, signature: list) -> list: + """Serialize OutsideExecution + signature as calldata for execute_from_outside_v2.""" + calldata = [oe["caller"], oe["nonce"], oe["execute_after"], oe["execute_before"]] + calldata.append(len(calls)) + for call in calls: + calldata.append(to_int(call["address"])) + calldata.append(to_int(call["selector"])) + data = call["data"] + calldata.append(len(data)) + calldata.extend([to_int(d) for d in data]) + calldata.append(len(signature)) + calldata.extend(signature) + return calldata + + +# ============================================================================ +# RPC helpers +# ============================================================================ + + +async def get_nonce(rpc_url: str, account_address: int) -> int: + """Fetch the current nonce for the account.""" + payload = { + "jsonrpc": "2.0", + "method": "starknet_getNonce", + "params": {"block_id": "pending", "contract_address": hex(account_address)}, + "id": 1, + } + async with aiohttp.ClientSession() as session: + async with session.post(rpc_url, json=payload) as response: + result = await response.json() + if "error" in result: + raise Exception(f"RPC error: {result['error']}") + return int(result["result"], 16) + + +# ============================================================================ +# Transaction signing and sending +# ============================================================================ + + +def sign_tx( + calls: list[dict], + metadata: dict, + signer, + sn_chain_name: str, + contract_address: int, + evm_chain_id: int, + debug: bool = False, +) -> list: + """Sign a transaction and return 6-felt signature list.""" + canonical_calls = [normalize_call(c) for c in calls] + msg_hash = transaction_msg_hash( + canonical_calls, metadata, sn_chain_name, contract_address, evm_chain_id, + ) + if debug: + ds = domain_separator(sn_chain_name, contract_address, evm_chain_id) + sh = hash_transaction(canonical_calls, metadata) + print(f" Domain separator: {hex(ds)}") + print(f" Struct hash: {hex(sh)}") + print(f" Message hash: {hex(msg_hash)}") + sig = sign_and_split(msg_hash, signer.key, evm_chain_id) + return [sig["r_high"], sig["r_low"], sig["s_high"], sig["s_low"], sig["v"], sig["chain_id"]] + + +def build_invoke_v3( + calls: list, sender_address: int, signature: list, nonce: int, + resource_bounds: ResourceBoundsMapping, tip: int = 0, version: int = 3, +) -> InvokeV3: + """Build an InvokeV3 transaction object.""" + calldata = serialize_calls_to_felts(calls) + return InvokeV3( + version=version, + signature=signature, + nonce=nonce, + resource_bounds=resource_bounds, + calldata=calldata, + sender_address=sender_address, + tip=tip, + ) + + +async def send_invoke_v3( + calls: list, rpc_url: str, account_address: int, signer, + sn_chain_name: str, evm_chain_id: int, dry_run: bool = False, +): + """Prepare, sign, and send an InvokeV3 transaction.""" + sn_chain_id_felt = sn_chain_name_to_felt(sn_chain_name) + print(f"Account: {hex(account_address)}") + print(f"Signer ETH address: {signer.address}") + print(f"RPC: {rpc_url}") + print(f"SN Chain ID: {hex(sn_chain_id_felt)}") + print(f"EVM Chain ID: {evm_chain_id}") + + on_chain_nonce = await get_nonce(rpc_url, account_address) + print(f"On-chain nonce: {on_chain_nonce}") + + resource_bounds = build_resource_bounds() + print(f"\nResource bounds:") + print(f" l1_gas: amount={hex(resource_bounds.l1_gas.max_amount)}, price={hex(resource_bounds.l1_gas.max_price_per_unit)}") + print(f" l1_data_gas: amount={hex(resource_bounds.l1_data_gas.max_amount)}, price={hex(resource_bounds.l1_data_gas.max_price_per_unit)}") + print(f" l2_gas: amount={hex(resource_bounds.l2_gas.max_amount)}, price={hex(resource_bounds.l2_gas.max_price_per_unit)}") + + metadata = { + "version": 3, + "chain_id": sn_chain_id_felt, + "execution_resources": rb_mapping_to_felts(resource_bounds), + "tip": 0, + "nonce": on_chain_nonce, + } + + print(f"\nSigning metadata:") + print(f" version: {metadata['version']}") + print(f" chain_id: {hex(metadata['chain_id'])}") + print(f" nonce: {metadata['nonce']}") + + print("\nSigning transaction...") + print(f" Execution resources (9 felts):") + for i, v in enumerate(metadata["execution_resources"]): + print(f" [{i}]: {hex(v)}") + signature = sign_tx( + calls, metadata, signer, sn_chain_name, account_address, evm_chain_id, debug=True, + ) + print(f"Signature: {[hex(s) for s in signature]}") + + invoke_tx = build_invoke_v3(calls, account_address, signature, on_chain_nonce, resource_bounds) + print(f"\nInvokeV3 transaction:") + print(f" sender: {hex(invoke_tx.sender_address)}") + print(f" nonce: {invoke_tx.nonce}") + print(f" calldata length: {len(invoke_tx.calldata)}") + + tx_hash = invoke_tx.calculate_hash(chain_id=sn_chain_id_felt) + print(f" tx_hash: {hex(tx_hash)}") + + if dry_run: + print("\n[DRY RUN] Transaction not sent.") + return {"tx_hash": hex(tx_hash), "invoke_tx": invoke_tx} + + print("\nSending transaction...") + try: + client = FullNodeClient(node_url=rpc_url) + result = await client.send_transaction(invoke_tx) + print(f"Transaction sent! tx_hash: {hex(result.transaction_hash)}") + return result.transaction_hash + except Exception as e: + print(f"Error: {e}") + raise + + +# ============================================================================ +# EFO (Execute From Outside) handling +# ============================================================================ + + +def sign_efo( + oe: dict, calls: list[dict], signer, sn_chain_name: str, + contract_address: int, evm_chain_id: int, debug: bool = False, +) -> list: + """Sign an OutsideExecution and return 6-felt signature list.""" + canonical_calls = [normalize_call(c) for c in calls] + oe_with_calls = {**oe, "calls": canonical_calls} + msg_hash = outside_execution_msg_hash( + oe_with_calls, sn_chain_name, contract_address, evm_chain_id, + ) + if debug: + ds = domain_separator(sn_chain_name, contract_address, evm_chain_id) + oe_hash = hash_outside_execution(oe_with_calls) + print(f" Domain separator: {hex(ds)}") + print(f" Struct hash: {hex(oe_hash)}") + print(f" Message hash: {hex(msg_hash)}") + sig = sign_and_split(msg_hash, signer.key, evm_chain_id) + return [sig["r_high"], sig["r_low"], sig["s_high"], sig["s_low"], sig["v"], sig["chain_id"]] + + +def handle_efo(args, calls, calls_source, signer, account_address, sn_chain_name): + """Handle EFO mode: sign and print calldata for execute_from_outside_v2.""" + evm_chain_id = args.evm_chain_id + caller = to_int(args.sender) if args.sender else ANY_CALLER + nonce = args.nonce if args.nonce is not None else int(time.time()) + + oe = { + "caller": caller, + "nonce": nonce, + "execute_after": args.execute_after, + "execute_before": args.execute_before, + } + + print("=" * 60) + print("ETH 712 Account - Execute From Outside (EFO)") + print("=" * 60) + print(f"\nAccount: {hex(account_address)}") + print(f"Signer ETH address: {signer.address}") + print(f"SN Chain ID: {sn_chain_name}") + print(f"EVM Chain ID: {evm_chain_id}") + + caller_label = " (ANY_CALLER)" if caller == ANY_CALLER else "" + print(f"\nOutsideExecution:") + print(f" caller: {hex(caller)}{caller_label}") + print(f" nonce: {nonce}") + print(f" execute_after: {oe['execute_after']}") + print(f" execute_before: {oe['execute_before']}") + print(f" calls ({calls_source}): {len(calls)}") + for i, call in enumerate(calls, start=1): + print(f" [{i}] to={str(call['address'])[:20]}... selector={str(call['selector'])[:20]}...") + + print("\nSigning outside execution...") + signature = sign_efo( + oe, calls, signer, sn_chain_name, account_address, evm_chain_id, debug=True, + ) + print(f"Signature: {[hex(s) for s in signature]}") + + calldata = serialize_efo_calldata(oe, calls, signature) + calldata_hex = " ".join(hex(v) for v in calldata) + + print(f"\n{'=' * 60}") + print("Calldata for execute_from_outside_v2:") + print(f"{'=' * 60}") + print(f"\nstarkli invoke {hex(account_address)} execute_from_outside_v2 \\") + print(f" {calldata_hex}") + + return calldata + + +# ============================================================================ +# CLI and main +# ============================================================================ + + +def parse_args(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Send InvokeV3 transaction to eth_712_account on Starknet", + ) + parser.add_argument("--calls", "-c", help="JSON file containing calls array") + parser.add_argument("--rpc", "-r", default=DEFAULT_RPC_URL, help="Starknet RPC URL") + parser.add_argument("--account", "-a", required=True, help="eth_712_account contract address") + parser.add_argument("--eth-private-key", "-k", default=DEFAULT_ETH_PRIVATE_KEY) + parser.add_argument( + "--sn-chain-id", default=DEFAULT_SN_CHAIN_ID, + help=f"SN chain name (e.g. SN_SEPOLIA), default: {DEFAULT_SN_CHAIN_ID}", + ) + parser.add_argument( + "--evm-chain-id", type=int, default=DEFAULT_EVM_CHAIN_ID, + help=f"EVM chain ID (default: {DEFAULT_EVM_CHAIN_ID})", + ) + parser.add_argument("--dry-run", "-d", action="store_true", help="Prepare but do not send") + parser.add_argument("--efo", action="store_true", help="EFO mode: print calldata, no tx sent") + parser.add_argument("--sender", default=None, help="EFO caller address (default: ANY_CALLER)") + parser.add_argument( + "--nonce", type=int, default=None, help="EFO nonce (default: unix timestamp)", + ) + parser.add_argument("--execute-after", type=int, default=0, help="EFO execute_after timestamp") + parser.add_argument( + "--execute-before", type=int, default=0xFFFFFFFFFFFFFFFF, + help="EFO execute_before timestamp (default: 0xFFFFFFFFFFFFFFFF)", + ) + return parser.parse_args() + + +async def main(): + """Main entry point.""" + args = parse_args() + + account_address = to_int(args.account) + signer = Account.from_key(args.eth_private_key) + sn_chain_name = args.sn_chain_id + + if args.calls: + calls = load_calls_from_json(args.calls) + calls_source = f"Loaded from: {args.calls}" + else: + calls = [DEFAULT_CALL] + calls_source = "Default: [STRK approval]" + + if args.efo: + return handle_efo(args, calls, calls_source, signer, account_address, sn_chain_name) + + print("=" * 60) + print("ETH 712 Account - Send InvokeV3 Transaction") + print("=" * 60) + print(f"\nCalls ({calls_source}): {len(calls)}") + for i, call in enumerate(calls, start=1): + print(f" [{i}] to={str(call['address'])[:20]}... selector={str(call['selector'])[:20]}...") + + return await send_invoke_v3( + calls, args.rpc, account_address, signer, sn_chain_name, args.evm_chain_id, args.dry_run, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/eth_712_account/src/eth_712_account.cairo b/eth_712_account/src/eth_712_account.cairo index bb1f89e..751ebe0 100644 --- a/eth_712_account/src/eth_712_account.cairo +++ b/eth_712_account/src/eth_712_account.cairo @@ -3,29 +3,29 @@ /// StarknetEth712Account /// -/// Account contract that supports ISRC9_V2 (Execute from outside v2) and ISRC5 (Introspection). -/// The Account contract is initialized with an Ethereum address. -/// The transaction executed by the account is validated using EIP-712. -/// and signed using Secp256k1. -/// This allows the account to sign the txs from the wallet of a remote chain, -/// and execute them locally on Starknet. +/// Account contract that supports ISRC6, ISRC9_V2 (Execute from outside v2) and ISRC5. +/// Initialized with an Ethereum address; transactions are validated using EIP-712 +/// and signed with Secp256k1, allowing signing from a remote chain's wallet +/// and execution on Starknet. #[starknet::contract(account)] pub mod StarknetEth712Account { use core::num::traits::Zero; use eth_712_account::eth_712_utils::{ - assert_valid_owner, extract_signature, get_outside_execution_hash, is_valid_signature, + Transaction, TransactionMetadata, assert_valid_owner, extract_signature, + extract_signature_flexible, get_outside_execution_hash, get_transaction_hash, + is_tx_version_valid, is_valid_eth_signature, resource_bounds_as_felts, }; use eth_712_account::interface::{ IAccount712Admin, IEICDispatcherTrait, IEICLibraryDispatcher, Upgraded, }; - use openzeppelin::account::AccountComponent; use openzeppelin::account::extensions::src9::interface::ISRC9_V2_ID; use openzeppelin::account::extensions::src9::{ISRC9_V2, OutsideExecution}; - use openzeppelin::account::interface::ISRC6_ID; - use openzeppelin::account::utils::execute_calls; + use openzeppelin::account::interface::{ISRC6, ISRC6_ID}; + use openzeppelin::account::utils::{execute_calls, execute_single_call}; use openzeppelin::introspection::src5::SRC5Component; use openzeppelin::introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; + use starknet::account::Call; use starknet::secp256_trait::Signature; use starknet::storage::{ Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, @@ -35,12 +35,12 @@ pub mod StarknetEth712Account { use starknet::{ClassHash, EthAddress, SyscallResultTrait}; component!(path: SRC5Component, storage: src5, event: SRC5Event); - component!(path: AccountComponent, storage: account, event: AccountEvent); + + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; #[storage] pub struct Storage { - #[substorage(v0)] - pub account: AccountComponent::Storage, #[substorage(v0)] pub src5: SRC5Component::Storage, pub SRC9_nonces: Map, @@ -50,22 +50,62 @@ pub mod StarknetEth712Account { #[event] #[derive(Drop, starknet::Event)] enum Event { - #[flat] - AccountEvent: AccountComponent::Event, #[flat] SRC5Event: SRC5Component::Event, Upgraded: Upgraded, } - // We need an account component implementation, as it's required by pay-master. - // However, the make-up of the contract (e.g. not initializing the account component) - // renders the __validate__ method unusable. + // ================================ + // Account Entrypoints (ISRC6) + // ================================ + #[abi(embed_v0)] - pub(crate) impl AccountMixinImpl = - AccountComponent::AccountMixinImpl; - impl AccountInternalImpl = AccountComponent::InternalImpl; + impl ISRC6Impl of ISRC6 { + fn __validate__(self: @ContractState, calls: Array) -> felt252 { + let tx_info = starknet::get_tx_info().unbox(); + let (signature, evm_chain_id) = extract_signature(tx_info.signature); + + let transaction = Transaction { + calls: calls.span(), + metadata: @TransactionMetadata { + version: tx_info.version, + chain_id: tx_info.chain_id, + execution_resources: resource_bounds_as_felts(tx_info.resource_bounds), + tip: tx_info.tip.into(), + nonce: tx_info.nonce, + }, + }; + let msg_hash = get_transaction_hash(@transaction, chain_id: evm_chain_id); + assert( + is_valid_eth_signature(:msg_hash, :signature, eth_address: self.eth_address.read()), + 'INVALID_SIGNATURE', + ); + starknet::VALIDATED + } + + fn __execute__(self: @ContractState, calls: Array) { + assert(starknet::get_caller_address().is_zero(), 'INVALID_CALLER'); + assert(is_tx_version_valid(), 'INVALID_TX_VERSION'); + for call in calls.span() { + execute_single_call(call); + } + } + + fn is_valid_signature( + self: @ContractState, hash: felt252, signature: Array, + ) -> felt252 { + let sig = extract_signature_flexible(signature.span()); + if is_valid_eth_signature(hash.into(), sig, self.eth_address.read()) { + starknet::VALIDATED + } else { + 0 + } + } + } - // ABI implementation. + // ================================ + // Admin Implementation + // ================================ #[abi(embed_v0)] impl AdminImpl of IAccount712Admin { @@ -126,7 +166,7 @@ pub mod StarknetEth712Account { let (signature, evm_chain_id) = extract_signature(:signature); let msg_hash = get_outside_execution_hash(@outside_execution, chain_id: evm_chain_id); assert( - is_valid_signature(:msg_hash, :signature, eth_address: self.eth_address.read()), + is_valid_eth_signature(:msg_hash, :signature, eth_address: self.eth_address.read()), 'INVALID_SIGNATURE', ); execute_calls(calls) @@ -141,8 +181,8 @@ pub mod StarknetEth712Account { pub impl InternalImpl of InternalTrait { fn assert_only_self(self: @ContractState) { let caller = starknet::get_caller_address(); - let self = starknet::get_contract_address(); - assert(self == caller, 'UNAUTHORIZED'); + let self_addr = starknet::get_contract_address(); + assert(self_addr == caller, 'UNAUTHORIZED'); } } } diff --git a/eth_712_account/src/eth_712_utils.cairo b/eth_712_account/src/eth_712_utils.cairo index 669300e..de0b889 100644 --- a/eth_712_account/src/eth_712_utils.cairo +++ b/eth_712_account/src/eth_712_utils.cairo @@ -1,6 +1,7 @@ use core::integer::u256; use core::keccak::compute_keccak_byte_array; use openzeppelin::account::extensions::src9::OutsideExecution; +use starknet::ResourcesBounds; use starknet::account::Call; use starknet::eth_address::EthAddress; use starknet::eth_signature::public_key_point_to_eth_address; @@ -15,9 +16,23 @@ const EIP712_DOMAIN_TYPE_HASH: u256 = const CALL_TYPE_HASH: u256 = 0x7793b9bed3b87c6119fe923f0da4e85e1f97a03272a446514622ee7bd62ad25f_u256; +// keccak256("OutsideExecution(Call[] calls,uint256 caller,uint256 nonce,uint256 +// execute_after,uint256 execute_before)Call(...)") const OUTSIDE_EXECUTION_TYPE_HASH: u256 = 0x57fbef2abe14202f3651b3935a8feddd357b8f83a862e046239d196ec76f281e_u256; +// EIP-712 encodeType hash for TransactionMetadata +// keccak256("TransactionMetadata(uint256 version,uint256 chain_id,uint256[] +// execution_resources,uint256 tip,uint256 nonce)") +const TRANSACTION_METADATA_TYPE_HASH: u256 = + 0x3e1a84b9a25a2ffe216927b61cc91a10921dabd3305985281d0bb9707b0d8310_u256; + +// EIP-712 encodeType hash for Transaction (includes referenced types sorted alphabetically) +// keccak256("Transaction(Call[] calls,TransactionMetadata +// metadata)Call(...)TransactionMetadata(...)") +const TRANSACTION_TYPE_HASH: u256 = + 0x1dc45489b8d4418703686ca441c4ea8ead534ff02815a47b9059490edf3a0c68_u256; + // keccak("2") (version of the EIP-712 domain). const VERSION_HASH: u256 = 0xad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a5_u256; @@ -26,6 +41,29 @@ const VERSION_HASH: u256 = 0xad7c5bef027816a800da1736444fb58a807ef4c9603b7848673 const OWNERSHIP_TRANSFER_MSG_HASH: u256 = 0x3ce976d55131cd0bdd49f20afbded052d8e907dc6034d95cdf117a8fd7752e3c_u256; +// Transaction version validation constants +pub const MIN_TRANSACTION_VERSION: u256 = 3; +pub const QUERY_OFFSET: u256 = 0x100000000000000000000000000000000; + +// ================================ +// Transaction types for __validate__ +// ================================ + +#[derive(Drop)] +pub struct TransactionMetadata { + pub version: felt252, + pub chain_id: felt252, + pub execution_resources: Span, + pub tip: felt252, + pub nonce: felt252, +} + +#[derive(Drop)] +pub struct Transaction { + pub calls: Span, + pub metadata: @TransactionMetadata, +} + /// Adds a felt252 to the byte array (as 32 bytes). fn push_felt(ref res: ByteArray, val: felt252) { push_u256(ref res, val.into()); @@ -87,6 +125,46 @@ pub fn push_outside_execution(ref res: ByteArray, outside_execution: @OutsideExe push_keccak(ref res, @byte_array); } +// ================================ +// Transaction hashing functions +// ================================ + +pub fn push_metadata(ref res: ByteArray, metadata: @TransactionMetadata) { + let mut byte_array: ByteArray = ""; + push_u256(ref byte_array, TRANSACTION_METADATA_TYPE_HASH); + + push_felt(ref byte_array, *metadata.version); + push_felt(ref byte_array, *metadata.chain_id); + push_felt_array(ref byte_array, *metadata.execution_resources); + push_felt(ref byte_array, *metadata.tip); + push_felt(ref byte_array, *metadata.nonce); + + push_keccak(ref res, @byte_array); +} + +pub fn push_transaction(ref res: ByteArray, transaction: @Transaction) { + let mut byte_array: ByteArray = ""; + push_u256(ref byte_array, TRANSACTION_TYPE_HASH); + + push_call_array(ref byte_array, *transaction.calls); + push_metadata(ref byte_array, *transaction.metadata); + + push_keccak(ref res, @byte_array); +} + +pub fn get_transaction_hash(transaction: @Transaction, chain_id: felt252) -> u256 { + let mut byte_array: ByteArray = ""; + + // EIP-191 header. + byte_array.append_byte(0x19); + byte_array.append_byte(0x1); + + push_domain_separator(ref byte_array, chain_id); + push_transaction(ref byte_array, transaction); + + reverse_u256(compute_keccak_byte_array(@byte_array)) +} + pub fn push_domain_separator(ref res: ByteArray, chain_id: felt252) { let mut byte_array: ByteArray = ""; @@ -147,14 +225,57 @@ pub fn extract_signature(signature: Span) -> (Signature, felt252) { } /// Returns `true` if the signature is valid for the given message hash and eth address. -pub fn is_valid_signature(msg_hash: u256, signature: Signature, eth_address: EthAddress) -> bool { +pub fn is_valid_eth_signature( + msg_hash: u256, signature: Signature, eth_address: EthAddress, +) -> bool { recover_eth_address(:msg_hash, :signature) == Some(eth_address) } +/// Extract signature - accepts 5 or 6 felts. +/// 5 felts: [r_high, r_low, s_high, s_low, v] +/// 6 felts: [r_high, r_low, s_high, s_low, v, chain_id] - chain_id ignored +pub fn extract_signature_flexible(signature: Span) -> Signature { + assert(signature.len() == 5 || signature.len() == 6, 'INVALID_SIGNATURE_LENGTH'); + let r_high: u128 = (*signature[0]).try_into().unwrap(); + let r_low: u128 = (*signature[1]).try_into().unwrap(); + let s_high: u128 = (*signature[2]).try_into().unwrap(); + let s_low: u128 = (*signature[3]).try_into().unwrap(); + let v: u128 = (*signature[4]).try_into().unwrap(); + Signature { + r: u256 { low: r_low, high: r_high }, + s: u256 { low: s_low, high: s_high }, + y_parity: v % 2 == 0, + } +} + +/// Converts resource bounds to a span of felt252 for EIP-712 hashing. +pub fn resource_bounds_as_felts(resource_bounds: Span) -> Span { + let mut rb_felts: Array = array![]; + for res in resource_bounds { + rb_felts.append(*res.resource); + rb_felts.append((*res.max_amount).into()); + rb_felts.append((*res.max_price_per_unit).into()); + } + rb_felts.span() +} + +/// Validates that the transaction version is supported (v3 or query v3). +pub fn is_tx_version_valid() -> bool { + let tx_info = starknet::get_tx_info().unbox(); + let tx_version: u256 = tx_info.version.into(); + if tx_version >= QUERY_OFFSET { + tx_version >= QUERY_OFFSET + MIN_TRANSACTION_VERSION + } else { + tx_version >= MIN_TRANSACTION_VERSION + } +} + /// Asserts eth address ownership signature is valid. pub fn assert_valid_owner(eth_address: EthAddress, signature: Signature) { let msg_hash = OWNERSHIP_TRANSFER_MSG_HASH; - assert(is_valid_signature(:msg_hash, :signature, :eth_address), 'INVALID_OWNERSHIP_SIGNATURE'); + assert( + is_valid_eth_signature(:msg_hash, :signature, :eth_address), 'INVALID_OWNERSHIP_SIGNATURE', + ); } fn sn_chain_id_keccak() -> u256 { diff --git a/eth_712_account/src/test.cairo b/eth_712_account/src/test.cairo index a316708..273e5de 100644 --- a/eth_712_account/src/test.cairo +++ b/eth_712_account/src/test.cairo @@ -1,27 +1,35 @@ use eth_712_account::interface::{IAccount712AdminDispatcher, IAccount712AdminDispatcherTrait}; use eth_712_account::test_utils::{ - EXECUTE_AFTER, EXECUTE_BEFORE, MOCK_ERC20_INITIAL_SUPPLY, NONCE_ATOMICITY, NONCE_MULTI_CALL, - NONCE_SINGLE_CALL, NONCE_SPECIFIC_CALLER, SPECIFIC_CALLER, TEST_ETH_ADDRESS, TEST_NONCE, - build_approve_call, build_outside_execution_with_calls, - build_outside_execution_with_specific_caller, build_transfer_call, - declare_register_interfaces_eic, deploy_eth712_account, get_atomicity_test_signature, + APPROVE_AMOUNT, APPROVE_SPENDER, EXECUTE_AFTER, EXECUTE_BEFORE, FIXED_UPGRADE_TARGET_CLASS_HASH, + MOCK_ERC20_INITIAL_SUPPLY, NONCE_ATOMICITY, NONCE_EFO_UPGRADE, NONCE_MULTI_CALL, + NONCE_SINGLE_CALL, NONCE_SPECIFIC_CALLER, PROTOCOL_ADDRESS, SPECIFIC_CALLER, TEST_ETH_ADDRESS, + TEST_NONCE, VALIDATE_NONCE_UPGRADE, VALIDATE_NONCE_WITH_CALLS, assert_upgraded_event, + build_outside_execution_with_calls, build_outside_execution_with_specific_caller, + build_transfer_call, declare_register_interfaces_eic, deploy_eth712_account, deploy_mock_erc20, + get_approve_call, get_atomicity_test_signature, get_efo_upgrade_signature, get_invalid_outside_execution_signature, get_invalid_signature, get_multi_call_signature, get_outside_execution_signature, get_ownership_signature, get_signature_evm_chain_id_2, get_signature_wrong_contract_address, get_signature_wrong_sn_chain_name, get_single_call_approve_signature, get_specific_caller_signature, get_test_outside_execution, - setup_efo_test, setup_efo_test_with_erc20, setup_efo_test_with_timestamp, + get_validate_empty_calls_signature, get_validate_upgrade_signature, + get_validate_with_approve_signature, get_validate_wrong_chain_signature, setup_efo_test, + setup_efo_test_with_erc20, setup_efo_test_with_timestamp, setup_initialized_account, + setup_validate_test, }; use openzeppelin::account::extensions::src9::interface::{ ISRC9_V2Dispatcher, ISRC9_V2DispatcherTrait, ISRC9_V2SafeDispatcher, ISRC9_V2SafeDispatcherTrait, ISRC9_V2_ID, }; -use openzeppelin::account::interface::ISRC6_ID; +use openzeppelin::account::interface::{ISRC6DispatcherTrait, ISRC6_ID}; use openzeppelin::introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use openzeppelin::token::erc20::interface::IERC20DispatcherTrait; -use snforge_std::{EventSpyTrait, load, spy_events}; +use snforge_std::cheatcodes::CheatSpan; +use snforge_std::{ + cheat_caller_address, cheat_nonce, cheat_signature, cheat_transaction_version, load, spy_events, +}; use starknet::EthAddress; use starkware_utils_testing::test_utils::cheat_caller_address_once; -use testing_utils::event_helpers::get_event_by_selector; + // ================================ // initialize tests @@ -102,15 +110,7 @@ fn test_upgrade_from_self_succeeds() { let mut spy = spy_events(); account_contract.upgrade(class_hash, Option::None); - // Verify Upgraded event was emitted - let events = spy.get_events(); - let event_option = get_event_by_selector(events.events.span(), selector!("Upgraded")); - assert!(event_option.is_some(), "Upgraded event not found"); - let (from, event) = event_option.unwrap(); - assert!(*from == account_address, "Event from wrong address"); - assert!(event.data.len() > 0, "Event has no data"); - let class_hash_felt: felt252 = class_hash.into(); - assert!(*event.data.at(0) == class_hash_felt, "Wrong class hash in event"); + assert_upgraded_event(ref spy, account_address, class_hash.into()); } #[test] @@ -137,6 +137,143 @@ fn test_upgrade_with_eic() { assert!(src5.supports_interface(custom_interface_id), "Custom interface not registered"); } +#[test] +fn test_upgrade_via_efo_succeeds() { + let src9 = setup_efo_test(); + let account_address = src9.contract_address; + + // Declare the upgrade target so replace_class_syscall can find the class. + declare_register_interfaces_eic(); + + // Calldata: upgrade(target, Option::None). + // In Cairo, Option::None has discriminant 1 (Some = 0, None = 1). + let upgrade_call = starknet::account::Call { + to: account_address, + selector: selector!("upgrade"), + calldata: array![FIXED_UPGRADE_TARGET_CLASS_HASH, 1].span() // 1 = Option::None + }; + let outside_execution = build_outside_execution_with_calls( + array![upgrade_call].span(), NONCE_EFO_UPGRADE, + ); + + let mut spy = spy_events(); + src9.execute_from_outside_v2(outside_execution, get_efo_upgrade_signature().span()); + + assert_upgraded_event(ref spy, account_address, FIXED_UPGRADE_TARGET_CLASS_HASH); +} + +#[test] +fn test_upgrade_via_real_tx_succeeds() { + let (account, account_address) = setup_validate_test(); + + // Declare the upgrade target so replace_class_syscall can find the class. + declare_register_interfaces_eic(); + + cheat_nonce(account_address, VALIDATE_NONCE_UPGRADE, CheatSpan::Indefinite); + cheat_signature( + account_address, get_validate_upgrade_signature().span(), CheatSpan::Indefinite, + ); + + // Calldata: upgrade(target, Option::None). + // In Cairo, Option::None has discriminant 1 (Some = 0, None = 1). + let upgrade_call = starknet::account::Call { + to: account_address, + selector: selector!("upgrade"), + calldata: array![FIXED_UPGRADE_TARGET_CLASS_HASH, 1].span() // 1 = Option::None + }; + + // Validate the signed upgrade transaction + let result = account.__validate__(array![upgrade_call]); + assert!(result == starknet::VALIDATED, "Expected VALIDATED for upgrade tx"); + + // Execute via protocol (zero address caller). + // Use once-variant so the cheat doesn't also apply to the re-entrant upgrade call, + // which expects account_address as its caller (assert_only_self). + cheat_caller_address_once( + contract_address: account_address, caller_address: PROTOCOL_ADDRESS.try_into().unwrap(), + ); + let mut spy = spy_events(); + account.__execute__(array![upgrade_call]); + + assert_upgraded_event(ref spy, account_address, FIXED_UPGRADE_TARGET_CLASS_HASH); +} + +// ================================ +// is_valid_signature tests (ISRC6) +// ================================ + +#[test] +fn test_is_valid_signature_truncated_hash_returns_invalid() { + let account = setup_initialized_account(); + + // Use the ownership message hash (truncated to fit felt252) and signature (5-felt format) + // Original: 0x3ce976d55131cd0bdd49f20afbded052d8e907dc6034d95cdf117a8fd7752e3c + // Only lower 251 bits fit in felt252 + let msg_hash: felt252 = 0x3ce976d55131cd0bdd49f20afbded052d8e907dc6034d95cdf117a8fd7752e; + let signature = array![ + 0xe994c0e202b390bddaffacf04bdea826, // r_high + 0xda47d5165a4577a024d18b8d61c5fe53, // r_low + 0x2e117624d8cc474d0641c3168944eb67, // s_high + 0x46dd0af587023ccad738aa0a82b05f98, // s_low + 28 // v + ]; + + let result = account.is_valid_signature(msg_hash, signature); + assert!(result == 0, "Signature invalid for truncated hash"); +} + +#[test] +fn test_is_valid_signature_with_chain_id_valid() { + let account = setup_initialized_account(); + + // Known good felt252 hash + signature for TEST_ETH_ADDRESS. + // Vector verified against on-chain is_valid_signature. + let msg_hash: felt252 = 0x9ef76cafa86fee7f1360c5df0868875116d63c2a5eaad26bd23a9a045321b; + let signature = array![ + 0x93fd5f812fbbe930977ca8ac1d7c1a1b, // r_high + 0x2602e266add21880b89061774eab3f55, // r_low + 0x1fb7aa49412cbcd146b1fcc1834dcbba, // s_high + 0xefc75ce10df130b47fd99ba747cae707, // s_low + 27, // v + 1 // chain_id (ignored for is_valid_signature) + ]; + + let result = account.is_valid_signature(msg_hash, signature); + assert!(result == starknet::VALIDATED, "Expected VALIDATED for known-good signature"); +} + +#[test] +fn test_is_valid_signature_invalid() { + let account = setup_initialized_account(); + + // Wrong hash + let msg_hash: felt252 = 0x1234567890; + let signature = array![ + 0xe994c0e202b390bddaffacf04bdea826, // r_high + 0xda47d5165a4577a024d18b8d61c5fe53, // r_low + 0x2e117624d8cc474d0641c3168944eb67, // s_high + 0x46dd0af587023ccad738aa0a82b05f98, // s_low + 28 // v + ]; + + let result = account.is_valid_signature(msg_hash, signature); + assert!(result == 0, "Signature should be invalid for wrong hash"); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE_LENGTH')] +fn test_is_valid_signature_too_short_reverts() { + let account = setup_initialized_account(); + account.is_valid_signature(0x1234, array![0x1, 0x2, 0x3, 0x4]); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE_LENGTH')] +fn test_is_valid_signature_too_long_reverts() { + let account = setup_initialized_account(); + account.is_valid_signature(0x1234, array![0x1, 0x2, 0x3, 0x4, 28, 1, 0x99]); +} + // ================================ // execute_from_outside_v2 tests // ================================ @@ -198,6 +335,24 @@ fn test_execute_from_outside_invalid_signature_reverts() { src9.execute_from_outside_v2(outside_execution, invalid_signature.span()); } +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE_LENGTH')] +fn test_execute_from_outside_short_signature_reverts() { + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); + + src9.execute_from_outside_v2(outside_execution, array![0x1, 0x2, 0x3, 0x4, 28].span()); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE_LENGTH')] +fn test_execute_from_outside_long_signature_reverts() { + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); + + src9.execute_from_outside_v2(outside_execution, array![0x1, 0x2, 0x3, 0x4, 28, 1, 0x99].span()); +} + #[test] fn test_is_valid_outside_execution_nonce() { let (account_address, _) = deploy_eth712_account(); @@ -230,7 +385,7 @@ fn test_execute_from_outside_different_evm_chain_id_succeeds() { // Signature generated with EVM chain ID 2 - valid because contract uses chain_id from signature let chain_id_2_signature = get_signature_evm_chain_id_2(); - let _results = src9.execute_from_outside_v2(outside_execution, chain_id_2_signature.span()); + src9.execute_from_outside_v2(outside_execution, chain_id_2_signature.span()); } #[test] @@ -276,11 +431,7 @@ fn test_execute_from_outside_wrong_contract_address_reverts() { src9.execute_from_outside_v2(outside_execution, wrong_contract_signature.span()); } -/// Test helper: arbitrary address for spender/recipient in tests. -fn TEST_SPENDER() -> starknet::ContractAddress { - 0x1234_felt252.try_into().unwrap() -} - +/// Test helper: arbitrary address for recipient in tests. fn TEST_RECIPIENT() -> starknet::ContractAddress { 0x5678_felt252.try_into().unwrap() } @@ -291,44 +442,39 @@ fn TEST_RECIPIENT() -> starknet::ContractAddress { #[test] fn test_execute_from_outside_single_call_succeeds() { - let (src9, token_address, token) = setup_efo_test_with_erc20(); + let (src9, _, token) = setup_efo_test_with_erc20(); - // Build approve call: account approves spender for 500 tokens - let approve_amount: u256 = 500_u256; - let approve_call = build_approve_call(token_address, TEST_SPENDER(), approve_amount); + let approve_call = get_approve_call(); let outside_execution = build_outside_execution_with_calls( array![approve_call].span(), NONCE_SINGLE_CALL, ); src9.execute_from_outside_v2(outside_execution, get_single_call_approve_signature().span()); - // Verify the approval was set - let allowance = token.allowance(src9.contract_address, TEST_SPENDER()); - assert!(allowance == approve_amount, "Approval not set correctly"); + let spender: starknet::ContractAddress = APPROVE_SPENDER.try_into().unwrap(); + let allowance = token.allowance(src9.contract_address, spender); + assert!(allowance == APPROVE_AMOUNT, "Approval not set correctly"); } #[test] fn test_execute_from_outside_multi_call_succeeds() { let (src9, token_address, token) = setup_efo_test_with_erc20(); - // Build two calls: approve 500 + transfer 100 - let approve_amount: u256 = 500_u256; + let approve_call = get_approve_call(); let transfer_amount: u256 = 100_u256; - let approve_call = build_approve_call(token_address, TEST_SPENDER(), approve_amount); let transfer_call = build_transfer_call(token_address, TEST_RECIPIENT(), transfer_amount); let outside_execution = build_outside_execution_with_calls( array![approve_call, transfer_call].span(), NONCE_MULTI_CALL, ); - // Record initial balances let account_address = src9.contract_address; let initial_account_balance = token.balance_of(account_address); let initial_recipient_balance = token.balance_of(TEST_RECIPIENT()); src9.execute_from_outside_v2(outside_execution, get_multi_call_signature().span()); - // Verify both side effects - assert!(token.allowance(account_address, TEST_SPENDER()) == approve_amount, "Approval failed"); + let spender: starknet::ContractAddress = APPROVE_SPENDER.try_into().unwrap(); + assert!(token.allowance(account_address, spender) == APPROVE_AMOUNT, "Approval failed"); assert!( token.balance_of(account_address) == initial_account_balance - transfer_amount, "Account balance not reduced", @@ -348,10 +494,8 @@ fn test_execute_from_outside_multi_call_succeeds() { fn test_execute_from_outside_multi_call_failure_propagates() { let (src9, token_address, _) = setup_efo_test_with_erc20(); - // Build two calls: approve 500 (would succeed) + transfer more than balance (will fail) - let approve_amount: u256 = 500_u256; + let approve_call = get_approve_call(); let transfer_amount: u256 = MOCK_ERC20_INITIAL_SUPPLY + 1_u256; - let approve_call = build_approve_call(token_address, TEST_SPENDER(), approve_amount); let transfer_call = build_transfer_call(token_address, TEST_RECIPIENT(), transfer_amount); let outside_execution = build_outside_execution_with_calls( array![approve_call, transfer_call].span(), NONCE_ATOMICITY, @@ -406,3 +550,150 @@ fn test_execute_from_outside_wrong_caller_reverts() { // Should fail because caller doesn't match src9.execute_from_outside_v2(outside_execution, get_specific_caller_signature().span()); } + +// ================================ +// __validate__ tests +// ================================ + +#[test] +fn test_validate_success() { + let (account, account_address) = setup_validate_test(); + let sig = get_validate_empty_calls_signature(); + cheat_signature(account_address, sig.span(), CheatSpan::Indefinite); + + let result = account.__validate__(array![]); + assert!(result == starknet::VALIDATED, "Expected VALIDATED"); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_validate_invalid_signature_reverts() { + let (account, account_address) = setup_validate_test(); + let garbage_sig = array![0x1, 0x2, 0x3, 0x4, 28, 1]; + cheat_signature(account_address, garbage_sig.span(), CheatSpan::Indefinite); + + account.__validate__(array![]); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_validate_wrong_chain_id_reverts() { + let (account, account_address) = setup_validate_test(); + // Signature was generated with SN_SEPOLIA domain, but chain_id is cheated to SN_MAIN + let sig = get_validate_wrong_chain_signature(); + cheat_signature(account_address, sig.span(), CheatSpan::Indefinite); + + account.__validate__(array![]); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_validate_wrong_nonce_reverts() { + let (account, account_address) = setup_validate_test(); + // Signature was generated with nonce=0, but we cheat nonce to 999 + let sig = get_validate_empty_calls_signature(); + cheat_signature(account_address, sig.span(), CheatSpan::Indefinite); + cheat_nonce(account_address, 999, CheatSpan::Indefinite); + + account.__validate__(array![]); +} + +#[test] +fn test_validate_with_approve_call() { + let (account, account_address) = setup_validate_test(); + let sig = get_validate_with_approve_signature(); + cheat_signature(account_address, sig.span(), CheatSpan::Indefinite); + cheat_nonce(account_address, VALIDATE_NONCE_WITH_CALLS, CheatSpan::Indefinite); + + let approve_call = get_approve_call(); + + let result = account.__validate__(array![approve_call]); + assert!(result == starknet::VALIDATED, "Expected VALIDATED with approve call"); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE_LENGTH')] +fn test_validate_short_signature_reverts() { + let (account, account_address) = setup_validate_test(); + cheat_signature(account_address, array![0x1, 0x2, 0x3, 0x4, 28].span(), CheatSpan::Indefinite); + + account.__validate__(array![]); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE_LENGTH')] +fn test_validate_long_signature_reverts() { + let (account, account_address) = setup_validate_test(); + cheat_signature( + account_address, array![0x1, 0x2, 0x3, 0x4, 28, 1, 0x99].span(), CheatSpan::Indefinite, + ); + + account.__validate__(array![]); +} + +// ================================ +// __execute__ tests +// ================================ + +#[test] +fn test_execute_from_protocol_succeeds() { + let (account, account_address) = setup_validate_test(); + cheat_caller_address( + account_address, PROTOCOL_ADDRESS.try_into().unwrap(), CheatSpan::Indefinite, + ); + cheat_transaction_version(account_address, 3, CheatSpan::Indefinite); + + account.__execute__(array![]); +} + +#[test] +#[should_panic(expected: 'INVALID_CALLER')] +fn test_execute_invalid_caller_reverts() { + let (account, account_address) = setup_validate_test(); + let external_caller: starknet::ContractAddress = 0xDEAD_felt252.try_into().unwrap(); + cheat_caller_address(account_address, external_caller, CheatSpan::Indefinite); + cheat_transaction_version(account_address, 3, CheatSpan::Indefinite); + + account.__execute__(array![]); +} + +#[test] +#[should_panic(expected: 'INVALID_CALLER')] +fn test_execute_from_self_reverts() { + let (account, account_address) = setup_validate_test(); + // Self-calls are no longer allowed (protocol-only, matching OZ) + cheat_caller_address(account_address, account_address, CheatSpan::Indefinite); + cheat_transaction_version(account_address, 3, CheatSpan::Indefinite); + + account.__execute__(array![]); +} + +#[test] +#[should_panic(expected: 'INVALID_TX_VERSION')] +fn test_execute_invalid_tx_version_reverts() { + let (account, account_address) = setup_validate_test(); + cheat_caller_address( + account_address, PROTOCOL_ADDRESS.try_into().unwrap(), CheatSpan::Indefinite, + ); + cheat_transaction_version(account_address, 1, CheatSpan::Indefinite); + + account.__execute__(array![]); +} + +#[test] +fn test_execute_with_erc20_call() { + let (account, account_address) = setup_validate_test(); + let (_, token) = deploy_mock_erc20(account_address); + + cheat_caller_address( + account_address, PROTOCOL_ADDRESS.try_into().unwrap(), CheatSpan::Indefinite, + ); + cheat_transaction_version(account_address, 3, CheatSpan::Indefinite); + + let approve_call = get_approve_call(); + account.__execute__(array![approve_call]); + + let spender: starknet::ContractAddress = APPROVE_SPENDER.try_into().unwrap(); + let allowance = token.allowance(account_address, spender); + assert!(allowance == APPROVE_AMOUNT, "Approval not set correctly"); +} diff --git a/eth_712_account/src/test_utils.cairo b/eth_712_account/src/test_utils.cairo index f886a73..51c6d80 100644 --- a/eth_712_account/src/test_utils.cairo +++ b/eth_712_account/src/test_utils.cairo @@ -2,12 +2,40 @@ use core::serde::Serde; use eth_712_account::interface::{IAccount712AdminDispatcher, IAccount712AdminDispatcherTrait}; use openzeppelin::account::extensions::src9::OutsideExecution; use openzeppelin::account::extensions::src9::interface::ISRC9_V2Dispatcher; +use openzeppelin::account::interface::ISRC6Dispatcher; use openzeppelin::token::erc20::interface::IERC20Dispatcher; use snforge_std::cheatcodes::CheatSpan; -use snforge_std::{ContractClassTrait, DeclareResultTrait, cheat_block_timestamp, cheat_chain_id}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, EventSpyTrait, cheat_block_timestamp, cheat_chain_id, + cheat_nonce, cheat_resource_bounds, cheat_tip, cheat_transaction_version, +}; use starknet::account::Call; use starknet::secp256_trait::Signature; -use starknet::{ClassHash, ContractAddress, EthAddress, SyscallResultTrait}; +use starknet::{ClassHash, ContractAddress, EthAddress, ResourcesBounds, SyscallResultTrait}; +use testing_utils::event_helpers::get_event_by_selector; + +// ================================ +// Fixed addresses for deterministic signatures +// ================================ +// These addresses are used with deploy_at to ensure signatures remain valid +// regardless of contract code changes. + +/// Fixed account contract address for EFO tests. +/// All pre-computed signatures use this address in the domain separator. +pub const FIXED_ACCOUNT_ADDRESS: felt252 = 0x07120acc07120acc07120acc07120acc; + +/// Fixed ERC20 mock address for EFO tests with calls. +/// All pre-computed signatures involving ERC20 calls use this address. +pub const FIXED_ERC20_ADDRESS: felt252 = 0x0e2c200e2c200e2c200e2c200e2c2000; + +/// Protocol address (zero) used as caller for __execute__. +pub const PROTOCOL_ADDRESS: felt252 = 0; + +/// Class hash of the RegisterInterfacesEIC contract, used as upgrade target in signing tests. +/// Must match FIXED_UPGRADE_TARGET_CLASS_HASH in generate_test_signatures.py. +/// Re-run generate_test_signatures.py after recompiling if this contract changes. +pub const FIXED_UPGRADE_TARGET_CLASS_HASH: felt252 = + 0x4775e80641f54baffdce08e82de59d491bc9cbef8d674c7f76f94c2b80b1035; /// Test Ethereum address corresponding to private key: /// 0xa6d86467b6ec9e161649b27edfd8519e75a2e1cf5f4c309c628706e6999780e8 @@ -16,11 +44,6 @@ pub fn TEST_ETH_ADDRESS() -> EthAddress { 0xbF60187c5dFfA627249f1C3000A4168dbB9D7A1A_felt252.try_into().unwrap() } -/// A different Ethereum address for testing invalid cases. -pub fn WRONG_ETH_ADDRESS() -> EthAddress { - 0x1234567890123456789012345678901234567890_felt252.try_into().unwrap() -} - /// Returns the ownership signature for TEST_ETH_ADDRESS. /// This signature was generated by signing the OWNERSHIP_TRANSFER_MSG_HASH /// (0x3ce976d55131cd0bdd49f20afbded052d8e907dc6034d95cdf117a8fd7752e3c) @@ -50,13 +73,14 @@ pub fn get_invalid_signature() -> Signature { } } -/// Declare and deploy the StarknetEth712Account contract. +/// Declare and deploy the StarknetEth712Account contract at FIXED_ACCOUNT_ADDRESS. /// Returns the contract address and the class hash. pub fn deploy_eth712_account() -> (ContractAddress, ClassHash) { let contract_class = snforge_std::declare("StarknetEth712Account") .unwrap_syscall() .contract_class(); - let (contract_address, _) = contract_class.deploy(@array![]).unwrap_syscall(); + let fixed_address: ContractAddress = FIXED_ACCOUNT_ADDRESS.try_into().unwrap(); + let (contract_address, _) = contract_class.deploy_at(@array![], fixed_address).unwrap_syscall(); (contract_address, *contract_class.class_hash) } @@ -65,6 +89,27 @@ pub fn declare_register_interfaces_eic() -> ClassHash { *snforge_std::declare("RegisterInterfacesEIC").unwrap_syscall().contract_class().class_hash } +/// Deploy, initialize, and return an ISRC6Dispatcher for the account. +pub fn setup_initialized_account() -> ISRC6Dispatcher { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + ISRC6Dispatcher { contract_address: account_address } +} + +/// Assert that an "Upgraded" event was emitted from the expected address with the expected class +/// hash. +pub fn assert_upgraded_event( + ref spy: snforge_std::EventSpy, account_address: ContractAddress, expected_class_hash: felt252, +) { + let events = spy.get_events(); + let event_option = get_event_by_selector(events.events.span(), selector!("Upgraded")); + assert!(event_option.is_some(), "Upgraded event not found"); + let (from, event) = event_option.unwrap(); + assert!(*from == account_address, "Event from wrong address"); + assert!(*event.data.at(0) == expected_class_hash, "Wrong class hash in event"); +} + // ================================ // Execute-from-outside (EFO) test setup helpers // ================================ @@ -94,12 +139,44 @@ pub fn setup_efo_test_with_erc20() -> (ISRC9_V2Dispatcher, ContractAddress, IERC (src9, token_address, token) } +// ================================ +// __validate__ / __execute__ test setup +// ================================ + +/// Fixed resource bounds for __validate__ tests. +/// Must match VALIDATE_RESOURCE_BOUNDS in generate_test_signatures.py. +pub fn validate_resource_bounds() -> Span { + array![ + ResourcesBounds { resource: 'L1_GAS', max_amount: 100, max_price_per_unit: 1000 }, + ResourcesBounds { resource: 'L2_GAS', max_amount: 200, max_price_per_unit: 2000 }, + ResourcesBounds { resource: 'L1_DATA', max_amount: 300, max_price_per_unit: 3000 }, + ] + .span() +} + +/// Setup an initialized account with all tx_info fields cheated for __validate__/__execute__ tests. +/// Cheats: chain_id, version, nonce, resource_bounds, tip. +/// Signature must be cheated separately per test. +/// Returns (src6_dispatcher, account_address). +pub fn setup_validate_test() -> (ISRC6Dispatcher, ContractAddress) { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + cheat_transaction_version(account_address, 3, CheatSpan::Indefinite); + cheat_nonce(account_address, VALIDATE_NONCE_EMPTY, CheatSpan::Indefinite); + cheat_resource_bounds(account_address, validate_resource_bounds(), CheatSpan::Indefinite); + cheat_tip(account_address, 0, CheatSpan::Indefinite); + + (ISRC6Dispatcher { contract_address: account_address }, account_address) +} + // ================================ // OutsideExecution test fixtures // ================================ /// Fixed timestamps for testing execute_from_outside_v2. -/// Use warp() to set block timestamp to TEST_TIMESTAMP. pub const EXECUTE_AFTER: u64 = 1000; pub const EXECUTE_BEFORE: u64 = 3000; pub const TEST_TIMESTAMP: u64 = 2000; @@ -120,26 +197,6 @@ pub fn get_test_outside_execution() -> OutsideExecution { } } -/// Returns the pre-computed signature for the test OutsideExecution. -/// This signature was generated using the Python script with: -/// - Contract address: 0x651b6cc1595bcd7edddc42163b57e066956b8fba487dd781cd7e4b3a671ffe4 -/// - ETH address: 0xbF60187c5dFfA627249f1C3000A4168dbB9D7A1A -/// - Domain: SN_MAIN, version 2, chainId 1 -/// - OutsideExecution: caller=ANY_CALLER, nonce=1, execute_after=1000, execute_before=3000, -/// calls=[] -/// -/// Format: [r_high, r_low, s_high, s_low, v, chain_id] -pub fn get_outside_execution_signature() -> Array { - array![ - 0xa97889fc4116632d7b0cbc136257ebdf, // r_high - 0xf2e7384ae672ce351da295c704e0265a, // r_low - 0x4b52469cfdda3614944d145122ee96ab, // s_high - 0xca19b48f5b51afacc161928fe72cdf69, // s_low - 28, // v - 1 // chain_id (EVM=Ethereum) - ] -} - /// Returns an invalid signature for testing signature validation. pub fn get_invalid_outside_execution_signature() -> Array { array![ @@ -152,44 +209,6 @@ pub fn get_invalid_outside_execution_signature() -> Array { ] } -/// Returns a signature with Chain ID=2 (instead of the usual 1). -pub fn get_signature_evm_chain_id_2() -> Array { - array![ - 0xe2aebdd44a9a03902eedb97065c2c279, // r_high - 0xfcfd2f2325c7a0bc4af74252f84b10b, // r_low - 0x73394d79eeda1d151b2facdcb2c9bd91, // s_high - 0x69b65ab69e218022a936846ea383df37, // s_low - 27, // v - 2 // chain_id: Not Ethereum - ] -} - -/// Returns a signature with WRONG Starknet chain name (SN_SEPOLIA instead of SN_MAIN). -/// This should fail signature validation because the domain separator is different. -pub fn get_signature_wrong_sn_chain_name() -> Array { - array![ - 0x4ac352e68f296423e4f628aa8f632a9b, // r_high - 0x48b6738df23e337034763d57868522bf, // r_low - 0x3a03669270b5ad9fa6817c7079724802, // s_high - 0x8db208ab4e2089aa7dfaa5eee2864132, // s_low - 28, // v - 1 // chain_id - ] -} - -/// Returns a signature with WRONG target contract address. -/// This should fail signature validation because the domain separator is different. -pub fn get_signature_wrong_contract_address() -> Array { - array![ - 0x40b3c6efc80e9325f49b1a5c4a38f21c, // r_high - 0xd42f46f2833f2388054df879ee09475e, // r_low - 0x239eec5938ebaa0f4ce69664f17727ac, // s_high - 0xd75388e53f1099eaae03b1ac49d0227a, // s_low - 28, // v - 1 // chain_id - ] -} - // ================================ // ERC20 Mock helpers for execute_from_outside tests // ================================ @@ -197,48 +216,45 @@ pub fn get_signature_wrong_contract_address() -> Array { /// Initial supply for mock ERC20 token. pub const MOCK_ERC20_INITIAL_SUPPLY: u256 = 1000_u256; -/// Deploy a mock ERC20 token with initial supply minted to the specified owner. +/// Deploy a mock ERC20 token at FIXED_ERC20_ADDRESS with initial supply minted to the owner. /// Returns (token_address, dispatcher). pub fn deploy_mock_erc20(owner: ContractAddress) -> (ContractAddress, IERC20Dispatcher) { let contract_class = snforge_std::declare("DualCaseERC20Mock") .unwrap_syscall() .contract_class(); - // Constructor args: name (ByteArray), symbol (ByteArray), decimals (u8), initial_supply (u256), - // recipient (ContractAddress) + // Constructor: (name, symbol, decimals, initial_supply, recipient) let mut calldata: Array = array![]; - - // name: ByteArray - serialize properly let name: ByteArray = "MockToken"; name.serialize(ref calldata); - - // symbol: ByteArray - serialize properly let symbol: ByteArray = "MTK"; symbol.serialize(ref calldata); - - // decimals: u8 let decimals: u8 = 18; decimals.serialize(ref calldata); - - // initial_supply: u256 MOCK_ERC20_INITIAL_SUPPLY.serialize(ref calldata); - - // recipient: ContractAddress owner.serialize(ref calldata); - let (token_address, _) = contract_class.deploy(@calldata).unwrap_syscall(); + let fixed_address: ContractAddress = FIXED_ERC20_ADDRESS.try_into().unwrap(); + let (token_address, _) = contract_class.deploy_at(@calldata, fixed_address).unwrap_syscall(); let dispatcher = IERC20Dispatcher { contract_address: token_address }; (token_address, dispatcher) } -/// Build an approve Call for ERC20. -pub fn build_approve_call( - token_address: ContractAddress, spender: ContractAddress, amount: u256, -) -> Call { +/// Fixed spender address used in pre-computed approve call signatures. +pub const APPROVE_SPENDER: felt252 = 0x1234; + +/// Fixed approve amount used in pre-computed approve call signatures. +pub const APPROVE_AMOUNT: u256 = 500_u256; + +/// Returns the fixture approve Call matching pre-computed signatures. +pub fn get_approve_call() -> Call { + let token_address: ContractAddress = FIXED_ERC20_ADDRESS.try_into().unwrap(); + let spender: ContractAddress = APPROVE_SPENDER.try_into().unwrap(); Call { to: token_address, selector: selector!("approve"), - calldata: array![spender.into(), amount.low.into(), amount.high.into()].span(), + calldata: array![spender.into(), APPROVE_AMOUNT.low.into(), APPROVE_AMOUNT.high.into()] + .span(), } } @@ -253,11 +269,17 @@ pub fn build_transfer_call( } } -/// Nonces for different test scenarios (to avoid conflicts). +/// EFO nonces for different test scenarios (to avoid conflicts). pub const NONCE_SINGLE_CALL: felt252 = 100; pub const NONCE_MULTI_CALL: felt252 = 101; pub const NONCE_ATOMICITY: felt252 = 102; pub const NONCE_SPECIFIC_CALLER: felt252 = 103; +pub const NONCE_EFO_UPGRADE: felt252 = 200; + +/// __validate__ nonces matching generate_test_signatures.py. +pub const VALIDATE_NONCE_EMPTY: felt252 = 0; +pub const VALIDATE_NONCE_WITH_CALLS: felt252 = 1; +pub const VALIDATE_NONCE_UPGRADE: felt252 = 2; /// Specific caller address for testing non-ANY_CALLER scenarios. pub const SPECIFIC_CALLER: felt252 = 0xCAFE; @@ -266,10 +288,10 @@ pub const SPECIFIC_CALLER: felt252 = 0xCAFE; pub fn build_outside_execution_with_calls(calls: Span, nonce: felt252) -> OutsideExecution { OutsideExecution { caller: ANY_CALLER.try_into().unwrap(), - nonce: nonce, + nonce, execute_after: EXECUTE_AFTER, execute_before: EXECUTE_BEFORE, - calls: calls, + calls, } } @@ -278,74 +300,119 @@ pub fn build_outside_execution_with_specific_caller( nonce: felt252, caller: ContractAddress, ) -> OutsideExecution { OutsideExecution { - caller: caller, - nonce: nonce, + caller, + nonce, execute_after: EXECUTE_AFTER, execute_before: EXECUTE_BEFORE, calls: array![].span(), } } -// ================================ -// Signatures for execute_from_outside tests with calls -// ================================ -// NOTE: These signatures are generated by scripts/generate_test_signatures.py -// and must be regenerated if any test parameters change. -// The signatures depend on: contract_address, token_address, call parameters, nonce. -// Pre-computed for: -// - Contract: 0x651b6cc1595bcd7edddc42163b57e066956b8fba487dd781cd7e4b3a671ffe4 -// - Token: 0x405ea0439568d265140400aa7b31e896604406bdfa7e73e18dec06303c31c6c -// - Spender: 0x1234, Recipient: 0x5678 - -/// Signature for single approve call test. -/// OutsideExecution with nonce=100, approve 500 tokens to spender. +// GENERATED-SIGNATURES-START (by scripts/generate_test_signatures.py -- do not edit manually) + +/// EFO signature: empty calls, nonce=1, chain_id=1. +pub fn get_outside_execution_signature() -> Array { + array![ + 0x7ffb66a7163f54ab83a435079d74198d, 0x5ceb653460c57bda62685e60d4b67dc9, + 0x7c07a7689645c4ec1775cd794e6a6bdc, 0xfaecbd63a4629b6e3d43e88568000326, 27, 1, + ] +} + +/// EFO signature: empty calls, nonce=1, chain_id=2. +pub fn get_signature_evm_chain_id_2() -> Array { + array![ + 0x18a89b5013e9920a4a6a7f68a96e7ae4, 0xaedf914a0262920a3587429fbe9fc6a1, + 0x71d90cd8ba933785e1f9310f75527dc4, 0x4af653a5e6924e30858c257ad6dc36bd, 28, 2, + ] +} + +/// EFO signature signed with SN_SEPOLIA domain (fails against SN_MAIN). +pub fn get_signature_wrong_sn_chain_name() -> Array { + array![ + 0x17ffed4d61c7339e41abad0f195e514f, 0x12fc8d4f5d27d5e2465955842e13c655, + 0x68f5bb339084078d1a34bbed9c5a3c52, 0xaf67088632eee196f68414a550359178, 28, 1, + ] +} + +/// EFO signature signed with wrong contract address (domain mismatch). +pub fn get_signature_wrong_contract_address() -> Array { + array![ + 0x060836bf3cbe32460860af643ccd889d, 0x31673c0a6f4686d5cd41c9003f390a81, + 0x1d39adf7397f46f16ca26ee7979844eb, 0xd88fdcb5a1f5602ad4d95c8cf0ada12a, 28, 1, + ] +} + +/// EFO signature: approve(0x1234, 500), nonce=100. pub fn get_single_call_approve_signature() -> Array { array![ - 0x1df31ee91675558108ce242888ccbf09, // r_high - 0xce39f1955774fd02a7c93cb9a7ccf33b, // r_low - 0x1b35465d87708c6d1c70d216e91b6a79, // s_high - 0x916b0be291ada83ecea84291854f2e98, // s_low - 27, // v - 1 // chain_id (EVM) + 0x964d17d195fc47aba6702c78cbb8efc2, 0x9f154a7cb4f786c87247bb20aaef2800, + 0x4fb1263e4d824bf8886d1809927e10b3, 0x0630802ef735ec030b55521174002356, 27, 1, ] } -/// Signature for multi-call test (approve 500 + transfer 100). -/// OutsideExecution with nonce=101, two calls. +/// EFO signature: approve(500) + transfer(100), nonce=101. pub fn get_multi_call_signature() -> Array { array![ - 0xe823335915d3f9c1a8cf05182f4d5366, // r_high - 0xa83e96990bab81e018fd27aa284c0ad0, // r_low - 0x7c4d2c87ace56a14eb444063a5bdb343, // s_high - 0xf23b9d84fc44d9a10fbb0abb7cfa2bad, // s_low - 28, // v - 1 // chain_id (EVM) + 0xb914302a1acb85e3f6b155625fea3dad, 0x9e0069305c9c2cc94ec6180529e265ce, + 0x2fe4565842cb96b600ff4c58e45b55e6, 0xd55a87fddd1cc8e553dca9a7456d465e, 27, 1, ] } -/// Signature for atomicity test (approve 500 + transfer 1001 - fails). -/// OutsideExecution with nonce=102, second call will fail due to insufficient balance. +/// EFO signature: approve(500) + transfer(1001, fails), nonce=102. pub fn get_atomicity_test_signature() -> Array { array![ - 0xd0cbbfc6638e59a5e3b576ae9b2305dd, // r_high - 0xbabcee977ce0c6b0ee9ece7f944dbce3, // r_low - 0x6f27c710ab23db5cf1e101e87602d5fd, // s_high - 0x9170f7129929d19c9a6234d0797ca300, // s_low - 28, // v - 1 // chain_id (EVM) + 0x1609bcddfc92674937de1c281392b0ea, 0xb96e843d8911d3e009ce55aecdca7ab4, + 0x110742199fc56f913d2eeab31f8c5251, 0x53c98399effa51de52da5a029b57e578, 28, 1, ] } -/// Signature for specific caller test. -/// OutsideExecution with nonce=103, caller=0xCAFE, empty calls. -/// Used for testing non-ANY_CALLER scenarios. +/// EFO signature: specific caller=0xCAFE, nonce=103, empty calls. pub fn get_specific_caller_signature() -> Array { array![ - 0x661b512c1158c255c61c5f0214f167c7, // r_high - 0x961538890df420779799bd7a8d236e06, // r_low - 0x3fbcc975607ffe4640fc6c175ee5cdae, // s_high - 0x5fc913ba9a2de3bbe95914339a7a8666, // s_low - 27, // v - 1 // chain_id (EVM) + 0xa9855b38d21f9f8aea399f9a1d187319, 0xc8f9e2b75f011d953bb7206bd62fd0c6, + 0x640bd21aa3ccccfa412427e8a0ceeed3, 0xee65414d86523ac9b8772e99468d7e80, 27, 1, + ] +} + +/// __validate__ signature: empty calls, nonce=0. +pub fn get_validate_empty_calls_signature() -> Array { + array![ + 0x0c7ab95484df32ee1f457501214b5c51, 0x0fd461a1c92f777d97545e640f893731, + 0x78afd3e69cdf4b6c57b49b8fd719ff0e, 0x24553f8f1b1cdb5121ef8641a2dbcab5, 28, 1, ] } + +/// __validate__ signature: approve(0x1234, 500), nonce=1. +pub fn get_validate_with_approve_signature() -> Array { + array![ + 0xfdf4a332c8f6bded4b9670e59d8d90e6, 0xb1c10fd29b606f3522c62fc237d5ec1f, + 0x7a4518381cf3240b4b96169848677726, 0xf642df7a6d69205f1f390f6205adab44, 27, 1, + ] +} + +/// __validate__ signature signed with SN_SEPOLIA domain (fails against SN_MAIN). +pub fn get_validate_wrong_chain_signature() -> Array { + array![ + 0x455a3be29526aebe2731210b357f666b, 0x1ca8edb5a5e21e7f16f9b2d663fe0141, + 0x629a920e9971312499308cbadf8d0d25, 0xb406cd3e690bead8d1923dfd93a5a466, 28, 1, + ] +} + +/// EFO signature: upgrade(FIXED_UPGRADE_TARGET_CLASS_HASH, None), nonce=200. +pub fn get_efo_upgrade_signature() -> Array { + array![ + 0xa5bf0961e796aa50e1c3e7fea52d8f85, 0x5ef33aa9e631ac4b43cadc46ce8f0ec4, + 0x6b48aa24b15cf9f0d7e8bdd0d880aa61, 0x5e9a9cbaa86bca675b873ca30aafe49f, 28, 1, + ] +} + +/// __validate__ signature: upgrade(FIXED_UPGRADE_TARGET_CLASS_HASH, None), nonce=2. +pub fn get_validate_upgrade_signature() -> Array { + array![ + 0xb4146b116881b636b3e5df03f3d88139, 0x522594e6adbc196ed5747efc87f8e896, + 0x77d92acd1c9e71fc89917065b8bb20d7, 0xfcdbe021378a82e41a644300aa089f2d, 28, 1, + ] +} +// GENERATED-SIGNATURES-END + +