Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-swap with your app logic while keeping a similar pattern of state_transition function, storage, events, and queries. 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 implementation
    • core/: Pure Rust state machine and read-only queries
    • contracts/: Solidity contracts and deploy scripts
  • void/: Shared libraries and tools
    • void-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.rs to add read-only helpers your node will expose over JSON-RPC.
  • Update the contracts in app-swap/contracts to 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:

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 from void/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_root and events_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_transition and queries without 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_number and sender: [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_id allows for identifying events occurring on different chains (cross-chain friendly).
  • The inclusion of block_number allows for resequencing of events on a per-block basis within the state_transition function.

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 State that also implements KvStore: 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 specific events.

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 KvStore trait 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 validation contract.

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).

  • AppSwap extends VoidUpgradeableWithBridge.

    • 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).
  • AppSwapNull extends VoidNullUpgradeableWithBridge.

    • Constructor: (address initialOwner, IVoidValidator voidValidator, uint256 blocksPerBufferSlot, uint256 maxTxnsPerBufferSlot) (same as AppSwap but without a bufferSize).
    • Bridge: (same as AppSwap) deposit/withdraw entrypoints are part of the base pattern in VoidUpgradeableWithBridge.
    • 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).

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: verifies eventsHash with ECDSA attestation.
    • validateLog: checks a nonce (validateLogNonce), re-derives sha256(abi.encode(logHash, nonce)), validates state proof, then verifies a Binary Merkle Trie proof against stateRoot.
  • 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.

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) then newHash = 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 provided eventsHash matches the buffered end-state at index. 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 maxTxnsPerBufferSlot per blocksPerBufferSlot window.
    • During proof verification, there is no voidValidate step 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.
  • 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 bufferSize in addition to blocksPerBufferSlot and maxTxnsPerBufferSlot.
    • VoidNull: cheaper per call; constructor omits bufferSize.
  • Operational sizing (Void only)

    • Choose bufferSize so that bufferSize * blocksPerBufferSlot exceeds the max finalization delay (in blocks) for withdrawals, to avoid overwriting the referenced slot.
    • Set maxTxnsPerBufferSlot based on expected throughput to avoid reverts during bursts.

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-only
  • run_attesting_app(RunAttestingConfig, ...) – execution + attestations
  • run_proving_app(RunProvingConfig, ...) – execution + ZK proving

For startup, they either:

  • start_app(state_buffer, fetcher, archiver) – initialize from archive, then start fetching
  • start_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
  • Attesting node (TEE / signature-based)

    • Example: e2e/tee/node/
    • Uses OracleEventFetcher (multiple EVM RPCs), ByteArchiver, StreamingKvStateBuffer, ECDSAAttester, run_attesting_app
  • Proving node (ZK)

    • Example: e2e/zk/proving-node/host/
    • Uses EvmChainEventFetcher, ByteArchiver, StreamingKvStateBuffer, Risc0Prover, run_proving_app
  • Benchmark node (uses test scenario)

    • Example: benchmarks/max-tps/
    • Uses TestEventFetcher, ByteArchiver, StreamingKvStateBuffer, start_app_with, run_app

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 chain
      • oracle-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 StateReader for 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)
  • 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: ECDSAAttester takes 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.

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() or state.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_router and call axum_server::start("127.0.0.1:<port>", rpc_router).
  • The json_rpc module in void/void-node/ provides helpers to build routers from a StateBuffer and 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)