# Account Abstraction & Using Standard Contracts Full-Stack Starknet: * **[Part 1]** 🚧 [Getting Started in Cairo & Deploying with Nile](https://hackmd.io/@sambarnes/BJvGs0JpK) * **[Part 2]** 🐍 [Contract Interaction with starknet.py](https://hackmd.io/@sambarnes/H1Fx7OMaF) * **[Part 3]** 👥 [StarkNet Account Abstraction & Using Standard Contracts](https://hackmd.io/@sambarnes/rkGekNvAY) (***you are here***) * **[Part 4]** 💽 [Local Devnet & Starknet.py's Account Capabilities](https://hackmd.io/@sambarnes/By7kitOCt) * **[Part 5]** 🎨 [StarkNet Frontends w/ Cairopal & Argent X](https://hackmd.io/@sambarnes/HydPlH9CY) * **[Notes]** 💰 [Contract Costs & Why Our Design Needs Work](https://hackmd.io/@sambarnes/SkxMZHhRK) *Completed code on GitHub [here](https://github.com/sambarnes/fullstack-starknet)*. Alright, so by now you might be seeing how writing/maintaining our own signature schemes could get pretty annoying if we have to do this everywhere. Fortunately, [a standard is emerging](https://github.com/OpenZeppelin/cairo-contracts/blob/main/docs/Account.md) for "Accounts" on StarkNet. In the future, when fees are introduced to the platform, all contract invocations will be required to be called from an Account contract (*\*citation needed\**). ## Accounts on starknet The Account Abstraction is *somewhat similar* to the concept of accounts in Ethereum, however, there are a few fundamental differences in the approach. Primarily, that an Account contract can be rigid enough to have a static/fixed address -- its primary id -- while being flexible enough to have a dynamic/updatable signing keypair. Although not necessary for this tutorial, I highly recommended that you come back and digest [Perama's blogpost on the topic](https://perama-v.github.io/cairo/account-abstraction/). In the linked post, a solid tl;dr of Ethereum vs Starknet Accounts: > Tired (concrete): Check that transaction comes with a correctly signed signature for the given address. > > Wired (abstract): Check that the transaction comes from the given address. The account contract will handle the authentication of a call (i.e. checking that the signature is valid for the current public key, nonce, and calldata). Separately, our contract need only assert that the transaction comes from the address it's claiming to be from -- relying on the assumption that the user's account contract would only execute the call if signature verification succeeded. ## Forking the Account Abstraction locally Because it's not (yet) a native functionality of the platform, we'll copy [the OpenZeppelin example implementation](https://github.com/OpenZeppelin/cairo-contracts/blob/main/contracts/Account.cairo) for now. The [OpenZeppelin/cairo-contracts](https://github.com/OpenZeppelin/cairo-contracts) repo has the (🚧 WIP) standard contracts that are being developed for devs to build & rely on. They are constantly evolving these days, though as the language matures & solidifies, so will the standards. No package manager exists for the ecosystem yet either, so we're going to simply fork the following from that repo into our project: ``` contracts/ ├── Account.cairo ├── ERC165_base.cairo ├── IAccount.cairo ├── Initializable.cairo └── utils ├── constants.cairo └── safemath.cairo tests/ ├── mocks │   └── safemath_mock.cairo ├── test_Account.py ├── test_Initializable.py ├── test_safemath.py └── utils.py ``` > NOTE: At the time of writing, we were encouraged to just copy the contracts directly with little direction on project structure. > > Feb 13, 2022: the cairo-contracts repo has evolved significantly. Now, we can just copy the whole `openzeppelin` folder into our repo to be used as a dependency. You may need to update the python tests below accordingly, to account for the different contract filepaths to deploy. Take a minute to read the test suite in `tests/test_Account.py`. It's deployed like any other contract in a test, while also passing a public key parameter to the constructor: ```python= @pytest.fixture(scope='module') async def account_factory(): starknet = await Starknet.empty() account = await starknet.deploy( "contracts/Account.cairo", constructor_calldata=[signer.public_key] ) return starknet, account ``` Calling a contract function is done using: 1) a `Signer` helper that wraps your key-pair 2) a `StarknetContract` to send the transaction to, after going through the account contract 3) a function name on that contract being called 4) the calldata ```python= @pytest.mark.asyncio async def test_execute(account_factory): starknet, account = account_factory initializable = await starknet.deploy("contracts/Initializable.cairo") execution_info = await initializable.initialized().call() assert execution_info.result == (0,) await signer.send_transaction(account, initializable.contract_address, 'initialize', []) execution_info = await initializable.initialized().call() assert execution_info.result == (1,) ``` ## Modifying our contract Now, that we've included an off-the shelf account implementation, we'll want to refactor our `contracts/contract.cairo` to delegate the signature verification to it. This allows us to worry less about signature schemes and replay attacks. Instead, our code can rely on the assumption that an account contract executing the tx has already signed-off on the action. Our storage variables will remain largely the same, though we no longer need to keep track of nonces, and instead have a `state_id`: ```python= # # Storage # # Who owns/controls the car (can update signing authority) @storage_var func vehicle_owner_address(vehicle_id : felt) -> (address : felt): end # Who signs commitments on behalf of the car @storage_var func vehicle_signer_address(vehicle_id : felt) -> (address : felt): end # Hashes for vehicle state at some id @storage_var func vehicle_state(vehicle_id : felt, state_id : felt) -> (state_hash : felt): end ``` > ✨ Exercise: using the updated storage vars, update our getters to reflect the changes *(answer hidden below)* :::spoiler ```python= # # Getters # @view func get_owner{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt) -> (owner_address : felt): let (owner_address) = vehicle_owner_address.read(vehicle_id=vehicle_id) return (owner_address=owner_address) end @view func get_signer{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt) -> (signer_address : felt): let (signer_address) = vehicle_signer_address.read(vehicle_id=vehicle_id) return (signer_address=signer_address) end @view func get_state{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt, state_id : felt) -> (state_hash : felt): let (state_hash) = vehicle_state.read(vehicle_id=vehicle_id, state_id=state_id) return (state_hash=state_hash) end ``` ::: --- When registering a vehicle, we can use `get_caller_address()` to implicitly determine who the vehicle owner will be: ```python= from starkware.cairo.common.cairo_builtins import HashBuiltin from starkware.cairo.common.math import assert_not_zero from starkware.starknet.common.syscalls import get_caller_address ... # Initializes the vehicle with a given owner & signer @external func register_vehicle{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt, signer_address : felt): # Verify that the vehicle ID is available let (is_vehicle_id_taken) = vehicle_owner_address.read(vehicle_id=vehicle_id) assert is_vehicle_id_taken = 0 # Caller is the owner. Verify caller & signer are non zero let (owner_address) = get_caller_address() assert_not_zero(owner_address) assert_not_zero(signer_address) # Initialize the vehicle's owner and signer vehicle_owner_address.write(vehicle_id=vehicle_id, value=owner_address) vehicle_signer_address.write(vehicle_id=vehicle_id, value=signer_address) return () end ``` A user can now deploy their owner account contract, their delegated signing contract, and register them to some vehicle id. The meat of the application used to live in the `attest_state()` function. Because we've offloaded the signature verification to the account contract, we can gut a lot of the existing code: ```python= # Vehicle signers can attest to a state hash -- data storage & verification off-chain @external func attest_state{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt, state_id : felt, state_hash : felt): # TODO: Verify the vehicle has been registered & the caller is the SIGNER (not the owner) # TODO: Make sure a unique state id was used # Register state vehicle_state.write(vehicle_id=vehicle_id, state_id=state_id, value=state_hash) return () end ``` Leaving us with only a few restrictions to enforce on the contract calls. > ✨ Exercise: Are you able to implement the checks marked as TODOs above? *(answer hidden below)* :::spoiler ```python= # Vehicle signers can attest to a state hash -- data storage & verification off-chain @external func attest_state{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt, state_id : felt, state_hash : felt): # Verify the vehicle has been registered & the caller is the signer let (signer_address) = vehicle_signer_address.read(vehicle_id=vehicle_id) let (caller) = get_caller_address() assert_not_zero(caller) assert signer_address = caller # Make sure a unique state id was used let (state) = vehicle_state.read(vehicle_id=vehicle_id, state_id=state_id) assert state = 0 # Register state vehicle_state.write(vehicle_id=vehicle_id, state_id=state_id, value=state_hash) return () end ``` ::: --- That brings us to the `set_signer()` function: ```python= # Vehicle owners can change the signing authority for a car they own @external func set_signer{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt, signer_address : felt): # TODO: implement return () end ``` > ✨ Exercise: Implement `set_signer` -- the logic will be very similar to `attest_state()`, though the vehicle owner must authorize the tx instead of the vehicle signer. *(answer hidden below)* :::spoiler ```python= # Vehicle owners can change the signing authority for a car they own @external func set_signer{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}( vehicle_id : felt, signer_address : felt): # Verify the vehicle has been registered & the caller is the owner let (owner_address) = vehicle_owner_address.read(vehicle_id=vehicle_id) let (caller) = get_caller_address() assert_not_zero(caller) assert owner_address = caller # Update signer vehicle_signer_address.write(vehicle_id=vehicle_id, value=signer_address) return () end ``` ::: ## Testing our changes We start off very similar to our existing test setup code, deploying the application contract. In addition, two account contracts will also be deployed to the local devnet: one for the vehicle owner and signer. ```python= from dataclasses import dataclass from typing import Tuple import pytest import asyncio from starkware.starknet.testing.starknet import Starknet, StarknetContract from starkware.starkware_utils.error_handling import StarkException from utils import Signer some_vehicle = 1 @dataclass class Account: signer: Signer contract: StarknetContract @pytest.fixture(scope="module") def event_loop(): return asyncio.new_event_loop() # Reusable local network & contracts to save testing time @pytest.fixture(scope="module") async def contract_factory() -> Tuple[Starknet, Account, Account, StarknetContract]: starknet = await Starknet.empty() some_signer = Signer(private_key=12345) owner_account = Account( signer=some_signer, contract=await starknet.deploy( "contracts/Account.cairo", constructor_calldata=[some_signer.public_key] ) ) some_other_signer = Signer(private_key=123456789) signer_account = Account( signer=some_other_signer, contract=await starknet.deploy( "contracts/Account.cairo", constructor_calldata=[some_other_signer.public_key] ) ) contract = await starknet.deploy("contracts/contract.cairo") return starknet, owner_account, signer_account, contract ``` Using the `utils.Signer` that wraps our key-pair, we'll sign a transaction that targets the `register_vehicle` function with the right calldata. This transaction will go through the account contract, and upon successful signature verification, be executed against our dapp's contract. ```python= @pytest.mark.asyncio async def test_register_vehicle(contract_factory): """Should register a vehicle to a given owner address""" _, owner_account, signer_account, contract = contract_factory await owner_account.signer.send_transaction( account=owner_account.contract, to=contract.contract_address, selector_name='register_vehicle', calldata=[some_vehicle, signer_account.contract.contract_address], ) # Check the owner is registered observed_registrant = await contract.get_owner(vehicle_id=some_vehicle).call() assert observed_registrant.result == (owner_account.contract.contract_address,) # ... and the signer observed_signer = await contract.get_signer(vehicle_id=some_vehicle).call() assert observed_signer.result == (signer_account.contract.contract_address,) ``` That covers the happy path of registration. What happens when a vehicle is re-registered? > ✨ Exercise: Implement a test that asserts a vehicle can only be registered once *(answer hidden below)* :::spoiler ```python= @pytest.mark.asyncio async def test_register_vehicle_again(contract_factory): """Should fail to register a vehicle a second time""" _, owner_account, signer_account, contract = contract_factory with pytest.raises(StarkException): await signer_account.signer.send_transaction( account=signer_account.contract, to=contract.contract_address, selector_name="register_vehicle", calldata=[some_vehicle, signer_account.contract.contract_address], ) # Check the original owner is still registered observed_registrant = await contract.get_owner(vehicle_id=some_vehicle).call() assert observed_registrant.result == (owner_account.contract.contract_address,) ``` ::: --- As for committing a state hash, we can reuse a lot of the code we just wrote. The happy path consists of the signer account (not the owner) submitting a new hash for its vehicle, followed by successfully reading that value back out of the contract. ```python= @pytest.mark.asyncio async def test_attest_state(contract_factory): """Should successfully attest to a state hash""" _, _, signer_account, contract = contract_factory state_id = 1 state_hash = 1234 await signer_account.signer.send_transaction( account=signer_account.contract, to=contract.contract_address, selector_name="attest_state", calldata=[some_vehicle, state_id, state_hash], ) # Check the state hash was committed observed_state = await contract.get_state( vehicle_id=some_vehicle, state_id=state_id ).call() assert observed_state.result == (state_hash,) ``` Can we call `attest_state()` on an unregisterd vehicle? ```python= @pytest.mark.asyncio async def test_attest_state_unregistered_vehicle(contract_factory): """Should fail with an unregistered vehicle""" _, _, signer_account, contract = contract_factory state_id = 1 state_hash = 1234 some_unregistered_vehicle = 5 with pytest.raises(StarkException): await signer_account.signer.send_transaction( signer_account.contract, contract.contract_address, "attest_state", [some_unregistered_vehicle, state_id, state_hash], ) ``` > ✨ Exercise: Any other edge cases we might want to test for? *(potential answers hidden below)* :::spoiler Signing an attestation with an account other than the authorized signer: ```python= @pytest.mark.asyncio async def test_attest_state_invalid_account(contract_factory): """Should fail when attesting from owner instead of signer""" _, owner_account, _, contract = contract_factory state_id = 1 state_hash = 1234 with pytest.raises(StarkException): # Attest with owner rather than delegate signer await owner_account.signer.send_transaction( account=owner_account.contract, to=contract.contract_address, selector_name="attest_state", calldata=[some_vehicle, state_id, state_hash], ) ``` Or calling the function with no account at all: ```python= @pytest.mark.asyncio async def test_attest_state_no_account(contract_factory): """Should fail to commit state if no account signed the tx""" _, _, _, contract = contract_factory with pytest.raises(StarkException): # Transaction not sent through an account await contract.attest_state( vehicle_id=some_vehicle, state_id=5, state_hash=4567, ).invoke() ``` ::: --- Testing the `set_signer` function is practically the same thing, only minor tweaks. Feel free to try and implement some coverage for these functions, or just peek at the [part3's tests](https://github.com/sambarnes/fullstack-starknet/blob/master/part3/starknet/tests/test_contract.py#L156-L201) in the github repo. ## To be continued... Our python application is still using our custom signature scheme. In the next part of the series, we'll need to modify it to send transactions through a deployed account contract.