VOID Reference Implementation
A minimal example stack for building verifiable, opinionated, chain‑integrated decentralized apps.
What is VOID?
VOID is a minimal pattern for building Verifiable, Opinionated, chain Integrated Decentralized apps. You write your app logic once as a state machine and run it inside a generic node/server. Your app can then be opinionated about many aspects of how it operates.
- Utilize either lightweight attestations (TEE/signature) or zero‑knowledge proofs to relay app state to users and/or back on-chain.
- Interactions with the app can still be conducted on-chain through lightweight contracts, making the app composable with other smart contracts as well as compatible with a user's existing wallet and favorite chain.
- The app can be written in any language using various tooling as long as its execution can be verified.
- App transactions can solely be based on transactions made on a representative contract on a single chain, or integrate with an oracle and respond to transactions being made on multiple chains (as if the app lived on multiple chains simultaneously).
- The app can re-sequence events to fully capture any value leak from priority fees (MEV capture).
- And much more!
What VOID is not
VOID is Not a general‑purpose rollup or a monolithic chain. VOID is a design pattern that results in powerful decentralized apps that are more opinionated than can be achieved through typical contracts on an existing chain.
How to use the VOID Reference Implementation Repo
Use the VOID Reference Implementation repo to:
- Start from the example app
app-swap/(a simple AMM with deposits, swaps, LP, and withdrawals). - Reuse the shared tooling under
void/to run nodes, expose JSON-RPC, archive state, and (optionally) prove/attest state. - Run end-to-end tests and benchmarks locally.
Building your own app:
- Clone the repo and replace
app-swapwith your app logic while keeping a similar pattern ofstate_transitionfunction,storage,events, andqueries. Continue through the documentation for more details on App setup, architecture, and running in a Node.
Getting Started
Welcome to the Void Reference Implementation! This guide will help you set up the project, run local examples, and start building your own applications using the provided modular architecture. Whether you're interested in TEE-style attestation or ZK proving, you'll find step-by-step instructions below.
Prerequisites
- Linux (tested on Ubuntu)
- Rust toolchain (via rustup) and Cargo
- Foundry (forge, anvil, cast)
More detailed setup instructions
Clone the repo
git clone https://github.com/essential-contributions/void-reference-impl.git
cd void-reference-impl
Repo Layout
app-swap/: Example app implementationcore/: Pure Rust state machine and read-only queriescontracts/: Solidity contracts and deploy scripts
void/: Shared libraries and toolsvoid-core/: primitives for events, types, hashes, and state stores (binary Merkle trie, ZK witness variants)void-node/: generic node runtime (fetchers, archivers, state buffers, JSON-RPC router and Axum server glue)void-contracts/: reusable Solidity building blocks and interfaces
e2e/: runnable examples with nodes (that run the app code) and testing scripts (TEE and ZK flavors)benchmarks/: performance benchmarks for ZK proving and raw single-threaded transaction processing throughput
What's Next
Feel free to start by running the Benchmarks and/or Tests locally (see benchmarks/ and e2e/).
Next, you can dive deeper into understanding the core code of the example app (see app-swap/). Feel free to use it as a reference for building your own app:
- Copy
app-swap/into a new crate, implement your domain-specific events and state transition function. - Keep the same state pattern (Binary Merkle Trie store) so you can reuse the node and proofs.
- Adjust
queries.rsto add read-only helpers your node will expose over JSON-RPC. - Update the contracts in
app-swap/contractsto match your app’s actions.
You can also dig around the repo for more documentation, including architecture overviews and details about Nodes.
In Depth Setup
The code in this repo has only been tested on Linux machines using Ubuntu, but it may work on other distributions or operating systems with minor adjustments. If you encounter issues on non-Ubuntu systems, please refer to the documentation for troubleshooting tips or consider contributing compatibility fixes. The following is a guide for how to setup the appropriate environment for two different reference machines. If looking to run on your own machine, please choose the one that closest matches your machine (the biggest difference is whether you have an NVIDIA GPU or not).
CPU Only Machine (ex. AWS c6i.8xlarge)
CUDA Accelerated Machine (ex. AWS g6.xlarge)
CPU Only Machine (ex. AWS c6i.8xlarge)
Using Ubuntu 24.04 (Noble Numbat) and an x86_64 CPU architecture. Install the following dependencies:
# Base
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential libssl-dev git curl jq pkg-config
# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
# Foundry
curl -L https://foundry.paradigm.xyz | bash
source "/home/ubuntu/.bashrc"
foundryup
# Docker
sudo apt install ca-certificates gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker
# (Optional for proving with RISC Zero)
curl -L https://risczero.com/install | bash
source "/home/ubuntu/.bashrc"
rzup install
rzup install risc0-groth16
# (Optional for proving with SP1)
curl -L https://sp1.succinct.xyz | bash
source "/home/ubuntu/.bashrc"
sp1up
# Clone repository
git clone https://github.com/essential-contributions/void-reference-impl.git
cd void-reference-impl
# Install solidity dependencies
cd app-swap/contracts && forge soldeer install && cd -
cd void/void-contracts && forge soldeer install && cd -
CUDA Accelerated Machine (ex. AWS g6.xlarge)
Using Ubuntu 24.04 (Noble Numbat), an x86_64 CPU architecture and an NVIDIA GPU supported by the CUDA 12.8 toolkit. Install the following dependencies:
# Base
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential libssl-dev git curl jq pkg-config
# Build deps needed by circom-witnesscalc/bindgen
sudo apt install -y protobuf-compiler libc6-dev clang libclang-dev llvm-dev
# NVIDIA driver for G6 (L4)
sudo apt install -y ubuntu-drivers-common
sudo ubuntu-drivers install
# Reboot to load driver
sudo reboot
-------------------------------------------------------------
# CUDA repo + toolkit (Ubuntu 24.04)
wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb
sudo dpkg -i cuda-keyring_1.1-1_all.deb
sudo apt update
sudo apt install -y cuda-toolkit-12-8
# Env for CUDA
echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc
source ~/.bashrc
# Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
# Foundry
curl -L https://foundry.paradigm.xyz | bash
source "/home/ubuntu/.bashrc"
foundryup
# Docker
sudo apt install ca-certificates gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
newgrp docker
# (Optional for proving with RISC Zero)
curl -L https://risczero.com/install | bash
source "/home/ubuntu/.bashrc"
rzup install
rzup install risc0-groth16
# (Optional for proving with SP1)
curl -L https://sp1.succinct.xyz | bash
source "/home/ubuntu/.bashrc"
sp1up
# Clone repository
git clone https://github.com/essential-contributions/void-reference-impl.git
cd void-reference-impl
# Install solidity dependencies
cd app-swap/contracts && forge soldeer install && cd -
cd void/void-contracts && forge soldeer install && cd -
End-to-End Tests
The end-to-end tests are meant to serve as a proof of concept for how VOID Apps function. At the core, a VOID App includes at least one Node that keeps track of state based on Events that occur on one or more sources, along with some sort of proof system. A VOID App can have many different opinions on how it operates. This repo contains two different tests which aim to cover a large surface area of possibilities.
-
TEE with Multi-Chain Support
Demonstrates how a VOID App can observe and process events from multiple blockchains using a Node configured for multi-chain support. All components, including the Node and blockchain clients, are designed to run inside a Trusted Execution Environment (TEE) for enhanced security. -
ZK with No Added Trust Assumptions
Showcases a VOID App that relies solely on zero-knowledge proofs, with the on-chain contract maintaining a hash chain of all events. The contract verifies zk proofs against these inputs, ensuring correctness without additional trust assumptions.
TEE with Multi-Chain Support
This test is meant to prove how a VOID App can source its events from multiple blockchains. This is done through the Node, which is configured to observe events from two separate EVM chains. In production, every component of this test would run inside a TEE, including the Node itself, along with the clients for the EVM chains being observed. The Node, inside the TEE, produces attestations that are then trusted on-chain when performing withdrawals out of the App.
Test Explanation
The test scenario consists of two users interacting with the App. User1 first deposits some of TokenA (the native chain asset) and TokenB (a standard ERC-20 token) into the App from Chain1. User2 then deposits some of only TokenA from Chain2. User1 then adds liquidity to an AMM pool consisting of the two tokens.
The main body of the test consists of User2 using the AMM pool to swap TokenA for TokenB, submitting their swap transactions on Chain2. After a couple of swaps, User2 then continues to withdraw a little bit of both TokenA and TokenB on Chain1. User2 is able to finalize their withdrawal by submitting a proof to Chain1 which then releases the tokens. The proof consists of a Merkle proof demonstrating the withdrawal occurred, along with an ECDSA signature from the oracle running with the Node. In production, the Node and oracle would be running in a TEE to maintain security. This swap and withdrawal process is then repeated 10 times.
Finally, to cover all capabilities of the App, the final action is User1 removing all their liquidity, submitting their transaction on Chain1.
Things to Note
- The cost of making a transaction for the VOID App is very cheap (around 28,000 gas no matter how complex the transaction)
- 4x cheaper than a typical on-chain swap (est. 110,000 gas)
- 5x cheaper than a typical on-chain NFT sale (est. 140,000 gas)
- The cost of withdrawing is more expensive because it requires two transactions but is still relatively inexpensive
- Around 36,000 gas to submit the withdrawal
- Another 105,000 gas to finalize the withdrawal
Run the Test
1) Setup
Before running the test, make sure your machine is set up and ready to go. Refer to the Setup Guide for how to set everything up with dependencies, etc.
2) Start the EVMs
The first thing to do is start two local EVM chains that will hold the VOID App contracts and where users will submit their transactions. Run the following script twice to start up two local EVM chains and deploy copies of the contracts to each.
sh ./run-evm.sh
sh ./run-evm.sh
3) Start the Node
Next, we need to start the App Node. This Node will observe App transactions being submitted on both EVM chains and compute the resulting state of the App. Users use this node to get information about the state of the App in real-time. To start the Node, run the following script.
sh ./run-node.sh
4) Run the Test
We now have everything we need to simulate users making transactions and interacting with the VOID App. Run the test with the following script.
sh ./e2e-test.sh
ZK with No Added Trust Assumptions
This test is meant to prove how a VOID App can function with only a zkVM trust assumption. This is done by the VOID on-chain contract keeping track of a hash chain of all events input into the App. A zk proof is then generated that proves the state of the App for a given hash chain head. Put another way, the contract remembers what the inputs were and expects a proof against those specific inputs.
Test Explanation
The test scenario consists of two users interacting with the App. User1 first deposits some of TokenA (the native chain asset) and TokenB (a standard ERC-20 token) into the App. User2 then deposits some of only TokenA. User1 then adds liquidity to an AMM pool consisting of the two tokens.
The main body of the test consists of User2 using the AMM pool to swap TokenA for TokenB. After a couple of swaps, User2 then continues to withdraw a little bit of both TokenA and TokenB. User2 is able to finalize their withdrawal by submitting a proof to the EVM chain which then releases the tokens. The proof consists of a Merkle proof demonstrating the withdrawal occurred, along with a Groth16 proof that proves the state (that the Merkle proof is based on) is correct. This test uses the Risc Zero zkVM to do the state proving. This swap and withdrawal process is then repeated 10 times.
Finally, to cover all capabilities of the App, the final action is User1 removing all their liquidity.
Things to Note
- The cost of making a transaction for the VOID App is very cheap (around 35,000 gas no matter how complex the transaction)
- 3x cheaper than a typical on-chain swap (est. 110,000 gas)
- 4x cheaper than a typical on-chain NFT sale (est. 140,000 gas)
- The cost of withdrawing is on the more expensive side due to Groth16 proof verification (around 350,000 gas)
- However, this can be amortized if users/solvers are willing to wait for withdrawal finalization
- Even though proof generation has a latency to it, users can see the effects of their transaction with little latency querying directly from an App Node
Run the Test
1) Setup
Before running the test, make sure your machine is set up and ready to go. Refer to the Setup Guide for how to set everything up with dependencies, etc.
2) Start the EVM
The first thing to do is start a local EVM chain that will hold the VOID App contracts and where users will submit their transactions. Run the following script to start up a local EVM and deploy the contracts.
sh ./run-evm.sh
3) Start the Node
Next, we need to start the App Node. This Node will observe App transactions being submitted on the EVM chain and compute the resulting state of the App. Users use this node to get information about the state of the App in real-time. To start the Node, run the following script.
sh ./run-node.sh
4) Start the Prover
Next, we need a second Node that keeps track of state just like the first, but also generates zk proofs of the state. The reason this is a separate Node is because it runs a little bit behind on state while generating proofs. Users ping the Node for the latest App state, and ping the Proving Node when they want a proof of a previous state (for example, when trying to finalize a withdrawal on-chain). There are two options for running the Proving Node.
To start the Proving Node with GPU acceleration, run the script with the following option.
sh ./run-prover.sh cuda
Note: GPU proving takes a VERY long time to compile due to having to build the GPU kernels
To start the Proving Node with just CPU proving, run the script with the following option.
sh ./run-prover.sh
5) Run the Test
We now have everything we need to simulate users making transactions and interacting with the VOID App. Run the test with the following script.
sh ./e2e-test.sh
Benchmarks
The benchmarks are meant to give insight into the throughput of VOID Apps. This repo contains three separate benchmarks.
-
Max TPS
Determination of the maximum transactions per second that can be processed in a single thread. This is useful as an upper bound on max throughput assuming infinite proving speeds due to a parallel setup. -
ZK Proving (Risc Zero)
Determination of the expected proving speed (in TPS) for a given machine and configuration. This benchmark produces results with a tradeoff between proof latency and max throughput. -
ZK Proving (SP1)
Determination of the expected proving speed (in TPS) for a given machine and configuration. This benchmark produces results with a tradeoff between proof latency and max throughput.
Benchmark Results
The best benchmark numbers recorded so far come from a high-end gaming computer with an AMD Ryzen 9950X and an RTX 5090 with the following results:
- Approximately 225,000 transactions (swaps) per second (single-threaded)
- Proving speeds of 12 transactions (swaps) per second at a latency of 30s (single GPU)
- Proving speeds of 8 transactions (swaps) per second at a latency of 10s (single GPU)
- Proving latency as low as 4s (single GPU)
Note: these are early performance numbers with optimizations still in the works (particularly for zk proving)
Benchmark: Max Transactions Per Second
Determination of the maximum transactions per second that can be processed in a single thread. This is useful as an upper bound on max throughput assuming infinite proving speeds (ex. TEE or parallel ZK proving).
Note: Please keep in mind that this benchmark shows results for single-threaded performance only.
Setup
Before running the benchmark, make sure your machine is set up and ready to go. Refer to the Setup Guide for how to set everything up with dependencies.
Running
Run the benchmark with the following command:
cd benchmarks/max-tps
cargo run --release
Options
The following options are available:
Options:
--tps <TPS>
The target TPS [default: 100000]
--active-accounts-percentage <ACTIVE_ACCOUNTS_PERCENTAGE>
The target percentage of accounts active per second [default: 10]
--block-time <BLOCK_TIME>
The target block time in ms [default: 1000]
--max-blocks-per-fetch <MAX_BLOCKS_PER_FETCH>
Maximum blocks per fetch [default: 4]
-h, --help
Print help
-V, --version
Print version
Output
The code will run a node in a mock setup where it simulates a large load of transactions to process. The benchmark will tell you if your machine was able to keep up with the target TPS or not. Keep an eye on the output. If your machine is struggling to keep up, you will see output like:
2025-09-12T18:16:48.602157Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=200000 height=28
When the fetcher starts pulling more than one block at a time, it is because the node is behind and is trying to catch up by processing multiple blocks at a time. This is a sign that your machine might be struggling. You want to see a consistent num_blocks=1 for all fetching and processing.
Benchmark Results
The following results were obtained on the various reference machines.
AMD Ryzen 9 9950X
Benchmarking max single-threaded transactions per second.
Targeting 225000 tps, with 2250000 accounts (10% of accounts active per second).
Block time 1000ms, with 4 max blocks per fetch.
Running node with test scenario for 30 seconds...
2025-09-14T01:42:49.959604Z INFO [TestScenario]: Building scenario num_accounts=2250000
2025-09-14T01:43:00.528375Z INFO [TestScenario]: Building scenario finished [root hash: 0x913c053cc632c131e0d8c7fca22193fe20cea80356b74bcc01fca853c5e96355]
2025-09-14T01:43:00.528461Z INFO [ByteArchiver]: Clearing archive data
2025-09-14T01:43:01.048078Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=225000 height=1
2025-09-14T01:43:01.379821Z INFO [Main]: Processed 225000 events in 304.317354ms
2025-09-14T01:43:01.379869Z INFO [Main]: Flushing State
2025-09-14T01:43:01.936981Z INFO [Main]: State Flushed in 557.102564ms
2025-09-14T01:43:02.040865Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=225000 height=2
2025-09-14T01:43:02.334959Z INFO [Main]: Processed 225000 events in 265.485934ms
2025-09-14T01:43:02.335012Z INFO [Main]: Flushing State
2025-09-14T01:43:02.786079Z INFO [Main]: State Flushed in 451.058817ms
2025-09-14T01:43:03.037290Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=225000 height=3
2025-09-14T01:43:03.350340Z INFO [Main]: Processed 225000 events in 284.56389ms
2025-09-14T01:43:03.350388Z INFO [Main]: Flushing State
2025-09-14T01:43:03.796402Z INFO [Main]: State Flushed in 446.004813ms
...
2025-09-14T01:43:23.039395Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=225000 height=23
2025-09-14T01:43:23.375318Z INFO [Main]: Processed 225000 events in 308.390141ms
2025-09-14T01:43:23.375362Z INFO [Main]: Flushing State
2025-09-14T01:43:23.757519Z INFO [Main]: State Flushed in 382.146872ms
2025-09-14T01:43:23.757610Z INFO [ByteArchiver]: Archiving data height=23
2025-09-14T01:43:24.768893Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=450000 height=25
2025-09-14T01:43:25.338158Z INFO [Main]: Processed 450000 events in 512.587512ms
2025-09-14T01:43:25.338205Z INFO [Main]: Flushing State
2025-09-14T01:43:26.114338Z INFO [Main]: State Flushed in 776.123226ms
2025-09-14T01:43:26.137219Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=450000 height=27
2025-09-14T01:43:26.734739Z INFO [Main]: Processed 450000 events in 540.856893ms
2025-09-14T01:43:26.734795Z INFO [Main]: Flushing State
2025-09-14T01:43:27.668884Z INFO [Main]: State Flushed in 934.078759ms
2025-09-14T01:43:27.690574Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=450000 height=29
2025-09-14T01:43:28.308201Z INFO [Main]: Processed 450000 events in 561.210425ms
2025-09-14T01:43:28.308245Z INFO [Main]: Flushing State
2025-09-14T01:43:29.297216Z INFO [Main]: State Flushed in 988.961423ms
2025-09-14T01:43:29.306080Z WARN [TestEventFetcher]: Event fetching behind head (1 block behind) num_blocks=1 num_events=225000 height=30
2025-09-14T01:43:29.639557Z INFO [Main]: Processed 225000 events in 305.266548ms
2025-09-14T01:43:29.639608Z INFO [Main]: Flushing State
2025-09-14T01:43:30.110050Z INFO [Main]: State Flushed in 470.433196ms
AWS c6i.8xlarge
Benchmarking max single-threaded transactions per second.
Targeting 150000 tps, with 1500000 accounts (10% of accounts active per second).
Block time 1000ms, with 4 max blocks per fetch.
Running node with test scenario for 30 seconds...
2025-09-12T18:29:53.843441Z INFO [TestScenario]: Building scenario num_accounts=1500000
2025-09-12T18:30:01.798250Z INFO [TestScenario]: Building scenario finished [root hash: 0x630adb403c1a384def0b0b491f3df1fec0b899711ee200a2c1fce50fb94d18e9]
2025-09-12T18:30:01.798291Z INFO [ByteArchiver]: Clearing archive data
2025-09-12T18:30:02.384816Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=150000 height=1
2025-09-12T18:30:02.670385Z INFO [Main]: Processed 150000 events in 252.241129ms
2025-09-12T18:30:02.670409Z INFO [Main]: Flushing State
2025-09-12T18:30:03.128541Z INFO [Main]: State Flushed in 458.12584ms
2025-09-12T18:30:03.386808Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=150000 height=2
2025-09-12T18:30:03.619979Z INFO [Main]: Processed 150000 events in 199.967273ms
2025-09-12T18:30:03.619999Z INFO [Main]: Flushing State
2025-09-12T18:30:04.115729Z INFO [Main]: State Flushed in 495.722781ms
2025-09-12T18:30:04.385084Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=150000 height=3
2025-09-12T18:30:04.656647Z INFO [Main]: Processed 150000 events in 238.33803ms
2025-09-12T18:30:04.656671Z INFO [Main]: Flushing State
2025-09-12T18:30:05.152038Z INFO [Main]: State Flushed in 495.359951ms
...
2025-09-12T18:30:24.383323Z INFO [TestEventFetcher]: Fetched events num_blocks=1 num_events=150000 height=23
2025-09-12T18:30:24.680214Z INFO [Main]: Processed 150000 events in 263.571881ms
2025-09-12T18:30:24.680240Z INFO [Main]: Flushing State
2025-09-12T18:30:25.096985Z INFO [Main]: State Flushed in 416.7377ms
2025-09-12T18:30:25.097023Z INFO [ByteArchiver]: Archiving data height=23
2025-09-12T18:30:25.660007Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=300000 height=25
2025-09-12T18:30:26.194475Z INFO [Main]: Processed 300000 events in 468.049724ms
2025-09-12T18:30:26.194500Z INFO [Main]: Flushing State
2025-09-12T18:30:26.889529Z INFO [Main]: State Flushed in 695.021649ms
2025-09-12T18:30:26.927824Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=300000 height=27
2025-09-12T18:30:27.481511Z INFO [Main]: Processed 300000 events in 487.178985ms
2025-09-12T18:30:27.481537Z INFO [Main]: Flushing State
2025-09-12T18:30:28.300224Z INFO [Main]: State Flushed in 818.678707ms
2025-09-12T18:30:28.336438Z INFO [TestEventFetcher]: Fetched events num_blocks=2 num_events=300000 height=29
2025-09-12T18:30:28.910408Z INFO [Main]: Processed 300000 events in 507.384298ms
2025-09-12T18:30:28.910433Z INFO [Main]: Flushing State
2025-09-12T18:30:29.693407Z INFO [Main]: State Flushed in 782.966297ms
2025-09-12T18:30:29.707989Z WARN [TestEventFetcher]: Event fetching behind head (1 block behind) num_blocks=1 num_events=150000 height=30
2025-09-12T18:30:29.972715Z INFO [Main]: Processed 150000 events in 231.425624ms
2025-09-12T18:30:29.972748Z INFO [Main]: Flushing State
2025-09-12T18:30:30.381386Z INFO [Main]: State Flushed in 408.630229ms
Benchmark complete (elapsed time: 28.24s)
Node was able to keep up with targeted 150000 tps, with 1500000 accounts (10% of accounts active per second).
However, double check the node output to see if there was any struggle.
Try running the benchmark again with a higher tps target to find the limit (--tps <value>).
Benchmark: Proving with Risc Zero
Determination of the expected proving speed (in TPS) for a given machine and configuration using the Risc Zero zkVM. This benchmark produces results with a tradeoff between proof latency and max throughput.
Setup
Before running the benchmark, make sure your machine is set up and ready to go. Refer to the Setup Guide for how to set everything up with dependencies.
Running
The benchmark currently supports both CPU and GPU proving with Risc Zero.
For CPU proving, run the benchmark with the following command:
cd benchmarks/zk-proving/risczero
RUSTFLAGS="-Ctarget-cpu=native" cargo run --release
For GPU proving, run the benchmark with the following command:
cd benchmarks/zk-proving/risczero
RUSTFLAGS="-Ctarget-cpu=native" cargo run --release -F cuda
Note: GPU proving takes a VERY long time to compile due to having to build the GPU kernels
Options
The following options are available:
Options:
--num-accounts <NUM_ACCOUNTS>
The number of accounts to fill the state trie (default: 1_000_000) [default: 1000000]
--max-proof-duration <MAX_PROOF_DURATION>
The maximum amount of proof generation time to target (default: 600 seconds) [default: 600]
-h, --help
Print help
-V, --version
Print version
Output
The benchmark outputs two tables. One lists the raw proving time and the number of transactions/events that were proven. The other lists estimated TPS values for a range of proof latencies.
Example:
Proof generation benchmark results:
Events Time (s) TPS
1 3.84 0.26
2 3.92 0.51
...
10000 566.03 17.67
20000 1021.02 19.59
Estimated TPS at various latencies:
Latency (s) TPS
5 3.07
10 7.94
...
120 14.50
300 16.10
Benchmark Results
The following results were obtained on the various reference machines.
AMD Ryzen 9 9950X + RTX 5090
GPU proving
Proof generation benchmark results:
Events Time (s) TPS
1 3.84 0.26
2 3.92 0.51
5 4.47 1.12
10 4.23 2.36
20 5.66 3.53
50 7.96 6.28
100 11.43 8.75
200 19.53 10.24
500 39.54 12.65
1000 74.62 13.40
2000 135.90 14.72
5000 309.93 16.13
10000 566.03 17.67
20000 1021.02 19.59
Estimated TPS at various latencies:
Latency (s) TPS
5 3.07
10 7.94
30 11.90
60 13.19
120 14.50
300 16.10
AWS g6.xlarge
GPU proving
Proof generation benchmark results:
Events Time (s) TPS
1 6.23 0.16
2 6.26 0.32
5 6.79 0.74
10 7.78 1.29
20 9.23 2.17
50 13.66 3.66
100 19.64 5.09
200 32.81 6.10
500 69.10 7.24
1000 126.09 7.93
2000 234.46 8.53
5000 520.38 9.61
10000 942.56 10.61
Estimated TPS at various latencies:
Latency (s) TPS
5 --
10 2.52
30 5.96
60 7.08
120 7.89
300 8.96
AWS c6i.8xlarge
CPU proving
Proof generation benchmark results:
Events Time (s) TPS
1 66.61 0.02
2 66.52 0.03
5 84.84 0.06
10 125.10 0.08
20 152.68 0.13
50 297.24 0.17
100 472.23 0.21
200 848.63 0.24
Estimated TPS at various latencies:
Latency (s) TPS
5 --
10 --
30 --
60 --
120 0.08
300 0.17
Benchmark: Proving with SP1
Determination of the expected proving speed (in TPS) for a given machine and configuration using the SP1 zkVM. This benchmark produces results with a tradeoff between proof latency and max throughput.
Setup
Before running the benchmark, make sure your machine is set up and ready to go. Refer to the Setup Guide for how to set everything up with dependencies.
Running
For CPU proving, run the benchmark with the following command:
cd benchmarks/zk-proving/sp1
RUSTFLAGS="-C target-cpu=native -C target-feature=+avx512f" cargo run --release
Note: The benchmark currently does not support GPU proving
Options
The following options are available:
Options:
--num-accounts <NUM_ACCOUNTS>
The number of accounts to fill the state trie (default: 1_000_000) [default: 1000000]
--max-proof-duration <MAX_PROOF_DURATION>
The maximum amount of proof generation time to target (default: 600 seconds) [default: 600]
-h, --help
Print help
-V, --version
Print version
Output
The benchmark outputs two tables. One lists the raw proving time and the number of transactions/events that were proven. The other lists estimated TPS values for a range of proof latencies.
Example:
Proof generation benchmark results:
Events Time (s) TPS
1 3.84 0.26
2 3.92 0.51
...
10000 566.03 17.67
20000 1021.02 19.59
Estimated TPS at various latencies:
Latency (s) TPS
5 3.07
10 7.94
...
120 14.50
300 16.10
Benchmark Results
The following results were obtained on the various reference machines.
AWS c6i.8xlarge
Proof generation benchmark results:
Events Time (s) TPS
1 217.23 0.00
2 216.40 0.01
5 220.98 0.02
10 231.88 0.04
20 272.03 0.07
50 356.95 0.14
100 474.74 0.21
200 692.59 0.29
500 1333.82 0.37
Estimated TPS at various latencies:
Latency (s) TPS
5 --
10 --
30 --
60 --
120 --
300 0.10
Swap App (app-swap)
A simple example app that allows users to deposit/withdraw tokens, transfer tokens, and swap tokens. The token swapping is powered by an AMM that users can also add/remove liquidity to. The App's architecture is broken down into two key components.
-
Core Architecture
A pure Rust state machine (written in Rust) that processes events to update the application state. It’s designed for testability, zkVM portability, and a clean separation between state logic and the external runtime. -
App Contracts
On-chain interfaces and state proof validation for the Swap App. App contracts are minimal, leveraging helper contracts fromvoid/void-contracts/. Functions are defined in a separate interface, with the main contract using a global fallback. State validation is managed by a separate, upgradeable contract.
Core Architecture (app-swap)
The core architecture is a pure Rust state machine that processes events to update the application state. It’s designed for testability, zkVM portability, and a clean separation between state logic and the external runtime.
State Transition Function
Defined in app-swap/core/src/app.rs.
#![allow(unused)] fn main() { pub fn state_transition<S: Storage>(state: &mut S, events: &Events) { /* ... */ } }
- Role: consumes a batch of
events, routes them to domain handlers, and mutates the state store deterministically. - Handlers:
Deposit,Withdraw,Transfer,Swap,AddLiquidity,RemoveLiquidity. - Logging: emits withdrawal logs via
state.log(hasher, log_hash, chain_id)so withdrawals can be proven later (see Storage).
Why this pattern:
- Separation of concerns: pure state machine + generic node that feeds events and serves queries.
- Deterministic roots: identical inputs yield the same
state_rootandevents_hash. - Proving-ready: a lighter state object can be passed in that only holds enough data to execute the corresponding events (stateless execution in zkVM).
- Reusable node: plug your
state_transitionandquerieswithout bespoke node logic.
Events
Defined in app-swap/core/src/app/events.rs.
#![allow(unused)] fn main() { pub fn decode_event(event: &[u8]) -> Option<Event> { /* ... */ } }
- Role: take an event blob and parse it into event objects.
- Format: event data is data that was logged from the contract and represents a call that was made to the contract along with extra details like
chain_id,block_numberandsender:[4 bytes selector][4 bytes chain id][8 bytes block number][20 bytes sender][remaining calldata ...]. - Events:
Deposit,Withdraw,Transfer,Swap,AddLiquidity,RemoveLiquidity.
Why this pattern:
- Minimizes the Solidity needed to write while keeping interactions simple: users still just call a function on a contract; the node replays the logged call off-chain.
- The baked-in
chain_idallows for identifying events occurring on different chains (cross-chain friendly). - The inclusion of
block_numberallows for resequencing of events on a per-block basis within thestate_transitionfunction.
Storage
Defined in app-swap/core/src/app/storage.rs.
#![allow(unused)] fn main() { pub trait Storage { //account balances fn account_balance(&mut self, account: &Address, token: &U32) -> U64; fn update_balance(&mut self, account: &Address, token: &U32, amount: &U64); //account data fn account_data(&mut self, account: &Address, index: &U64) -> Option<&Bytes32>; fn update_data(&mut self, account: &Address, index: &U64, data: Option<&Bytes32>); //logs (withdrawals) fn next_log_nonce(&mut self, chain_id: Option<&U32>) -> Bytes32; fn log<H: Hasher>(&mut self, hasher: &H, log_hash: &Bytes32, chain_id: Option<&U32>) -> Bytes32; } }
- Role: abstract persistent state access and mutation, providing a unified interface for reading and updating account balances, per-account data, and withdrawal logs. This enables the state machine to remain agnostic to the underlying storage backend, whether it's in-memory for testing, a merkle trie for production, or a ZK-friendly variant for proving.
- Implementation: a generic implementation is provided for any
Statethat also implementsKvStore:impl<T: KvStore<Key = Bytes32, Value = Bytes32>> Storage for T { /* ... */ } - Types used: (see
app-swap/core/src/lib.rs)AppState = BinaryTrieStore<StorageHash>: persistent KV store over a binary Merkle trie; its root is the app state root.ZkAppState = ZkBinaryTrieStore<StorageHash>: same API plus witness generation for proving.ZkAppStateWitness = BinaryTrieWitness<StorageHash>: a lighter-weight version of the binary Merkle trie which only carries enough data to prove specificevents.
Why this pattern:
- Abstracts having to deal with key generation so the rest of the code can operate at a higher conceptual level of storage.
- Any backend implementing the
KvStoretrait can be swapped transparently. This enables switching to "witness" variants during zk proving and adopting more optimized state objects later without changing app logic. - Keys/values live in a Merkle trie, enabling compact membership proofs and stable state roots for verification.
Queries
Defined in app-swap/core/src/queries.rs.
#![allow(unused)] fn main() { pub fn account_balance_a<S: StorageReader>(store: &S, account: &Address) -> U64 { /* ... */ } pub fn account_balance_b<S: StorageReader>(store: &S, account: &Address) -> U64 { /* ... */ } pub fn account_balance_lp<S: StorageReader>(store: &S, account: &Address) -> U64 { /* ... */ } /* ... */ }
- Role: provide read-only accessors for querying the current state, such as balances and liquidity positions, without mutating storage.
- Serving: these functions enable clients and external services to retrieve up-to-date information from the state machine in a consistent and efficient manner.
Why this pattern:
- Pluggable into various server solutions (JSON-RPC, REST, GraphQL, serverless handlers) since queries are pure read helpers decoupled from transport.
- Safer servers: read-only boundaries reduce blast radius and simplify auth/rate limits.
App Contracts (app-swap)
On-chain interfaces and state proof validation for the Swap App. The app contracts are kept minimal by making use of several helper contracts found in void/void-contracts/ and follow a pattern of defining functions in an interface only, while the main contract handles them with a global fallback. State validation is also handled in a separate validation contract, which is upgradeable.
Why this pattern:
- Minimal Solidity, maximal portability: most business logic lives in the Rust state machine; contracts are thin adapters.
- Pluggable proving: swap validation logic without changing app call surface.
- Easy upgrades: adding or removing functions doesn't require changes to the core contract.
- Cheaper gas: removing functions from the actual contract saves a bit of gas from the hidden function selector switch-case, but also allows for maintaining upgradability without needing calls to hit a proxy.
- Deterministic replay: on-chain writes are limited; off-chain processing replays compact inputs into the state machine.
- Updating the app involves only updating the
validationcontract.
App Interface
Defined in src/AppSwap.sol and src/AppSwapNull.sol (differences explained in Void vs VoidNull).
interface IAppSwap {
//note: transferData [20bytes to][4bytes tokenId][8bytes amount]
function transfer(bytes32 transferData) external;
//note: swapData [4bytes token in][8bytes amount in][8bytes min amount out]
function swap(bytes32 swapData) external;
//note: addLiquidityData [8bytes amount a][8bytes amount b]
function addLiquidity(bytes32 addLiquidityData) external;
//note: removeLiquidityData [8bytes amount lp]
function removeLiquidity(bytes32 removeLiquidityData) external;
/////////////////////////////////////////
// VoidUpgradeableWithBridge functions //
/////////////////////////////////////////
//note: depositData [20bytes to][4bytes tokenId][8bytes amount]
function deposit(bytes32 depositData) external;
//note: withdrawData [20bytes to][4bytes tokenId][8bytes amount]
function withdraw(bytes32 withdrawData) external;
//note: withdrawData [20bytes to][4bytes tokenId][8bytes amount]
function withdrawFinalize(bytes32 withdrawData, bytes calldata proof) external;
}
- Role: defines the functions available to call on the app contract (note: this is the only place some of the functions are defined as they are handled through a fallback in the core contract).
- Special case: the deposit/withdrawal functions are actual functions defined in the base contract:
VoidUpgradeableWithBridge. - Admin functions: additional admin functions exist and can be found in
VoidUpgradeableWithBridge.
Core Contracts
There are two different version of the core contract that extend from different versions of the core Void contract (see Void vs VoidNull).
-
AppSwapextendsVoidUpgradeableWithBridge.- Constructor:
(address initialOwner, IVoidValidator voidValidator, uint256 bufferSize, uint256 blocksPerBufferSlot, uint256 maxTxnsPerBufferSlot). - Bridge: deposit/withdraw entrypoints are part of the base pattern in
VoidUpgradeableWithBridge. - Functions: app specific functions are defined in the interface only, keeping the core contract minimal.
- Validation: handled through separate upgradeable contract (see Validation).
- Constructor:
-
AppSwapNullextendsVoidNullUpgradeableWithBridge.- Constructor:
(address initialOwner, IVoidValidator voidValidator, uint256 blocksPerBufferSlot, uint256 maxTxnsPerBufferSlot)(same asAppSwapbut without abufferSize). - Bridge: (same as
AppSwap) deposit/withdraw entrypoints are part of the base pattern inVoidUpgradeableWithBridge. - Functions: (same as
AppSwap) app specific functions are defined in the interface only, keeping the core contract minimal. - Validation: (same as
AppSwap) handled through separate upgradeable contract (see Validation).
- Constructor:
Validation
Implement IVoidValidator to verify state roots and log, which power things like withdrawals. There are two different validator contracts implemented in this repo which are used in different contexts.
-
AppValidatorECDSA: attestation-style validator using an ECDSA prover key- Constructor:
(address proverKey). validateState: verifieseventsHashwith ECDSA attestation.validateLog: checks a nonce (validateLogNonce), re-derivessha256(abi.encode(logHash, nonce)), validates state proof, then verifies a Binary Merkle Trie proof againststateRoot.
- Constructor:
-
AppValidatorRiscZero: ZK validator using RISC Zero Groth16 verifier- Constructor:
(RiscZeroGroth16Verifier verifier, bytes32 imageId). validateState: verifies the RISC Zero proof with fixed starting state.validateLog: same flow as ECDSA variant nonce check, state proof verification, then Merkle trie proof.
- Constructor:
Either of these validators can be plugged into the core App contract and are both used in the end-to-end tests included in this repo.
Void vs VoidNull
- Void (hashing + buffer): builds a SHA-256 hash chain of all calls and keeps a bounded ring buffer of end-state hashes on-chain for anchoring of proof verification.
- VoidNull (no hashing, no buffer): only emits the same structured log for each call and enforces per-slot txn limits. It relies on an external oracle/validator to fetch and aggregate logs across chains/sources.
What actually happens under the hood
From void/void-contracts/src/core:
-
Void (
Void.sol)voidRecord(): called on every app call (via the contract’s fallback or on deposit/withdraw hooks)- Builds a message: [4Bytes selector][4Bytes chainId][8Bytes blockNum][20Bytes sender][rest of calldata], and emits it as a log0.
- Computes
dataHash = sha256(message)thennewHash = sha256(prevHash || dataHash)to extend a hash chain. - Updates a ring buffer entry keyed by the current block “slot”.
- During proof verification,
voidValidate(eventsHash, index)checks that the providedeventsHashmatches the buffered end-state atindex. This anchors off-chain proofs to an on-chain truth.
-
VoidNull (
VoidNull.sol)voidRecord(): called on every app call (via the contract’s fallback or on deposit/withdraw hooks)- Builds the same message and emits it as a log0 (identical schema to Void).
- Does NOT compute a hash chain or maintain a ring buffer; it only stores data to enforce
maxTxnsPerBufferSlotperblocksPerBufferSlotwindow.
- During proof verification, there is no
voidValidatestep and proofs are accepted based solely on the validator contract’s checks (typically via an attestation from an oracle).
Design trade-offs and guidance:
-
Trust/anchoring
- Void: on-chain ring buffer anchors the prover’s
eventsHash. Validators still verify state/log proofs, but the additional buffer check ties proofs to a specific on-chain truth. - VoidNull: no on-chain anchoring; depends entirely on the validator/oracle to supply consistent, deduplicated, correctly ordered events.
- Void: on-chain ring buffer anchors the prover’s
-
Multi-source aggregation
- Void: best when a single chain is the canonical source of events or when you want minimal dependence on external oracles.
- VoidNull: best when an oracle aggregates logs from multiple chains or off-chain sources and you don’t want per-chain hash anchoring. Helpful for cross-domain aggregation.
-
Gas/complexity
- Void: slightly higher gas per call due to two SHA-256 operations and buffer writes; constructor requires
bufferSizein addition toblocksPerBufferSlotandmaxTxnsPerBufferSlot. - VoidNull: cheaper per call; constructor omits
bufferSize.
- Void: slightly higher gas per call due to two SHA-256 operations and buffer writes; constructor requires
-
Operational sizing (Void only)
- Choose
bufferSizeso thatbufferSize * blocksPerBufferSlotexceeds the max finalization delay (in blocks) for withdrawals, to avoid overwriting the referenced slot. - Set
maxTxnsPerBufferSlotbased on expected throughput to avoid reverts during bursts.
- Choose
Nodes
Nodes are long-running processes that apply an application's state and state transition function by processing events, updating metadata, and optionally producing attestations or proofs, while exposing APIs for external interaction. At a high level, a node continuously:
- Fetches new events from a source chain or oracle (Fetcher)
- Applies the app’s state transition to a local state (StateBuffer)
- Updates metadata (heights, hashes, roots), and archives state snapshots (Archiver)
- Optionally produces an attestation (signature) or a zero-knowledge proof for the new state (Attester/Prover)
- Optionally exposes an API (ex. JSON-RPC) to read state and metadata
Under the hood, node code calls into one of the void-node loops:
run_app(RunConfig, ...)– execution-onlyrun_attesting_app(RunAttestingConfig, ...)– execution + attestationsrun_proving_app(RunProvingConfig, ...)– execution + ZK proving
For startup, they either:
start_app(state_buffer, fetcher, archiver)– initialize from archive, then start fetchingstart_app_with(start_state, start_metadata, state_buffer, fetcher)– initialize from an explicit state (e.g., benchmarks)
Node flavors and examples
-
Execution node (execution only)
- Example:
e2e/zk/node/ - Uses
EvmChainEventFetcher,ByteArchiver,StreamingKvStateBuffer,run_app
- Example:
-
Attesting node (TEE / signature-based)
- Example:
e2e/tee/node/ - Uses
OracleEventFetcher(multiple EVM RPCs),ByteArchiver,StreamingKvStateBuffer,ECDSAAttester,run_attesting_app
- Example:
-
Proving node (ZK)
- Example:
e2e/zk/proving-node/host/ - Uses
EvmChainEventFetcher,ByteArchiver,StreamingKvStateBuffer,Risc0Prover,run_proving_app
- Example:
-
Benchmark node (uses test scenario)
- Example:
benchmarks/max-tps/ - Uses
TestEventFetcher,ByteArchiver,StreamingKvStateBuffer,start_app_with,run_app
- Example:
Core components (from void/void-node/)
-
Fetchers (
fetcher)- Role: connect to an event source and stream “bundles” of events, tracking heights and latest height boundaries (useful during syncing).
- Implementations (feature-gated):
test-fetcher– in-memory test/scenario driver (benchmarks)evm-fetcher– reads events from an EVM chainoracle-fetcher– reads via an oracle stream (supports multiple EVM endpoints)
-
Archivers (
archiver)- Role: persist and restore state and metadata; create periodic snapshots according to a min-height interval.
- Implementations (feature-gated):
byte-archiver– byte-oriented archive (used by examples)empty-archiver– no-op (useful for tests)
-
State Buffers (
state_buffer)- Role: own the app State and StateMetadata with safe concurrent access; expose read/write closures and a read-only
StateReaderfor powering APIs (ex. JSON-RPC). - Implementations (feature-gated):
stream-state-buffer– streaming KV buffer with concurrent readers (uses more memory, but allows for near limitless reads with low performance overhead)lock-state-buffer– lock-based buffer (reads slow down performance, but uses less memory)empty-state-buffer– no-op (useful for tests)
- Role: own the app State and StateMetadata with safe concurrent access; expose read/write closures and a read-only
-
Provers and Attesters (
prover/attester)- Role: produce proofs/attestations of an apps state (
state_root) and processed events (events_hash). - Attester (
StateAttester): produces a signature attesting to{events_hash, state_root}at intervals. Example:ECDSAAttestertakes a hex private key (or reads from env if not provided explicitly). - Attester Implementations (feature-gated):
ecdsa– takes a hex private key (or reads from env if not provided explicitly) to sign over{events_hash, state_root}.
- Prover (
StateProver): produces a recursive proof for the transition from prior proof + new events + witness. - Prover Implementations (feature-gated):
r0/r0-cuda– proves using the Risc Zero zkVM.sp1– proves using the SP1 zkVM.
- Role: produce proofs/attestations of an apps state (
Runtime flow
1) Startup
start_app_with(...)when you already have an initial state/metadata (benchmarks)start_app(...)to restore from archive and continue syncing
2) Loop (all variants)
- Fetch next events bundle
- Apply
state_transition(state, &events) - Flush state (
state.flush()orstate.flush_with_witness()for ZK) - Update metadata:
latest,sync_height,sync_hash,events_hash,state_root, and proof fields - When at configured height interval, archive snapshot via
archiver.archive
3) Additional steps by variant
- Attesting: when at configured height interval, call
attester.attest, and write to metadata - Proving: when at configured height interval, build witness, call
prover.prove, and update metadata with returned proof
JSON-RPC surface
Nodes often start a lightweight Axum-based server to expose read APIs over the StateBuffer’s StateReader, using additional tools found in void/void-node/.
- For example, the nodes in the end-to-end tests add an
rpc_routerand callaxum_server::start("127.0.0.1:<port>", rpc_router). - The
json_rpcmodule invoid/void-node/provides helpers to build routers from aStateBufferand method list.
Extending or writing your own node
To customize a node:
- Pick the appropriate fetcher (or implement
Fetcher) - Choose an archiver policy (or implement
Archiver<S>) - Choose a state buffer (or implement
StateBuffer<S>) - Decide whether you need attestations (
StateAttester) or proofs (StateProver) (or neither), and use the corresponding run loop - Expose the parts of state you need via the JSON-RPC helpers (or implement custom server solution)