Account Abstraction on Starknet, Part II
In part I of this series [1], I showed you the different traits that a smart contract must implement to be considered an account contract. In this article I’ll create an account contract from scratch following the SNIP-6 and SRC-5 standards.
Project Setup
In order to be able to compile an account contract to Sierra, a prerequisite to deploy it to testnet or mainnet, you’ll need to make sure to have a version of Scarb that includes a Cairo compiler that targets Sierra 1.3 as it’s the latest version supported by Starknet’s testnet [2]. For me at this point in time is Scarb 0.7.
~ $ scarb --version
>>>
scarb 0.7.0 (58cc88efb 2023-08-23)
cairo: 2.2.0 (https://crates.io/crates/cairo-lang-compiler/2.2.0)
sierra: 1.3.0
With Scarb we can create a new project using the new command.
~ $ scarb new aa
The command creates a folder with the same name that includes a configuration file for Scarb.
~ $ cd aa
aa $ tree .
>>>
.
├── Scarb.toml
└── src
└── lib.cairo
By default, Scarb configures our project for vanilla Cairo instead of Starknet smart contracts.
# Scarb.toml
[package]
name = "aa"
version = "0.1.0"
[dependencies]
# foo = { path = "vendor/foo" }
We need to make some changes to the configuration file to activate the Starknet plugin in the compiler so we can work with smart contracts.
# Scarb.toml
[package]
name = "aa"
version = "0.1.0"
[dependencies]
starknet = "2.2.0"
[[target.starknet-contract]]
We can now replace the content of the sample Cairo code that comes with a new project with the scaffold of our account contract.
#[starknet::contract]
mod Account {}
Given that one of the most important features of our account contract is to validate signatures, we need to store the public key associated with the private key of the signer.
#[starknet::contract]
mod Account {
#[storage]
struct Storage {
public_key: felt252
}
}
To make sure everything is wired up correctly, let’s compile our project.
aa $ scarb build
>>>
Compiling aa v0.1.0 (/Users/david/apps/sandbox/aa/Scarb.toml)
Finished release target(s) in 2 seconds
It works, time to move to the interesting part of our tutorial.
SNIP-6
As you know from the previous article in the series, for a smart contract to be considered an account contract, it must implement the trait defined by SNIP-6 [3].
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;
}
Because we will eventually annotate the implementation of this trait with the external attribute, the contract state will be the first argument provided to each method. We can define the type of the contract state with the generic T.
trait ISRC6<T> {
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
}
The __execute__ function is the only one that receives a reference to the contract state because it’s the only one likely to either modify its internal state or to modify the state of another smart contract and thus to require the payment of gas fees for its execution. The other two functions, __validate__ and is_valid_signature, are read-only and shouldn’t require the payment of gas fees. For this reason they are both receiving a snapshot of the contract state instead.
The question now becomes, how should we use this trait in our account contract. Should we annotate the trait with the interface attribute and then create an implementation like the code shown below?
#[starnet::interface]
trait ISRC6<T> {
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
}
#[starknet::contract]
mod Account {
...
#[external(v0)]
impl ISRC6Impl of super::ISRC6<ContractState> {...}
}
Or should we use it instead without the interface attribute?
trait ISRC6<T> {
fn __execute__(ref self: T, calls: Array<Call>) -> Array<Span<felt252>>;
fn __validate__(self: @T, calls: Array<Call>) -> felt252;
fn is_valid_signature(self: @T, hash: felt252, signature: Array<felt252>) -> felt252;
}
#[starknet::contract]
mod Account {
...
#[external(v0)]
impl ISRC6Impl of super::ISRC6<ContractState> {...}
}
What about without defining the trait explicitly?
#[starknet::contract]
mod Account {
...
#[external(v0)]
#[generate_trait]
impl ISRC6Impl of ISRC6Trait {...}
}
Technically speaking, they are all valid alternatives but they all fail to capture the right intention.
Every function inside an implementation annotated with the external attribute will have its own selector that other people and smart contracts can use to interact with my account contract. But the thing is, even though they can use the derived selectors to call those functions, I want to signal which ones I want people to actually use and which ones I’m reserving for the Starknet protocol.
The functions __execute__ and __validate__ are meant to be used only by the Starknet protocol even if the functions are publicly accessible via its selectors. The only function that I want to make public for web3 apps to use for signature validation is is_valid_signature.
To make my intention clear, I’m going to create a separate trait annotated with the interface attribute that will group all the functions in my account contract that I expect people to interact with. On the other hand, I will auto generate the trait for all those functions that I don’t want people to use directly even though they are public.
use starknet::account::Call;
#[starnet::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 { ... }
}
}
Protecting Protocol-Only Functions
Although there might be legitimate use cases for other smart contracts to directly interact with the functions __execute__ and __validate__ of my account contract, I would rather restrict them to be callable only by the Starknet protocol in case there’s an attack vector that I’m failing to foresee.
When the Starknet protocol calls a function it uses the zero address as the caller. We can use this fact to create a private function named only_protocol. To create private functions we simply create a new implementation that is not annotated with the external attribute so no public selectors are created.
...
#[starknet::contract]
mod Account {
use starknet::get_caller_address;
use zeroable::Zeroable;
...
#[generate_trait]
impl PrivateImpl of PrivateTrait {
fn only_protocol(self: @ContractState) {
let sender = get_caller_address();
assert(sender.is_zero(), 'Account: invalid caller');
}
}
}
We can now use this private function to protect our protocol-only functions.
...
#[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();
...
}
fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 {
self.only_protocol();
...
}
}
#[generate_trait]
impl PrivateImpl of PrivateTrait {
fn only_protocol(self: @ContractState) {...}
}
}
Notice that the function is_valid_signature is not protected by the only_protocol function because we do want to allow anyone to use it.
Signature Validation
To validate the signature of a transaction we will need to use the public key associated with the signer of the account contract. We have already defined public_key to be part of the storage of our account but we need to capture its value during deployment using the constructor.
...
#[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);
}
...
}
We can now implement the logic of the function is_valid_signature where, if the signature is valid, it should return the short string ‘VALID’ and if not it should return the value 0. Returning zero is just a convention, we can return any felt as long as it is not the felt that represents the short string ‘VALID’.
I find the logic of returning a felt252 value instead of a boolean confusing. That’s why I will create an internal function called is_valid_signature_bool that will perform the same logic but will return a boolean instead of a felt252 depending on the result of validating a signature.
...
#[starknet::contract]
mod Account {
...
use array::ArrayTrait;
use ecdsa::check_ecdsa_signature;
...
#[external(v0)]
impl AccountImpl of super::IAccount<ContractState> {
fn is_valid_signature(self: @ContractState, hash: felt252, signature: Array<felt252>) -> felt252 {
let is_valid = self.is_valid_signature_bool(hash, signature);
if is_valid { 'VALID' } else { 0 }
}
}
...
#[generate_trait]
impl PrivateImpl of PrivateTrait {
...
fn is_valid_signature_bool(self: @ContractState, hash: felt252, signature: Array<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)
)
}
}
}
We can make use of this private function to validate a transaction signature as required by the __validate__ function. In contrast to the function is_valid_signature we will use an assert to stop the transaction execution in case the signature is found to be invalid.
...
#[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); // ERROR!
assert(is_valid, 'Account: Incorrect tx signature');
'VALID'
}
}
...
}
Here’s a little casting problem. The function is_valid_signature_bool expects the signature to be passed as an Array but the signature variable inside the __validate__ function is a Span. Because it is easier (and cheaper) to derive a Span from an Array than the opposite, I’ll change the function signature of is_valid_signature_bool to expect a Span instead of an Array.
This little change will require deriving a Span from the signature variable inside the function is_valid_signature before calling is_valid_signature_bool which we can easily do with the span() method available on the ArrayTrait.
...
#[starknet::contract]
mod Account {
...
use array::SpanTrait;
...
#[external(v0)]
impl AccountImpl of super::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());
...
}
}
#[external(v0)]
#[generate_trait]
impl ProtocolImpl of ProtocolTrait {
...
fn __validate__(self: @ContractState, calls: Array<Call>) -> felt252 {
...
let is_valid = self.is_valid_signature_bool(tx_hash, signature);
...
}
}
#[generate_trait]
impl PrivateImpl of PrivateTrait {
...
fn is_valid_signature_bool(self: @ContractState, hash: felt252, signature: Span<felt252>) -> bool {
...
}
}
}
Validate Declare and Deploy
If you read the previous article in the series, you’ll know that there are two more functions that the Starknet protocol invokes to validate transaction signatures under special circumstances: __validate_declare__ and __validate_deploy__.
__validate_declare__ is used to validate the signature of a declare transaction while __validate_deploy__ is used for the same purpose but for the deploy_account transaction. The latter is often referred to as “counterfactual deployment”.
The reason why __validate_declare__ is not part of the SNIP-6 standard is because this is a function that is only used if you want to use your account contract to declare another smart contract. This is something that developers often do but not regular users. That’s why it is not considered a “required” function by SNIP-6 but we will implement it anyway. We are developers after all.
__validate_deploy__ is used for counterfactual deployment which is an alternative way to deploy an account contract without linking it to another deployer address. Because you can deploy a new account contract without doing counterfactual deployment, this validation function is considered to be “optional” and thus not part of the SNIP-6 but we, once again, will implement it for the sake of completeness.
To keep things simple in this tutorial, I’ll have the three signature validation functions to behave in exactly the same way. To reuse the same logic created for the __validate__ function in the other two signature validation functions mentioned above, I’ll extract the logic into a private function that they all can call.
...
#[starknet::contract]
mod Account {
...
#[external(v0)]
#[generate_trait]
impl ProtocolImpl of ProtocolTrait {
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 {
...
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'
}
}
}
Notice that the public key is passed as an argument to the function __validate_deploy__. I’m not using that parameter because I’m already capturing the public key in the constructor which is called by the protocol before the function __validate_deploy__ is called [4] but I’ll need to make sure to pass it as the constructor argument when sending the transaction. It is possible however to remove the need for using a constructor to capture the public key and simply use the value passed to the __validate_deploy__ function.
Execute Transactions
If you look at the signature of the __execute__ function you will see that an array of calls are being passed instead of a single element.
...
#[starknet::contract]
mod Account {
...
#[external(v0)]
#[generate_trait]
impl ProtocolImpl of ProtocolTrait {
fn __execute__(ref self: ContractState, calls: Array<Call>) -> Array<Span<felt252>> { ... }
...
}
}
This is because multicall is a feature of Account Abstraction that lets you bundle multiple user operations into a single transaction for a smoother UX.
The Call data type is a struct that has all the data you need to execute a single user operation.
#[derive(Drop, Serde)]
struct Call {
to: ContractAddress,
selector: felt252,
calldata: Array<felt252>
}
Instead of trying to face the multicall head on, let’s first create a private function that deals with a single call that we can then reuse by iterating over the array of calls.
...
#[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()
}
}
}
We first destructure the Call struct and then we use the low level syscall call_contract_syscall to invoke a function on another smart contract without the help of a dispatcher.
With our single call function, we can build a multi call function by iterating over a Call array and returning the responses as an array as well.
...
#[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, we can go back to our __execute__ function and make use of the functions we have just created.
...
#[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)
}
...
}
...
}
Supported Transaction Versions
As Starknet evolved, changes have been required to the structure of the transactions to accommodate more advanced functionality. To avoid creating breaking changes whenever a transaction structure needs to be updated, a “version” field was added to all transactions so older and newer transactions can co-exist.
Maintaining different transaction versions is complex and because this is just a tutorial, I’ll restrict my account contract to only support the newest version of each type of transaction and those are:
- Version 1 for invoke transactions
- Version 1 for deploy_account transactions
- Version 2 for declare transactions
I’m going to define the supported transaction versions in a module for logical grouping.
...
mod SUPPORTED_TX_VERSION {
const DEPLOY_ACCOUNT: felt252 = 1;
const DECLARE: felt252 = 2;
const INVOKE: felt252 = 1;
}
#[starknet::contract]
mod Account { ... }
I can now create a private function that will check if the executed transaction is of the latest version and hence supported by my account contract. If not, I’ll abort the transaction execution with an assert.
...
#[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'
);
}
}
}
Simulated Transactions
It’s possible to request the Sequencer to estimate the amount of gas required to execute a transaction without actually executing it. Starkli for example provides the flag estimate-only that you can append to any transaction to instruct the Sequencer to only simulate the transaction and return the estimated cost.
To differentiate a regular transaction from a transaction simulation while protecting against replay attacks, the version of a transaction simulation is the same value as the normal transaction but offset by the value 2^128. For example, the version of a simulated declare transaction is 2^128 + 2 because the latest version of a regular declare transaction is 2.
With that in mind, we can modify the function only_supported_tx_version to account for simulated transactions.
...
#[starknet::contract]
mod Account {
...
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
In the previous article in the series we mentioned the standard SRC-5 for introspection.
trait ISRC5 {
fn supports_interface(interface_id: felt252) -> bool;
}
For an account contract to self identify as such, it must return true when passed the interface_id 1270010605630597976495846281167968799381097569185364931397797212080166453709. The reason why that particular number is used is explained in the previous article so go check it out for more details.
Because this is a public function that I do expect people and other smart contracts to call on my account contract, I’ll add this function to its public interface.
...
#[starnet::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
}
}
...
}
Exposing the Public Key
Although not required, it is a good idea to expose the public key associated with the account contract’s signer. One use case I have in mind is to easily and safely debug the correct deployment of the account contract by reading the stored public key and comparing it (offline) to the public key of my signer.
...
#[starknet::contract]
mod Account {
...
#[external(v0)]
impl AccountImpl of IAccount<ContractState> {
...
fn public_key(self: @ContractState) -> felt252 {
self.public_key.read()
}
}
}
We finally have a fully functional account contract. You can find the final implementation of this account contract on Github [5].
Conclusion
The account contract we have created today might look complex but it’s actually one of the simplests you can create. The account contracts created by Braavos and Argent X are much more complex as they support features like social recovery, multisig, hardware signer, email/password signer, etc.
Both Braavos and Argent have open sourced their Cairo 0 version of their account contracts but Argent is the first one to also open source their Cairo version [6]. Open Zeppelin (OZ) is also developing their own implementation of a Cairo account contract but it’s still a work in progress [7]. I took inspiration from OZ’s implementation when creating this tutorial.
We have referenced SNIP-6 multiple times as a standard to follow for an account contract but so far it’s only a proposal under discussion that could change by the time you read this article. This will not only affect the interface of your account contract but also the ID used for introspection.
In part III of this series I’ll show you how to use Starkli to deploy and use the account contract we just created. I’ll add the link here once that final article is published.
References
[1] Account Abstraction on Starknet, Part I
[3] SNIP-6 PR
[4] Validate Deploy Transaction Flow