diff --git a/docs/guide/account_and_client.rst b/docs/guide/account_and_client.rst index fa111b5f1..baf28f75c 100644 --- a/docs/guide/account_and_client.rst +++ b/docs/guide/account_and_client.rst @@ -46,6 +46,17 @@ Account also provides a way of creating signed transaction without sending them. :language: python :dedent: 4 +Outside execution +----------------- + +Outside execution allows a protocol to submit a transaction on behalf of another account. This feature is implemented according to `SNIP-9 `_. + +Account also provides a way of signing transaction which later can be execute by another account. Signer does not need to be funded with tokens as executor will pay the fee. + +.. codesnippet:: ../../starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py + :language: python + :dedent: 4 + Multicall --------- diff --git a/docs/migration_guide.rst b/docs/migration_guide.rst index 12d933a0f..3132611d8 100644 --- a/docs/migration_guide.rst +++ b/docs/migration_guide.rst @@ -12,6 +12,8 @@ Migration guide 1. Added :class:`NonZeroType` in order to fix parsing ABI which contains Cairo`s `core::zeroable::NonZero `_ +2. Added `SNIP-9 `_ support to :class:`~starknet_py.net.account.account.Account`. Now it's possible to create a :class:`~starknet_py.net.client_models.Call` for outside execution using :meth:`~starknet_py.net.account.account.Account.sign_outside_execution_call`. + ****************************** 0.24.3 Migration guide ****************************** diff --git a/starknet_py/constants.py b/starknet_py/constants.py index 00f3d9a67..35199aa2c 100644 --- a/starknet_py/constants.py +++ b/starknet_py/constants.py @@ -1,3 +1,4 @@ +from enum import IntEnum from pathlib import Path # Address came from starkware-libs/starknet-addresses repository: https://github.com/starkware-libs/starknet-addresses @@ -45,3 +46,12 @@ PUBLIC_KEY_RESPONSE_LENGTH = 65 SIGNATURE_RESPONSE_LENGTH = 65 VERSION_RESPONSE_LENGTH = 3 + +# Result of `encode_shortstring("ANY_CALLER")` +ANY_CALLER = 0x414E595F43414C4C4552 + + +# OUTSIDE EXECUTION INTERFACE_VERSION with ID +class OutsideExecutionInterfaceID(IntEnum): + V1 = 0x68CFD18B92D1907B8BA3CC324900277F5A3622099431EA85DD8089255E4181 + V2 = 0x1D1144BB2138366FF28D8E9AB57456B1D332AC42196230C3A602003C89872 diff --git a/starknet_py/hash/outside_execution.py b/starknet_py/hash/outside_execution.py new file mode 100644 index 000000000..a5c5f8f38 --- /dev/null +++ b/starknet_py/hash/outside_execution.py @@ -0,0 +1,121 @@ +from starknet_py.constants import OutsideExecutionInterfaceID +from starknet_py.net.client_models import OutsideExecution +from starknet_py.net.schemas.common import Revision +from starknet_py.utils.typed_data import TypedData + +OUTSIDE_EXECUTION_INTERFACE_ID_TO_TYPED_DATA_REVISION = { + OutsideExecutionInterfaceID.V1: Revision.V0, + OutsideExecutionInterfaceID.V2: Revision.V1, +} + + +# TODO(#1537): Implement as method of OutsideExecution +def outside_execution_to_typed_data( + outside_execution: OutsideExecution, + outside_execution_version: OutsideExecutionInterfaceID, + chain_id: int, +) -> TypedData: + """ + SNIP-12 Typed Data for OutsideExecution implementation. For revision V0 and V1. + """ + + revision = OUTSIDE_EXECUTION_INTERFACE_ID_TO_TYPED_DATA_REVISION[ + outside_execution_version + ] + + if revision == Revision.V0: + return TypedData.from_dict( + { + "types": { + "StarkNetDomain": [ + {"name": "name", "type": "felt"}, + {"name": "version", "type": "felt"}, + {"name": "chainId", "type": "felt"}, + ], + "OutsideExecution": [ + {"name": "caller", "type": "felt"}, + {"name": "nonce", "type": "felt"}, + {"name": "execute_after", "type": "felt"}, + {"name": "execute_before", "type": "felt"}, + {"name": "calls_len", "type": "felt"}, + {"name": "calls", "type": "OutsideCall*"}, + ], + "OutsideCall": [ + {"name": "to", "type": "felt"}, + {"name": "selector", "type": "felt"}, + {"name": "calldata_len", "type": "felt"}, + {"name": "calldata", "type": "felt*"}, + ], + }, + "primaryType": "OutsideExecution", + "domain": { + "name": "Account.execute_from_outside", + "version": "1", + "chainId": str(chain_id), + "revision": Revision.V0, + }, + "message": { + "caller": outside_execution.caller, + "nonce": outside_execution.nonce, + "execute_after": outside_execution.execute_after, + "execute_before": outside_execution.execute_before, + "calls_len": len(outside_execution.calls), + "calls": [ + { + "to": call.to_addr, + "selector": call.selector, + "calldata_len": len(call.calldata), + "calldata": call.calldata, + } + for call in outside_execution.calls + ], + }, + } + ) + + # revision == Revision.V1 + return TypedData.from_dict( + { + "types": { + "StarknetDomain": [ + {"name": "name", "type": "shortstring"}, + {"name": "version", "type": "shortstring"}, + {"name": "chainId", "type": "shortstring"}, + {"name": "revision", "type": "shortstring"}, + ], + "OutsideExecution": [ + {"name": "Caller", "type": "ContractAddress"}, + {"name": "Nonce", "type": "felt"}, + {"name": "Execute After", "type": "u128"}, + {"name": "Execute Before", "type": "u128"}, + {"name": "Calls", "type": "Call*"}, + ], + "Call": [ + {"name": "To", "type": "ContractAddress"}, + {"name": "Selector", "type": "selector"}, + {"name": "Calldata", "type": "felt*"}, + ], + }, + "primaryType": "OutsideExecution", + "domain": { + "name": "Account.execute_from_outside", + "version": "2", + "chainId": str(chain_id), + "revision": Revision.V1, + }, + "message": { + "Caller": outside_execution.caller, + "Nonce": outside_execution.nonce, + "Execute After": outside_execution.execute_after, + "Execute Before": outside_execution.execute_before, + "Calls": [ + { + "To": call.to_addr, + "Selector": call.selector, + "Calldata": call.calldata, + } + for call in outside_execution.calls + ], + }, + } + ) diff --git a/starknet_py/net/account/account.py b/starknet_py/net/account/account.py index 15c878e7d..ee1c8cfca 100644 --- a/starknet_py/net/account/account.py +++ b/starknet_py/net/account/account.py @@ -4,18 +4,29 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from starknet_py.common import create_compiled_contract, create_sierra_compiled_contract -from starknet_py.constants import FEE_CONTRACT_ADDRESS, QUERY_VERSION_BASE +from starknet_py.constants import ( + ANY_CALLER, + FEE_CONTRACT_ADDRESS, + QUERY_VERSION_BASE, + OutsideExecutionInterfaceID, +) from starknet_py.hash.address import compute_address +from starknet_py.hash.outside_execution import outside_execution_to_typed_data from starknet_py.hash.selector import get_selector_from_name from starknet_py.hash.utils import verify_message_signature from starknet_py.net.account.account_deployment_result import AccountDeploymentResult -from starknet_py.net.account.base_account import BaseAccount +from starknet_py.net.account.base_account import ( + BaseAccount, + OutsideExecutionSupportBaseMixin, +) from starknet_py.net.client import Client from starknet_py.net.client_models import ( Call, Calls, EstimatedFee, Hash, + OutsideExecution, + OutsideExecutionTimeBounds, ResourceBounds, ResourceBoundsMapping, SentTransactionResponse, @@ -40,21 +51,21 @@ from starknet_py.net.signer import BaseSigner from starknet_py.net.signer.key_pair import KeyPair from starknet_py.net.signer.stark_curve_signer import StarkCurveSigner -from starknet_py.serialization.data_serializers.array_serializer import ArraySerializer -from starknet_py.serialization.data_serializers.felt_serializer import FeltSerializer -from starknet_py.serialization.data_serializers.payload_serializer import ( +from starknet_py.serialization.data_serializers import ( + ArraySerializer, + FeltSerializer, PayloadSerializer, -) -from starknet_py.serialization.data_serializers.struct_serializer import ( StructSerializer, + UintSerializer, ) from starknet_py.utils.iterable import ensure_iterable from starknet_py.utils.sync import add_sync_methods from starknet_py.utils.typed_data import TypedData +# pylint: disable=too-many-public-methods,disable=too-many-lines @add_sync_methods -class Account(BaseAccount): +class Account(BaseAccount, OutsideExecutionSupportBaseMixin): """ Default Account implementation. """ @@ -291,6 +302,55 @@ async def get_nonce( self.address, block_hash=block_hash, block_number=block_number ) + async def _check_outside_execution_nonce( + self, + nonce: int, + *, + block_hash: Optional[Union[Hash, Tag]] = None, + block_number: Optional[Union[int, Tag]] = None, + ) -> bool: + (is_valid,) = await self._client.call_contract( + call=Call( + to_addr=self.address, + selector=get_selector_from_name("is_valid_outside_execution_nonce"), + calldata=[nonce], + ), + block_hash=block_hash, + block_number=block_number, + ) + return bool(is_valid) + + async def get_outside_execution_nonce(self, retry_count=10) -> int: + while retry_count > 0: + random_stark_address = KeyPair.generate().public_key + if await self._check_outside_execution_nonce(random_stark_address): + return random_stark_address + retry_count -= 1 + raise RuntimeError("Failed to generate a valid nonce") + + async def _get_outside_execution_version( + self, + ) -> Union[OutsideExecutionInterfaceID, None]: + for version in [ + OutsideExecutionInterfaceID.V1, + OutsideExecutionInterfaceID.V2, + ]: + if await self.supports_interface(version): + return version + return None + + async def supports_interface( + self, interface_id: OutsideExecutionInterfaceID + ) -> bool: + (does_support,) = await self._client.call_contract( + Call( + to_addr=self.address, + selector=get_selector_from_name("supports_interface"), + calldata=[interface_id], + ) + ) + return bool(does_support) + async def get_balance( self, token_address: Optional[AddressRepresentation] = None, @@ -345,6 +405,56 @@ async def sign_invoke_v1( signature = self.signer.sign_transaction(execute_tx) return _add_signature_to_transaction(execute_tx, signature) + async def sign_outside_execution_call( + self, + calls: Calls, + execution_time_bounds: OutsideExecutionTimeBounds, + *, + caller: AddressRepresentation = ANY_CALLER, + nonce: Optional[int] = None, + interface_version: Optional[OutsideExecutionInterfaceID] = None, + ) -> Call: + if interface_version is None: + interface_version = await self._get_outside_execution_version() + + if interface_version is None: + raise RuntimeError( + "Can't initiate call, outside execution is not supported." + ) + + if nonce is None: + nonce = await self.get_outside_execution_nonce() + + outside_execution = OutsideExecution( + caller=parse_address(caller), + nonce=nonce, + execute_after=execution_time_bounds.execute_after_timestamp, + execute_before=execution_time_bounds.execute_before_timestamp, + calls=list(ensure_iterable(calls)), + ) + chain_id = await self._get_chain_id() + signature = self.signer.sign_message( + outside_execution_to_typed_data( + outside_execution, interface_version, chain_id + ), + self.address, + ) + selector_for_version = { + OutsideExecutionInterfaceID.V1: "execute_from_outside", + OutsideExecutionInterfaceID.V2: "execute_from_outside_v2", + } + + return Call( + to_addr=self.address, + selector=get_selector_from_name(selector_for_version[interface_version]), + calldata=_outside_transaction_serialiser.serialize( + { + "outside_execution": outside_execution.to_abi_dict(), + "signature": signature, + } + ), + ) + async def sign_invoke_v3( self, calls: Calls, @@ -890,3 +1000,17 @@ def _parse_calls_cairo_v1(calls: Iterable[Call]) -> List[Dict]: calls=ArraySerializer(_call_description_cairo_v1), ) ) +_outside_transaction_serialiser = StructSerializer( + OrderedDict( + outside_execution=StructSerializer( + OrderedDict( + caller=FeltSerializer(), + nonce=FeltSerializer(), + execute_after=UintSerializer(bits=64), + execute_before=UintSerializer(bits=64), + calls=ArraySerializer(_call_description_cairo_v1), + ) + ), + signature=ArraySerializer(FeltSerializer()), + ) +) diff --git a/starknet_py/net/account/base_account.py b/starknet_py/net/account/base_account.py index 5542a9dad..36838be24 100644 --- a/starknet_py/net/account/base_account.py +++ b/starknet_py/net/account/base_account.py @@ -1,11 +1,14 @@ from abc import ABC, abstractmethod from typing import List, Optional, Union +from starknet_py.constants import ANY_CALLER, OutsideExecutionInterfaceID from starknet_py.net.client import Client from starknet_py.net.client_models import ( + Call, Calls, EstimatedFee, Hash, + OutsideExecutionTimeBounds, ResourceBounds, SentTransactionResponse, Tag, @@ -25,7 +28,45 @@ from starknet_py.net.models.typed_data import TypedDataDict -class BaseAccount(ABC): +class OutsideExecutionSupportBaseMixin(ABC): + + @abstractmethod + async def get_outside_execution_nonce(self) -> int: + """ + Generate special valid nonce for outside execution calls. + """ + + @abstractmethod + async def supports_interface( + self, interface_id: OutsideExecutionInterfaceID + ) -> bool: + """ + Check if the account supports the given outside execution interface. Part of ISRC5 standard. + """ + + @abstractmethod + async def sign_outside_execution_call( + self, + calls: Calls, + execution_time_bounds: OutsideExecutionTimeBounds, + *, + caller: AddressRepresentation = ANY_CALLER, + nonce: Optional[int] = None, + interface_version: Optional[OutsideExecutionInterfaceID] = None, + ) -> Call: + """ + Creates a call for an outcide execution (SNIP-9 specification). + + :param calls: Single call or list of calls to be executed by outside caller. + :param execution_time_bounds: Execution time bounds for the call. + :param caller: Address of the caller. IMPORTANT! By default it is ANY_CALLER. + :param nonce: Nonce for the transaction. Is populated automatically if not provided. + :param interface_version: Outside execution interface version. Method will check which version account + supports and use the highest one and populate the value. + """ + + +class BaseAccount(OutsideExecutionSupportBaseMixin, ABC): """ Base class for all account implementations. diff --git a/starknet_py/net/client_models.py b/starknet_py/net/client_models.py index c198d3c97..a9394b1a9 100644 --- a/starknet_py/net/client_models.py +++ b/starknet_py/net/client_models.py @@ -7,11 +7,12 @@ to true. Consequently, any unknown fields in response will be excluded. """ +import datetime import json from abc import ABC from dataclasses import dataclass, field from enum import Enum -from typing import Any, Iterable, List, Literal, Optional, Union, cast +from typing import Any, Dict, Iterable, List, Literal, Optional, Union, cast from marshmallow import EXCLUDE @@ -115,6 +116,25 @@ def init_with_zeros(): return ResourceBounds(max_amount=0, max_price_per_unit=0) +@dataclass +class OutsideExecutionTimeBounds: + """ + Dataclass representing time bounds within which outside execution + transaction is valid and allowed to be executed. + """ + + execute_after: datetime.datetime + execute_before: datetime.datetime + + @property + def execute_after_timestamp(self) -> int: + return int(self.execute_after.timestamp()) + + @property + def execute_before_timestamp(self) -> int: + return int(self.execute_before.timestamp()) + + @dataclass class ResourceBoundsMapping: """ @@ -1106,3 +1126,38 @@ class BlockTransactionTrace: transaction_hash: int trace_root: TransactionTrace + + +@dataclass +class OutsideExecution: + """ + Dataclass representing an outside execution. + (SNIP-9)[https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md] + """ + + caller: int + nonce: int + execute_after: int + execute_before: int + calls: List[Call] + + # TODO(#1537): Use serialiser to convert to ABI dict. + def to_abi_dict(self) -> Dict: + """ + Returns a dictionary that can be serialized (compiled) into calldata + using StructSerializer + """ + return { + "caller": self.caller, + "nonce": self.nonce, + "execute_after": self.execute_after, + "execute_before": self.execute_before, + "calls": [ + { + "to": call.to_addr, + "selector": call.selector, + "calldata": call.calldata, + } + for call in self.calls + ], + } diff --git a/starknet_py/net/client_utils.py b/starknet_py/net/client_utils.py index 27a86ff3b..97b3f45a2 100644 --- a/starknet_py/net/client_utils.py +++ b/starknet_py/net/client_utils.py @@ -1,12 +1,10 @@ import re -from typing import Dict, Union, cast +from typing import Union from typing_extensions import get_args from starknet_py.hash.utils import encode_uint, encode_uint_list from starknet_py.net.client_models import Hash, L1HandlerTransaction, Tag -from starknet_py.net.models.transaction import AccountTransaction -from starknet_py.net.schemas.broadcasted_txn import BroadcastedTransactionSchema def hash_to_felt(value: Hash) -> str: @@ -79,10 +77,3 @@ def _is_valid_eth_address(address: str) -> bool: A function checking if an address matches Ethereum address regex. Note that it doesn't validate any checksums etc. """ return bool(re.fullmatch("^0x[a-fA-F0-9]{40}$", address)) - - -def _create_broadcasted_txn(transaction: AccountTransaction) -> dict: - return cast( - Dict, - BroadcastedTransactionSchema().dump(obj=transaction), - ) diff --git a/starknet_py/net/full_node_client.py b/starknet_py/net/full_node_client.py index 5137d9e4c..36c1a4abd 100644 --- a/starknet_py/net/full_node_client.py +++ b/starknet_py/net/full_node_client.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union, cast +from typing import Dict, List, Optional, Tuple, Union, cast import aiohttp @@ -37,7 +37,6 @@ TransactionTrace, ) from starknet_py.net.client_utils import ( - _create_broadcasted_txn, _is_valid_eth_address, _to_rpc_felt, _to_storage_key, @@ -50,6 +49,7 @@ DeployAccount, Invoke, ) +from starknet_py.net.schemas.broadcasted_txn import BroadcastedTransactionSchema from starknet_py.net.schemas.rpc.block import ( BlockHashAndNumberSchema, BlockStateUpdateSchema, @@ -85,6 +85,13 @@ from starknet_py.utils.sync import add_sync_methods +def _create_broadcasted_txn(transaction: AccountTransaction) -> dict: + return cast( + Dict, + BroadcastedTransactionSchema().dump(obj=transaction), + ) + + @add_sync_methods class FullNodeClient(Client): # pylint: disable=too-many-public-methods diff --git a/starknet_py/net/models/typed_data.py b/starknet_py/net/models/typed_data.py index 6a2c23aba..972391b29 100644 --- a/starknet_py/net/models/typed_data.py +++ b/starknet_py/net/models/typed_data.py @@ -2,10 +2,16 @@ TypedDict structures for TypedData """ +import sys from typing import Any, Dict, List, Optional, TypedDict from starknet_py.net.schemas.common import Revision +if sys.version_info < (3, 11): + from typing_extensions import NotRequired +else: + from typing import NotRequired + class ParameterDict(TypedDict): """ @@ -14,7 +20,7 @@ class ParameterDict(TypedDict): name: str type: str - contains: Optional[str] + contains: NotRequired[str] class DomainDict(TypedDict): diff --git a/starknet_py/net/schemas/common.py b/starknet_py/net/schemas/common.py index d0e03d85c..ee3584bf1 100644 --- a/starknet_py/net/schemas/common.py +++ b/starknet_py/net/schemas/common.py @@ -367,6 +367,9 @@ def _deserialize(self, value, attr, data, **kwargs) -> Revision: if isinstance(value, str): value = int(value) + if isinstance(value, Revision): + value = value.value + revisions = [revision.value for revision in Revision] if value not in revisions: allowed_revisions_str = "".join(list(map(str, revisions))) diff --git a/starknet_py/serialization/data_serializers/__init__.py b/starknet_py/serialization/data_serializers/__init__.py index f8a711184..3d67c45fe 100644 --- a/starknet_py/serialization/data_serializers/__init__.py +++ b/starknet_py/serialization/data_serializers/__init__.py @@ -8,3 +8,4 @@ from .struct_serializer import StructSerializer from .tuple_serializer import TupleSerializer from .uint256_serializer import Uint256Serializer +from .uint_serializer import UintSerializer diff --git a/starknet_py/tests/e2e/account/outside_execution_test.py b/starknet_py/tests/e2e/account/outside_execution_test.py new file mode 100644 index 000000000..00f24f6d3 --- /dev/null +++ b/starknet_py/tests/e2e/account/outside_execution_test.py @@ -0,0 +1,128 @@ +import datetime + +import pytest + +from starknet_py.constants import ANY_CALLER, OutsideExecutionInterfaceID +from starknet_py.hash.selector import get_selector_from_name +from starknet_py.net.account.account import BaseAccount +from starknet_py.net.client_models import Call, OutsideExecutionTimeBounds +from starknet_py.tests.e2e.fixtures.constants import MAX_FEE +from starknet_py.transaction_errors import TransactionRevertedError + + +@pytest.mark.asyncio +async def test_argent_account_outside_execution_compatibility( + argent_account: BaseAccount, +): + result = await argent_account.supports_interface(OutsideExecutionInterfaceID.V1) + assert result is True + result = await argent_account.supports_interface(OutsideExecutionInterfaceID.V2) + assert result is False + + +@pytest.mark.asyncio +async def test_account_outside_execution_any_caller( + argent_account: BaseAccount, + map_contract, +): + + assert any( + [ + await argent_account.supports_interface(OutsideExecutionInterfaceID.V1), + await argent_account.supports_interface(OutsideExecutionInterfaceID.V2), + ] + ) + + put_call = Call( + to_addr=map_contract.address, + selector=get_selector_from_name("put"), + calldata=[20, 20], + ) + + call = await argent_account.sign_outside_execution_call( + calls=[ + put_call, + ], + execution_time_bounds=OutsideExecutionTimeBounds( + execute_after=datetime.datetime.now() - datetime.timedelta(hours=1), + execute_before=datetime.datetime.now() + datetime.timedelta(hours=1), + ), + caller=ANY_CALLER, + ) + + tx = await argent_account.execute_v1(calls=[call], max_fee=MAX_FEE) + await argent_account.client.wait_for_tx(tx.transaction_hash) + + +@pytest.mark.asyncio +async def test_account_outside_execution_for_invalid_caller( + argent_account: BaseAccount, + account: BaseAccount, + map_contract, +): + assert any( + [ + await argent_account.supports_interface(OutsideExecutionInterfaceID.V1), + await argent_account.supports_interface(OutsideExecutionInterfaceID.V2), + ] + ) + + put_call = Call( + to_addr=map_contract.address, + selector=get_selector_from_name("put"), + calldata=[20, 20], + ) + + call = await argent_account.sign_outside_execution_call( + calls=[ + put_call, + ], + execution_time_bounds=OutsideExecutionTimeBounds( + execute_after=datetime.datetime.now() - datetime.timedelta(hours=1), + execute_before=datetime.datetime.now() + datetime.timedelta(hours=1), + ), + caller=account.address, + ) + + tx = await argent_account.execute_v1(calls=[call], max_fee=MAX_FEE) + + with pytest.raises(TransactionRevertedError) as err: + await argent_account.client.wait_for_tx(tx.transaction_hash) + + assert "argent/invalid-caller" in err.value.message + + +@pytest.mark.asyncio +async def test_account_outside_execution_for_impossible_time_bounds( + argent_account: BaseAccount, + map_contract, +): + + assert any( + [ + await argent_account.supports_interface(OutsideExecutionInterfaceID.V1), + await argent_account.supports_interface(OutsideExecutionInterfaceID.V2), + ] + ) + + put_call = Call( + to_addr=map_contract.address, + selector=get_selector_from_name("put"), + calldata=[20, 20], + ) + + call = await argent_account.sign_outside_execution_call( + calls=[put_call], + execution_time_bounds=OutsideExecutionTimeBounds( + execute_after=datetime.datetime.now() - datetime.timedelta(days=10), + execute_before=datetime.datetime.now() - datetime.timedelta(days=9), + ), + caller=ANY_CALLER, + ) + + tx = await argent_account.execute_v1(calls=[call], max_fee=MAX_FEE) + + with pytest.raises(TransactionRevertedError) as err: + await argent_account.client.wait_for_tx(tx.transaction_hash) + + assert "argent/invalid-timestamp" in err.value.message diff --git a/starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py b/starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py new file mode 100644 index 000000000..0d9fc4bf8 --- /dev/null +++ b/starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py @@ -0,0 +1,53 @@ +import pytest + +from starknet_py.net.client_models import TransactionFinalityStatus + + +@pytest.mark.asyncio +async def test_account_outside_execution_any_caller( + account, + argent_account, + map_contract, +): + # pylint: disable=import-outside-toplevel,too-many-locals + + # docs: start + import datetime + + from starknet_py.constants import ANY_CALLER + from starknet_py.hash.selector import get_selector_from_name + from starknet_py.net.client_models import Call, OutsideExecutionTimeBounds + + # Create a call to put value 100 at key 1. + put_call = Call( + to_addr=map_contract.address, + selector=get_selector_from_name("put"), + calldata=[1, 100], + ) + + # Create an outside execution call. This call can now be executed by + # the specified caller. In this case, anyone will be able to execute it. + + # Note that signing account does not need to have any funds to sign the transaction. + call = await argent_account.sign_outside_execution_call( + calls=[ + put_call, + ], + # The transaction can be executed in specified timeframe. + execution_time_bounds=OutsideExecutionTimeBounds( + execute_after=datetime.datetime.now() - datetime.timedelta(hours=1), + execute_before=datetime.datetime.now() + datetime.timedelta(hours=1), + ), + # Use ANY_CALLER, a special constant that allows anyone to execute the call. + caller=ANY_CALLER, + ) + + # Now, if you're in specified timeframe, you can perform the outside execution by another account. + tx = await account.execute_v1(calls=[call], max_fee=int(1e18)) + await account.client.wait_for_tx(tx.transaction_hash) + + # docs: end + + receipt = await account.client.get_transaction_receipt(tx_hash=tx.transaction_hash) + + assert receipt.finality_status == TransactionFinalityStatus.ACCEPTED_ON_L2 diff --git a/starknet_py/tests/unit/net/client_test.py b/starknet_py/tests/unit/net/client_test.py index e35d4152a..ae9fb87a3 100644 --- a/starknet_py/tests/unit/net/client_test.py +++ b/starknet_py/tests/unit/net/client_test.py @@ -12,8 +12,7 @@ TransactionType, TransactionV3, ) -from starknet_py.net.client_utils import _create_broadcasted_txn -from starknet_py.net.full_node_client import _to_storage_key +from starknet_py.net.full_node_client import _create_broadcasted_txn, _to_storage_key from starknet_py.net.http_client import RpcHttpClient, ServerError from starknet_py.net.models.transaction import ( DeclareV2,