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.
(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:
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!).
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:
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.
Because writes are expensive, it's important to be able to estimate how much they cost.
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)
.
64 bytes * 16 gas/byte * 130e-9 eth/gas * $4200/eth = ~$0.60
n
times within a single StarkNet batch (~1-4 hours), each write costs only 1/n
th as much. This is true even if the writes to the storage slot were caused by different tx, calls from other contracts, etc.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
.)
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.
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.
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:
(user_id, submission_id)
pair on-chain (too expensive!)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.
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.)
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
You can use these little tricks in asserts (as in the examples), or in other expressions, predicates of if
statements, etc.
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)
# ...
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.
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:
Let's say you only want to update the twitter
field.
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.
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!
You can revert a transaction by failing an assert on purpose, e.g. assert 0 = 1
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 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.
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.
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:
execute
function is invoked.execute
method verifies the signature and then looks at the inputs it was passed to determine:
to
arg which is 0x12ERC20Address34
selector
arg which is 23267048542
in this case. (It's just a hash of the string "transfer"
)calldata_len
, which is 2
in this case, because the transfer
function takes two arguments.calldata
arg which is set to [0x567RecipientAddr89
, 30
] — the recipient and the amount being transferred.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.
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.
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.
A felt safely fits 252 bits. That fits 31 bytes, not 32 bytes!
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.
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:
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.
This may change at some point, but not very soon.
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.
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.
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).
Starkware has committed to:
The verifier is already open source.
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.)
Starkware and the community are generally working together to figure out what these should look like.
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.
cairo
extension that can use cairo-format
to format your codeI'd recommend using pytest
as described in StarkNet's documentation. Rationale:
starknet-devnet
or directly against the goerli
testnet.StarknetState
copy
methodThanks 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!