From e833b008c43bc96bed1f6f8b4228243795931f32 Mon Sep 17 00:00:00 2001 From: Remo Date: Tue, 17 Feb 2026 10:35:34 +0200 Subject: [PATCH 1/2] EthAccount tests for execute_from_outside_v2 --- eth_712_account/src/test.cairo | 266 ++++++++++++++++++++++++++- eth_712_account/src/test_utils.cairo | 98 ++++++++++ 2 files changed, 360 insertions(+), 4 deletions(-) diff --git a/eth_712_account/src/test.cairo b/eth_712_account/src/test.cairo index c7e7b94..9c5b9bb 100644 --- a/eth_712_account/src/test.cairo +++ b/eth_712_account/src/test.cairo @@ -1,12 +1,18 @@ use eth_712_account::interface::{IAccount712AdminDispatcher, IAccount712AdminDispatcherTrait}; use eth_712_account::test_utils::{ - TEST_ETH_ADDRESS, declare_register_interfaces_eic, deploy_eth712_account, get_invalid_signature, - get_ownership_signature, + EXECUTE_AFTER, EXECUTE_BEFORE, TEST_ETH_ADDRESS, TEST_NONCE, TEST_TIMESTAMP, + declare_register_interfaces_eic, deploy_eth712_account, get_invalid_outside_execution_signature, + get_invalid_signature, get_outside_execution_signature, get_ownership_signature, + get_signature_wrong_contract_address, get_signature_wrong_evm_chain_id, + get_signature_wrong_sn_chain_name, get_test_outside_execution, +}; +use openzeppelin::account::extensions::src9::interface::{ + ISRC9_V2Dispatcher, ISRC9_V2DispatcherTrait, ISRC9_V2_ID, }; -use openzeppelin::account::extensions::src9::interface::ISRC9_V2_ID; use openzeppelin::account::interface::ISRC6_ID; use openzeppelin::introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait}; -use snforge_std::{EventSpyTrait, load, spy_events}; +use snforge_std::cheatcodes::CheatSpan; +use snforge_std::{EventSpyTrait, cheat_block_timestamp, cheat_chain_id, 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; @@ -124,3 +130,255 @@ fn test_upgrade_with_eic() { let src5 = ISRC5Dispatcher { contract_address: account_address }; assert!(src5.supports_interface(custom_interface_id), "Custom interface not registered"); } + +// ================================ +// execute_from_outside_v2 tests +// ================================ + +#[test] +fn test_execute_from_outside_success() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set timestamp to be within the valid window + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + + // Set chain ID to SN_MAIN for domain separator + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + let signature = get_outside_execution_signature(); + + // Execute - this should succeed + let _results = src9.execute_from_outside_v2(outside_execution, signature.span()); + + // Verify nonce is now used + assert!(!src9.is_valid_outside_execution_nonce(TEST_NONCE), "Nonce should be marked as used"); +} + +#[test] +#[should_panic(expected: 'EXECUTED_TOO_EARLY')] +fn test_execute_from_outside_too_early_reverts() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set timestamp BEFORE execute_after (too early) + cheat_block_timestamp(account_address, EXECUTE_AFTER - 1, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + let signature = get_outside_execution_signature(); + + // This should fail with EXECUTED_TOO_EARLY + src9.execute_from_outside_v2(outside_execution, signature.span()); +} + +#[test] +#[should_panic(expected: 'EXECUTED_TOO_LATE')] +fn test_execute_from_outside_too_late_reverts() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set timestamp AFTER execute_before (too late) + cheat_block_timestamp(account_address, EXECUTE_BEFORE + 1, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + let signature = get_outside_execution_signature(); + + // This should fail with EXECUTED_TOO_LATE + src9.execute_from_outside_v2(outside_execution, signature.span()); +} + +#[test] +#[should_panic(expected: 'DUPLICATE_NONCE')] +fn test_execute_from_outside_duplicate_nonce_reverts() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set valid timestamp and chain ID + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + let signature = get_outside_execution_signature(); + + // First execution should succeed + src9.execute_from_outside_v2(outside_execution, signature.span()); + + // Second execution with same nonce should fail + let outside_execution2 = get_test_outside_execution(account_address); + let signature2 = get_outside_execution_signature(); + src9.execute_from_outside_v2(outside_execution2, signature2.span()); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_execute_from_outside_invalid_signature_reverts() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set valid timestamp and chain ID + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + let invalid_signature = get_invalid_outside_execution_signature(); + + // This should fail with INVALID_SIGNATURE + src9.execute_from_outside_v2(outside_execution, invalid_signature.span()); +} + +#[test] +fn test_is_valid_outside_execution_nonce() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + + // Fresh nonces should be valid + assert!(src9.is_valid_outside_execution_nonce(1), "Nonce 1 should be valid"); + assert!(src9.is_valid_outside_execution_nonce(2), "Nonce 2 should be valid"); + assert!(src9.is_valid_outside_execution_nonce(12345), "Nonce 12345 should be valid"); +} + +// ================================ +// Domain separator validation tests +// ================================ + +#[test] +fn test_execute_from_outside_different_evm_chain_id_succeeds() { + // NOTE: The EVM chain ID is passed WITH the signature (signature[5]) and is used + // to compute the message hash. This means signatures from different EVM chains + // are valid as long as they were signed with the correct domain separator. + // This is by design - it allows signing from multiple EVM chains. + + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set valid timestamp and chain ID + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + + // Signature generated with EVM chain ID 2 (e.g., from a different EVM chain) + // This is valid because the contract uses the chain_id from the signature + let chain_id_2_signature = get_signature_wrong_evm_chain_id(); + + // This succeeds because the signature is valid for chain_id=2 + let _results = src9.execute_from_outside_v2(outside_execution, chain_id_2_signature.span()); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_execute_from_outside_mismatched_chain_id_in_signature_reverts() { + // Test that passing a different chain_id in the signature than what was used + // to generate the signature causes validation to fail. + + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set valid timestamp and chain ID + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + + // Get valid signature (generated with chain_id=1) but change chain_id to 2 + // This creates a mismatch: signature was computed with chain_id=1 domain, + // but we tell the contract to verify with chain_id=2 domain + let sig = get_outside_execution_signature(); + // Modify the last element (chain_id) from 1 to 2 + let tampered_signature = array![ + *sig.at(0), // r_high + *sig.at(1), // r_low + *sig.at(2), // s_high + *sig.at(3), // s_low + *sig.at(4), // v + 2 // chain_id = 2 (but signature was generated with chain_id=1) + ]; + + // This should fail with INVALID_SIGNATURE because the contract will compute + // the hash using chain_id=2 but the signature was created with chain_id=1 + src9.execute_from_outside_v2(outside_execution, tampered_signature.span()); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_execute_from_outside_wrong_sn_chain_name_reverts() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set valid timestamp and chain ID (SN_MAIN) + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + + // Use signature generated with wrong Starknet chain name (SN_SEPOLIA instead of SN_MAIN) + let wrong_sn_chain_signature = get_signature_wrong_sn_chain_name(); + + // This should fail with INVALID_SIGNATURE because the domain name hash + // (keccak of SN_SEPOLIA) doesn't match what the contract computes (keccak of SN_MAIN) + src9.execute_from_outside_v2(outside_execution, wrong_sn_chain_signature.span()); +} + +#[test] +#[should_panic(expected: 'INVALID_SIGNATURE')] +fn test_execute_from_outside_wrong_contract_address_reverts() { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + + // Initialize the account + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + + // Set valid timestamp and chain ID + cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + + let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; + let outside_execution = get_test_outside_execution(account_address); + + // Use signature generated with wrong contract address in domain separator + let wrong_contract_signature = get_signature_wrong_contract_address(); + + // This should fail with INVALID_SIGNATURE because the verifyingContract + // in the signature doesn't match this contract's address + src9.execute_from_outside_v2(outside_execution, wrong_contract_signature.span()); +} diff --git a/eth_712_account/src/test_utils.cairo b/eth_712_account/src/test_utils.cairo index 269f5bd..8116051 100644 --- a/eth_712_account/src/test_utils.cairo +++ b/eth_712_account/src/test_utils.cairo @@ -1,3 +1,4 @@ +use openzeppelin::account::extensions::src9::OutsideExecution; use snforge_std::{ContractClassTrait, DeclareResultTrait}; use starknet::secp256_trait::Signature; use starknet::{ClassHash, ContractAddress, EthAddress, SyscallResultTrait}; @@ -57,3 +58,100 @@ pub fn deploy_eth712_account() -> (ContractAddress, ClassHash) { pub fn declare_register_interfaces_eic() -> ClassHash { *snforge_std::declare("RegisterInterfacesEIC").unwrap_syscall().contract_class().class_hash } + +// ================================ +// 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; +pub const TEST_NONCE: felt252 = 1; + +/// ANY_CALLER constant from SNIP-9. +pub const ANY_CALLER: felt252 = 'ANY_CALLER'; + +/// Returns a test OutsideExecution with fixed values and empty calls. +/// Using empty calls to avoid ENTRYPOINT_NOT_FOUND errors during testing. +pub fn get_test_outside_execution(_contract_address: ContractAddress) -> OutsideExecution { + OutsideExecution { + caller: ANY_CALLER.try_into().unwrap(), + nonce: TEST_NONCE, + execute_after: EXECUTE_AFTER, + execute_before: EXECUTE_BEFORE, + calls: array![].span() // Empty calls for testing + } +} + +/// 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) + ] +} + +/// Returns an invalid signature for testing signature validation. +pub fn get_invalid_outside_execution_signature() -> Array { + array![ + 0x1234567890abcdef1234567890abcdef, // r_high (invalid) + 0xfedcba0987654321fedcba0987654321, // r_low + 0xabcdef1234567890abcdef1234567890, // s_high + 0x0987654321fedcba0987654321fedcba, // s_low + 27, // v + 1 // chain_id + ] +} + +/// Returns a signature with WRONG EVM chain ID (chain_id=2 instead of 1). +/// This should fail signature validation because the domain separator is different. +pub fn get_signature_wrong_evm_chain_id() -> Array { + array![ + 0xe2aebdd44a9a03902eedb97065c2c279, // r_high + 0xfcfd2f2325c7a0bc4af74252f84b10b, // r_low + 0x73394d79eeda1d151b2facdcb2c9bd91, // s_high + 0x69b65ab69e218022a936846ea383df37, // s_low + 27, // v + 2 // chain_id (WRONG - should be 1) + ] +} + +/// 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 + ] +} From 448409053dfc69530e2f62f5fa50260599e2038c Mon Sep 17 00:00:00 2001 From: Remo Date: Tue, 17 Feb 2026 20:17:30 +0200 Subject: [PATCH 2/2] refactor-tests --- Scarb.lock | 1 + eth_712_account/Scarb.toml | 7 + .../scripts/generate_test_signatures.py | 481 ++++++++++++++++++ eth_712_account/scripts/requirements.txt | 2 + eth_712_account/src/test.cairo | 316 ++++++------ eth_712_account/src/test_utils.cairo | 208 +++++++- 6 files changed, 862 insertions(+), 153 deletions(-) create mode 100644 eth_712_account/scripts/generate_test_signatures.py create mode 100644 eth_712_account/scripts/requirements.txt diff --git a/Scarb.lock b/Scarb.lock index f7ee919..09ef38b 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -38,6 +38,7 @@ version = "0.1.0" dependencies = [ "openzeppelin", "snforge_std", + "starkware_utils", "starkware_utils_testing", "testing_utils", ] diff --git a/eth_712_account/Scarb.toml b/eth_712_account/Scarb.toml index 8b4bb7a..d5553aa 100644 --- a/eth_712_account/Scarb.toml +++ b/eth_712_account/Scarb.toml @@ -10,12 +10,19 @@ starknet.workspace = true [dev-dependencies] assert_macros.workspace = true snforge_std.workspace = true +starkware_utils.workspace = true starkware_utils_testing.workspace = true testing_utils = { path = "../testing_utils" } [[target.starknet-contract]] sierra = true +[[test]] +name = "eth_712_account_unittest" +build-external-contracts = [ + "starkware_utils::erc20::erc20_mocks::DualCaseERC20Mock", +] + [profile.dev.cairo] unstable-add-statements-functions-debug-info = true unstable-add-statements-code-locations-debug-info = true diff --git a/eth_712_account/scripts/generate_test_signatures.py b/eth_712_account/scripts/generate_test_signatures.py new file mode 100644 index 0000000..aa4b178 --- /dev/null +++ b/eth_712_account/scripts/generate_test_signatures.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +""" +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. + +Setup: + cd eth_712_account/scripts + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + +Usage: + python generate_test_signatures.py + +Dependencies: eth-account, web3 (see requirements.txt) +""" + +from eth_account import Account +from eth_account.messages import encode_typed_data +from web3 import Web3 + +# 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 + +# 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 + +# 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 + +# Starknet chain ID for domain name (keccak of this string) +SN_CHAIN_ID = "SN_MAIN" + + +def keccak256(data: bytes) -> bytes: + """Compute keccak256 hash.""" + return Web3.keccak(data) + + +def keccak256_str(s: str) -> bytes: + """Compute keccak256 of a string.""" + return keccak256(s.encode("utf-8")) + + +def to_bytes32(val: int) -> bytes: + """Convert u256 to 32 bytes (big-endian).""" + return val.to_bytes(32, "big") + + +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) + + +# 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 + + +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 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 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 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. + + 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 + + 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 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) + + data = b"\x19\x01" + domain_separator + struct_hash + return keccak256(data) + + +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, +) -> dict: + """ + Sign an OutsideExecution and return signature components. + + 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) + + 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) + + 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 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) + ] +}} +""" + + +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 + + +# ============================================================================ +# Test Case Definitions +# ============================================================================ + +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. + + 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], + } + + outside_execution = { + "caller": ANY_CALLER, + "nonce": TEST_NONCE, + "execute_after": EXECUTE_AFTER, + "execute_before": EXECUTE_BEFORE, + "calls": [call], + } + + sig = sign_outside_execution(outside_execution, contract_address) + return outside_execution, sig + + +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). + + 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 + + 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": 2, # Different nonce for this test + "execute_after": EXECUTE_AFTER, + "execute_before": EXECUTE_BEFORE, + "calls": calls, + } + + sig = sign_outside_execution(outside_execution, contract_address) + return outside_execution, sig + + +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). + + 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 + + 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, + } + + sig = sign_outside_execution(outside_execution, contract_address) + return outside_execution, sig + + +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) + """ + outside_execution = { + "caller": caller, + "nonce": nonce, + "execute_after": EXECUTE_AFTER, + "execute_before": EXECUTE_BEFORE, + "calls": [], + } + + sig = sign_outside_execution(outside_execution, contract_address) + return outside_execution, sig + + +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 + + # Nonces matching test_utils.cairo + NONCE_SINGLE_CALL = 100 + NONCE_MULTI_CALL = 101 + NONCE_ATOMICITY = 102 + NONCE_SPECIFIC_CALLER = 103 + + # Test amounts (matching test.cairo). + APPROVE_AMOUNT = 500 + TRANSFER_AMOUNT = 100 + INITIAL_SUPPLY = 1000 + + 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 __name__ == "__main__": + main() diff --git a/eth_712_account/scripts/requirements.txt b/eth_712_account/scripts/requirements.txt new file mode 100644 index 0000000..66dfe68 --- /dev/null +++ b/eth_712_account/scripts/requirements.txt @@ -0,0 +1,2 @@ +eth-account>=0.13.0 +web3>=7.0.0 diff --git a/eth_712_account/src/test.cairo b/eth_712_account/src/test.cairo index 9c5b9bb..a316708 100644 --- a/eth_712_account/src/test.cairo +++ b/eth_712_account/src/test.cairo @@ -1,18 +1,24 @@ use eth_712_account::interface::{IAccount712AdminDispatcher, IAccount712AdminDispatcherTrait}; use eth_712_account::test_utils::{ - EXECUTE_AFTER, EXECUTE_BEFORE, TEST_ETH_ADDRESS, TEST_NONCE, TEST_TIMESTAMP, - declare_register_interfaces_eic, deploy_eth712_account, get_invalid_outside_execution_signature, - get_invalid_signature, get_outside_execution_signature, get_ownership_signature, - get_signature_wrong_contract_address, get_signature_wrong_evm_chain_id, - get_signature_wrong_sn_chain_name, get_test_outside_execution, + 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, + 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, }; use openzeppelin::account::extensions::src9::interface::{ - ISRC9_V2Dispatcher, ISRC9_V2DispatcherTrait, ISRC9_V2_ID, + ISRC9_V2Dispatcher, ISRC9_V2DispatcherTrait, ISRC9_V2SafeDispatcher, + ISRC9_V2SafeDispatcherTrait, ISRC9_V2_ID, }; use openzeppelin::account::interface::ISRC6_ID; use openzeppelin::introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait}; -use snforge_std::cheatcodes::CheatSpan; -use snforge_std::{EventSpyTrait, cheat_block_timestamp, cheat_chain_id, load, spy_events}; +use openzeppelin::token::erc20::interface::IERC20DispatcherTrait; +use snforge_std::{EventSpyTrait, 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; @@ -137,24 +143,12 @@ fn test_upgrade_with_eic() { #[test] fn test_execute_from_outside_success() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set timestamp to be within the valid window - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - - // Set chain ID to SN_MAIN for domain separator - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); let signature = get_outside_execution_signature(); // Execute - this should succeed - let _results = src9.execute_from_outside_v2(outside_execution, signature.span()); + src9.execute_from_outside_v2(outside_execution, signature.span()); // Verify nonce is now used assert!(!src9.is_valid_outside_execution_nonce(TEST_NONCE), "Nonce should be marked as used"); @@ -163,89 +157,44 @@ fn test_execute_from_outside_success() { #[test] #[should_panic(expected: 'EXECUTED_TOO_EARLY')] fn test_execute_from_outside_too_early_reverts() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set timestamp BEFORE execute_after (too early) - cheat_block_timestamp(account_address, EXECUTE_AFTER - 1, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); + let src9 = setup_efo_test_with_timestamp(EXECUTE_AFTER - 1); + let outside_execution = get_test_outside_execution(); let signature = get_outside_execution_signature(); - // This should fail with EXECUTED_TOO_EARLY src9.execute_from_outside_v2(outside_execution, signature.span()); } #[test] #[should_panic(expected: 'EXECUTED_TOO_LATE')] fn test_execute_from_outside_too_late_reverts() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set timestamp AFTER execute_before (too late) - cheat_block_timestamp(account_address, EXECUTE_BEFORE + 1, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); + let src9 = setup_efo_test_with_timestamp(EXECUTE_BEFORE + 1); + let outside_execution = get_test_outside_execution(); let signature = get_outside_execution_signature(); - // This should fail with EXECUTED_TOO_LATE src9.execute_from_outside_v2(outside_execution, signature.span()); } #[test] #[should_panic(expected: 'DUPLICATE_NONCE')] fn test_execute_from_outside_duplicate_nonce_reverts() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set valid timestamp and chain ID - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); let signature = get_outside_execution_signature(); // First execution should succeed src9.execute_from_outside_v2(outside_execution, signature.span()); // Second execution with same nonce should fail - let outside_execution2 = get_test_outside_execution(account_address); - let signature2 = get_outside_execution_signature(); - src9.execute_from_outside_v2(outside_execution2, signature2.span()); + src9.execute_from_outside_v2(outside_execution, signature.span()); } #[test] #[should_panic(expected: 'INVALID_SIGNATURE')] fn test_execute_from_outside_invalid_signature_reverts() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set valid timestamp and chain ID - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); let invalid_signature = get_invalid_outside_execution_signature(); - // This should fail with INVALID_SIGNATURE src9.execute_from_outside_v2(outside_execution, invalid_signature.span()); } @@ -275,25 +224,12 @@ fn test_execute_from_outside_different_evm_chain_id_succeeds() { // to compute the message hash. This means signatures from different EVM chains // are valid as long as they were signed with the correct domain separator. // This is by design - it allows signing from multiple EVM chains. + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + // 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(); - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set valid timestamp and chain ID - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); - - // Signature generated with EVM chain ID 2 (e.g., from a different EVM chain) - // This is valid because the contract uses the chain_id from the signature - let chain_id_2_signature = get_signature_wrong_evm_chain_id(); - - // This succeeds because the signature is valid for chain_id=2 let _results = src9.execute_from_outside_v2(outside_execution, chain_id_2_signature.span()); } @@ -302,83 +238,171 @@ fn test_execute_from_outside_different_evm_chain_id_succeeds() { fn test_execute_from_outside_mismatched_chain_id_in_signature_reverts() { // Test that passing a different chain_id in the signature than what was used // to generate the signature causes validation to fail. - - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set valid timestamp and chain ID - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); - - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); // Get valid signature (generated with chain_id=1) but change chain_id to 2 - // This creates a mismatch: signature was computed with chain_id=1 domain, - // but we tell the contract to verify with chain_id=2 domain let sig = get_outside_execution_signature(); - // Modify the last element (chain_id) from 1 to 2 let tampered_signature = array![ - *sig.at(0), // r_high - *sig.at(1), // r_low - *sig.at(2), // s_high - *sig.at(3), // s_low - *sig.at(4), // v + *sig.at(0), *sig.at(1), *sig.at(2), *sig.at(3), *sig.at(4), 2 // chain_id = 2 (but signature was generated with chain_id=1) ]; - // This should fail with INVALID_SIGNATURE because the contract will compute - // the hash using chain_id=2 but the signature was created with chain_id=1 + // Fails because contract computes hash with chain_id=2 but signature used chain_id=1 src9.execute_from_outside_v2(outside_execution, tampered_signature.span()); } #[test] #[should_panic(expected: 'INVALID_SIGNATURE')] fn test_execute_from_outside_wrong_sn_chain_name_reverts() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; - - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); - - // Set valid timestamp and chain ID (SN_MAIN) - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); - - // Use signature generated with wrong Starknet chain name (SN_SEPOLIA instead of SN_MAIN) + // Signature generated with SN_SEPOLIA instead of SN_MAIN - domain name hash mismatch let wrong_sn_chain_signature = get_signature_wrong_sn_chain_name(); - // This should fail with INVALID_SIGNATURE because the domain name hash - // (keccak of SN_SEPOLIA) doesn't match what the contract computes (keccak of SN_MAIN) src9.execute_from_outside_v2(outside_execution, wrong_sn_chain_signature.span()); } #[test] #[should_panic(expected: 'INVALID_SIGNATURE')] fn test_execute_from_outside_wrong_contract_address_reverts() { - let (account_address, _) = deploy_eth712_account(); - let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + let src9 = setup_efo_test(); + let outside_execution = get_test_outside_execution(); - // Initialize the account - account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + // Signature generated with wrong contract address - verifyingContract mismatch + let wrong_contract_signature = get_signature_wrong_contract_address(); - // Set valid timestamp and chain ID - cheat_block_timestamp(account_address, TEST_TIMESTAMP, CheatSpan::Indefinite); - cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + src9.execute_from_outside_v2(outside_execution, wrong_contract_signature.span()); +} - let src9 = ISRC9_V2Dispatcher { contract_address: account_address }; - let outside_execution = get_test_outside_execution(account_address); +/// Test helper: arbitrary address for spender/recipient in tests. +fn TEST_SPENDER() -> starknet::ContractAddress { + 0x1234_felt252.try_into().unwrap() +} - // Use signature generated with wrong contract address in domain separator - let wrong_contract_signature = get_signature_wrong_contract_address(); +fn TEST_RECIPIENT() -> starknet::ContractAddress { + 0x5678_felt252.try_into().unwrap() +} - // This should fail with INVALID_SIGNATURE because the verifyingContract - // in the signature doesn't match this contract's address - src9.execute_from_outside_v2(outside_execution, wrong_contract_signature.span()); +// ================================ +// execute_from_outside_v2 tests with actual calls +// ================================ + +#[test] +fn test_execute_from_outside_single_call_succeeds() { + let (src9, token_address, 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 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"); +} + +#[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 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"); + assert!( + token.balance_of(account_address) == initial_account_balance - transfer_amount, + "Account balance not reduced", + ); + assert!( + token.balance_of(TEST_RECIPIENT()) == initial_recipient_balance + transfer_amount, + "Recipient balance not increased", + ); +} + +/// Test that when one call in a multi-call execution fails, the entire execution fails. +/// NOTE: Starknet guarantees atomicity at the protocol level - all state changes are rolled +/// back when a transaction fails. However, snforge's test environment doesn't fully simulate +/// this rollback behavior, so we can only verify that the error is properly propagated. +#[test] +#[feature("safe_dispatcher")] +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 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, + ); + + // Due cairo test limitations, we cannot verify that the state changes are rolled back. + // So we just verify that the error is propagated. + + // Use safe dispatcher to verify the error is propagated + let safe_src9 = ISRC9_V2SafeDispatcher { contract_address: src9.contract_address }; + let result = safe_src9 + .execute_from_outside_v2(outside_execution, get_atomicity_test_signature().span()); + + // Verify that the execution failed (error from second call propagates) + assert!(result.is_err(), "Expected call to fail due to insufficient balance"); +} + +/// Test execute_from_outside_v2 with a specific caller (not ANY_CALLER). +/// The execution should succeed when called by the exact specified caller. +#[test] +fn test_execute_from_outside_specific_caller_succeeds() { + let src9 = setup_efo_test(); + + let specific_caller: starknet::ContractAddress = SPECIFIC_CALLER.try_into().unwrap(); + let outside_execution = build_outside_execution_with_specific_caller( + NONCE_SPECIFIC_CALLER, specific_caller, + ); + + // Spoof caller to be the specific caller + cheat_caller_address_once(src9.contract_address, specific_caller); + + // Should succeed because caller matches + src9.execute_from_outside_v2(outside_execution, get_specific_caller_signature().span()); +} + +/// Test execute_from_outside_v2 with a specific caller, but called from wrong address. +/// The execution should revert when the actual caller doesn't match. +#[test] +#[should_panic(expected: 'INVALID_CALLER')] +fn test_execute_from_outside_wrong_caller_reverts() { + let src9 = setup_efo_test(); + + let specific_caller: starknet::ContractAddress = SPECIFIC_CALLER.try_into().unwrap(); + let wrong_caller: starknet::ContractAddress = 0xDEAD.try_into().unwrap(); + let outside_execution = build_outside_execution_with_specific_caller( + NONCE_SPECIFIC_CALLER, specific_caller, + ); + + // Spoof caller to be a DIFFERENT address than specified + cheat_caller_address_once(src9.contract_address, wrong_caller); + + // Should fail because caller doesn't match + src9.execute_from_outside_v2(outside_execution, get_specific_caller_signature().span()); } diff --git a/eth_712_account/src/test_utils.cairo b/eth_712_account/src/test_utils.cairo index 8116051..f886a73 100644 --- a/eth_712_account/src/test_utils.cairo +++ b/eth_712_account/src/test_utils.cairo @@ -1,5 +1,11 @@ +use core::serde::Serde; +use eth_712_account::interface::{IAccount712AdminDispatcher, IAccount712AdminDispatcherTrait}; use openzeppelin::account::extensions::src9::OutsideExecution; -use snforge_std::{ContractClassTrait, DeclareResultTrait}; +use openzeppelin::account::extensions::src9::interface::ISRC9_V2Dispatcher; +use openzeppelin::token::erc20::interface::IERC20Dispatcher; +use snforge_std::cheatcodes::CheatSpan; +use snforge_std::{ContractClassTrait, DeclareResultTrait, cheat_block_timestamp, cheat_chain_id}; +use starknet::account::Call; use starknet::secp256_trait::Signature; use starknet::{ClassHash, ContractAddress, EthAddress, SyscallResultTrait}; @@ -59,6 +65,35 @@ pub fn declare_register_interfaces_eic() -> ClassHash { *snforge_std::declare("RegisterInterfacesEIC").unwrap_syscall().contract_class().class_hash } +// ================================ +// Execute-from-outside (EFO) test setup helpers +// ================================ + +/// Setup an initialized account with configurable timestamp for EFO tests. +/// Returns src9_dispatcher (use src9.contract_address if account address is needed). +pub fn setup_efo_test_with_timestamp(timestamp: u64) -> ISRC9_V2Dispatcher { + let (account_address, _) = deploy_eth712_account(); + let account_contract = IAccount712AdminDispatcher { contract_address: account_address }; + account_contract.initialize(TEST_ETH_ADDRESS(), get_ownership_signature()); + cheat_block_timestamp(account_address, timestamp, CheatSpan::Indefinite); + cheat_chain_id(account_address, 'SN_MAIN', CheatSpan::Indefinite); + ISRC9_V2Dispatcher { contract_address: account_address } +} + +/// Setup an initialized account with default TEST_TIMESTAMP for EFO tests. +/// Returns src9_dispatcher (use src9.contract_address if account address is needed). +pub fn setup_efo_test() -> ISRC9_V2Dispatcher { + setup_efo_test_with_timestamp(TEST_TIMESTAMP) +} + +/// Setup an initialized account with ERC20 mock for EFO tests. +/// Returns (src9_dispatcher, token_address, erc20_dispatcher). +pub fn setup_efo_test_with_erc20() -> (ISRC9_V2Dispatcher, ContractAddress, IERC20Dispatcher) { + let src9 = setup_efo_test(); + let (token_address, token) = deploy_mock_erc20(src9.contract_address); + (src9, token_address, token) +} + // ================================ // OutsideExecution test fixtures // ================================ @@ -75,7 +110,7 @@ pub const ANY_CALLER: felt252 = 'ANY_CALLER'; /// Returns a test OutsideExecution with fixed values and empty calls. /// Using empty calls to avoid ENTRYPOINT_NOT_FOUND errors during testing. -pub fn get_test_outside_execution(_contract_address: ContractAddress) -> OutsideExecution { +pub fn get_test_outside_execution() -> OutsideExecution { OutsideExecution { caller: ANY_CALLER.try_into().unwrap(), nonce: TEST_NONCE, @@ -101,7 +136,7 @@ pub fn get_outside_execution_signature() -> Array { 0x4b52469cfdda3614944d145122ee96ab, // s_high 0xca19b48f5b51afacc161928fe72cdf69, // s_low 28, // v - 1 // chain_id (EVM) + 1 // chain_id (EVM=Ethereum) ] } @@ -117,16 +152,15 @@ pub fn get_invalid_outside_execution_signature() -> Array { ] } -/// Returns a signature with WRONG EVM chain ID (chain_id=2 instead of 1). -/// This should fail signature validation because the domain separator is different. -pub fn get_signature_wrong_evm_chain_id() -> 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 (WRONG - should be 1) + 2 // chain_id: Not Ethereum ] } @@ -155,3 +189,163 @@ pub fn get_signature_wrong_contract_address() -> Array { 1 // chain_id ] } + +// ================================ +// ERC20 Mock helpers for execute_from_outside tests +// ================================ + +/// 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. +/// 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) + 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 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 { + Call { + to: token_address, + selector: selector!("approve"), + calldata: array![spender.into(), amount.low.into(), amount.high.into()].span(), + } +} + +/// Build a transfer Call for ERC20. +pub fn build_transfer_call( + token_address: ContractAddress, recipient: ContractAddress, amount: u256, +) -> Call { + Call { + to: token_address, + selector: selector!("transfer"), + calldata: array![recipient.into(), amount.low.into(), amount.high.into()].span(), + } +} + +/// 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; + +/// Specific caller address for testing non-ANY_CALLER scenarios. +pub const SPECIFIC_CALLER: felt252 = 0xCAFE; + +/// Build OutsideExecution with specified calls (uses ANY_CALLER). +pub fn build_outside_execution_with_calls(calls: Span, nonce: felt252) -> OutsideExecution { + OutsideExecution { + caller: ANY_CALLER.try_into().unwrap(), + nonce: nonce, + execute_after: EXECUTE_AFTER, + execute_before: EXECUTE_BEFORE, + calls: calls, + } +} + +/// Build OutsideExecution with a specific caller (not ANY_CALLER). +pub fn build_outside_execution_with_specific_caller( + nonce: felt252, caller: ContractAddress, +) -> OutsideExecution { + OutsideExecution { + caller: caller, + nonce: 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. +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) + ] +} + +/// Signature for multi-call test (approve 500 + transfer 100). +/// OutsideExecution with nonce=101, two calls. +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) + ] +} + +/// Signature for atomicity test (approve 500 + transfer 1001 - fails). +/// OutsideExecution with nonce=102, second call will fail due to insufficient balance. +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) + ] +} + +/// Signature for specific caller test. +/// OutsideExecution with nonce=103, caller=0xCAFE, empty calls. +/// Used for testing non-ANY_CALLER scenarios. +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) + ] +}