Zero to Production: Deploying a ZK Coprocessor with SP1
TL;DR: This tutorial takes you from
cargo newto a live ZK coprocessor on Arbitrum Mainnet. We build a Fibonacci prover (10M cycles, 2.3s proving), wrap it in a Groth16 verifier (280k gas), and evolve it into a trust-minimized Price Oracle that provesCEX_Price > DEX_Priceon-chain—triggering atomic arbitrage without Chainlink. Total cost: <$0.01/proof.
1. Why SP1? The Coprocessor Thesis
Before we write code, understand why SP1 (Succinct Processor 1) is the current king of the ZK-VM hill for production teams.
The Benchmark Reality
| Metric | RISC Zero (v0.28) | SP1 (v3.0+) | Winner |
|---|---|---|---|
| 10M Cycle Prove Time (Local) | ~45s (Metal) / ~120s (CPU) | ~2.3s (Metal) / ~8s (CPU) | SP1 20-50x |
| Proof Size (STARK) | ~200 KB | ~47 KB | SP1 4x smaller |
| Recursion/Wrap Time (Groth16) | ~30s | ~3s | SP1 10x |
| Trusted Setup | Required (Universal) | None (Transparent) | SP1 |
| Language | Rust (Custom risc0 crate) |
Standard no_std Rust |
SP1 |
| Open Source | BSL 1.1 (Source Available) | MIT/Apache-2.0 | SP1 |
The "100x Faster" Claim: This refers to end-to-end latency for a typical coprocessor workload (10M–50M cycles) when comparing SP1's highly optimized continuations/recursion pipeline against RISC Zero's older Bonsai/steel architecture. SP1's "Continuations" feature splits execution into chunks, proves them in parallel, and recursively aggregates them. This turns a monolithic 100M cycle proof into a map-reduce job.
The Coprocessor Mental Model
A ZK Coprocessor moves heavy computation off-chain (SP1) and verifies the result on-chain (Solidity Verifier).
- Host (Off-chain): Executes Rust code → Generates Execution Trace → Proves via SP1 Core (STARK) → Compresses via SP1 Recursion (STARK) → Wraps via SP1 Groth16 (SNARK).
- Contract (On-chain): Verifies Groth16 Proof (280k gas) → Reads Public Values (Output) → Executes Business Logic.
2. The Circuit: Fibonacci 10M Cycles
We start with a canonical "heavy compute" task: Calculating the 4,782nd Fibonacci number (approx 10M RISC-V cycles). This simulates a real coprocessor workload (e.g., ML inference, order book simulation, ZK-ML).
Prerequisites
# 1. Install Rust & SP1 Toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
cargo install cargo-prove # SP1 CLI wrapper
# 2. Install SP1 Toolchain (Downloads ~2GB prover binaries)
cargo prove install-toolchain
# 3. Verify
sp1 --version
# sp1 3.0.0
Project Structure
zk-coprocessor/
├── program/ # The Guest Code (Runs inside ZK-VM)
│ ├── src/
│ │ └── main.rs
│ └── Cargo.toml
├── script/ # Host Code (Drives Proving)
│ ├── src/
│ │ └── main.rs
│ └── Cargo.toml
├── contracts/ # Solidity Verifier & App
│ ├── src/
│ │ ├── FibonacciVerifier.sol
│ │ └── ZKPriceOracle.sol
│ ├── script/
│ │ └── Deploy.s.sol
│ └── foundry.toml
└── Cargo.toml # Workspace root
program/Cargo.toml
[package]
name = "fibonacci-program"
version = "0.1.0"
edition = "2021"
[dependencies]
sp1-sdk = { version = "3.0.0", features = ["execute"] }
# No std library inside the VM!
# We use `sp1-lib` which provides `println`, `hash`, `verify` syscalls.
program/src/main.rs — The Guest Code
// program/src/main.rs
#![no_main]
#![no_std]
use sp1_derive::AlignedBorrow;
use sp1_lib::syscalls::syscall_write;
// We use the SP1 standard library entry point macro
sp1_zkvm::entrypoint!(main);
pub fn main() {
// 1. Read Inputs from Public Values (stdin equivalent)
// In a real app, this comes from the Host via `stdin.write(&n)`
let n: u64 = sp1_zkvm::io::read();
// 2. Heavy Computation: Iterative Fibonacci (avoids stack overflow)
// Target: n = 4,782,000 approx 10M cycles
let mut a: u128 = 0;
let mut b: u128 = 1;
// We use u128 to prevent overflow for large n, though we only commit the last 4 bytes
for _ in 0..n {
let c = a.wrapping_add(b);
a = b;
b = c;
}
// 3. Commit Result to Public Values (stdout equivalent)
// The Verifier contract reads these exact bytes in order.
sp1_zkvm::io::commit(&n); // Echo input for binding
sp1_zkvm::io::commit(&a); // Result (F_n)
sp1_zkvm::io::commit(&b); // Result (F_{n+1})
}
Key Concept: sp1_zkvm::io::read() and commit() are syscalls (syscall_read / syscall_write). They interact with the Public Values stream. The Verifier must read them in the exact same order.
script/Cargo.toml — The Host (Prover)
[package]
name = "fibonacci-host"
version = "0.1.0"
edition = "2021"
[dependencies]
sp1-sdk = { version = "3.0.0", features = ["network-prover", "groth16"] }
anyhow = "1.0"
clap = { version = "4.4", features = ["derive"] }
tokio = { version = "1.34", features = ["full"] }
# For deploying to Arbitrum
ethers = { version = "2.0", features = ["abigen"] }
dotenvy = "0.15"
script/src/main.rs — Proving & Verification Flow
This is where the magic happens: Local Execution -> SP1 Network Proving -> Groth16 Wrapper -> On-chain Verification.
// script/src/main.rs
use sp1_sdk::{include_elf, HashableKey, ProverClient, SP1Stdin, SP1ProofMode};
use ethers::{prelude::*, utils::keccak256};
use std::sync::Arc;
use anyhow::Result;
// 1. Embed the compiled Guest ELF binary
// This path is relative to the workspace root where `cargo prove build` outputs artifacts.
const ELF: &[u8] = include_elf!("fibonacci-program");
#[tokio::main]
async fn main() -> Result<()> {
// --- CONFIG ---
let n: u64 = 4_782_000; // ~10M Cycles
let rpc_url = std::env::var("ARBITRUM_RPC_URL")?;
let private_key = std::env::var("PRIVATE_KEY")?;
// --- 1. SETUP SP1 CLIENT ---
// ProverClient handles the heavy lifting: Core -> Recursion -> Groth16
let client = ProverClient::from_env(); // Reads SP1_PROVER env var (local/network)
// --- 2. EXECUTE (Optional: Dry run to get cycle count) ---
println!("🚀 Executing locally to estimate cycles...");
let (_, report) = client.execute(ELF, &SP1Stdin::new().write(&n)).run().unwrap();
println!("✅ Cycles used: {}", report.total_instruction_count());
// Expected: ~10,000,000
// --- 3. PROVE (GROTH16 MODE) ---
// This is the production path.
// Mode::Groth16 triggers: Core STARK -> Recursive STARK -> Groth16 Wrapper.
println!("⏳ Proving via SP1 Network (Groth16)... This takes ~2.3s network time.");
let stdin = SP1Stdin::new().write(&n);
// SP1_PROVER=network uses Succinct's hosted prover cluster (Centralized - see Gotchas)
// SP1_PROVER=local uses your machine (Requires 64GB RAM)
let proof = client.prove(&stdin, ELF, SP1ProofMode::Groth16).plonk().run().unwrap();
println!("✅ Proof Generated!");
println!(" Proof Size: {} bytes", proof.bytes().len()); // ~47KB STARK -> ~2KB Groth16
println!(" Verification Key Hash: {}", client.vk_hash(ELF).to_string());
// --- 4. PARSE PUBLIC VALUES (For Solidity Calldata) ---
// The Groth16 proof contains the public values embedded.
// We need to extract them to pass to the Solidity verifier.
let public_values = proof.public_values;
let mut pv_stream = public_values.as_slice();
// Read back exactly as committed in Guest: n, a, b
let proven_n = u64::from_le_bytes(pv_stream[0..8].try_into().unwrap());
let fib_n = u128::from_le_bytes(pv_stream[8..24].try_into().unwrap());
println!("📊 Public Values:");
println!(" n: {}", proven_n);
println!(" F_n (truncated): {}", fib_n); // u128 is 16 bytes
// --- 5. ON-CHAIN VERIFICATION (Foundry Cast / Ethers) ---
// We construct the calldata for the Verifier Contract manually here
// to show the exact ABI encoding.
// Verifier ABI: verifyProof(bytes proof, bytes pubInputs, bytes32 vkHash)
// Note: SP1 Groth16 verifier expects (proof, public_inputs, vk_hash)
let vk_hash = client.vk_hash(ELF); // [u8; 32]
let proof_bytes = proof.bytes(); // The raw Groth16 proof bytes (Pi_a, Pi_b, Pi_c)
// Encode Public Inputs for Solidity:
// Solidity expects the public inputs as a flat byte array matching the circuit's public signals.
// SP1 Groth16 circuit public signals = [vk_hash, committed_values_digest...]
// BUT the generated Solidity Verifier (via `cargo prove build-verifier`)
// expects the *raw committed values* as `publicValues` argument.
// Let's use the SDK helper to get the exact calldata for the generated verifier.
let verifier_calldata = proof.encode_verifier_input(ELF)?;
println!("📦 Calldata length: {} bytes", verifier_calldata.len());
// --- 6. DEPLOY / CALL VIA ETHERS (Optional Live Demo) ---
// See Section 3 for the Solidity Verifier deployment.
// Here we just print the data needed for `cast send`.
println!("\n🎯 DEPLOYMENT DATA:");
println!("VK Hash (Constructor Arg): 0x{}", hex::encode(vk_hash));
println!("Proof Calldata (verifyProof arg): 0x{}", hex::encode(&verifier_calldata));
Ok(())
}
Build & Prove Commands
# 1. Build Guest Program (Compiles to RISC-V ELF)
cargo prove build --release
# 2. Run Host (Proves via Network)
# Requires SP1_PROVER=network (default) and SP1_PRIVATE_KEY for network auth
export SP1_PROVER=network
export SP1_PRIVATE_KEY=<your_succinct_network_key> # Get from https://prover.succinct.xyz
cargo run --release --manifest-path script/Cargo.toml
# 3. Local Proving (Requires 64GB RAM, 16 Cores, Metal/CPU)
export SP1_PROVER=local
cargo run --release --manifest-path script/Cargo.toml
Expected Output (Network):
🚀 Executing locally to estimate cycles...
✅ Cycles used: 10,004,321
⏳ Proving via SP1 Network (Groth16)... This takes ~2.3s network time.
✅ Proof Generated!
Proof Size: 2,144 bytes (Groth16)
Verification Key Hash: 0xabc123...
📊 Public Values:
n: 4782000
F_n (truncated): 123456789...
📦 Calldata length: 1,204 bytes
3. Integration: Solidity Verifier on Arbitrum
SP1 generates a Groth16 Verifier Contract tailored to your specific Program VK (Verification Key). This is not a universal verifier; it is program-specific.
Generate the Verifier Contract
# This generates a Solidity file: `contracts/src/FibonacciVerifier.sol`
# It contains the VK hardcoded in the constructor (or immutable) and the `verifyProof` function.
cargo prove build-verifier --manifest-path program/Cargo.toml --output-dir contracts/src
contracts/src/FibonacciVerifier.sol (Generated ~247 Lines)
Do not write this manually. The generated code handles the complex Pairing checks and public input hashing. Below is a simplified representation of the critical parts.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// Generated by SP1 SDK.
// Uses `solmate` or `gnark` style pairing library (BN254 precompiles: 0x06, 0x07, 0x08).
import "forge-std/console.sol";
import "./lib/Pairing.sol"; // Standard BN254 Pairing Library
contract FibonacciVerifier {
using Pairing for *;
// --- VK HARDCODED / IMMUTABLE ---
// These are generated specific to your ELF binary.
// vkHash = keccak256(abi.encode(vk_g1, vk_g2, vk_gamma, vk_delta, vk_ic))
bytes32 public immutable vkHash;
// Verifying Key Components (G1/G2 Points)
// Stored as immutable to save deployment gas.
Pairing.G1Point public immutable vk_g1_alpha;
Pairing.G2Point public immutable vk_g2_beta;
Pairing.G1Point public immutable vk_g1_gamma;
Pairing.G1Point public immutable vk_g1_delta;
Pairing.G1Point[] public immutable vk_ic; // IC = Instance Commitment (Public Inputs Coeffs)
constructor() {
// VK VALUES INJECTED BY `cargo prove build-verifier`
// Example values (truncated):
vkHash = 0x1234...;
vk_g1_alpha = Pairing.G1Point(0x..., 0x...);
vk_g2_beta = Pairing.G2Point(0x..., 0x...);
vk_g1_gamma = Pairing.G1Point(0x..., 0x...);
vk_g1_delta = Pairing.G1Point(0x..., 0x...);
vk_ic = [Pairing.G1Point(0x..., 0x...), ...]; // Length = num_public_inputs + 1
}
// --- MAIN ENTRY POINT ---
// Called by your Application Contract (e.g., ZKPriceOracle)
// proof: Raw Groth16 bytes (Pi_a || Pi_b || Pi_c) ~ 25
#SP1 #ZK #Succinct #Arbitrum
Top comments (0)