ZK Circuits for Axiom Queries
Axiom proves in ZK the validity of historic Ethereum on-chain data with respect to historical Ethereum block hashes. In addition, Axiom verifies the validity of a user-provided ZK proof that does compute on top of this data. In this page, we explain how these ZK circuits work.
Proving Ethereum Data
Below we describe what is needed to verify the validity of Ethereum On-chain Data against historical block hashes.
Account and Storage Proofs
Account and account storage data is committed to in an Ethereum block header via several Merkle-Patricia tries. Inclusion proofs for this data into the block header are provided by Ethereum light client proofs. For example, consider the value at storage slot slot
for address address
at block blockNumber
. The light client proof for this value is available from the eth_getProof
JSON-RPC call and consists of:
- The
stateRoot
at blockblockNumber
, which is contained in the block header. - An account proof of Merkle-Patricia inclusion for the key-value pair
(keccak(address), rlp([nonce, balance, storageRoot, codeHash]))
of the RLP-encoded account data in the state trie rooted atstateRoot
. - A storage proof of Merkle-Patricia inclusion for the key-value pair
(keccak(slot), rlp(slotValue))
of the storage slot data in the storage trie rooted atstorageRoot
.
Verifying this light client proof against a block hash blockHash
for block blockNumber
requires checking:
- The block header is properly formatted, has Keccak hash
blockHash
, and containsstateRoot
. - The state trie proof is properly formatted, has key
keccak(address)
, Keccak hashes of each node along the Merkle-Patricia inclusion proof match the appropriate field in the previous node, and has value containingstorageRoot
. - A similar validity check for the Merkle-Patricia inclusion proof for the storage trie.
Solidity Mapping Proofs
Mappings in Solidity are a common way to store data in a smart contract's Ethereum state. Given the slot corresponding to a mapping and a mapping key, Solidity assigns a raw EVM storage slot to the key according to the Solidity storage layout rules.
For mappings in particular, a key k
for a mapping at slot p
is located at raw storage slot
keccak256(h(k) . p)
where h(k)
is k
padded to bytes32
according to Solidity memory rules when k
is a value type.
To prove a mapping of a key in an address
at a block blockNumber
, one must first prove the correct calculation of the raw storage slot corresponding to the key, and then prove the account and storage proofs for that slot and address
as described above. For nested mappings (e.g., mappings of mappings), one must iteratively repeat this process for each key to get the raw storage slot.
Finding storage slots provides information on identifying the storage slot of the nested mapping you're looking for.
Transaction Proofs
Each transaction in a block blockNumber
has a transactionIndex
for its position in the block. The block header of each block contains a transactionsRoot
, which is the root of the transactions trie, another Merkle-Patricia trie.
Proving the inclusion of a Transaction
in a block with hash blockHash
consists of checking:
- The block header is properly formatted, has Keccak hash
blockHash
, and containstransactionsRoot
. - There is a properly formatted Merkle-Patricia inclusion proof into
transactionsRoot
with keyrlp(transactionIndex)
, valueTransaction
, and where the Keccak hashes of each nodes in the proof match the appropriate field in the previous node.
To extract particular values from the Transaction
, one needs to further check that Transaction
is either TransactionType . TransactionPayload
or LegacyTransaction
according to EIP 2718. Then one further RLP decodes TransactionPayload
or LegacyTransaction
to get the transaction fields.
There is no JSON-RPC call that directly provides the transaction trie proof. However one can get all transactions from a block with eth_getBlockByNumber and reconstruct the transactions trie using libraries like cita_trie
or reth
.
Receipt Proofs
Receipt proofs are very similar to transaction proofs. The block header of each block contains a receiptsRoot
, which is the root of the receipts trie, another Merkle-Patricia trie.
Proving the inclusion of a Receipt
for a transaction with index transactionIndex
in a block with hash blockHash
consists of checking:
- The block header is properly formatted, has Keccak hash
blockHash
, and containsreceiptsRoot
. - There is a properly formatted Merkle-Patricia inclusion proof into
receiptsRoot
with keyrlp(transactionIndex)
, valueReceipt
, and where the Keccak hashes of each nodes in the proof match the appropriate field in the previous node.
To extract particular values from the Receipt
, one needs to further check that Receipt
is either TransactionType . ReceiptPayload
or LegacyReceipt
according to EIP 2718. Then one further RLP decodes ReceiptPayload
or LegacyReceipt
to get the receipt fields. For the logs
field, one further RLP decodes the logs
to get values from individual logs.
There is no JSON-RPC call that directly provides the receipts trie proof. However one can get all receipts from a block with eth_getBlockReceipts (or other provider-specific API calls) and reconstruct the receipts trie using libraries like cita_trie
or reth
.
ZK Circuits
Axiom verifies the light client proofs described above in ZK using the open-source axiom-eth
ZK circuit library. These proofs require the following core primitives:
- Parsing RLP serialization: Ethereum data is serialized in the Recursive Length Prefix (RLP) format. We support parsing of individual fields in RLP-serialized fields and arrays.
- Merkle-Patricia trie inclusion: All Ethereum data is committed to in 16-ary Merkle-Patricia tries whose roots are in the block header. We support inclusion proofs into trie roots, which are used to prove inclusion into the account, storage, transaction, and receipt tries.
Components Framework
In order to fulfill Axiom V2 Queries, our ZK circuits must prove different statements (account, storage, transaction, receipt proofs, RLP decomposition, parsing, etc) with different dependencies and assumptions. To do this, we have multiple component circuits which prove different statements and output a commitment to a virtual table of results, specific to that circuit.
In addition, component circuits can make promise calls to other component circuits. This means that a component circuit can use the output virtual table of another component circuit, assuming that the outputs have been proved to be correct. All promise calls are verified alongside the ZK proofs from the component circuits themselves in aggregation circuits.
We use the following component circuits in Axiom V2:
- Keccak: computes the
keccak256
hash of a collection of variable length byte arrays. - Block header subqueries: RLP decomposes block headers for a set of blocks, computes the block hash of each block by Keccak hashing the header, and verifies a Merkle inclusion proof of each block hash into a Merkle mountain range commiting to a range of block hashes starting from genesis. Makes promise calls to the Keccak component.
- Account subqueries: verifies the account trie proofs corresponding to account subqueries and gets the requested account field. Account proofs are validated against
stateRoot
s, which are obtained by promise calls to the block header component. Also makes promise calls to the Keccak component. - Storage subqueries: verifies the storage trie proofs corresponding to storage subqueries and gets the storage value. Storage proofs are validated against
storageRoot
s, which are obtained by promise calls to the account component. Also makes promise calls to the Keccak component. - Solidity nested mapping subqueries: calculates the raw storage slot corresponding to a sequence of keys for each Solidity nested mapping subquery. Gets the value at the raw storage slot for each subquery by making a promise call to the storage component. Also makes promise calls to the Keccak component.
- Transaction subqueries: verifies the transaction trie proofs corresponding to transaction subqueries and further parses each transaction for the requested field. The transaction proofs are validated against
transactionsRoot
s, which are obtained by promise calls to the block header component. Also makes promise calls to the Keccak component. - Receipt subqueries: verifies the receipt trie proofs corresponding to receipt subqueries and further parses each receipt for the requested field. Even further parses the
logs
field for the requested log data. The receipt proofs are validated againstreceiptsRoot
s, which are obtained by promise calls to the block header component. Also makes promise calls to the Keccak component. - Results root: makes promise calls to all components above to collect and order all subqueries and their results. Computes the Poseidon Merkle root of the subqueries and their results in a standardized format that can be used by other ZK circuits.
- Verify compute: uses proof aggregation to aggregate the user-provided compute proof. Computes the Poseidon Merkle root of the subqueries and their results used in the compute proof. Also calculates the keccak
dataQueryHash
from all subqueries. Makes promise calls to the Keccak component.
You can read more about the Component Framework and the Axiom V2 component circuits here.
Aggregating Proofs
An Axiom V2 Query includes a user-provided compute proof which has the data subqueries and their results as public inputs. We use proof aggregation with the snark-verifier
library developed by the Privacy Scaling Explorations group at the Ethereum Foundation to aggregate the compute proof with the proofs of all the component circuits. We incorporate into this proof aggregation the verification of all component promise calls as well as consistency between the compute proof inputs and the component circuit outputs. At the end of the aggregation, we have a single ZK proof, which can be verified by a single smart contract AxiomV2QueryVerifier
that attests to the validity of:
- the results of the compute proof along with all historic Ethereum data the compute proof requested, with root of trust in a single Merkle mountain range of Ethereum block hashes
The AxiomV2Query
contract will verify the validity of this Merkle mountain range (see Axiom Query Protocol).
Universal Aggregation
To maximize proof parallelization, we use multiple circuits to aggregate our ZK proofs, and we have different aggregation strategies depending on the properties of the requested query (e.g., the number of subqueries requested). To support these different aggregation strategies while maintaining a single final on-chain verifier, we have added a new feature to the snark-verifier
library to support universal aggregation circuits -- these are circuits that can aggregate proofs from different circuits.
These universal aggregation circuits also allow us to verify compute proofs from different user-created circuits.
We commit to the different aggregation strategies used to fulfill an Axiom V2 Query in a list of aggregateVkeyHash
es, which is stored in the AxiomV2Query
smart contract.
Aggregate Vkey Hashes
The final validity of all ZK proofs depends on successful verification by a SNARK verifier smart contract. In a ZK circuit that is not a universal aggregation circuit, the specification of the computation the circuit is proving is cryptographically encoded in a verifying key (vkey
). This vkey
is hard coded into the SNARK verifier smart contract for that circuit, so upon successful contract verification, the contract attests to the validity of the computation specified by the vkey
.
When the ZK circuit is a universal aggregation circuit, however, the vkey
of the circuit is specifying that the circuit can verify a ZK proof from any other circuit. Therefore the universal verification circuit itself must output a commitment to the vkey
of the circuit it is verifying. Moreover, if universal verification circuit A is verifying another universal verification circuit B, then circuit A must output a commitment to the vkey
of circuit B itself as well as the vkey
s of all circuits that circuit B is verifying.
To ensure that a universal aggregation circuit always outputs a commitment to the exact computation it is verifying, every universal aggregation circuit must output an aggregateVkeyHash
as part of their public output. The aggregateVkeyHash
of a universal aggregation circuit is recursively defined as the Poseidon hash of:
- the normal
vkey
of any non-universal aggregation circuit it is verifying, - the
aggregateVkeyHash
of any universal aggregation circuit it is verifying.
When we have an aggregation tree consisting of many circuits with multiple universal aggregation circuits, the aggregateVkeyHash
of the final ZK proof is a commitment to the exact configuration of this tree and hence of the full computation being proven.
The final smart contract that verifies a universal aggregation circuit will store a list of supported aggregateVkeyHash
es. After it verifies the ZK proof, the contract must check that the aggregateVkeyHash
in the proof output belongs to the supported list.