Demystifying StarkNet accounts

Introduction

When trying StarkNet for the first time, it’s easy to go through the account setup without paying much attention to it.

The documentation just says:

Unlike Ethereum, which distinguishes between Externally Owned Accounts (EOA) and contracts, StarkNet doesn’t have this distinction. Instead, an account is represented by a deployed contract that defines the account’s logic — most notably the signature scheme that controls who can issue transactions from it.

Then, you are invited to chose a wallet implementation to be used by StarkNet commands (CLI) by setting the STARKNET_WALLET environment variable, and finally, you are asked to deploy an account by executing the following command:

starknet deploy_account

This is great, but it doesn’t give a real understanding of what you’ve just done, and why.

It becomes even more confusing if you read the rest of the documentation:

The STARKNET_WALLET environment variable instructs the StarkNet CLI to use your account in the starknet invoke and starknet call commands.** If you want to do a direct call to a contract, without passing through your account contract, you can pass the --no_wallet argument to the CLI**, which overrides the STARKNET_WALLET variable.

So, why are you asking me to setup an account if I don’t actually need it to call and invoke contracts?!

A little project to make some real tests

For the sake of this article, I created a GitHub repository which contains a contract and some commands that will help us make some tests.

The contract is very simple, it contains one storage variable to store information about the last invocation, one view method to retrieve these information at any time, and one external function to be invoked.

The external function looks like this:

@external
func try_me{syscall_ptr : felt*, pedersen_ptr : HashBuiltin*, range_check_ptr}(foo : felt):
    let (block_number) = get_block_number()
    let (tx_info : TxInfo*) = get_tx_info()
    let (caller_address) = get_caller_address()

    let (previous_invoke_info : InvokeInfo) = last_invoke_info.read()
    let new_id = previous_invoke_info.id + 1

    let invoke_info = InvokeInfo(
        id=new_id,
        version=tx_info.version,
        account_contract_address=tx_info.account_contract_address,
        chain_id=tx_info.chain_id,
        block_number=block_number,
        caller_address=caller_address)

    last_invoke_info.write(invoke_info)
    return ()
end

As you see, we simply get the block number, the transaction information, the caller address, and we save all of it in the last_invoke_info storage variable so we can retrieve it later.

Explaining direct invocations

Alright, we are now ready to make some tests to understand what exactly is going on when we do direct invocations vs. invocations through an account contract. Seeing what’s going on when doing direct invocations will help us understand why we need account contracts and what they must do.

You can try all this by yourself, by following the instructions in the README of this repository. All commands and results I put in this article come from this.

Okay, let’s go.

After deploying the test contract to the address 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945, we invoke it directly (without going through an account) by doing:

starknet invoke --no_wallet --network=alpha-goerli --address 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945 --abi artifacts/callme_abi.json --function try_me --inputs 1

This command give use the following output:

Invoke transaction was sent.
Contract address: 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945
Transaction hash: 0x1c44ab253e5e4a8d21eeaad8246654bc6553eebd4b880cc8e8f7dfac957ea51

We wait until the transaction has been accepted on L2, and then we can retrieve information about this invocation by calling the get_last_invoke_info function, which returns:

1 0 0x8eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945 0x534e5f474f45524c49 179127 0

Well, this is not very easy to read so I’ll put this in form for you:

  • id = 1
  • version = 0
  • account_contract_address = 0x8eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945
  • chain_id = 0x534e5f474f45524c49
  • block_number = 179127
  • caller_address = 0

This is very interesting. The account contract address is actually set to the address of the contract we are calling (which is not an account contract by the way). Even more disturbing, the caller address is equal to 0.

So, we were able to invoke a function in our contract, but we can see that transaction information and caller address are somehow not properly defined.

As you see, it is easy to call a contract with a caller_address being equal to 0. It looks like it might be a good idea — for example — to check that the owner address of an ownable contract is never set to zero (which the the default value of any felt by the way), otherwise it means anyone can virtually invoke as an owner.

One more thing about direct invocations

There is another very interesting thing to notice about direct calls.

Let’s just make the same direct invocation again:

starknet invoke --no_wallet --network=alpha-goerli --address 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945 --abi artifacts/callme_abi.json --function try_me --inputs 1

The output is:

Invoke transaction was sent.
Contract address: 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945
Transaction hash: 0x1c44ab253e5e4a8d21eeaad8246654bc6553eebd4b880cc8e8f7dfac957ea51

I won’t blame you if you didn’t notice it, but the transaction hash is exactly the same as in the previous invocation.

Now if we try to get some information about this transaction, the old one is returned (it’s the same hash, after all). And if we call the get_last_invoke_info function, it returns the exact same output, including the same id, which means the contract has actually never been invoked the second time.

So, here is what you must keep in mind:

  • **directly-**invoking the same function of the same contract, with the same input, multiple times, always produces a transaction with the same hash.
  • in a direct call/invocation, the caller address is equal to 0, and the account-contract-address is equal to the address of the contract itself.

Transaction Hash

It’s time to talk about transaction hashes.

The documentation tells us that a transaction hash is computed as follow:

invoke_txn_hash := pedersen(
   “invoke”, 
   contract_address,
   entry_point_selector,
   calldata,
   chain_id
)

As you can see, the hash value only depends on the contract address, the function being invoked, the function’s inputs (calldata), and the chain id.

So, the documentation confirms that if you call/invoke the same function of the same contract, with the same input, multiple times, the hash will always be the same.

What is interesting, however, is to notice that reciprocally, changing the inputs (calldata) is enough to change the transaction hash.

Account contract

Account contracts are contracts like any other. We know that from the beginning, right? That’s what the documentation told us.

Using the knowledge we acquired about direct calls/invocations, we know we can directly make calls or invocations to account contracts — as they are just like any other contracts — but we also know that in order to get a different transaction hash every time we call them, we need to change the calldata.

Well, that’s precisely one of the responsibilities of an account contract.

An account contract acts like a proxy, transferring calls and invocations to other contracts, but on top of target-contracts’ inputs, it asks the caller to send a nonce that changes every time. This way, the calldata will be different at every call/invocation, and consequently, the transaction hash will also be different.

Of course, the account contract should only allow its owner (you) to call it, otherwise, anyone could make a call through your account, which would be the same as giving away your private key to the entire world.

This is why another responsibility (and probably the most important one) of an account contract is to check that the signature of the transaction is valid and matches your public key. Having this step done in a contract is actually very interesting, as it decouples the account from the signer. Julien Niset explains it very well in this video.

You can get a look at the OpenZeppelin account implementation, or Argent’s one, but basically, the over-simplified pseudo-code looks like this:

@storage
current_nonce

@storage
public_key

@external
func __execute__(target_contract, function_selector, calldata, nonce):
    assert nonce = current_nonce
    increase_current_nonce()
    verify_transaction_signature()
    call_contract(target_contract, function_selector, calldata)
end

Testing invocations with an account

Let’s try it for real. First, we deploy an account contract:

starknet deploy_account --network=alpha-goerli --account=demystifyer

This command deployed an account contract, and saved its address associated with a private key and a public key pair in the ~/.starknet_accounts/starknet_open_zeppelin_account.json file, under the alias “demystifyer”.

The address of our newly deployed account contract is 0x786715489b99eb5b82eaaf8196c6eee0b7a3c762718b7306434daf3176ed7ee.

Now, let’s do the same invocation as before, except that this time we will be using our account:

starknet invoke --account=demystifyer --network=alpha-goerli --address 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945 --abi artifacts/callme_abi.json --function try_me --inputs 1

As you see, this is the same command we executed when we were testing direct-invocations, except that it has the --account=demystifyer option instead of --no-wallet.

Behind the scene, this starknet command does the following:

  1. it gets the next nonce from the account contract
  2. it (directly) invokes the account contract, passing the target contract, target function selector, calldata, and nonce as parameters.

Then, the account contract will take care of “transferring” the call to the target contract, as explained in the previous section.

The output of the command is:

Sending the transaction with max_fee: 0.000018 ETH.
Invoke transaction was sent.
Contract address: 0x008eaea6f30623b9887c1166bbb45e2a2bd1e3c0f5f70937436dea659e1db945
Transaction hash: 0x61c7b829985ef425f0f008457bd8e642491890cb3e04805366133e45df75c21

The first thing we see — as expected — is that thanks to the nonce, the transaction hash is different than the one we got with the direct invocation. And executing the same command again will give us another transaction hash every time.

Just like we did for the direct invocation, we wait until the transaction has been accepted on L2, and then we retrieve information about this invocation by calling the get_last_invoke_info function, which returns:

3 0 0x786715489b99eb5b82eaaf8196c6eee0b7a3c762718b7306434daf3176ed7ee 0x534e5f474f45524c49 181248 0x786715489b99eb5b82eaaf8196c6eee0b7a3c762718b7306434daf3176ed7ee

Human readable version:

  • id = 3
  • version = 0
  • account_contract_address = 0x786715489b99eb5b82eaaf8196c6eee0b7a3c762718b7306434daf3176ed7ee
  • chain_id = 0x534e5f474f45524c49
  • block_number = 181248
  • caller_address = 0x786715489b99eb5b82eaaf8196c6eee0b7a3c762718b7306434daf3176ed7ee

As you see, the account_contract_address is now actually equal to the address of our account contract, and the caller_address is not 0, but it is the address of the account contract.

This result is not surprising. As explained in the previous section, we invoked our test contract through the account contract, acting like a proxy. It is logical that the caller_address as seen by the target contract is the address of our account contract.

Conclusion

An account contract acts like a proxy, transferring calls to target contracts.

Calling or invoking a contract through an account contract means you will be forced to send a different nonce every time — as required by the account contract implementation — which ensures the transaction hash will be different. It will also make sure the transaction is valid and comes from you by checking its signature. As a consequence, you are identified as the one who made the transaction, and the caller address will be properly set. This also decouples the signer from the account.

As you see — and as expected — account contracts are mandatory to make everything work fine.

However, it is interesting to notice that StarkNet doesn’t enforce the way accounts work at the protocol level. Instead, it allows to write custom account implementations.

For instance, multiple account contracts are under development to support multi-sig wallets, fraud detection (white/black listing of contracts), session key, social recovery, etc.

Subscribe to Only Dust
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.