Try   HackMD

Practical StarkNet lessons learned

About this guide

This guide will probably be most helpful for people who have already used StarkNet some.

I've been implementing a project on StarkNet full time for the past 3-4 weeks, and this contains some of the lessons learned. Questions or suggestions: ping me on twitter @RoboTeddy.

  • Public Service Announcement: You don't need to understand how STARKs work in order to use StarkNet!
  • Thanks to hard work by the Starkware team, you can just learn and use Cairo (a programming language) and StarkNet (a place to deploy programs written in Cairo).
  • The best way to learn is by following the StarkNet documentation. You can learn any Cairo you need as you go by using the Cairo documentation and completing exercises in the cairo playground.
    • Eventually, you may want to read the more advanced How Cairo Works, but you can definitely begin writing code without reading it.

StarkNet architecture mini primer

(Note: A more complete explanation of how rollups work is out of scope here — for that, try Vitalik's guide.)

In StarkNet, you submit tx (e.g. contract deployment, contract calls, etc) to a sequencer. This sequencer is currently centralized and closed-source, but will later be decentralized and open-source.

The sequencer executes batches of tx and generates two things:

  1. A list of state deltas caused by the transaction batch (e.g. ["Update storage cell 5 to value 10", "Update storage cell 9 to value 12345"])
  2. A proof that there exists a set of transactions that, were they executed faithfully against the previous StarkNet state, results in the state deltas listed in item (1)

Notably, the StarkNet transactions themselves never end up recorded on-chain at all. (They actually don't even need to be published at all for the sytem to operate safely!).

  • In StarkNet, transactions are off-chain and the resultant state is stored on-chain in L1 calldata.
  • In e.g. Arbitrum, by comparison, the transactions are stored on-chain in L1 calldata and the resultant state is computed off-chain.

Maxim: Computation is cheap. Writes are expensive.

StarkNet doesn't have fees on testnet. Fees on mainnet will be charged in eth, at least at first. Fees might start out simple and inaccurate, but will over time likely evolve to reflect the fundamental costs of running StarkNet:

  • Cheap things:
    • Transaction calldata. It's totally fine to have large tx! As noted above, these aren't stored on-chain.
    • Computation (adding, multiplying, calling functions, etc) — this all happens off-chain in a batch and is relatively cheaply verified on-chain.
    • Reading from a storage var — this happens off-chain.
  • Expensive things:
    • Modifying a storage var: these modifications have to be written to L1 calldata, which is expensive.

While writing code, I tend to think of everything as ~free (within reason) except for state modifications.

Note: StarkNet has released a description of their initial fee structure — it lines up with this document.

Costs of particular kinds of writes

Because writes are expensive, it's important to be able to estimate how much they cost.

A basic write that modifies a single storage_var slot

Say you define a storage var like this:

@storage_var
func _balances(addr: felt) -> (res: felt):
end

Let's examine much it might cost to execute _balances.write(1, 123456).

  • Base cost: Writing a single storage slot costs ~$0.60 (as of Nov 2021). The math:
    • The write causes a state diff of 64 bytes (32 bytes for the slot index number, and 32 bytes for the slot storage value)
    • L1 calldata costs 16 gas/byte
    • Gas price is ~130 gwei (Nov 2021)
    • Eth price is ~$4200 (Nov 2021)
    • 64 bytes * 16 gas/byte * 130e-9 eth/gas * $4200/eth = ~$0.60
    • This is still 20x cheaper than an Ethereum SSTORE
  • 'Batching rebate': If a particular storage slot is written to n times within a single StarkNet batch (~1-4 hours), each write costs only 1/nth as much. This is true even if the writes to the storage slot were caused by different tx, calls from other contracts, etc.
  • 'Compression rebate': If the value you're storing is a common one, it will likely compress well and take a bit less calldata. It might be complicated for StarkNet to pass these savings on to you, so I wouldn't rely on it happening soon.

Note that you only pay for changing a value. If you write 0 to a value that was already a 0, you don't pay anything for it. (Likewise if you write 1234 to a value that was already 1234.)

Cost of writing a struct to storage

It's possible to write structs to storage vars, and often useful to do so:

struct Account:
    member id: felt
    member username: felt
    member karma: felt
    member num_followers : felt
end

@storage_var
func _accounts(addr: felt) -> (res: Account):
end

# ...

_accounts.write(2, Account(id=3, username=23434, karma=1000, num_followers=0))

Writing a struct that has n members costs roughly n times as much as a basic write that modifies a single storage slot.

Remember that you only pay for changing a storage value. All storage is initialized to be 0, so in the above case we won't actually have to pay for writing the num_followers struct member, since we're just writing a 0 to something that is already 0! We'll only be charged for three writes.

Cost of writing to a storage var that has compound keys

It's possible to create a storage var that has a compound key, e.g.:

@storage_var
func _profiles(world: felt, country: felt, user_id: felt) -> (res: felt):
end

# ...

_profiles.write(10, 1, 1234, 100)

In this case, the key is (10, 1, 1234), and the value written is 100. Inside of StarkNet (h/t Tom Brand), this compiles down into storage.write(key=hash(hash(10, 1), 1234), value=100) — i.e., it requires slightly more computation due to the extra hashes, but it's still only modifying a single storage value. It costs about the same as a single basic write.

Use extra computation to avoid writes

Remember the maxim: computation is cheap, writes are expensive.

Imagine you're implementing Reddit on StarkNet and want to prevent people from upvoting submissions multiple times. Options:

  1. Store every (user_id, submission_id) pair on-chain (too expensive!)
  2. Store an application-wide bloom filter. When someone attempts to upvote a submission increment the upvote counter iff hash(user_id, submission_id) isn't already in the bloom filter.

The second option is probabilistic (upvotes won't be tracked perfectly), but could be much cheaper: there's a single small set of felts (those for storing the bloom filter) which are updated many times in each batch, resulting in a big batching rebate.

Cheaper writes are on the horizon

Starkware seems to be working hard on validium/volition options that could make storage writes much cheaper (possibly at the expense of some censorship resistance and liveness guarantees, depending on their implementation.)

Learn by reading Cairo code

StarkNet/Cairo design patterns and language tricks

Boolean expressions

Cairo doesn't have built-in boolean expressions like x && y or p || q, but there are some tricks you can use instead. Let's say you know that x and y are each either 0 or 1. then

  • assert x || y -> assert (x - 1) * (y - 1) = 0
  • assert !x || !y -> assert x * y = 0
  • assert x && y -> assert x + y = 2
  • etc

You can use these little tricks in asserts (as in the examples), or in other expressions, predicates of if statements, etc.

You can store arrays

You can define a storage_var with a key and use it to store an array. For example:

@storage_var
func _my_array(i : felt) -> (res : felt):
end

# ...

_my_array.write(0, 123)
_my_array.write(1, 456)
_my_array.write(2, 789)
# ...

Struct enum pattern

Cairo doesn't have enums, but you can abuse a struct as an enum. For example, if you define this struct:

struct DirectionEnum:
    member north: felt
    member south: felt
    member west: felt
    member east: felt
end

You'll discover that DirectionEnum.north == 0, DirectionEnum.south == 1, DirectionEnum.west == 2, and DirectionEnum.east == 3.

This works because Struct.member_name returns the memory offset of that member of the struct. Each felt has a size of one, so each subsequent member ends up with a unique incremented value.

Concisely update large structs

It can be a bit awkward to make updates to a large struct. For example, let's say you're implementing ENS and there are lots of records:

  • name
  • url
  • description
  • avatar
  • keywords
  • twitter
  • reddit

Let's say you only want to update the twitter field.

Verbose way

One approach is to update the entire struct each time:

struct Domain:
    member name: felt
    member url: felt
    member description: felt
    member avatar: felt
    member keywords: felt
    member twitter: felt
    member reddit: felt
end

@storage_var
func _domains(addr: felt) -> (res : Domain)
end

# ...

lew new_twitter = 234234
let domain = _domains.read(1)
let new_domain = Domain(
    name=domain.name
    url=domain.url
    description=domain.description
    avatar=domain.avatar
    keywords=domain.keywords
    twitter=new_twitter
    reddit=domain.twitter
)

_domains.write(1, new_domain)

This is a bit verbose because it requires writing out all the struct members, even the ones that aren't changing. It also means that if you ever add a struct member, you have to make updates in many places.

Concise way

Define an enum and storage var like this:

struct DomainStorageEnum:
    member name: felt
    member url: felt
    member description: felt
    member avatar: felt
    member keywords: felt
    member twitter: felt
    member reddit: felt
end

@storage_var
func _domains(addr: felt, storage_index : felt) -> (res : felt):
end

And then you can write values piecemeal, like this:

lew new_twitter = 234234
_domains.write(addr, DomainStorageEnum.twitter, new_twitter)

This requires a bit more computation (longer merkle paths need to be proven), but has the advantage of making your code a bit more concise. I'm not sure if it's worth it, but it's an option!

Intentionally reverting a transaction

You can revert a transaction by failing an assert on purpose, e.g. assert 0 = 1

Function pointers

You can use get_label_address to obtain a pointer to a function, and then invoke to call the function. Example. (h/t Martriay)

Functions not marked @view or @external are internal helpers

Functions that aren't decorated cannot be called or invoked by transactions or other contracts — they're purely helper functions which are internal to your contract.

Account abstraction

Account contracts

On Ethereum L1, there are built-in "accounts" that are based on public/private keypairs and are capable of verifying signatures, sending value, and calling contracts.

StarkNet doesn't have anything like that built in. Instead, people create this functionality by explicitly deploying contracts to StarkNet that have the ability to verify signatures and call other contracts.

For example, someone might deploy an "Account" contract that looks like this:

# Credit: this is an abbreviated version of OpenZeppelin's contract

%lang starknet
%builtins pedersen range_check ecdsa

from starkware.cairo.common.hash import hash2
from starkware.cairo.common.registers import get_fp_and_pc
from starkware.cairo.common.cairo_builtins import HashBuiltin, SignatureBuiltin
from starkware.starknet.common.syscalls import call_contract, get_caller_address, get_tx_signature

@storage_var
func _public_key() -> (res: felt):
end

@constructor
func constructor(_public_key: felt):
    _public_key.write(_public_key)
    return ()
end

@external
func execute{pedersen_ptr : HashBuiltin*, syscall_ptr : felt*, range_check_ptr}(
        to : felt, selector : felt, calldata_len : felt, calldata : felt*)

    # 1. Verify that whoever invoked `execute` signed everything
    #    with the right private key
    let (hash) = hash_message(to, selector, calldata_len, calldata)
    let (signature_len, signature) = get_tx_signature()
    is_valid_signature(hash, signature_len, signature)

    #2. Call the contract that the account owner wants to interact with
    call_contract(
        contract_address=to,
        function_selector=selector,
        calldata_size=calldata_len,
        calldata=calldata)
end

When the account contract is deployed, a particular public key is included in the constructor. Whenever someone wants to call execute, they need to include a signature from the associated private key (or else their tx is rejected). In this way, it's possible to gate access by public/private keypairs — it's just like in Ethereum, except it's not built in — we did it ourselves with contract code.

Concrete example of calling an account contract

Let's say you have an account contract deployed on StarkNet, and that you want to use it to call an ERC20 contract's transfer function.

The ERC20's transfer function might look something like this:

func transfer(recipient: felt, amount: felt):
    let (caller_address) = get_caller_address()
    _balances.write(caller_address, ...)
    # ...
end

What you're going to do is invoke your account contract's execute function, and instruct it to call the transfer function for you. Note that you're not directly calling transfer: instead, you're telling your account contract what to call on your behalf.

Here's how you might invoke your account contract to get it to call the ERC20 contract's transfer function in order to transfer say 30 tokens to some recipient 0x567RecipientAddr89:

starknet invoke \
    --address 0x1234AccountAddress5678 \
    --abi account_contract_abi.json \
    --function execute \
    --inputs \
        0x12ERC20Address34 \
        23267048542 \
        2 \
        0x567RecipientAddr89 \
        30

Here's what would happen:

  1. The account contract's execute function is invoked.
  2. The execute method verifies the signature and then looks at the inputs it was passed to determine:
    1. which contract it should call: the to arg which is 0x12ERC20Address34
    2. which function on that contract should be called: the selector arg which is 23267048542 in this case. (It's just a hash of the string "transfer")
    3. how many args it should pass on: calldata_len, which is 2 in this case, because the transfer function takes two arguments.
    4. the actual args it should pass on: the calldata arg which is set to [0x567RecipientAddr89, 30] — the recipient and the amount being transferred.
  3. The transfer function is called, which then uses get_caller_address to learn the address of the contract that called it (namely: the address of the account contract that you originally invoked)

Rather than: "I call transfer with the args 0x567RecipientAddr89 and 30", it's "I tell my account contract to call transfer with the args 0x567RecipientAddr89 and 30".

In short, the account contract receives args, verifies them, and then takes some of the args and passes them on to the indended contract.

Contracts that are called can always use get_caller_address() to see who called them, and in this way the address of an account contract can be a stable identifier, similar to an Ethereum public address.

Account abstraction can be kind of confusing, to put it lightly, so don't worry if you don't pick it up right away.

For more details, check out this discussion about standard interface for account contracts and OpenZeppelin's example implementation.

Pitfalls and current limitations

Ambiguity between uninitialized memory and the value 0

By default, if you read a key from a storage_var that has never previously been written to, the value you get back will be 0. So, zero can mean uninitialized storage space.

Depending on the logic of your application, it could be possible to run into a scenario where you read a 0 out of storage and can't be sure whether the storage was (a) uninitialized, or (b) previously actually had the value 0 written to it.

To avoid this ambiguity, either rely on a different storage slot to tell you whether the one of interest is already initialized, or avoid writing the value 0, thus leaving the 0 value an unambiguous marker of uninitialized storage.

Revoked references

Jumps and ifs can revoke references. You can generally solve this by making more things local. If you'd like to understand what's going on under the hood in these cases, it may be worth reading the more advanced How Cairo Works guide, which has a section on revoked references.

Max size of a felt

A felt safely fits 252 bits. That fits 31 bytes, not 32 bytes!

Functions marked @view can be invoked

Functions marked @external and functions marked @view behave the same way right now, i.e. it's possible to write a @view function that causes state changes, and someone could invoke it.

Multiple transactions can have the same hash

It's currently possible to submit the same tx more than once (although certain wallet contracts have a nonce that guards against this.)

The gateway will only reply about the first tx with a given hash.

So, for example:

  1. You submit tx A, which succeeds
  2. You poll for the tx by its hash, and eventually get back PENDING (aka, success)
  3. You submit an identical tx B, which fails
  4. You poll for the tx by its hash, and get back PENDING (aka, success) — despite the fact that tx B failed!

When you polled to learn about tx B, the gateway actually told you about tx A. In other words, there's no direct way to get the gateway to tell you about the fate of any duplicate tx.

Starkware is planning on a protocol modification to prevent duplicate tx.

Rejected tx don't show up in the Voyager block explorer.

This may change at some point, but not very soon.

Getting symbolicated stacktraces is a bit tricky

You can request a stacktrace like this:

starknet tx_status \
    --hash "0xsometxhashgoeshere" \
    --contract starknet-artifacts/contract.cairo/contract.json
    --error_message

The only part of the stacktrace that will be symbolicated with human-readable names is the part that involves the compiled contract definition that you passed in via --contract.

You might need to pass in different compiled contracts to symbolicate different sections of the stacktrace.

You can't get back return data from using invoke

Hopefully this might change in the future! In the mean-time, you can first invoke() and then subsequently call() to get the data you need.

There isn't a syscall to get the current timestamp yet

It's high up on Starkware's TODO list, so hopefully this will change soon.

In the mean-time, you can simulate/stub by creating/reading/writing a timestamp storage_var.

When the timestamp syscall is implemented, it will initially require trusting Starkware's sequencer. Eventually, timestamps returned by the syscall will be trust-minimized (e.g. somehow bounded by Ethereum's block timestamps).

Not everything is open source yet

Starkware has committed to:

  • Open-sourcing and decentralizing the sequencer
  • Making the prover source-available under a license that requires that proofs it generates be submitted to particular on-chain open-source verifier contracts.

The verifier is already open source.

Once on mainnet, sending an L2->L1 message might have ~4 hrs of latency

Once StarkNet is operating at greater scale (and it's economical to have more frequent batches on-chain), this latency may come down some. (On the other hand, 4 hrs is already much less than the 7 days required by optimistic rollups.)

Important interfaces like IAccount, IERC20, etc are in flux

Starkware and the community are generally working together to figure out what these should look like.

Ethereum signature verification is coming soon

Ethereum signature verification hasn't been implemented on StarkNet yet, but will be in the future. Once it's implemented, it'll be possible to make account contracts that are controlled directly by existing Ethereum pub/priv keypairs.

Starkware may also make it possible to create StarkNet account contracts whose addresses match Ethereum L1 public addresses. This would allow for recovery of funds, etc.

Tooling

Testing

I'd recommend using pytest as described in StarkNet's documentation. Rationale:

  • It's stable as new versions of Cairo are released
  • It's easy to see full stack traces
  • It executes faster than tests run against starknet-devnet or directly against the goerli testnet.
  • There are major opportunities for speeding it up (haven't had a chance to document these — ping me if you want to learn!):
    • caching fixtures using pytest's caching framework and dill
    • using the StarknetState copy method
    • parallelism with pytest-xdist

Overall experience so far

  • The StarkNet team is quite supportive. They're also extremely talented and are moving quickly.
  • People in the space are collaborative — folks discuss how things should work, contribute to open source projects that support the ecosystem, etc.
  • Cairo isn't too hard to learn. If you've learned a bunch of languages before, it might take 1-2 weeks before you become really productive.
  • StarkNet itself is early — the protocol itself is still evolving. There are plenty of rough edges both within StarkNet, the surrounding tooling, and the ecosystem/standards. Sometimes this is fun, sometimes it's frustrating.
  • That said, StarkNet is the only place I know of that lets you write turing-complete code that will be proven on-chain (on a testnet for now) out of the box. Things are moving quickly, which is a positive early sign about the emergence of an ecosystem.

Thanks to community members who have been helping develop the ecosystem and answering each others' questions (e.g. Martriay, perama, Sean, janek, Julien, corbt, et al)

And thanks to all the Starkware folks who have been answering questions / making improvements / being generally supportive (e.g. Tom, Uri, Eli, Lior, guthl, FeedTheFed, et al)

And thanks to Kyle for working with me + reviewing this document!