Hello World Account Contract
In this subchapter we will create an account contract from scratch following the SNIP-6 and SRC-5 standards.
Project Setup
To deploy an account contract to Starknet's testnet or mainnet, ensure you're using a version of Scarb that supports the Sierra 1.3.0 target, as both Starknet's testnet and mainnet currently support this version. Refer to Starknet Release Notes for more details. As of November 2023, the recommended Scarb version is 2.3.1.
$ scarb --version
scarb 2.3.1 (0c8def3aa 2023-10-31)
cairo: 2.3.1 (https://crates.io/crates/cairo-lang-compiler/2.3.1)
sierra: 1.3.0
To install or Update Scarb follow the instructions here.
Setting up a new Scarb project
Initialize a new project:
$ scarb new aa
Created `aa` package.
Inspect the default project structure:
$ tree .
.
โโโ aa
โโโ Scarb.toml
โโโ src
โโโ lib.cairo
By default, Scarb sets up for vanilla Cairo. To target Starknet,
modify Scarb.toml
to activate the Starknet plugin.
[package]
name = "aa"
version = "0.1.0"
cairo-version = "2.3.0"
[dependencies]
starknet = ">=2.3.0"
[[target.starknet-contract]]
sierra = true
casm = true
In the src/lib.cairo
file,replace the Cairo code with the scaffold for your account contract:
#![allow(unused)] fn main() { #[starknet::contract] mod Account { } }
To validate signatures, store the public key associated with the signer's private key.
#![allow(unused)] fn main() { #[starknet::contract] mod Account { #[storage] struct Storage { public_key: felt252 } } }
Finally, compile the project to verify the setup:
aa/src$ scarb build
Compiling aa v0.1.0 (/home/Cyndie/account_abstraction__starknet/aa/Scarb.toml)
Finished release target(s) in 1 second
Implementing SNIP-6
As explained in the previous subchapter, to classify a smart contract as an
account contract, it must adhere to the ISRC6
trait:
#![allow(unused)] fn main() { trait ISRC6 { fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>; fn __validate__(calls: Array<Call>) -> felt252; fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252; } }
Functions within the account contract annotated with #[external(v0)]
attribute
possess unique selectors, facilitating interactions with the contract by external entities.
However, while these functions are publicly accessible via their selectors, it is essential
to distinguish their intended users. Specifically, the __execute__
and __validate__
functions are reserved exclusively for the Starknet protocol. In contrast, is_valid_signature
is publicly available, catering to web3 applications for signature validation.
The trait IAccount<T>
trait, marked with the #[starknet::interface]
attribute,
encapsulates functions intended for public interaction,such as is_valid_signature
.
The __execute__
and __validate__
functions, though public, are indirectly accessed.
#![allow(unused)] fn main() { use starknet::account::Call; #[starknet::interface] trait IAccount<T> { fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252; } #[starknet::contract] mod Account { use super::Call; #[storage] struct Storage { public_key: felt252 } #[external(v0)] impl AccountImpl of super::IAccount<ContractState> { fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 { ... } } #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { ... } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { ... } } } }
Protocol-Only Function Protection
For enhanced account security,__execute__
and __validate__
functions are exclusively
callable by the Starknet protocol, even though they are publicly accessible.
The Starknet protocol uses the zero address when invoking a function.
The private function only_protocol
ensures that only Starknet protocol can access these functions
#![allow(unused)] fn main() { ... //Starknet uses zero address as caller when calling function #[starknet::contract] mod Account { use starknet::get_caller_address; use zeroable::Zeroable; ... //protection of protocol-only functions using only_protocol() #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); ... } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); ... } } //creation of private function only_protocol() #[generate_trait] impl PrivateImpl of PrivateTrait { fn only_protocol(self: @ContractState) {...} } } }
Note that the function is_valid_signature
is not protected by the only_protocol
function
as its to be used freely.
Signature Validation
The public key associated with the account contract's signer is stored for transaction signature validation.
The constructor
method is defined to capture the public key's value during deployment.
.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[storage] struct Storage { public_key: felt252 } #[constructor] fn constructor(ref self: ContractState, public_key: felt252) { self.public_key.write(public_key); } ... } }
The is_valid_signature
function returns VALID
for a valid signature and 0
otherwise.
An internal function, is_valid_signature_bool
, provides a boolean result for signature validation.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... use array::ArrayTrait; use ecdsa::check_ecdsa_signature; use array::SpanTrait; //Implements span() method ... //Implementation of is_valid_signature method #[external(v0)] impl AccountImpl of super::IAccount<ContractState> { fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 { //Derive Span from signature passed as Array to be used in is_valid_signature_bool() let is_valid = self.is_valid_signature_bool(hash, signature.span()); if is_valid { 'VALID' } else { 0 } } } ... //Implementation of is_valid_signature_bool to return bool #[generate_trait] impl PrivateImpl of PrivateTrait { ... //function signature expects a Span instead of an Array fn is_valid_signature_bool(self: @ContractState, hash: felt252, signature: Span<felt252>) -> bool { let is_valid_length = signature.len() == 2_u32; if !is_valid_length { return false; } check_ecdsa_signature( hash, self.public_key.read(), *signature.at(0_u32), *signature.at(1_u32) ) } } } }
The __validate__
function uses is_valid_signature_bool
to ensure transaction signature validity.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... use box::BoxTrait; use starknet::get_tx_info; ... #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { ... fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); let tx_info = get_tx_info().unbox(); let tx_hash = tx_info.transaction_hash; let signature = tx_info.signature; let is_valid = self.is_valid_signature_bool(tx_hash, signature); assert(is_valid, 'Account: Incorrect tx signature'); //assert stops transaction execution if signature invalid 'VALID' } } ... } }
Validation for Declare and Deploy Functions
The __validate_declare__
function is responsible for validating the signature
of the declare
function. On the other hand, __validate_deploy__
facilitates
counterfactual deployment,a method to deploy an account contract without
associating it to a specific deployer address.
To streamline the validation process, we'll unify the behavior of the three
validation functions __validate__
,__validate_declare__
and __validate_deploy__
.
The core logic from __validate__
is abstracted to validate_transaction
private
function, which is then invoked by the other two validation functions.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { //The three validation signatures pooled to function similarly fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); self.validate_transaction() } fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 { self.only_protocol(); self.validate_transaction() } fn __validate_deploy__(self: @ContractState, class_hash: felt252, salt: felt252, public_key: felt252) -> felt252 { self.only_protocol(); self.validate_transaction() } } #[generate_trait] impl PrivateImpl of PrivateTrait { ... //Logic extraction from __validate__ to validate_transaction fn validate_transaction(self: @ContractState) -> felt252 { let tx_info = get_tx_info().unbox(); let tx_hash = tx_info.transaction_hash; let signature = tx_info.signature; let is_valid = self.is_valid_signature_bool(tx_hash, signature); assert(is_valid, 'Account: Incorrect tx signature'); 'VALID' } } } }
It's important to note that the __validate_deploy__
function receives the public key
as an argument. While this key is captured during the constructor phase before this function
is invoked, it remains crucial to provide it when initiating the transaction.
Alternatively, the public key can be directly utilized within the __validate_deploy__
function,
bypassing the constructor.
Transaction Execution
The __execute__
function in the Account
module accepts an array of Call
structures,
allowing for multicall functionality. This feature bundles multiple user operations
into one transaction, enhancing user experience.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { ... } ... } } }
The Call
data structure contains the necessary information for a single user operation.
#![allow(unused)] fn main() { #[derive(Drop, Serde)] struct Call { to: ContractAddress, selector: felt252, calldata: Array<felt252> } }
To manage individual calls, a private function execute_single_call
is defined. It uses the low-level
call_contract_syscall
syscall to invoke another smart contract function directly.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... use starknet::call_contract_syscall; #[generate_trait] impl PrivateImpl of PrivateTrait { ... fn execute_single_call(self: @ContractState, call: Call) -> Span<felt252> { let Call{to, selector, calldata} = call; call_contract_syscall(to, selector, calldata.span()).unwrap_syscall() } } } }
For handling multiple calls, the execute_multiple_calls
function iterates over the
Call
array and returns an array of responses.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[generate_trait] impl PrivateImpl of PrivateTrait { ... fn execute_multiple_calls(self: @ContractState, mut calls: Array<Call>) -> Array<Span<felt252>> { let mut res = ArrayTrait::new(); loop { match calls.pop_front() { Option::Some(call) => { let _res = self.execute_single_call(call); res.append(_res); }, Option::None(_) => { break (); }, }; }; res } } } }
Finally, the main __execute__
function utilizes these helper functions to process the array
of Call
structures:
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); self.execute_multiple_calls(calls) } ... } ... } }
Transaction Version Support
To accommodate the evolution of Starknet and its enhanced functionalities, a versioning system was introduced for transactions. This ensures backward compatibility, allowing both old and new transaction structures to operate concurrently.
For simplicity in this tutorial, the account contract is designed to support only the latest versions of each transaction type:
- Version 1 for
invoke
transactions - Version 1 for
deploy_account
transactions - Version 2 for
declare
transactions
These supported versions are logically grouped in a module called SUPPORTED_TX_VERSION
:
#![allow(unused)] fn main() { ... mod SUPPORTED_TX_VERSION { const DEPLOY_ACCOUNT: felt252 = 1; const DECLARE: felt252 = 2; const INVOKE: felt252 = 1; } #[starknet::contract] mod Account { ... } }
To ensure that only the latest transaction versions are processed,
a private function only_supported_tx_version
is introduced.
This function checks the version of the incoming transaction against the supported versions.
If there's a mismatch, the transaction execution is halted with an assertion error.
The main functions __execute__
, __validate__
, __validate_declare__
and __validate_deploy__
utilize this version check to ensure only the supported transaction versions are processed.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... use super::SUPPORTED_TX_VERSION; ... #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.execute_multiple_calls(calls) } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.validate_transaction() } fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DECLARE); self.validate_transaction() } fn __validate_deploy__(self: @ContractState, class_hash: felt252, salt: felt252, public_key: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DEPLOY_ACCOUNT); self.validate_transaction() } } #[generate_trait] impl PrivateImpl of PrivateTrait { ... fn only_supported_tx_version(self: @ContractState, supported_tx_version: felt252) { let tx_info = get_tx_info().unbox(); let version = tx_info.version; assert( version == supported_tx_version, 'Account: Unsupported tx version' ); } } } }
Handling Simulated Transactions
In Starknet, transactions can be simulated to estimate gas without actual execution.
Tools like Starkli offer an estimate-only
flag for this purpose, signaling the Sequencer
to simulate the transaction and return the estimated cost.
To differentiate between real and simulated transactions, the version of a simulated transaction is offset
by 2^128 from its actual counterpart. For instance, a simulated declare
transaction has a version of 2^128 + 2
if the regular declare
transaction's latest version is 2.
The only_supported_tx_version
function is adjusted to recognize both actual and simulated versions,
ensuring accurate processing for both types.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... //Represents simulated transactions const SIMULATE_TX_VERSION_OFFSET: felt252 = 340282366920938463463374607431768211456; // 2**128 ... #[generate_trait] impl PrivateImpl of PrivateTrait { ... fn only_supported_tx_version(self: @ContractState, supported_tx_version: felt252) { let tx_info = get_tx_info().unbox(); let version = tx_info.version; assert( version == supported_tx_version || version == SIMULATE_TX_VERSION_OFFSET + supported_tx_version, 'Account: Unsupported tx version' ); } } } }
Introspection
Introspection allows an account contract to self-identify using the SRC-5 standard.
#![allow(unused)] fn main() { trait ISRC5 { fn supports_interface(interface_id: felt252) -> bool; } }
An account contract should return true
for the supports_interface
function when provided with the
specific interface_id
of 1270010605630597976495846281167968799381097569185364931397797212080166453709
.
The reason for using this specific identifier is explained in the previous subchapter.
The supports_interface
function has been added to the public interface of the account
contract to facilitate external queries by other smart contracts.
#![allow(unused)] fn main() { ... #[starknet::interface] trait IAccount<T> { ... fn supports_interface(self: @T, interface_id: felt252) -> bool; } #[starknet::contract] mod Account { ... const SRC6_TRAIT_ID: felt252 = 1270010605630597976495846281167968799381097569185364931397797212080166453709; ... #[external(v0)] impl AccountImpl of super::IAccount<ContractState> { ... fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { interface_id == SRC6_TRAIT_ID } } ... } }
Public Key Accessibility
For enhanced transparency and debugging purposes, it's recommended to make the public key of the account contract's signer accessible. This allows users to verify the correct deployment of the account contract by comparing the stored public key with the signer's public key offline.
#![allow(unused)] fn main() { ... #[starknet::contract] mod Account { ... #[external(v0)] impl AccountImpl of IAccount<ContractState> { ... fn public_key(self: @ContractState) -> felt252 { self.public_key.read() } } } }
Final Implementation
We now have a fully functional account contract. Here's the final implementation;
#![allow(unused)] fn main() { use starknet::account::Call; mod SUPPORTED_TX_VERSION { const DEPLOY_ACCOUNT: felt252 = 1; const DECLARE: felt252 = 2; const INVOKE: felt252 = 1; } #[starknet::interface] trait IAccount<T> { fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252; fn supports_interface(self: @T, interface_id: felt252) -> bool; fn public_key(self: @T) -> felt252; } #[starknet::contract] mod Account { use super::{Call, IAccount, SUPPORTED_TX_VERSION}; use starknet::{get_caller_address, call_contract_syscall, get_tx_info, VALIDATED}; use zeroable::Zeroable; use array::{ArrayTrait, SpanTrait}; use ecdsa::check_ecdsa_signature; use box::BoxTrait; const SIMULATE_TX_VERSION_OFFSET: felt252 = 340282366920938463463374607431768211456; // 2**128 const SRC6_TRAIT_ID: felt252 = 1270010605630597976495846281167968799381097569185364931397797212080166453709; // hash of SNIP-6 trait #[storage] struct Storage { public_key: felt252 } #[constructor] fn constructor(ref self: ContractState, public_key: felt252) { self.public_key.write(public_key); } #[external(v0)] impl AccountImpl of IAccount<ContractState> { fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 { let is_valid = self.is_valid_signature_bool(hash, signature.span()); if is_valid { VALIDATED } else { 0 } } fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { interface_id == SRC6_TRAIT_ID } fn public_key(self: @ContractState) -> felt252 { self.public_key.read() } } #[external(v0)] #[generate_trait] impl ProtocolImpl of ProtocolTrait { fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.execute_multiple_calls(calls) } fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::INVOKE); self.validate_transaction() } fn __validate_declare__(self: @ContractState, class_hash: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DECLARE); self.validate_transaction() } fn __validate_deploy__(self: @ContractState, class_hash: felt252, salt: felt252, public_key: felt252) -> felt252 { self.only_protocol(); self.only_supported_tx_version(SUPPORTED_TX_VERSION::DEPLOY_ACCOUNT); self.validate_transaction() } } #[generate_trait] impl PrivateImpl of PrivateTrait { fn only_protocol(self: @ContractState) { let sender = get_caller_address(); assert(sender.is_zero(), 'Account: invalid caller'); } fn is_valid_signature_bool(self: @ContractState, hash: felt252, signature: Span<felt252>) -> bool { let is_valid_length = signature.len() == 2_u32; if !is_valid_length { return false; } check_ecdsa_signature( hash, self.public_key.read(), *signature.at(0_u32), *signature.at(1_u32) ) } fn validate_transaction(self: @ContractState) -> felt252 { let tx_info = get_tx_info().unbox(); let tx_hash = tx_info.transaction_hash; let signature = tx_info.signature; let is_valid = self.is_valid_signature_bool(tx_hash, signature); assert(is_valid, 'Account: Incorrect tx signature'); VALIDATED } fn execute_single_call(self: @ContractState, call: Call) -> Span<felt252> { let Call{to, selector, calldata} = call; call_contract_syscall(to, selector, calldata.span()).unwrap() } fn execute_multiple_calls(self: @ContractState, mut calls: Array<Call>) -> Array<Span<felt252>> { let mut res = ArrayTrait::new(); loop { match calls.pop_front() { Option::Some(call) => { let _res = self.execute_single_call(call); res.append(_res); }, Option::None(_) => { break (); }, }; }; res } fn only_supported_tx_version(self: @ContractState, supported_tx_version: felt252) { let tx_info = get_tx_info().unbox(); let version = tx_info.version; assert( version == supported_tx_version || version == SIMULATE_TX_VERSION_OFFSET + supported_tx_version, 'Account: Unsupported tx version' ); } } } }
Recap
Account Contract Creation Recap:
-
SNIP-6 Implementation
- The account contract is designed to adhere to the
ISRC6
trait, which dictates the structure of an account contract.
- The account contract is designed to adhere to the
-
Protecting Protocol-Only Functions
-
__validate__
and__execute__
functions were restricted to be accessed only by the Starknet protocol. -
is_valid_signature
was exposed for external interactions. -
Private function
only_protocol
was introduced to enforce this restriction.
-
-
Signature Validation
-
Public key associated with signer of account contract was stored to facilitate transaction signature validation.
-
constructor
method was defined to capture the public key's value during deployment. -
The
is_valid_signature
function was implemented to validate signatures, returningVALID
or0
. -
The helper function
is_valid_signature_bool
was introduced to return a boolean result.
-
-
Validation of Declare and Deploy Functions
-
The
__validate_declare__
function was setup to validate signature ofdeclare
function. -
The
__validate_deploy__
function was designed for counterfactual deployment. -
The core validation logic was abstracted to a private function named
validate_transaction
.
-
-
Transaction Execution
-
Introduced multicall functionality via the
__execute__
function. -
Implemented
execute_single_call
andexecute_multiple_calls
to manage individual and multiple calls respectively.
-
-
Transaction Version Support
-
Implemented a versioning system to ensure backward compatibility with evolving Starknet functionalities.
-
Created
SUPPORTED_TX_VERSION
module to define supported versions for various transaction types. -
Introduced
only_supported_tx_version
to validate transaction versions.
-
-
Handling Simulated Transactions
- Adjusted the
only_supported_tx_version
function to recognize both actual and simulated transaction versions.
- Adjusted the
-
Introspection
-
Enabled the account contract to self-identify using the SRC-5 standard.
-
The
supports_interface
function was added to the public interface for external queries about the contract's capabilities.
-
-
Public Key Accessibility
- Enhanced transparency by making the public key of the account contract's signer accessible.
-
Final Implementation
- Final Implementation of the account contract.
Coming up, we'll use Starkli to deploy to testnet the account created, and use it to interact with other smart contracts.