Accounts

Unlike Ethereum where accounts are derived from a private key, all Starknet accounts are contracts. This means there’s no Externally Owned Account (EOA) concept on Starknet.

Instead, the network features native account abstraction and signature validation happens at the contract level.

For a general overview of account abstraction, see Starknet’s documentation. A more detailed discussion on the topic can be found in Starknet Shaman’s forum.

For detailed information on the usage and implementation check the API Reference section.

Standard Account Interface

Accounts in Starknet are smart contracts, and so they can be deployed and interacted with like any other contract, and can be extended to implement any custom logic. However, an account is a special type of contract that is used to validate and execute transactions. For this reason, it must implement a set of entrypoints that the protocol uses for this execution flow. The SNIP-6 proposal defines a standard interface for accounts, supporting this execution flow and interoperability with DApps in the ecosystem.

ISRC6 Interface

/// Represents a call to a target contract function.
struct Call {
    to: ContractAddress,
    selector: felt252,
    calldata: Array<felt252>
}

/// Standard Account Interface
trait ISRC6 {
    /// Executes a transaction through the account.
    fn __execute__(calls: Array<Call>) -> Array<Span<felt252>>;

    /// Asserts whether the transaction is valid to be executed.
    fn __validate__(calls: Array<Call>) -> felt252;

    /// Asserts whether a given signature for a given hash is valid.
    fn is_valid_signature(hash: felt252, signature: Array<felt252>) -> felt252;
}

SNIP-6 adds the is_valid_signature method. This method is not used by the protocol, but it’s useful for DApps to verify the validity of signatures, supporting features like Sign In with Starknet.

SNIP-6 also defines that compliant accounts must implement the SRC5 interface following SNIP-5, as a mechanism for detecting whether a contract is an account or not through introspection.

ISRC5 Interface

/// Standard Interface Detection
trait ISRC5 {
    /// Queries if a contract implements a given interface.
    fn supports_interface(interface_id: felt252) -> bool;
}

SNIP-6 compliant accounts must return true when queried for the ISRC6 interface Id.

Even though these interfaces are not enforced by the protocol, it’s recommended to implement them for enabling interoperability with the ecosystem.

Protocol-level methods

In this section we will describe the methods that the protocol uses for abstracting the accounts. The first two are required for enabling accounts to be used for executing transactions. The rest are optional:

  1. __validate__ verifies the validity of the transaction to be executed. This is usually used to validate signatures, but the entrypoint implementation can be customized to feature any validation mechanism with some limitations.

  2. __execute__ executes the transaction if the validation is successful.

  3. __validate_declare__ optional entrypoint similar to __validate__ but for transactions meant to declare other contracts.

  4. __validate_deploy__ optional entrypoint similar to __validate__ but meant for counterfactual deployments.

Although these entrypoints are available to the protocol for its regular transaction flow, they can also be called like any other method.

Deploying an account

In Starknet there are two ways of deploying smart contracts: using the deploy_syscall and doing counterfactual deployments. The former can be easily done with the Universal Deployer Contract (UDC), a contract that wraps and exposes the deploy_syscall to provide arbitrary deployments through regular contract calls. But if you don’t have an account to invoke it, you will probably want to use the latter.

To do counterfactual deployments, you need to implement another protocol-level entrypoint named __validate_deploy__. Check the counterfactual deployments guide to learn how.

Sending transactions

Contracts for Cairo offers two flavors of components to use for creating accounts: AccountComponent and EthAccountComponent. Though the ISRC6 interface is agnostic regarding validation schemes, both available components utilize public-private key pairs that control the account. The public key is set in the constructor.

Mixin implementations are available to use for both components which include basic account functionality as well as additional features such as the ability to:

  • View and transfer the account’s public key.

  • Declare contract class hashes.

  • Counterfactually deploy.

AccountComponent

To create an account, implement the AccountMixin and set up the constructor to initialize the contract. The initializer method sets the public key for the account contract and registers the account interface id. A vanilla account contract looks like this:

#[starknet::contract(account)]
mod MyAccount {
    use openzeppelin::account::AccountComponent;
    use openzeppelin::introspection::src5::SRC5Component;

    component!(path: AccountComponent, storage: account, event: AccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // Account Mixin
    #[abi(embed_v0)]
    impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
    impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        account: AccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        AccountEvent: AccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: felt252) {
        self.account.initializer(public_key);
    }
}

To deploy the Contracts for Cairo account variant, compile the contract and declare the class hash because custom accounts are likely not declared. This means that you’ll need an account already deployed.

Next, create the account JSON with Starknet Foundry’s custom account setup and include the --class-hash flag with the declared class hash. The flag enables custom account variants.

The following examples use sncast v0.23.0.
$ sncast \
  --url http://127.0.0.1:5050 \
  account create \
  --name my-custom-account \
  --class-hash 0x123456...

This command will output the precomputed contract address and the recommended max-fee. To counterfactually deploy the account, send funds to the address and then deploy the custom account.

$ sncast \
  --url http://127.0.0.1:5050 \
  account deploy \
  --name my-custom-account

Once the account is deployed, set the --account flag with the custom account name to send transactions from that account.

$ sncast \
  --account my-custom-account \
  --url http://127.0.0.1:5050 \
  invoke \
  --contract-address 0x123... \
  --function "some_function" \
  --calldata 1 2 3

EthAccountComponent

To create an Ethereum-flavored account, implement the EthAccountMixin and set up the constructor to initialize the contract. Since this is an EthAccount, the initializer expects the EthPublicKey type (alias for Secp256k1Point) to store as the account’s public key. The contract also requires the Secp256K1Impl implementation in order to serialize and deserialize the EthPublicKey type. A basic Ethereum-flavored account contract looks like this:

#[starknet::contract(account)]
mod MyEthAccount {
    use openzeppelin::account::EthAccountComponent;
    use openzeppelin::account::interface::EthPublicKey;
    use openzeppelin::account::utils::secp256k1::Secp256k1PointSerde;
    use openzeppelin::introspection::src5::SRC5Component;

    component!(path: EthAccountComponent, storage: eth_account, event: EthAccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // EthAccount Mixin
    #[abi(embed_v0)]
    impl EthAccountMixinImpl =
        EthAccountComponent::EthAccountMixinImpl<ContractState>;
    impl EthAccountInternalImpl = EthAccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        eth_account: EthAccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        EthAccountEvent: EthAccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: EthPublicKey) {
        self.eth_account.initializer(public_key);
    }
}

Special tooling is required in order to deploy and send transactions with an Ethereum-flavored account contract. The following examples utilize the StarknetJS library.

Compile and declare the contract on the target network. Next, precompute the EthAccount contract address using the declared class hash.

The following examples use unreleased features from StarknetJS (starknetjs@next) at commit d002baea0abc1de3ac6e87a671f3dec3757437b3.
import * as dotenv from 'dotenv';
import { CallData, EthSigner, hash } from 'starknet';
import { ABI as ETH_ABI } from '../abis/eth_account.js';
dotenv.config();

// Calculate EthAccount address
const ethSigner = new EthSigner(process.env.ETH_PRIVATE_KEY);
const ethPubKey = await ethSigner.getPubKey();
const ethAccountClassHash = '<ETH_ACCOUNT_CLASS_HASH>';
const ethCallData = new CallData(ETH_ABI);
const ethAccountConstructorCalldata = ethCallData.compile('constructor', {
    public_key: ethPubKey
})
const salt = '0x12345';
const deployerAddress = '0x0';
const ethContractAddress = hash.calculateContractAddressFromHash(
    salt,
    ethAccountClassHash,
    ethAccountConstructorCalldata,
    deployerAddress
);
console.log('Pre-calculated EthAccount address: ', ethContractAddress);

Send funds to the pre-calculated EthAccount address and deploy the contract.

import * as dotenv from 'dotenv';
import { Account, CallData, EthSigner, RpcProvider, stark } from 'starknet';
import { ABI as ETH_ABI } from '../abis/eth_account.js';
dotenv.config();

// Prepare EthAccount
const provider = new RpcProvider({ nodeUrl: process.env.API_URL });
const ethSigner = new EthSigner(process.env.ETH_PRIVATE_KEY);
const ethPubKey = await ethSigner.getPubKey();
const ethAccountAddress = '<ETH_ACCOUNT_ADDRESS>'
const ethAccount = new Account(provider, ethAccountAddress, ethSigner);

// Prepare payload
const ethAccountClassHash = '<ETH_ACCOUNT_CLASS_HASH>'
const ethCallData = new CallData(ETH_ABI);
const ethAccountConstructorCalldata = ethCallData.compile('constructor', {
    public_key: ethPubKey
})
const salt = '0x12345';
const deployPayload = {
    classHash: ethAccountClassHash,
    constructorCalldata: ethAccountConstructorCalldata,
    addressSalt: salt,
};

// Deploy
const { suggestedMaxFee: feeDeploy } = await ethAccount.estimateAccountDeployFee(deployPayload);
const { transaction_hash, contract_address } = await ethAccount.deployAccount(
    deployPayload,
    { maxFee: stark.estimatedFeeToMaxFee(feeDeploy, 100) }
);
await provider.waitForTransaction(transaction_hash);
console.log('EthAccount deployed at: ', contract_address);

Once deployed, connect the EthAccount instance to the target contract which enables calls to come from the EthAccount. Here’s what an ERC20 transfer from an EthAccount looks like.

import * as dotenv from 'dotenv';
import { Account, RpcProvider, Contract, EthSigner } from 'starknet';
dotenv.config();

// Prepare EthAccount
const provider = new RpcProvider({ nodeUrl: process.env.API_URL });
const ethSigner = new EthSigner(process.env.ETH_PRIVATE_KEY);
const ethAccountAddress = '<ETH_ACCOUNT_CONTRACT_ADDRESS>'
const ethAccount = new Account(provider, ethAccountAddress, ethSigner);

// Prepare target contract
const erc20 = new Contract(compiledErc20.abi, erc20Address, provider);

// Connect EthAccount with the target contract
erc20.connect(ethAccount);

// Execute ERC20 transfer
const transferCall = erc20.populate('transfer', {
    recipient: recipient.address,
    amount: 50n
});
const tx = await erc20.transfer(
    transferCall.calldata, { maxFee: 900_000_000_000_000 }
);
await provider.waitForTransaction(tx.transaction_hash);