The Starknet OS is a cairo program run by SHARP to prove Starknet's state transitions. Yes, that means that Starknet itself is written in Cairo! SHARP doesn't know anything about Starknet, all it takes as an input are base64 encoded Cairo PIE (Program Independent Execution) files which are yielded by the cairo runner.
The program hash of the Starknet OS is registered on the Starknet Core Contract which prevents one to prove a state transition with a different version of the OS.
I've decided to write this document to walk through the Starknet OS cairo program which is not studied enough. As we are finalizing the work on SHARP integration into Madara it is also a good opportunity to understand how one could tweak the Starknet OS for their own app chain and what would be the consequences of such changes.
The full program can be found in the cairo-lang repository
As every cairo program, it all starts with a main
entrypoint.
To begin with, the OS takes some input in the following form
As we can see the input consists of the block info, contracts deployed, declared, transactions. We will see after where do these input come from.
Then, we retrieve what is called the Block Context
, we can picture this as the necessary information to execute the transactions within this block such as classes, block number/timestamp, sequencer address, chain_id, fee token address..
A few interesting lines we can zoom on
First, we can see the use of the keyword nondet
, which implies some non determinism.
To quote Lior on this keyword:
"nondet" is just a way to inline a hint. In this case, it's a way to populate the OS inputs into Cairo structs. It's non-deterministic from the point of view of the verifier as the verifier doesn't know where these values come from and whether they are "correct" in any sense, until there's actual (non-hint) Cairo code that deals with them
Then, we observe that some parameters are given through deprecated syscalls while others come directly from a hint variable os_input
.
It's set when loading the program input as part of the 1st hint, the program input is given to the cairo runner.
The OS will then initialize the state changes
This merely initializes some dictionaries given the fact that we know from the hint variable os_input
which contracts were interacted with in this block.
Interestingly, we also see that dict_new
constructor seems to use the initial_dict
scope variable under the hood.
Now, it's time for some pre-processing!
Here is the full source code which we will explain step by step
old_block_number
, this is computed by substracting the block_number of the current context (which we retrieved earlier!) and some buffer.This buffer is chosen such that there is enough time to do all this computation client side. In Madara, we tried to do the state root computation synchronously (at the end of every block) but that was just taking too long especially as the state was growing. With this buffer we make sure we have enough time, only drawback is that syscalls can only give you info about the block 10 blocks behind (?).
contract_state_changes
), we fetch the StateEntry
storage changes dictionary as specified in the comment // A dictionary from contract address to a dict of storage changes of type StateEntry.
Here we learn that 1 contracts address is reserved for the OS (source):
This contract is not deployed per se but given as part of the input. As per lior, it's not an actual contract, it's just a storage that contains the block hashes block[i] = block_hash
.
execution_helper
hint variable. As the python code states its role is to maintain the information needed for executing transactions in the OS. In this hint, we use it to fetch the old block number and hash.Here again, we find our buffer constant, in fact it will only return a value if we are at a block past that buffer. If we look closely at the create method from the OsExecutionHelper
, we see that it fetches this information from the updated state which was written by the Batcher
.
Finally assert old_block_number == ids.old_block_number
makes sure that the old_block_hash
that was returned by get_old_block_number_and_hash
is in fact the current_block - STORED_BLOCK_HASH_BUFFER
.
StateEntry
dict such that it reflects the storage change from our block_number to block_hash mapping inside our contract (you know the one reserved for the OS).OsExecutionHelper
hint variable to write in the storage :Specifically, we get the storage of our super contract thanks to the Mapping[int, OsSingleStarknetStorage]
.
This class represents a single contract storage and it is used by the Starknet OS run in the GpsAmbassador.
It also exposes read/write functions to ongoing storage changes.
As per Lior, the GpsAmbassador is the service responsible for running the OS and sending the run to SHARP to be proven. In Madara this refers to the sharp worker run in a separate thread in the node.
contract_state_changes
dictionary with our new StateEntry
constructed with our new storage!After we've pre-processed the block and updated our block number -> block hash mapping, we can execute the transactions within the block.
Let's dive into the internal of the execute_transactions
function. If you're familiar with the blockifier, you will see some similar logic but written in Cairo instead of Rust.
The comments help us understand the global flow
Q: Add details (?)
What's the most interesting for us is the execute_transactions_inner
function.
Given a block context and a number of transactions it will execute them sequentially.
This first hint is used to retrieve the type of the transaction (declare/deploy/invoke/l1_handler).
Then the OS will call the function corresponding to the transaction's type e.g execute_invoke_function_transaction
.
We will only dive into the invoke
function but feel free to explore the code by yourself to understand the other entrypoints. We can do the parallel with the blockifier code which follows the same flow as the OS.
__validate__
entrypoint responsible for signature verificationRight before we run the transaction validation, we find this mysterious hint
This is described in the python comments
In simpler words, it just tells the OS where to look for the transaction info during execution.
Before we look at the code responsible for computing state updates and commitments. I wanted to highlight one specific hint used throughout the OS.
%{ vm_enter_scope(...) %}
and %{ vm_exit_scope() %}
It has been implemented within the rust Cairo VM here which we can probably leverage. It's a very simple struct that just holds scopes
defined as HashMap<String, Box<dyn Any>>
. Within one scope you can access the local variables and the variables from upper scopes.
This hint creates the scope that will be used by the next hint, mainly we make accessible to the vm the os input and the storage commitments.
The state_update
function does a few things but mostly it updates the different commitment trees which will be used to update and validate the global state.
It outputs both the initial_root
and the final_root
.
For more information about how Starknet stores its own state please refer to the official documentation.
The state update is performed in 3 main steps:
contract_state_update
contract_class_update
calculate_global_state_root
Q: Why is the initial root not passed through a hint ?
The 2 first steps are quite similar so we'll only take a close look at the contract_state_update
.
The function starts by setting up everything so the merkle trie updates can happen smoothly. Mostly doing some dictionary allocation, hashing the state changes and storing the number of actual state changes.
The main hint of this function is the following:
We set the initial and final roots by retrieving the state commitments from the input (remember the beginning).
Then we set the preimage by looking at the commitment facts, this preimage will then be used by the merkle_multi_update function.
Finally we set the MERKLE_HEIGHT
value of the outer scope to the correct trie height (251).
Now that we have everything, we can perform the actual trie update
One interesting trick used here is to pass the powers of 2 as pre-computed constants to the function which saves us a ton of computation.
As we've seen in the previous section, the Starknet OS uses merkle patricia tries with pedersen or poseidon depending on the trie.
For instance let's look at the first major hint
This shouldn't be necessary difficult as it ends up mostly translating python code to rust and we can probably gain performance from this too. However it will need extensive testing for each of these against their respective python implementations which are battle-tested.
This post is still a draft and doesn't aim to be fully exhaustive. It's here to give an overview in the inner workings of the Starknet OS and help during the development of the SNOS rust library.
Here are a few interesting tweaks that could ba applied to the OS in the context of an app-chain:
There are probably a ton of other changes that could be made to tailor the Starknet OS to your specific use case. If you have ideas please create an issue on the SNOS repository.
Thank you to everyone contributing and reviewing this document.
Lior Goldberg from Starkware, Ben from Starkware, Abdel from Starkware, Apoorv from Pragma.