# 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](https://twitter.com/RoboTeddy). ### Sidebar for anyone who is new to StarkNet - 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](https://www.cairo-lang.org/docs/hello_starknet/index.html). You can learn any Cairo you need as you go by using the [Cairo documentation](https://www.cairo-lang.org/docs/hello_cairo/index.html) and completing exercises in the [cairo playground](https://www.cairo-lang.org/playground/). - Eventually, you may want to read the more advanced [How Cairo Works](https://www.cairo-lang.org/docs/how_cairo_works/index.html), 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](https://vitalik.ca/general/2021/01/05/rollup.html).) 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](https://community.starknet.io/t/fees-in-starknet-alpha/286) — 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: ```ruby @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/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. - **'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: ```ruby 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.: ```ruby @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 - [Cairo standard library](https://github.com/starkware-libs/cairo-lang/tree/master/src/starkware/cairo/common) — read this both to learn Cairo, and to learn what library functions are available for your use (they aren't well-documented elsewhere yet) - [starknet-dai-bridge](https://github.com/makerdao/starknet-dai-bridge/) is a small but high-quality example. It also has an interesting deployment script that deploys to both L1 and L2. - [OpenZeppelin StarkNet contracts](https://github.com/OpenZeppelin/cairo-contracts/tree/main/contracts) - [Argent wallet Starknet contracts](https://github.com/argentlabs/argent-contracts-starknet/tree/main/contracts) - [StarkNet OS](https://github.com/starkware-libs/cairo-lang/tree/master/src/starkware/starknet/core/os): an example of a lot of Cairo code written by the real experts! This is the Cairo code that implements StarkNet itself. It handles things like invoking contracts, etc. ## 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: ```ruby @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: ```ruby 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: ```ruby 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: ```ruby 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](https://gist.github.com/fracek/846d3082f9803a7e65edc44292da9241#file-ownable-cairo-L15). (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: ```ruby # 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: ```ruby 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](https://github.com/OpenZeppelin/cairo-contracts/discussions/41) and [OpenZeppelin's example implementation](https://github.com/OpenZeppelin/cairo-contracts/blob/main/contracts/Account.cairo). ## 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](https://www.cairo-lang.org/docs/how_cairo_works/index.html), 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](https://voyager.online/). 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 - Argent has created an alpha [StarkNet wallet browser extension](https://github.com/argentlabs/argent-x) — thanks Argent! - [starknet-hardhat-plugin](https://github.com/Shard-Labs/starknet-hardhat-plugin) - [starknet-devnet](https://github.com/Shard-Labs/starknet-devnet/) — this lets you create a local devnet. Note that it doesn't support L1<-->L2 messages yet. - Visual Studio Code has a `cairo` extension that can use `cairo-format` to format your code ## 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](https://pypi.org/project/dill/) - using the `StarknetState` `copy` method - parallelism with [pytest-xdist](https://pypi.org/project/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](https://twitter.com/corbtt) for working with me + reviewing this document!