diff --git a/README.md b/README.md index 66b577f3..f61d632a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,21 @@ -# Argent Account on Starknet +# Argent Account on StarkNet -Preliminary work for an Argent Account on Starknet. +*Warning: StarkNet is still in alpha, so is this project. In particular the `ArgentAccount.cairo` contract has not been audited yet and should not be used to store significant value.* ## High-Level Specification -The account is a 2-of-2 custom multisig where the `signer` key is typically stored on the user's phone and the `guardian` key is managed by an off-chain service to enable fraud monitoring (e.g. trusted contacts, daily limits, etc). The user can always opt-out of the guardian service and manage the `guardian` key himself. +The account is a 2-of-2 custom multisig where the `signer` key is typically stored on the user's phone and the `guardian` is an external contract that can validate the signatures of one or more keys. +The `guardian` acts both as a co-validator for typical operations of the wallet, and as the trusted actor that can recover the wallet in case the `signer` key is lost or compromised. +These two features may have different key requirements (e.g. a single key for fraud monitoring, and a n-of-m setup for 'social' recovery) as encapsulated by the logic of the `guardian` contract. -Normal operations of the wallet (`execute`, `change_signer` and `change_guardian`) require both signatures to be executed. +By default the `guardian` has a single key managed by an off-chain service to enable fraud monitoring (e.g. trusted contacts, daily limits, etc) and recovery. The user can always opt-out of the guardian service and select a `guardian` contract with different key requirements. -Each party alone can trigger the `escape` mode on the wallet if the other party is not cooperating or lost. An escape takes 7 days before being active, after which the non-cooperating party can be replaced. The wallet is asymmetric in favor of the `signer` who can override an escape triggered by the `guardian`. +Normal operations of the wallet (`execute`, `change_signer`, `change_guardian`, `cancel_escape`) require the approval of both parties to be executed. -A triggered escape can always be cancelled with both signatures. +Each party alone can trigger the `escape` mode (a.k.a. recovery) on the wallet if the other party is not cooperating or lost. An escape takes 7 days before being active, after which the non-cooperating party can be replaced. +The wallet is always asymmetric in favor of one of the party depending on the `weight` of the `guardian`. The favoured party can always override an escape triggered by the other party. + +A triggered escape can always be cancelled with the approval of both parties. We assume that the `signer` key is backed up such that the probability of the `signer` key being lost should be close to zero. diff --git a/contracts/ArgentAccount.cairo b/contracts/ArgentAccount.cairo index 1d7d94a3..965d14ad 100644 --- a/contracts/ArgentAccount.cairo +++ b/contracts/ArgentAccount.cairo @@ -5,12 +5,26 @@ from starkware.cairo.common.cairo_builtins import HashBuiltin, SignatureBuiltin from starkware.cairo.common.signature import verify_ecdsa_signature from starkware.cairo.common.registers import get_fp_and_pc from starkware.cairo.common.alloc import alloc +from starkware.cairo.common.memcpy import memcpy from starkware.cairo.common.math import assert_not_zero, assert_le, assert_nn from starkware.starknet.common.syscalls import call_contract, get_tx_signature, get_contract_address, get_caller_address from starkware.cairo.common.hash_state import ( hash_init, hash_finalize, hash_update, hash_update_single ) +#################### +# INTERFACE +#################### + +@contract_interface +namespace IGuardian: + func is_valid_signature(hash: felt, sig_len: felt, sig: felt*): + end + + func weight() -> (weight: felt): + end +end + #################### # CONSTANTS #################### @@ -104,6 +118,11 @@ func execute{ # compute message hash let (message_hash) = get_message_hash(to, selector, calldata_len, calldata, nonce) + # rebind pointers + local syscall_ptr: felt* = syscall_ptr + local range_check_ptr = range_check_ptr + local pedersen_ptr: HashBuiltin* = pedersen_ptr + if to == self: tempvar signer_condition = (selector - ESCAPE_GUARDIAN_SELECTOR) * (selector - TRIGGER_ESCAPE_GUARDIAN_SELECTOR) tempvar guardian_condition = (selector - ESCAPE_SIGNER_SELECTOR) * (selector - TRIGGER_ESCAPE_SIGNER_SELECTOR) @@ -113,8 +132,10 @@ func execute{ jmp do_execute end if guardian_condition == 0: + # add flag to indicate an escape + let (extended_sig) = add_escape_flag(sig, sig_len) # validate guardian signature - validate_guardian_signature(message_hash, sig, sig_len) + validate_guardian_signature(message_hash, extended_sig, sig_len + 1) jmp do_execute end end @@ -130,7 +151,6 @@ func execute{ calldata_size=calldata_len, calldata=calldata ) - return (response=response.retdata_size) end @@ -142,7 +162,6 @@ func change_signer{ } ( new_signer: felt ): - # only called via execute assert_only_self() @@ -175,11 +194,27 @@ func trigger_escape_guardian{ pedersen_ptr: HashBuiltin*, range_check_ptr } (): - + alloc_locals + # only called via execute assert_only_self() # no escape when the guardian is not set - assert_guardian_set() + let (guardian) = assert_guardian_set() + + # no escape if there is an escape by the guardian and guardian has weight > 1 + let (current_escape) = _escape.read() + if current_escape.caller == guardian: + let (weight) = IGuardian.weight(contract_address=guardian) + # assert weight <= 1 + assert_nn(1 - weight) + tempvar syscall_ptr: felt* = syscall_ptr + tempvar range_check_ptr = range_check_ptr + tempvar pedersen_ptr: HashBuiltin* = pedersen_ptr + else: + tempvar syscall_ptr: felt* = syscall_ptr + tempvar range_check_ptr = range_check_ptr + tempvar pedersen_ptr: HashBuiltin* = pedersen_ptr + end # store new escape let (block_timestamp) = _block_timestamp.read() @@ -202,10 +237,19 @@ func trigger_escape_signer{ # no escape when the guardian is not set let (guardian) = assert_guardian_set() - # no escape when there is an ongoing escape by the signer + # no escape if there is an escape by the signer and guardian has weight <= 1 let (current_escape) = _escape.read() - if current_escape.active_at != 0: - assert current_escape.caller = guardian + if ((current_escape.caller - guardian) * current_escape.caller) != 0: + let (weight) = IGuardian.weight(contract_address=guardian) + # assert weight > 1 + assert_nn(weight - 2) + tempvar syscall_ptr: felt* = syscall_ptr + tempvar range_check_ptr = range_check_ptr + tempvar pedersen_ptr: HashBuiltin* = pedersen_ptr + else: + tempvar syscall_ptr: felt* = syscall_ptr + tempvar range_check_ptr = range_check_ptr + tempvar pedersen_ptr: HashBuiltin* = pedersen_ptr end # store new escape @@ -425,27 +469,39 @@ end func validate_guardian_signature{ syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, - ecdsa_ptr : SignatureBuiltin*, + ecdsa_ptr: SignatureBuiltin*, range_check_ptr } ( message: felt, signatures: felt*, signatures_len: felt ) -> (): + alloc_locals let (guardian) = _guardian.read() if guardian == 0: return() else: assert_nn(signatures_len - 2) - verify_ecdsa_signature( - message=message, - public_key=guardian, - signature_r=signatures[0], - signature_s=signatures[1]) + IGuardian.is_valid_signature(contract_address=guardian, hash=message, sig_len=signatures_len, sig=signatures) return() end end +func add_escape_flag{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } ( + sig: felt*, + sig_len: felt + ) -> (extended: felt*): + alloc_locals + let (local extended : felt*) = alloc() + memcpy(extended, sig, sig_len) + assert [extended+sig_len] = 'escape' + return(extended=extended) +end + func get_message_hash{ syscall_ptr: felt*, pedersen_ptr: HashBuiltin*, diff --git a/contracts/guardians/SCSKGuardian.cairo b/contracts/guardians/SCSKGuardian.cairo new file mode 100644 index 00000000..586a68b4 --- /dev/null +++ b/contracts/guardians/SCSKGuardian.cairo @@ -0,0 +1,94 @@ +%lang starknet +%builtins pedersen range_check ecdsa + +from starkware.cairo.common.cairo_builtins import HashBuiltin, SignatureBuiltin +from starkware.cairo.common.signature import verify_ecdsa_signature +from starkware.cairo.common.hash import hash2 +from starkware.cairo.common.math import assert_not_zero, assert_nn +from starkware.starknet.common.syscalls import get_tx_signature + +###################################### +# Single Common Stark Key Guardian +###################################### + +@storage_var +func _signing_key() -> (res: felt): +end + +@constructor +func constructor{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } ( + signing_key: felt + ): + assert_not_zero(signing_key) + _signing_key.write(signing_key) + return () +end + +@external +func set_signing_key{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + ecdsa_ptr: SignatureBuiltin*, + range_check_ptr + } ( + new_signing_key: felt + ) -> (): + + # get the signature + let (sig_len : felt, sig : felt*) = get_tx_signature() + # Verify the signature length. + assert_nn(sig_len - 2) + # Compute the hash of the message. + let (hash) = hash2{hash_ptr=pedersen_ptr}(new_signing_key, 0) + # get the existing signing key + let (signing_key) = _signing_key.read() + # verify the signature + verify_ecdsa_signature( + message=hash, + public_key=signing_key, + signature_r=sig[0], + signature_s=sig[1]) + # set the new key + _signing_key.write(new_signing_key) + return() +end + +@view +func is_valid_signature{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + ecdsa_ptr: SignatureBuiltin*, + range_check_ptr + } ( + hash: felt, + sig_len: felt, + sig: felt* + ) -> (): + assert_nn(sig_len - 2) + let (signing_key) = _signing_key.read() + verify_ecdsa_signature( + message=hash, + public_key=signing_key, + signature_r=sig[0], + signature_s=sig[1]) + return() +end + +@view +func weight() -> (weight: felt): + return (weight=1) +end + +@view +func get_signing_key{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } () -> (signing_key: felt): + let (signing_key) = _signing_key.read() + return (signing_key=signing_key) +end \ No newline at end of file diff --git a/contracts/guardians/USDKGuardian.cairo b/contracts/guardians/USDKGuardian.cairo new file mode 100644 index 00000000..2365a22a --- /dev/null +++ b/contracts/guardians/USDKGuardian.cairo @@ -0,0 +1,107 @@ +%lang starknet +%builtins pedersen range_check ecdsa + +from starkware.cairo.common.cairo_builtins import HashBuiltin, SignatureBuiltin +from starkware.cairo.common.signature import verify_ecdsa_signature +from starkware.cairo.common.math import assert_not_zero +from starkware.starknet.common.syscalls import get_caller_address + +############################################## +# User selected different single keys Guardian +############################################## + +@storage_var +func _signing_key(account : felt) -> (res: felt): +end + +@storage_var +func _escape_key(account : felt) -> (res: felt): +end + +@view +func is_valid_signature{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + ecdsa_ptr: SignatureBuiltin*, + range_check_ptr + } ( + hash: felt, + sig_len: felt, + sig: felt* + ) -> (): + + let (account) = get_caller_address() + if sig_len == 3: + assert [sig + 2] = 'escape' + let (key) = _escape_key.read(account) + verify_ecdsa_signature( + message=hash, + public_key=key, + signature_r=sig[0], + signature_s=sig[1]) + return() + end + + if sig_len == 2: + let (key) = _signing_key.read(account) + verify_ecdsa_signature( + message=hash, + public_key=key, + signature_r=sig[0], + signature_s=sig[1]) + return() + end + assert_not_zero(0) + return() +end + +@view +func weight() -> (weight: felt): + return (weight=1) +end + +@external +func set_signing_key{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } ( + key: felt + ) -> (): + let (account) = get_caller_address() + _signing_key.write(account, key) + return() +end + +@external +func set_escape_key{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } ( + key: felt + ) -> (): + let (account) = get_caller_address() + _escape_key.write(account, key) + return() +end + +@view +func get_signing_key{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } (account: felt) -> (signing_key: felt): + let (signing_key) = _signing_key.read(account) + return (signing_key=signing_key) +end + +@view +func get_escape_key{ + syscall_ptr: felt*, + pedersen_ptr: HashBuiltin*, + range_check_ptr + } (account: felt) -> (escape_key: felt): + let (escape_key) = _escape_key.read(account) + return (escape_key=escape_key) +end \ No newline at end of file diff --git a/test/argent_account.py b/test/argent_account.py index 8ac892de..592a08ef 100644 --- a/test/argent_account.py +++ b/test/argent_account.py @@ -8,7 +8,10 @@ from utils.TransactionSender import TransactionSender signer = Signer(123456789987654321) -guardian = Signer(456789987654321123) +guardian_signer = Signer(456789987654321123) + +wrong_signer = Signer(666666666666666666) +wrong_guardian_signer = Signer(6767676767) ESCAPE_SECURITY_PERIOD = 500 VERSION = 206933405232 # '0.1.0' = 30 2E 31 2E 30 = 0x302E312E30 = 206933405232 @@ -33,8 +36,9 @@ async def get_starknet(): @pytest.fixture async def account_factory(get_starknet): starknet = get_starknet - account = await deploy(starknet, "contracts/ArgentAccount.cairo", [signer.public_key, guardian.public_key]) - return account + guardian = await deploy(starknet, "contracts/guardians/SCSKGuardian.cairo", [guardian_signer.public_key]) + account = await deploy(starknet, "contracts/ArgentAccount.cairo", [signer.public_key, guardian.contract_address]) + return account, guardian @pytest.fixture async def dapp_factory(get_starknet): @@ -44,35 +48,40 @@ async def dapp_factory(get_starknet): @pytest.mark.asyncio async def test_initializer(account_factory): - account = account_factory + account, guardian = account_factory assert (await account.get_signer().call()).result.signer == (signer.public_key) - assert (await account.get_guardian().call()).result.guardian == (guardian.public_key) + assert (await account.get_guardian().call()).result.guardian == (guardian.contract_address) assert (await account.get_version().call()).result.version == VERSION @pytest.mark.asyncio async def test_call_dapp_with_guardian(account_factory, dapp_factory): - account = account_factory + account, _ = account_factory dapp = dapp_factory sender = TransactionSender(account) # should revert with the wrong nonce await assert_revert( - sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer, guardian], nonce=3) + sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer, guardian_signer], nonce=3) ) # should revert with the wrong signer await assert_revert( - sender.send_transaction(dapp.contract_address, 'set_number', [47], [Signer(121212121), guardian]) + sender.send_transaction(dapp.contract_address, 'set_number', [47], [wrong_signer, guardian_signer]) + ) + + # should revert with the wrong guardian key + await assert_revert( + sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer, wrong_guardian_signer]) ) - # should revert with the wrong guardian + # should fail with only 1 signer await assert_revert( - sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer, Signer(121212121)]) + sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer]) ) # should call the dapp assert (await dapp.get_number(account.contract_address).call()).result.number == 0 - await sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer, guardian]) + await sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer, guardian_signer]) assert (await dapp.get_number(account.contract_address).call()).result.number == 47 @pytest.mark.asyncio @@ -87,33 +96,72 @@ async def test_call_dapp_no_guardian(get_starknet, dapp_factory): await sender.send_transaction(dapp.contract_address, 'set_number', [47], [signer]) assert (await dapp.get_number(account_no_guardian.contract_address).call()).result.number == 47 + # should change the signer + new_signer = Signer(4444444444) + assert (await account_no_guardian.get_signer().call()).result.signer == (signer.public_key) + await sender.send_transaction(account_no_guardian.contract_address, 'change_signer', [new_signer.public_key], [signer]) + assert (await account_no_guardian.get_signer().call()).result.signer == (new_signer.public_key) + + # should reverts calls that require the guardian to be set + await assert_revert( + sender.send_transaction(account_no_guardian.contract_address, 'trigger_escape_guardian', [], [signer]) + ) + + # should add a guardian + new_guardian = Signer(34567788966) + assert (await account_no_guardian.get_guardian().call()).result.guardian == (0) + await sender.send_transaction(account_no_guardian.contract_address, 'change_guardian', [new_guardian.public_key], [new_signer]) + assert (await account_no_guardian.get_guardian().call()).result.guardian == (new_guardian.public_key) + @pytest.mark.asyncio async def test_change_signer(account_factory): - account = account_factory + account, _ = account_factory sender = TransactionSender(account) new_signer = Signer(4444444444) assert (await account.get_signer().call()).result.signer == (signer.public_key) - await sender.send_transaction(account.contract_address, 'change_signer', [new_signer.public_key], [signer, guardian]) + # should revert with the wrong signer + await assert_revert( + sender.send_transaction(account.contract_address, 'change_signer', [new_signer.public_key], [wrong_signer, guardian_signer]) + ) + + # should revert with the wrong guardian signer + await assert_revert( + sender.send_transaction(account.contract_address, 'change_signer', [new_signer.public_key], [signer, wrong_guardian_signer]) + ) + + # should work with the correct signers + await sender.send_transaction(account.contract_address, 'change_signer', [new_signer.public_key], [signer, guardian_signer]) assert (await account.get_signer().call()).result.signer == (new_signer.public_key) @pytest.mark.asyncio async def test_change_guardian(account_factory): - account = account_factory + account, guardian = account_factory sender = TransactionSender(account) new_guardian = Signer(55555555) - assert (await account.get_guardian().call()).result.guardian == (guardian.public_key) + assert (await account.get_guardian().call()).result.guardian == (guardian.contract_address) - await sender.send_transaction(account.contract_address, 'change_guardian', [new_guardian.public_key], [signer, guardian]) + # should revert with the wrong signer + await assert_revert( + sender.send_transaction(account.contract_address, 'change_guardian', [new_guardian.public_key], [wrong_signer, guardian_signer]) + ) + + # should revert with the wrong guardian signer + await assert_revert( + sender.send_transaction(account.contract_address, 'change_guardian', [new_guardian.public_key], [signer, wrong_guardian_signer]) + ) + + # should work with the correct signers + await sender.send_transaction(account.contract_address, 'change_guardian', [new_guardian.public_key], [signer, guardian_signer]) assert (await account.get_guardian().call()).result.guardian == (new_guardian.public_key) @pytest.mark.asyncio async def test_trigger_escape_guardian(account_factory): - account = account_factory + account, _ = account_factory sender = TransactionSender(account) await sender.set_block_timestamp(127) @@ -127,21 +175,21 @@ async def test_trigger_escape_guardian(account_factory): @pytest.mark.asyncio async def test_trigger_escape_signer(account_factory): - account = account_factory + account, guardian = account_factory sender = TransactionSender(account) await sender.set_block_timestamp(127) escape = (await account.get_escape().call()).result assert (escape.active_at == 0 and escape.caller == 0) - await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian]) + await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian_signer]) escape = (await account.get_escape().call()).result - assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian.public_key) + assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian.contract_address) @pytest.mark.asyncio async def test_escape_guardian(account_factory): - account = account_factory + account, guardian = account_factory sender = TransactionSender(account) new_guardian = Signer(55555555) await sender.set_block_timestamp(127) @@ -160,7 +208,7 @@ async def test_escape_guardian(account_factory): await sender.set_block_timestamp(127 + ESCAPE_SECURITY_PERIOD) # should escape after the security period - assert (await account.get_guardian().call()).result.guardian == (guardian.public_key) + assert (await account.get_guardian().call()).result.guardian == (guardian.contract_address) await sender.send_transaction(account.contract_address, 'escape_guardian', [new_guardian.public_key], [signer]) assert (await account.get_guardian().call()).result.guardian == (new_guardian.public_key) @@ -170,19 +218,19 @@ async def test_escape_guardian(account_factory): @pytest.mark.asyncio async def test_escape_signer(account_factory): - account = account_factory + account, guardian = account_factory sender = TransactionSender(account) new_signer = Signer(5555555578895) await sender.set_block_timestamp(127) # trigger escape - await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian]) + await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian_signer]) escape = (await account.get_escape().call()).result - assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian.public_key) + assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian.contract_address) # should fail to escape before the end of the period await assert_revert( - sender.send_transaction(account.contract_address, 'escape_signer', [new_signer.public_key], [guardian]) + sender.send_transaction(account.contract_address, 'escape_signer', [new_signer.public_key], [guardian_signer]) ) # wait security period @@ -190,7 +238,7 @@ async def test_escape_signer(account_factory): # should escape after the security period assert (await account.get_signer().call()).result.signer == (signer.public_key) - await sender.send_transaction(account.contract_address, 'escape_signer', [new_signer.public_key], [guardian]) + await sender.send_transaction(account.contract_address, 'escape_signer', [new_signer.public_key], [guardian_signer]) assert (await account.get_signer().call()).result.signer == (new_signer.public_key) # escape should be cleared @@ -199,17 +247,22 @@ async def test_escape_signer(account_factory): @pytest.mark.asyncio async def test_cancel_escape(account_factory): - account = account_factory + account, guardian = account_factory sender = TransactionSender(account) await sender.set_block_timestamp(127) # trigger escape - await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian]) + await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian_signer]) escape = (await account.get_escape().call()).result - assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian.public_key) + assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian.contract_address) + + # should fail to cancel with only the signer + await assert_revert( + sender.send_transaction(account.contract_address, 'cancel_escape', [], [signer]) + ) # cancel escape - await sender.send_transaction(account.contract_address, 'cancel_escape', [], [signer, guardian]) + await sender.send_transaction(account.contract_address, 'cancel_escape', [], [signer, guardian_signer]) # escape should be cleared escape = (await account.get_escape().call()).result @@ -217,11 +270,11 @@ async def test_cancel_escape(account_factory): @pytest.mark.asyncio async def test_is_valid_signature(account_factory): - account = account_factory + account, guardian = account_factory hash = 1283225199545181604979924458180358646374088657288769423115053097913173815464 signatures = [] - for sig in [signer, guardian]: + for sig in [signer, guardian_signer]: signatures += list(sig.sign(hash)) await account.is_valid_signature(hash, signatures).call() \ No newline at end of file diff --git a/test/guardians.py b/test/guardians.py new file mode 100644 index 00000000..6048ce59 --- /dev/null +++ b/test/guardians.py @@ -0,0 +1,117 @@ +import pytest +import asyncio +from starkware.starknet.testing.starknet import Starknet +from starkware.starkware_utils.error_handling import StarkException +from starkware.starknet.definitions.error_codes import StarknetErrorCode +from starkware.starknet.testing.objects import StarknetContractCall +from starkware.crypto.signature.signature import pedersen_hash +from utils.Signer import Signer +from utils.deploy import deploy +from utils.TransactionSender import TransactionSender + +account_signer = Signer(123456789987654321) +guardian_1_key = Signer(1111111111111) +guardian_2_key_1 = Signer(2222222222222) +guardian_2_key_2 = Signer(3333333333333) + +ESCAPE_SECURITY_PERIOD = 500 + +async def assert_revert(expression): + try: + await expression + assert False + except StarkException as err: + _, error = err.args + assert error['code'] == StarknetErrorCode.TRANSACTION_FAILED + +@pytest.fixture(scope='module') +def event_loop(): + return asyncio.new_event_loop() + +@pytest.fixture(scope='module') +async def get_starknet(): + starknet = await Starknet.empty() + return starknet + +@pytest.fixture +async def account_factory(get_starknet): + async def _account_factory(guardian): + starknet = get_starknet + account = await deploy(starknet, "contracts/ArgentAccount.cairo", [account_signer.public_key, guardian]) + return account + return _account_factory + +@pytest.fixture +async def guardian_factory(get_starknet): + starknet = get_starknet + guardian1 = await deploy(starknet, "contracts/guardians/SCSKGuardian.cairo", [guardian_1_key.public_key]) + guardian2 = await deploy(starknet, "contracts/guardians/USDKGuardian.cairo") + return guardian1, guardian2 + +@pytest.fixture +async def dapp_factory(get_starknet): + starknet = get_starknet + dapp = await deploy(starknet, "contracts/TestDapp.cairo") + return dapp + +@pytest.mark.asyncio +async def test_scsk_guardian(account_factory, guardian_factory, dapp_factory): + guardian1, _ = guardian_factory + account = await account_factory(guardian1.contract_address) + dapp = dapp_factory + sender = TransactionSender(account) + + # is configured correctly + assert (await guardian1.get_signing_key().call()).result.signing_key == (guardian_1_key.public_key) + + # can approve transactions + assert (await dapp.get_number(account.contract_address).call()).result.number == 0 + await sender.send_transaction(dapp.contract_address, 'set_number', [47], [account_signer, guardian_1_key]) + assert (await dapp.get_number(account.contract_address).call()).result.number == 47 + + # can set a new key + guardian_1_key_new = Signer(455667754) + hash = pedersen_hash(guardian_1_key_new.public_key, 0) + signature = list(guardian_1_key.sign(hash)) + await guardian1.set_signing_key(guardian_1_key_new.public_key).invoke(signature=signature) + assert (await guardian1.get_signing_key().call()).result.signing_key == (guardian_1_key_new.public_key) + + # new key can approve transactions + await sender.send_transaction(dapp.contract_address, 'set_number', [57], [account_signer, guardian_1_key_new]) + assert (await dapp.get_number(account.contract_address).call()).result.number == 57 + + # old key cannot approve transactions + await assert_revert( + sender.send_transaction(dapp.contract_address, 'set_number', [67], [account_signer, guardian_1_key]) + ) + +@pytest.mark.asyncio +async def test_guardian_suite(account_factory, guardian_factory, dapp_factory): + guardian1, guardian2 = guardian_factory + account = await account_factory(guardian1.contract_address) + dapp = dapp_factory + sender = TransactionSender(account) + # check the guardian at start + assert (await account.get_guardian().call()).result.guardian == (guardian1.contract_address) + # configure the second guardian + await sender.send_transaction(guardian2.contract_address, 'set_signing_key', [guardian_2_key_1.public_key], [account_signer, guardian_1_key]) + await sender.send_transaction(guardian2.contract_address, 'set_escape_key', [guardian_2_key_2.public_key], [account_signer, guardian_1_key]) + # check that the configuration worked + assert (await guardian2.get_signing_key(account.contract_address).call()).result.signing_key == guardian_2_key_1.public_key + assert (await guardian2.get_escape_key(account.contract_address).call()).result.escape_key == guardian_2_key_2.public_key + # change guardian + await sender.send_transaction(account.contract_address, 'change_guardian', [guardian2.contract_address], [account_signer, guardian_1_key]) + # check that the change worked + assert (await account.get_guardian().call()).result.guardian == (guardian2.contract_address) + # check that the USKD guardian can sign regular calls with the signing key + await sender.send_transaction(dapp.contract_address, 'set_number', [47], [account_signer, guardian_2_key_1]) + assert (await dapp.get_number(account.contract_address).call()).result.number == 47 + # check that the USKD guardian cannot sign escape with the signing key + await sender.set_block_timestamp(127) + await assert_revert( + sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian_2_key_1]) + ) + # check that the USKD guardian can sign escape with the escape key + await sender.send_transaction(account.contract_address, 'trigger_escape_signer', [], [guardian_2_key_2]) + escape = (await account.get_escape().call()).result + assert (escape.active_at == (127 + ESCAPE_SECURITY_PERIOD) and escape.caller == guardian2.contract_address) \ No newline at end of file