Skip to content

Commit a8d7353

Browse files
Support SNIP-9 (#1530)
* Big things: - Added new Transaction models: InvokeOutsideV1, InvokeOutsideV2 - Added method for SNIP9 nonce verification - Added Account methods for population and broadcasting(execution) of InvokeOutside transactions Small fixes: - Allowed for incomplete definition of ParameterDict as `contains` fiels is often missing and linter is complaining. WIP: miss docs and tests * - Added outside execution model, defined hashing - Extended base account interface - Added all utilities to deal with SNIP-9 nonce and generating OutsideExecution call - Bugfix type data generation - Cleaned up as much as I could to keep changes minimal - Added docs - Added test for positive scenario - Added one test for wrong caller scenario * * lint * test fixes * ok. so I am not allowed to change ci configs * fixing tests * lint * switched back to increase balance * tiny revert * comment documentation test to check if that is what affects test account balance * Revert "comment documentation test to check if that is what affects test account balance" This reverts commit 5851f12. * change for another call in documentation. removed account deployment. * fix * fix doctest * more fixes * remove balance change calls * Fixed naming * fix documentation * added secrets * trigger build * Review comments fixws * linter * fixup * Added comment * fix * lost empty line * comments fixes * Update starknet_py/constants.py Co-authored-by: Franciszek Job <[email protected]> * comment fixupi * fix wordings * fix * fixes * more fixes * more fixes * rename * Fix * linter * Update starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py Co-authored-by: Franciszek Job <[email protected]> * Update starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py Co-authored-by: Franciszek Job <[email protected]> * Update starknet_py/net/account/base_account.py Co-authored-by: Franciszek Job <[email protected]> * a bit more review comment fixes * revert execute_v1 * remove auto fee estimation * Update docs/guide/account_and_client.rst Co-authored-by: Franciszek Job <[email protected]> * Update starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py Co-authored-by: Franciszek Job <[email protected]> * import fix * fix doctest * Update starknet_py/tests/e2e/account/outside_execution_test.py Co-authored-by: Franciszek Job <[email protected]> * Update starknet_py/tests/e2e/account/outside_execution_test.py Co-authored-by: Franciszek Job <[email protected]> * changelog * Update docs/migration_guide.rst Co-authored-by: Franciszek Job <[email protected]> * fixing fixes --------- Co-authored-by: Franciszek Job <[email protected]>
1 parent 3996323 commit a8d7353

15 files changed

+577
-25
lines changed

docs/guide/account_and_client.rst

+11
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ Account also provides a way of creating signed transaction without sending them.
4646
:language: python
4747
:dedent: 4
4848

49+
Outside execution
50+
-----------------
51+
52+
Outside execution allows a protocol to submit a transaction on behalf of another account. This feature is implemented according to `SNIP-9 <https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md>`_.
53+
54+
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.
55+
56+
.. codesnippet:: ../../starknet_py/tests/e2e/docs/guide/test_account_sign_outside_transaction.py
57+
:language: python
58+
:dedent: 4
59+
4960
Multicall
5061
---------
5162

docs/migration_guide.rst

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ Migration guide
1212

1313
1. Added :class:`NonZeroType` in order to fix parsing ABI which contains Cairo`s `core::zeroable::NonZero <https://github.com/starkware-libs/cairo/blob/a2b9dddeb3212c8d529538454745b27d7a34a6cd/corelib/src/zeroable.cairo#L78>`_
1414

15+
2. Added `SNIP-9 <https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md>`_ 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`.
16+
1517
******************************
1618
0.24.3 Migration guide
1719
******************************

starknet_py/constants.py

+10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import IntEnum
12
from pathlib import Path
23

34
# Address came from starkware-libs/starknet-addresses repository: https://github.com/starkware-libs/starknet-addresses
@@ -45,3 +46,12 @@
4546
PUBLIC_KEY_RESPONSE_LENGTH = 65
4647
SIGNATURE_RESPONSE_LENGTH = 65
4748
VERSION_RESPONSE_LENGTH = 3
49+
50+
# Result of `encode_shortstring("ANY_CALLER")`
51+
ANY_CALLER = 0x414E595F43414C4C4552
52+
53+
54+
# OUTSIDE EXECUTION INTERFACE_VERSION with ID
55+
class OutsideExecutionInterfaceID(IntEnum):
56+
V1 = 0x68CFD18B92D1907B8BA3CC324900277F5A3622099431EA85DD8089255E4181
57+
V2 = 0x1D1144BB2138366FF28D8E9AB57456B1D332AC42196230C3A602003C89872

starknet_py/hash/outside_execution.py

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from starknet_py.constants import OutsideExecutionInterfaceID
2+
from starknet_py.net.client_models import OutsideExecution
3+
from starknet_py.net.schemas.common import Revision
4+
from starknet_py.utils.typed_data import TypedData
5+
6+
OUTSIDE_EXECUTION_INTERFACE_ID_TO_TYPED_DATA_REVISION = {
7+
OutsideExecutionInterfaceID.V1: Revision.V0,
8+
OutsideExecutionInterfaceID.V2: Revision.V1,
9+
}
10+
11+
12+
# TODO(#1537): Implement as method of OutsideExecution
13+
def outside_execution_to_typed_data(
14+
outside_execution: OutsideExecution,
15+
outside_execution_version: OutsideExecutionInterfaceID,
16+
chain_id: int,
17+
) -> TypedData:
18+
"""
19+
SNIP-12 Typed Data for OutsideExecution implementation. For revision V0 and V1.
20+
"""
21+
22+
revision = OUTSIDE_EXECUTION_INTERFACE_ID_TO_TYPED_DATA_REVISION[
23+
outside_execution_version
24+
]
25+
26+
if revision == Revision.V0:
27+
return TypedData.from_dict(
28+
{
29+
"types": {
30+
"StarkNetDomain": [
31+
{"name": "name", "type": "felt"},
32+
{"name": "version", "type": "felt"},
33+
{"name": "chainId", "type": "felt"},
34+
],
35+
"OutsideExecution": [
36+
{"name": "caller", "type": "felt"},
37+
{"name": "nonce", "type": "felt"},
38+
{"name": "execute_after", "type": "felt"},
39+
{"name": "execute_before", "type": "felt"},
40+
{"name": "calls_len", "type": "felt"},
41+
{"name": "calls", "type": "OutsideCall*"},
42+
],
43+
"OutsideCall": [
44+
{"name": "to", "type": "felt"},
45+
{"name": "selector", "type": "felt"},
46+
{"name": "calldata_len", "type": "felt"},
47+
{"name": "calldata", "type": "felt*"},
48+
],
49+
},
50+
"primaryType": "OutsideExecution",
51+
"domain": {
52+
"name": "Account.execute_from_outside",
53+
"version": "1",
54+
"chainId": str(chain_id),
55+
"revision": Revision.V0,
56+
},
57+
"message": {
58+
"caller": outside_execution.caller,
59+
"nonce": outside_execution.nonce,
60+
"execute_after": outside_execution.execute_after,
61+
"execute_before": outside_execution.execute_before,
62+
"calls_len": len(outside_execution.calls),
63+
"calls": [
64+
{
65+
"to": call.to_addr,
66+
"selector": call.selector,
67+
"calldata_len": len(call.calldata),
68+
"calldata": call.calldata,
69+
}
70+
for call in outside_execution.calls
71+
],
72+
},
73+
}
74+
)
75+
76+
# revision == Revision.V1
77+
return TypedData.from_dict(
78+
{
79+
"types": {
80+
"StarknetDomain": [
81+
{"name": "name", "type": "shortstring"},
82+
{"name": "version", "type": "shortstring"},
83+
{"name": "chainId", "type": "shortstring"},
84+
{"name": "revision", "type": "shortstring"},
85+
],
86+
"OutsideExecution": [
87+
{"name": "Caller", "type": "ContractAddress"},
88+
{"name": "Nonce", "type": "felt"},
89+
{"name": "Execute After", "type": "u128"},
90+
{"name": "Execute Before", "type": "u128"},
91+
{"name": "Calls", "type": "Call*"},
92+
],
93+
"Call": [
94+
{"name": "To", "type": "ContractAddress"},
95+
{"name": "Selector", "type": "selector"},
96+
{"name": "Calldata", "type": "felt*"},
97+
],
98+
},
99+
"primaryType": "OutsideExecution",
100+
"domain": {
101+
"name": "Account.execute_from_outside",
102+
"version": "2",
103+
"chainId": str(chain_id),
104+
"revision": Revision.V1,
105+
},
106+
"message": {
107+
"Caller": outside_execution.caller,
108+
"Nonce": outside_execution.nonce,
109+
"Execute After": outside_execution.execute_after,
110+
"Execute Before": outside_execution.execute_before,
111+
"Calls": [
112+
{
113+
"To": call.to_addr,
114+
"Selector": call.selector,
115+
"Calldata": call.calldata,
116+
}
117+
for call in outside_execution.calls
118+
],
119+
},
120+
}
121+
)

starknet_py/net/account/account.py

+132-8
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,29 @@
44
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
55

66
from starknet_py.common import create_compiled_contract, create_sierra_compiled_contract
7-
from starknet_py.constants import FEE_CONTRACT_ADDRESS, QUERY_VERSION_BASE
7+
from starknet_py.constants import (
8+
ANY_CALLER,
9+
FEE_CONTRACT_ADDRESS,
10+
QUERY_VERSION_BASE,
11+
OutsideExecutionInterfaceID,
12+
)
813
from starknet_py.hash.address import compute_address
14+
from starknet_py.hash.outside_execution import outside_execution_to_typed_data
915
from starknet_py.hash.selector import get_selector_from_name
1016
from starknet_py.hash.utils import verify_message_signature
1117
from starknet_py.net.account.account_deployment_result import AccountDeploymentResult
12-
from starknet_py.net.account.base_account import BaseAccount
18+
from starknet_py.net.account.base_account import (
19+
BaseAccount,
20+
OutsideExecutionSupportBaseMixin,
21+
)
1322
from starknet_py.net.client import Client
1423
from starknet_py.net.client_models import (
1524
Call,
1625
Calls,
1726
EstimatedFee,
1827
Hash,
28+
OutsideExecution,
29+
OutsideExecutionTimeBounds,
1930
ResourceBounds,
2031
ResourceBoundsMapping,
2132
SentTransactionResponse,
@@ -40,21 +51,21 @@
4051
from starknet_py.net.signer import BaseSigner
4152
from starknet_py.net.signer.key_pair import KeyPair
4253
from starknet_py.net.signer.stark_curve_signer import StarkCurveSigner
43-
from starknet_py.serialization.data_serializers.array_serializer import ArraySerializer
44-
from starknet_py.serialization.data_serializers.felt_serializer import FeltSerializer
45-
from starknet_py.serialization.data_serializers.payload_serializer import (
54+
from starknet_py.serialization.data_serializers import (
55+
ArraySerializer,
56+
FeltSerializer,
4657
PayloadSerializer,
47-
)
48-
from starknet_py.serialization.data_serializers.struct_serializer import (
4958
StructSerializer,
59+
UintSerializer,
5060
)
5161
from starknet_py.utils.iterable import ensure_iterable
5262
from starknet_py.utils.sync import add_sync_methods
5363
from starknet_py.utils.typed_data import TypedData
5464

5565

66+
# pylint: disable=too-many-public-methods,disable=too-many-lines
5667
@add_sync_methods
57-
class Account(BaseAccount):
68+
class Account(BaseAccount, OutsideExecutionSupportBaseMixin):
5869
"""
5970
Default Account implementation.
6071
"""
@@ -291,6 +302,55 @@ async def get_nonce(
291302
self.address, block_hash=block_hash, block_number=block_number
292303
)
293304

305+
async def _check_outside_execution_nonce(
306+
self,
307+
nonce: int,
308+
*,
309+
block_hash: Optional[Union[Hash, Tag]] = None,
310+
block_number: Optional[Union[int, Tag]] = None,
311+
) -> bool:
312+
(is_valid,) = await self._client.call_contract(
313+
call=Call(
314+
to_addr=self.address,
315+
selector=get_selector_from_name("is_valid_outside_execution_nonce"),
316+
calldata=[nonce],
317+
),
318+
block_hash=block_hash,
319+
block_number=block_number,
320+
)
321+
return bool(is_valid)
322+
323+
async def get_outside_execution_nonce(self, retry_count=10) -> int:
324+
while retry_count > 0:
325+
random_stark_address = KeyPair.generate().public_key
326+
if await self._check_outside_execution_nonce(random_stark_address):
327+
return random_stark_address
328+
retry_count -= 1
329+
raise RuntimeError("Failed to generate a valid nonce")
330+
331+
async def _get_outside_execution_version(
332+
self,
333+
) -> Union[OutsideExecutionInterfaceID, None]:
334+
for version in [
335+
OutsideExecutionInterfaceID.V1,
336+
OutsideExecutionInterfaceID.V2,
337+
]:
338+
if await self.supports_interface(version):
339+
return version
340+
return None
341+
342+
async def supports_interface(
343+
self, interface_id: OutsideExecutionInterfaceID
344+
) -> bool:
345+
(does_support,) = await self._client.call_contract(
346+
Call(
347+
to_addr=self.address,
348+
selector=get_selector_from_name("supports_interface"),
349+
calldata=[interface_id],
350+
)
351+
)
352+
return bool(does_support)
353+
294354
async def get_balance(
295355
self,
296356
token_address: Optional[AddressRepresentation] = None,
@@ -345,6 +405,56 @@ async def sign_invoke_v1(
345405
signature = self.signer.sign_transaction(execute_tx)
346406
return _add_signature_to_transaction(execute_tx, signature)
347407

408+
async def sign_outside_execution_call(
409+
self,
410+
calls: Calls,
411+
execution_time_bounds: OutsideExecutionTimeBounds,
412+
*,
413+
caller: AddressRepresentation = ANY_CALLER,
414+
nonce: Optional[int] = None,
415+
interface_version: Optional[OutsideExecutionInterfaceID] = None,
416+
) -> Call:
417+
if interface_version is None:
418+
interface_version = await self._get_outside_execution_version()
419+
420+
if interface_version is None:
421+
raise RuntimeError(
422+
"Can't initiate call, outside execution is not supported."
423+
)
424+
425+
if nonce is None:
426+
nonce = await self.get_outside_execution_nonce()
427+
428+
outside_execution = OutsideExecution(
429+
caller=parse_address(caller),
430+
nonce=nonce,
431+
execute_after=execution_time_bounds.execute_after_timestamp,
432+
execute_before=execution_time_bounds.execute_before_timestamp,
433+
calls=list(ensure_iterable(calls)),
434+
)
435+
chain_id = await self._get_chain_id()
436+
signature = self.signer.sign_message(
437+
outside_execution_to_typed_data(
438+
outside_execution, interface_version, chain_id
439+
),
440+
self.address,
441+
)
442+
selector_for_version = {
443+
OutsideExecutionInterfaceID.V1: "execute_from_outside",
444+
OutsideExecutionInterfaceID.V2: "execute_from_outside_v2",
445+
}
446+
447+
return Call(
448+
to_addr=self.address,
449+
selector=get_selector_from_name(selector_for_version[interface_version]),
450+
calldata=_outside_transaction_serialiser.serialize(
451+
{
452+
"outside_execution": outside_execution.to_abi_dict(),
453+
"signature": signature,
454+
}
455+
),
456+
)
457+
348458
async def sign_invoke_v3(
349459
self,
350460
calls: Calls,
@@ -890,3 +1000,17 @@ def _parse_calls_cairo_v1(calls: Iterable[Call]) -> List[Dict]:
8901000
calls=ArraySerializer(_call_description_cairo_v1),
8911001
)
8921002
)
1003+
_outside_transaction_serialiser = StructSerializer(
1004+
OrderedDict(
1005+
outside_execution=StructSerializer(
1006+
OrderedDict(
1007+
caller=FeltSerializer(),
1008+
nonce=FeltSerializer(),
1009+
execute_after=UintSerializer(bits=64),
1010+
execute_before=UintSerializer(bits=64),
1011+
calls=ArraySerializer(_call_description_cairo_v1),
1012+
)
1013+
),
1014+
signature=ArraySerializer(FeltSerializer()),
1015+
)
1016+
)

0 commit comments

Comments
 (0)