Repository: OpenZeppelin/cairo-contracts (language: python)

Issue:
Add SRC9 to Account presets.
This will make the transition to only accepting v3 transactions easier since accounts not having STRK to send transactions can use a paymaster to recover/transfer funds.




Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c89671ec4..922ec161f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## Unreleased
 
+### Added
+
+- SRC9 (Outside Execution) integration to account presets (#1201)
+
 ## 0.19.0 (2024-11-08)
 
 ### Added
diff --git a/docs/modules/ROOT/pages/api/account.adoc b/docs/modules/ROOT/pages/api/account.adoc
index d39b20d9b..dde7678fa 100644
--- a/docs/modules/ROOT/pages/api/account.adoc
+++ b/docs/modules/ROOT/pages/api/account.adoc
@@ -704,7 +704,8 @@ Initializes the account by registering the `ISRC9_V2` interface Id.
 use openzeppelin_presets::AccountUpgradeable;
 ```
 
-Upgradeable account contract leveraging xref:#AccountComponent[AccountComponent].
+Upgradeable account which can change its public key and declare, deploy, or call
+contracts. Supports outside execution by implementing xref:#SRC9Component[SRC9].
 
 include::../utils/_class_hashes.adoc[]
 
@@ -724,8 +725,10 @@ include::../utils/_class_hashes.adoc[]
 .Embedded Implementations
 --
 .AccountComponent
-
 * xref:#AccountComponent-Embeddable-Mixin-Impl[`++AccountMixinImpl++`]
+
+.SRC9Component
+* xref:#SRC9Component-Embeddable-Impls-OutsideExecutionV2Impl[`++OutsideExecutionV2Impl++`]
 --
 
 [.contract-index]
@@ -765,7 +768,8 @@ Requirements:
 use openzeppelin_presets::EthAccountUpgradeable;
 ```
 
-Upgradeable account contract leveraging xref:#EthAccountComponent[EthAccountComponent].
+Upgradeable account which can change its public key and declare, deploy, or call contracts, using Ethereum
+signing keys. Supports outside execution by implementing xref:#SRC9Component[SRC9].
 
 NOTE: The `EthPublicKey` type is an alias for `starknet::secp256k1::Secp256k1Point`.
 
@@ -787,8 +791,10 @@ include::../utils/_class_hashes.adoc[]
 .Embedded Implementations
 --
 .EthAccountComponent
-
 * xref:#EthAccountComponent-Embeddable-Mixin-Impl[`++EthAccountMixinImpl++`]
+
+.SRC9Component
+* xref:#SRC9Component-Embeddable-Impls-OutsideExecutionV2Impl[`++OutsideExecutionV2Impl++`]
 --
 
 [.contract-index]
diff --git a/packages/presets/Scarb.toml b/packages/presets/Scarb.toml
index 7ed4414f4..505d8e3bb 100644
--- a/packages/presets/Scarb.toml
+++ b/packages/presets/Scarb.toml
@@ -49,6 +49,7 @@ build-external-contracts = [
     "openzeppelin_test_common::mocks::account::DualCaseAccountMock",
     "openzeppelin_test_common::mocks::account::SnakeAccountMock",
     "openzeppelin_test_common::mocks::account::SnakeEthAccountMock",
+    "openzeppelin_test_common::mocks::simple::SimpleMock",
     "openzeppelin_test_common::mocks::erc20::DualCaseERC20Mock",
     "openzeppelin_test_common::mocks::erc20::SnakeERC20Mock",
     "openzeppelin_test_common::mocks::erc721::SnakeERC721Mock",
diff --git a/packages/presets/src/account.cairo b/packages/presets/src/account.cairo
index 5aa75f29d..c7b5986bb 100644
--- a/packages/presets/src/account.cairo
+++ b/packages/presets/src/account.cairo
@@ -4,10 +4,11 @@
 /// # Account Preset
 ///
 /// OpenZeppelin's upgradeable account which can change its public key and declare, deploy, or call
-/// contracts.
+/// contracts. Supports outside execution by implementing SRC9.
 #[starknet::contract(account)]
 pub mod AccountUpgradeable {
     use openzeppelin_account::AccountComponent;
+    use openzeppelin_account::extensions::SRC9Component;
     use openzeppelin_introspection::src5::SRC5Component;
     use openzeppelin_upgrades::UpgradeableComponent;
     use openzeppelin_upgrades::interface::IUpgradeable;
@@ -15,6 +16,7 @@ pub mod AccountUpgradeable {
 
     component!(path: AccountComponent, storage: account, event: AccountEvent);
     component!(path: SRC5Component, storage: src5, event: SRC5Event);
+    component!(path: SRC9Component, storage: src9, event: SRC9Event);
     component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);
 
     // Account Mixin
@@ -23,6 +25,12 @@ pub mod AccountUpgradeable {
         AccountComponent::AccountMixinImpl<ContractState>;
     impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;
 
+    // SRC9
+    #[abi(embed_v0)]
+    impl OutsideExecutionV2Impl =
+        SRC9Component::OutsideExecutionV2Impl<ContractState>;
+    impl OutsideExecutionInternalImpl = SRC9Component::InternalImpl<ContractState>;
+
     // Upgradeable
     impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl<ContractState>;
 
@@ -33,6 +41,8 @@ pub mod AccountUpgradeable {
         #[substorage(v0)]
         pub src5: SRC5Component::Storage,
         #[substorage(v0)]
+        pub src9: SRC9Component::Storage,
+        #[substorage(v0)]
         pub upgradeable: UpgradeableComponent::Storage
     }
 
@@ -44,12 +54,15 @@ pub mod AccountUpgradeable {
         #[flat]
         SRC5Event: SRC5Component::Event,
         #[flat]
+        SRC9Event: SRC9Component::Event,
+        #[flat]
         UpgradeableEvent: UpgradeableComponent::Event
     }
 
     #[constructor]
     pub fn constructor(ref self: ContractState, public_key: felt252) {
         self.account.initializer(public_key);
+        self.src9.initializer();
     }
 
     #[abi(embed_v0)]
diff --git a/packages/presets/src/eth_account.cairo b/packages/presets/src/eth_account.cairo
index 66954d92e..d0711191c 100644
--- a/packages/presets/src/eth_account.cairo
+++ b/packages/presets/src/eth_account.cairo
@@ -4,10 +4,12 @@
 /// # EthAccount Preset
 ///
 /// OpenZeppelin's upgradeable account which can change its public key and declare,
-/// deploy, or call contracts, using Ethereum signing keys.
+/// deploy, or call contracts, using Ethereum signing keys. Supports outside execution by
+/// implementing SRC9.
 #[starknet::contract(account)]
 pub(crate) mod EthAccountUpgradeable {
     use openzeppelin_account::EthAccountComponent;
+    use openzeppelin_account::extensions::SRC9Component;
     use openzeppelin_account::interface::EthPublicKey;
     use openzeppelin_introspection::src5::SRC5Component;
     use openzeppelin_upgrades::UpgradeableComponent;
@@ -16,6 +18,7 @@ pub(crate) mod EthAccountUpgradeable {
 
     component!(path: EthAccountComponent, storage: eth_account, event: EthAccountEvent);
     component!(path: SRC5Component, storage: src5, event: SRC5Event);
+    component!(path: SRC9Component, storage: src9, event: SRC9Event);
     component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);
 
     // EthAccount Mixin
@@ -24,6 +27,12 @@ pub(crate) mod EthAccountUpgradeable {
         EthAccountComponent::EthAccountMixinImpl<ContractState>;
     impl EthAccountInternalImpl = EthAccountComponent::InternalImpl<ContractState>;
 
+    // SRC9
+    #[abi(embed_v0)]
+    impl OutsideExecutionV2Impl =
+        SRC9Component::OutsideExecutionV2Impl<ContractState>;
+    impl OutsideExecutionInternalImpl = SRC9Component::InternalImpl<ContractState>;
+
     // Upgradeable
     impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl<ContractState>;
 
@@ -34,6 +43,8 @@ pub(crate) mod EthAccountUpgradeable {
         #[substorage(v0)]
         pub src5: SRC5Component::Storage,
         #[substorage(v0)]
+        pub src9: SRC9Component::Storage,
+        #[substorage(v0)]
         pub upgradeable: UpgradeableComponent::Storage
     }
 
@@ -45,12 +56,15 @@ pub(crate) mod EthAccountUpgradeable {
         #[flat]
         SRC5Event: SRC5Component::Event,
         #[flat]
+        SRC9Event: SRC9Component::Event,
+        #[flat]
         UpgradeableEvent: UpgradeableComponent::Event
     }
 
     #[constructor]
     pub(crate) fn constructor(ref self: ContractState, public_key: EthPublicKey) {
         self.eth_account.initializer(public_key);
+        self.src9.initializer();
     }
 
     #[abi(embed_v0)]
diff --git a/packages/presets/src/interfaces/account.cairo b/packages/presets/src/interfaces/account.cairo
index c7713c8e7..37b834320 100644
--- a/packages/presets/src/interfaces/account.cairo
+++ b/packages/presets/src/interfaces/account.cairo
@@ -1,3 +1,4 @@
+use openzeppelin_account::extensions::src9::interface::OutsideExecution;
 use starknet::ClassHash;
 use starknet::account::Call;
 
@@ -11,6 +12,12 @@ pub trait AccountUpgradeableABI<TState> {
     // ISRC5
     fn supports_interface(self: @TState, interface_id: felt252) -> bool;
 
+    // ISRC9
+    fn execute_from_outside_v2(
+        ref self: TState, outside_execution: OutsideExecution, signature: Span<felt252>,
+    ) -> Array<Span<felt252>>;
+    fn is_valid_outside_execution_nonce(self: @TState, nonce: felt252) -> bool;
+
     // IDeclarer
     fn __validate_declare__(self: @TState, class_hash: felt252) -> felt252;
 
diff --git a/packages/presets/src/interfaces/eth_account.cairo b/packages/presets/src/interfaces/eth_account.cairo
index 72aedb498..acdcf5575 100644
--- a/packages/presets/src/interfaces/eth_account.cairo
+++ b/packages/presets/src/interfaces/eth_account.cairo
@@ -1,3 +1,4 @@
+use openzeppelin_account::extensions::src9::interface::OutsideExecution;
 use openzeppelin_account::interface::EthPublicKey;
 use starknet::ClassHash;
 use starknet::account::Call;
@@ -12,6 +13,12 @@ pub trait EthAccountUpgradeableABI<TState> {
     // ISRC5
     fn supports_interface(self: @TState, interface_id: felt252) -> bool;
 
+    // ISRC9
+    fn execute_from_outside_v2(
+        ref self: TState, outside_execution: OutsideExecution, signature: Span<felt252>,
+    ) -> Array<Span<felt252>>;
+    fn is_valid_outside_execution_nonce(self: @TState, nonce: felt252) -> bool;
+
     // IDeclarer
     fn __validate_declare__(self: @TState, class_hash: felt252) -> felt252;
 


Test patch:
diff --git a/packages/presets/src/tests/test_account.cairo b/packages/presets/src/tests/test_account.cairo
index 541bd1757..3297641d4 100644
--- a/packages/presets/src/tests/test_account.cairo
+++ b/packages/presets/src/tests/test_account.cairo
@@ -4,6 +4,10 @@ use crate::interfaces::account::{
     AccountUpgradeableABISafeDispatcher, AccountUpgradeableABISafeDispatcherTrait
 };
 use crate::interfaces::{AccountUpgradeableABIDispatcher, AccountUpgradeableABIDispatcherTrait};
+use openzeppelin_account::account::AccountComponent::AccountMixinImpl;
+use openzeppelin_account::extensions::SRC9Component::{OutsideExecutionV2Impl, SNIP12MetadataImpl};
+use openzeppelin_account::extensions::src9::interface::{OutsideExecution, ISRC9_V2_ID};
+use openzeppelin_account::extensions::src9::snip12_utils::OutsideExecutionStructHash;
 use openzeppelin_account::interface::ISRC6_ID;
 use openzeppelin_introspection::interface::ISRC5_ID;
 use openzeppelin_test_common::account::{
@@ -15,18 +19,22 @@ use openzeppelin_testing as utils;
 use openzeppelin_testing::constants::stark::{KEY_PAIR, KEY_PAIR_2};
 use openzeppelin_testing::constants::{
     SALT, ZERO, CALLER, RECIPIENT, OTHER, QUERY_OFFSET, QUERY_VERSION, MIN_TRANSACTION_VERSION,
-    CLASS_HASH_ZERO
+    CLASS_HASH_ZERO, FELT_VALUE
 };
+use openzeppelin_testing::signing::SerializedSigning;
 use openzeppelin_testing::signing::StarkKeyPair;
 use openzeppelin_token::erc20::interface::IERC20DispatcherTrait;
+use openzeppelin_utils::cryptography::snip12::OffchainMessageHash;
 use openzeppelin_utils::serde::SerializedAppend;
+use snforge_std::{
+    spy_events, test_address, load, CheatSpan, start_cheat_caller_address, cheat_caller_address
+};
 use snforge_std::{
     start_cheat_signature_global, start_cheat_transaction_version_global,
-    start_cheat_transaction_hash_global
+    start_cheat_transaction_hash_global, start_cheat_block_timestamp_global
 };
-use snforge_std::{spy_events, test_address, start_cheat_caller_address};
 use starknet::account::Call;
-use starknet::{ContractAddress, ClassHash};
+use starknet::{contract_address_const, ContractAddress, ClassHash};
 
 //
 // Setup
@@ -60,6 +68,10 @@ fn setup_dispatcher_with_data(
     (account_dispatcher, account_class.class_hash.into())
 }
 
+fn setup_simple_mock() -> ContractAddress {
+    utils::declare_and_deploy("SimpleMock", array![])
+}
+
 //
 // constructor
 //
@@ -74,14 +86,17 @@ fn test_constructor() {
 
     spy.assert_only_event_owner_added(account_address, key_pair.public_key);
 
-    let public_key = AccountUpgradeable::AccountMixinImpl::get_public_key(@state);
+    let public_key = state.get_public_key();
     assert_eq!(public_key, key_pair.public_key);
 
-    let supports_isrc5 = AccountUpgradeable::AccountMixinImpl::supports_interface(@state, ISRC5_ID);
+    let supports_isrc5 = state.supports_interface(ISRC5_ID);
     assert!(supports_isrc5);
 
-    let supports_isrc6 = AccountUpgradeable::AccountMixinImpl::supports_interface(@state, ISRC6_ID);
+    let supports_isrc6 = state.supports_interface(ISRC6_ID);
     assert!(supports_isrc6);
+
+    let supports_isrc9 = state.supports_interface(ISRC9_V2_ID);
+    assert!(supports_isrc9);
 }
 
 //
@@ -568,3 +583,192 @@ fn test_state_persists_after_upgrade() {
 
     assert_eq!(snake_public_key, expected_public_key);
 }
+
+//
+// execute_from_outside_v2
+//
+
+#[test]
+fn test_execute_from_outside_v2_any_caller() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, false);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash);
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+
+    assert_value(simple_mock, FELT_VALUE);
+}
+
+#[test]
+fn test_execute_from_outside_v2_specific_caller() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let mut outside_execution = setup_outside_execution(simple_mock, false);
+    outside_execution.caller = CALLER();
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash);
+
+    cheat_caller_address(account_address, CALLER(), CheatSpan::TargetCalls(1));
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+
+    assert_value(simple_mock, FELT_VALUE);
+}
+
+#[test]
+fn test_execute_from_outside_v2_uses_nonce() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, false);
+
+    let is_valid_nonce = dispatcher.is_valid_outside_execution_nonce(outside_execution.nonce);
+    assert!(is_valid_nonce);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash);
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+
+    assert_value(simple_mock, FELT_VALUE);
+
+    let is_invalid_nonce = !dispatcher.is_valid_outside_execution_nonce(outside_execution.nonce);
+    assert!(is_invalid_nonce);
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: invalid caller')]
+fn test_execute_from_outside_v2_caller_mismatch() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let mut outside_execution = setup_outside_execution(account_address, false);
+    outside_execution.caller = CALLER();
+
+    start_cheat_caller_address(account_address, OTHER());
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: now >= execute_before')]
+fn test_execute_from_outside_v2_call_after_execute_before() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(25);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: now >= execute_before')]
+fn test_execute_from_outside_v2_call_equal_to_execute_before() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(20);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: ('SRC9: now <= execute_after',))]
+fn test_execute_from_outside_v2_call_before_execute_after() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(5);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: now <= execute_after')]
+fn test_execute_from_outside_v2_call_equal_to_execute_after() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(10);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: duplicated nonce')]
+fn test_execute_from_outside_v2_invalid_nonce() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, false);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash);
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: invalid signature')]
+fn test_execute_from_outside_v2_invalid_signature() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash);
+    let invalid_signature = array![*signature.at(0), *signature.at(1) + 1];
+
+    dispatcher.execute_from_outside_v2(outside_execution, invalid_signature.span());
+}
+
+#[test]
+#[should_panic(expected: "Some error")]
+fn test_execute_from_outside_v2_panics_when_inner_call_panic() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, true);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash);
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+}
+
+//
+// Helpers
+//
+
+fn setup_outside_execution(target: ContractAddress, panic: bool) -> OutsideExecution {
+    let call = Call {
+        to: target,
+        selector: selector!("set_balance"),
+        calldata: array![FELT_VALUE, panic.into()].span(),
+    };
+    let caller = contract_address_const::<'ANY_CALLER'>();
+    let nonce = 5;
+    let execute_after = 10;
+    let execute_before = 20;
+    let calls = array![call].span();
+
+    // Set a valid timestamp for the execution time span
+    start_cheat_block_timestamp_global(15);
+
+    OutsideExecution { caller, nonce, execute_after, execute_before, calls }
+}
+
+fn assert_value(target: ContractAddress, expected_value: felt252) {
+    let value = *load(target, selector!("balance"), 1).at(0);
+    assert_eq!(value, expected_value);
+}
diff --git a/packages/presets/src/tests/test_eth_account.cairo b/packages/presets/src/tests/test_eth_account.cairo
index 7b457edfd..4a7fae491 100644
--- a/packages/presets/src/tests/test_eth_account.cairo
+++ b/packages/presets/src/tests/test_eth_account.cairo
@@ -6,6 +6,10 @@ use crate::interfaces::eth_account::{
 use crate::interfaces::{
     EthAccountUpgradeableABIDispatcher, EthAccountUpgradeableABIDispatcherTrait
 };
+use openzeppelin_account::eth_account::EthAccountComponent::EthAccountMixinImpl;
+use openzeppelin_account::extensions::SRC9Component::{OutsideExecutionV2Impl, SNIP12MetadataImpl};
+use openzeppelin_account::extensions::src9::interface::{OutsideExecution, ISRC9_V2_ID};
+use openzeppelin_account::extensions::src9::snip12_utils::OutsideExecutionStructHash;
 use openzeppelin_account::interface::ISRC6_ID;
 use openzeppelin_account::utils::secp256_point::{DebugSecp256Point, Secp256PointPartialEq};
 use openzeppelin_introspection::interface::ISRC5_ID;
@@ -17,21 +21,25 @@ use openzeppelin_test_common::eth_account::{
 use openzeppelin_test_common::upgrades::UpgradeableSpyHelpers;
 use openzeppelin_testing as utils;
 use openzeppelin_testing::constants::secp256k1::{KEY_PAIR, KEY_PAIR_2};
-use openzeppelin_testing::constants::{CLASS_HASH_ZERO, ZERO, RECIPIENT, CALLER, OTHER};
+use openzeppelin_testing::constants::{CLASS_HASH_ZERO, ZERO, RECIPIENT, CALLER, OTHER, FELT_VALUE};
 use openzeppelin_testing::constants::{SALT, QUERY_VERSION, MIN_TRANSACTION_VERSION};
 use openzeppelin_testing::signing::Secp256k1KeyPair;
+use openzeppelin_testing::signing::SerializedSigning;
 use openzeppelin_token::erc20::interface::IERC20DispatcherTrait;
+use openzeppelin_utils::cryptography::snip12::OffchainMessageHash;
 use openzeppelin_utils::serde::SerializedAppend;
+use snforge_std::{
+    spy_events, test_address, load, CheatSpan, cheat_caller_address, start_cheat_caller_address
+};
 use snforge_std::{
     start_cheat_signature_global, start_cheat_transaction_version_global,
-    start_cheat_transaction_hash_global, start_cheat_caller_address
+    start_cheat_transaction_hash_global, start_cheat_block_timestamp_global
 };
-use snforge_std::{spy_events, test_address};
-use starknet::ClassHash;
 use starknet::SyscallResultTrait;
 use starknet::account::Call;
 use starknet::secp256_trait::Secp256Trait;
 use starknet::secp256k1::Secp256k1Point;
+use starknet::{contract_address_const, ContractAddress, ClassHash};
 
 fn declare_v2_class() -> ClassHash {
     utils::declare_class("SnakeEthAccountMock").class_hash
@@ -41,12 +49,16 @@ fn declare_v2_class() -> ClassHash {
 // Setup
 //
 
-fn setup_dispatcher(key_pair: Secp256k1KeyPair) -> EthAccountUpgradeableABIDispatcher {
+fn setup_dispatcher(
+    key_pair: Secp256k1KeyPair
+) -> (ContractAddress, EthAccountUpgradeableABIDispatcher) {
     let mut calldata = array![];
     calldata.append_serde(key_pair.public_key);
 
     let contract_address = utils::declare_and_deploy("EthAccountUpgradeable", calldata);
-    EthAccountUpgradeableABIDispatcher { contract_address }
+    let dispatcher = EthAccountUpgradeableABIDispatcher { contract_address };
+
+    (contract_address, dispatcher)
 }
 
 fn setup_dispatcher_with_data(
@@ -68,6 +80,10 @@ fn setup_dispatcher_with_data(
     (dispatcher, contract_class.class_hash.into())
 }
 
+fn setup_simple_mock() -> ContractAddress {
+    utils::declare_and_deploy("SimpleMock", array![])
+}
+
 //
 // constructor
 //
@@ -82,17 +98,17 @@ fn test_constructor() {
 
     spy.assert_only_event_owner_added(test_address(), key_pair.public_key);
 
-    let public_key = EthAccountUpgradeable::EthAccountMixinImpl::get_public_key(@state);
+    let public_key = state.get_public_key();
     assert_eq!(public_key, key_pair.public_key);
 
-    let supports_isrc5 = EthAccountUpgradeable::EthAccountMixinImpl::supports_interface(
-        @state, ISRC5_ID
-    );
+    let supports_isrc5 = state.supports_interface(ISRC5_ID);
     assert!(supports_isrc5);
-    let supports_isrc6 = EthAccountUpgradeable::EthAccountMixinImpl::supports_interface(
-        @state, ISRC6_ID
-    );
+
+    let supports_isrc6 = state.supports_interface(ISRC6_ID);
     assert!(supports_isrc6);
+
+    let supports_isrc9 = state.supports_interface(ISRC9_V2_ID);
+    assert!(supports_isrc9);
 }
 
 //
@@ -102,7 +118,7 @@ fn test_constructor() {
 #[test]
 fn test_public_key_setter_and_getter() {
     let key_pair = KEY_PAIR();
-    let dispatcher = setup_dispatcher(key_pair);
+    let (_, dispatcher) = setup_dispatcher(key_pair);
     let contract_address = dispatcher.contract_address;
     let mut spy = spy_events();
 
@@ -122,7 +138,7 @@ fn test_public_key_setter_and_getter() {
 #[test]
 fn test_public_key_setter_and_getter_camel() {
     let key_pair = KEY_PAIR();
-    let dispatcher = setup_dispatcher(key_pair);
+    let (_, dispatcher) = setup_dispatcher(key_pair);
     let contract_address = dispatcher.contract_address;
     let mut spy = spy_events();
 
@@ -141,7 +157,7 @@ fn test_public_key_setter_and_getter_camel() {
 #[test]
 #[should_panic(expected: ('EthAccount: unauthorized',))]
 fn test_set_public_key_different_account() {
-    let dispatcher = setup_dispatcher(KEY_PAIR());
+    let (_, dispatcher) = setup_dispatcher(KEY_PAIR());
 
     dispatcher.set_public_key(KEY_PAIR_2().public_key, array![].span());
 }
@@ -149,7 +165,7 @@ fn test_set_public_key_different_account() {
 #[test]
 #[should_panic(expected: ('EthAccount: unauthorized',))]
 fn test_setPublicKey_different_account() {
-    let dispatcher = setup_dispatcher(KEY_PAIR());
+    let (_, dispatcher) = setup_dispatcher(KEY_PAIR());
 
     dispatcher.setPublicKey(KEY_PAIR_2().public_key, array![].span());
 }
@@ -160,7 +176,7 @@ fn test_setPublicKey_different_account() {
 
 fn is_valid_sig_dispatcher() -> (EthAccountUpgradeableABIDispatcher, felt252, Array<felt252>) {
     let key_pair = KEY_PAIR();
-    let dispatcher = setup_dispatcher(key_pair);
+    let (_, dispatcher) = setup_dispatcher(key_pair);
     let data = SIGNED_TX_DATA(key_pair);
 
     let mut serialized_signature = array![];
@@ -208,7 +224,7 @@ fn test_isValidSignature_bad_sig() {
 #[test]
 fn test_supports_interface() {
     let key_pair = KEY_PAIR();
-    let dispatcher = setup_dispatcher(key_pair);
+    let (_, dispatcher) = setup_dispatcher(key_pair);
 
     let supports_isrc5 = dispatcher.supports_interface(ISRC5_ID);
     assert!(supports_isrc5);
@@ -438,7 +454,7 @@ fn test_multicall_zero_calls() {
 #[test]
 #[should_panic(expected: ('EthAccount: invalid caller',))]
 fn test_account_called_from_contract() {
-    let account = setup_dispatcher(KEY_PAIR());
+    let (_, account) = setup_dispatcher(KEY_PAIR());
     let calls = array![];
 
     start_cheat_caller_address(account.contract_address, CALLER());
@@ -453,14 +469,14 @@ fn test_account_called_from_contract() {
 #[test]
 #[should_panic(expected: ('EthAccount: unauthorized',))]
 fn test_upgrade_access_control() {
-    let v1 = setup_dispatcher(KEY_PAIR());
+    let (_, v1) = setup_dispatcher(KEY_PAIR());
     v1.upgrade(CLASS_HASH_ZERO());
 }
 
 #[test]
 #[should_panic(expected: ('Class hash cannot be zero',))]
 fn test_upgrade_with_class_hash_zero() {
-    let v1 = setup_dispatcher(KEY_PAIR());
+    let (_, v1) = setup_dispatcher(KEY_PAIR());
 
     start_cheat_caller_address(v1.contract_address, v1.contract_address);
     v1.upgrade(CLASS_HASH_ZERO());
@@ -468,7 +484,7 @@ fn test_upgrade_with_class_hash_zero() {
 
 #[test]
 fn test_upgraded_event() {
-    let v1 = setup_dispatcher(KEY_PAIR());
+    let (_, v1) = setup_dispatcher(KEY_PAIR());
     let contract_address = v1.contract_address;
     let mut spy = spy_events();
 
@@ -482,7 +498,7 @@ fn test_upgraded_event() {
 #[test]
 #[feature("safe_dispatcher")]
 fn test_v2_missing_camel_selector() {
-    let v1 = setup_dispatcher(KEY_PAIR());
+    let (_, v1) = setup_dispatcher(KEY_PAIR());
     let contract_address = v1.contract_address;
     let v2_class_hash = declare_v2_class();
 
@@ -498,7 +514,7 @@ fn test_v2_missing_camel_selector() {
 #[test]
 fn test_state_persists_after_upgrade() {
     let key_pair = KEY_PAIR();
-    let v1 = setup_dispatcher(key_pair);
+    let (_, v1) = setup_dispatcher(key_pair);
     let contract_address = v1.contract_address;
 
     start_cheat_caller_address(contract_address, contract_address);
@@ -520,10 +536,197 @@ fn test_state_persists_after_upgrade() {
     assert_eq!(snake_public_key, new_key_pair.public_key);
 }
 
+//
+// execute_from_outside_v2
+//
+
+#[test]
+fn test_execute_from_outside_v2_any_caller() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, false);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash.into());
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+
+    assert_value(simple_mock, FELT_VALUE);
+}
+
+#[test]
+fn test_execute_from_outside_v2_specific_caller() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let mut outside_execution = setup_outside_execution(simple_mock, false);
+    outside_execution.caller = CALLER();
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash.into());
+
+    cheat_caller_address(account_address, CALLER(), CheatSpan::TargetCalls(1));
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+
+    assert_value(simple_mock, FELT_VALUE);
+}
+
+#[test]
+fn test_execute_from_outside_v2_uses_nonce() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, false);
+
+    let is_valid_nonce = dispatcher.is_valid_outside_execution_nonce(outside_execution.nonce);
+    assert!(is_valid_nonce);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash.into());
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+
+    assert_value(simple_mock, FELT_VALUE);
+
+    let is_invalid_nonce = !dispatcher.is_valid_outside_execution_nonce(outside_execution.nonce);
+    assert!(is_invalid_nonce);
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: invalid caller')]
+fn test_execute_from_outside_v2_caller_mismatch() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let mut outside_execution = setup_outside_execution(account_address, false);
+    outside_execution.caller = CALLER();
+
+    start_cheat_caller_address(account_address, OTHER());
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: now >= execute_before')]
+fn test_execute_from_outside_v2_call_after_execute_before() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(25);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: now >= execute_before')]
+fn test_execute_from_outside_v2_call_equal_to_execute_before() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(20);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: ('SRC9: now <= execute_after',))]
+fn test_execute_from_outside_v2_call_before_execute_after() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(5);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: now <= execute_after')]
+fn test_execute_from_outside_v2_call_equal_to_execute_after() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    start_cheat_block_timestamp_global(10);
+
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: duplicated nonce')]
+fn test_execute_from_outside_v2_invalid_nonce() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, false);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash.into());
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+    dispatcher.execute_from_outside_v2(outside_execution, array![].span());
+}
+
+#[test]
+#[should_panic(expected: 'SRC9: invalid signature')]
+fn test_execute_from_outside_v2_invalid_signature() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let outside_execution = setup_outside_execution(account_address, false);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash.into());
+    let invalid_signature = array![
+        *signature.at(0), *signature.at(1), *signature.at(2), *signature.at(3) + 1
+    ];
+
+    dispatcher.execute_from_outside_v2(outside_execution, invalid_signature.span());
+}
+
+#[test]
+#[should_panic(expected: "Some error")]
+fn test_execute_from_outside_v2_panics_when_inner_call_panic() {
+    let key_pair = KEY_PAIR();
+    let (account_address, dispatcher) = setup_dispatcher(key_pair);
+    let simple_mock = setup_simple_mock();
+    let outside_execution = setup_outside_execution(simple_mock, true);
+
+    let msg_hash = outside_execution.get_message_hash(account_address);
+    let signature = key_pair.serialized_sign(msg_hash.into());
+
+    dispatcher.execute_from_outside_v2(outside_execution, signature.span());
+}
+
 //
 // Helpers
 //
 
+fn setup_outside_execution(target: ContractAddress, panic: bool) -> OutsideExecution {
+    let call = Call {
+        to: target,
+        selector: selector!("set_balance"),
+        calldata: array![FELT_VALUE, panic.into()].span(),
+    };
+    let caller = contract_address_const::<'ANY_CALLER'>();
+    let nonce = 5;
+    let execute_after = 10;
+    let execute_before = 20;
+    let calls = array![call].span();
+
+    // Set a valid timestamp for the execution time span
+    start_cheat_block_timestamp_global(15);
+
+    OutsideExecution { caller, nonce, execute_after, execute_before, calls }
+}
+
+fn assert_value(target: ContractAddress, expected_value: felt252) {
+    let value = *load(target, selector!("balance"), 1).at(0);
+    assert_eq!(value, expected_value);
+}
+
 fn get_points() -> (Secp256k1Point, Secp256k1Point) {
     let curve_size = Secp256Trait::<Secp256k1Point>::get_curve_size();
     let point_1 = Secp256Trait::secp256_ec_get_point_from_x_syscall(curve_size, true)
diff --git a/packages/test_common/src/mocks/simple.cairo b/packages/test_common/src/mocks/simple.cairo
index d1eab519a..4ee015a61 100644
--- a/packages/test_common/src/mocks/simple.cairo
+++ b/packages/test_common/src/mocks/simple.cairo
@@ -1,6 +1,7 @@
 #[starknet::interface]
 pub trait ISimpleMock<TContractState> {
     fn increase_balance(ref self: TContractState, amount: felt252) -> bool;
+    fn set_balance(ref self: TContractState, value: felt252, panic: bool);
     fn get_balance(self: @TContractState) -> felt252;
 }
 
@@ -20,6 +21,13 @@ pub mod SimpleMock {
             true
         }
 
+        fn set_balance(ref self: ContractState, value: felt252, panic: bool) {
+            if panic {
+                panic!("Some error");
+            }
+            self.balance.write(value);
+        }
+
         fn get_balance(self: @ContractState) -> felt252 {
             self.balance.read()
         }