synor/crates/synor-zk/src/circuit.rs
Gulshan Yadav 694e62e735 feat(zk): add ZK-rollup foundation with Groth16 proof system
Milestone 3 of Phase 13 - ZK-Rollup Foundation:
- Circuit definitions (Transfer, Batch, Deposit, Withdraw)
- Proof system with Groth16/PLONK/STARK backends
- Sparse Merkle tree state management
- Rollup manager for batch processing

Technical details:
- Uses arkworks library for ZK-SNARKs
- R1CS constraint system with BN254 curve
- 32-depth state tree supporting 4B accounts
- Batch processing with 1000 tx max
2026-01-19 14:10:46 +05:30

508 lines
15 KiB
Rust

//! ZK Circuit definitions for Synor rollups.
//!
//! Circuits define the constraints that must be satisfied for valid state transitions.
//! We use R1CS (Rank-1 Constraint System) representation for Groth16 compatibility.
//!
//! # Circuit Types
//!
//! - **TransferCircuit**: Validates token transfers between accounts
//! - **DepositCircuit**: Validates deposits from L1 to L2
//! - **WithdrawCircuit**: Validates withdrawals from L2 to L1
//! - **BatchCircuit**: Aggregates multiple transactions into single proof
use ark_ff::PrimeField;
use ark_relations::r1cs::SynthesisError;
use ark_bn254::Fr as ScalarField;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::state::StateRoot;
/// Circuit configuration parameters.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CircuitConfig {
/// Maximum number of transactions in a batch
pub max_batch_size: usize,
/// State tree depth
pub tree_depth: usize,
/// Enable signature verification in circuit
pub verify_signatures: bool,
}
impl Default for CircuitConfig {
fn default() -> Self {
Self {
max_batch_size: crate::constants::MAX_BATCH_SIZE,
tree_depth: crate::constants::STATE_TREE_DEPTH,
verify_signatures: true,
}
}
}
/// Constraint system interface for building circuits.
pub trait ConstraintSystem {
/// Allocates a public input variable.
fn alloc_input(&mut self, name: &str, value: ScalarField) -> Result<Variable, CircuitError>;
/// Allocates a private witness variable.
fn alloc_witness(&mut self, name: &str, value: ScalarField) -> Result<Variable, CircuitError>;
/// Adds a constraint: a * b = c
fn enforce_constraint(
&mut self,
a: LinearCombination,
b: LinearCombination,
c: LinearCombination,
) -> Result<(), CircuitError>;
/// Returns the number of constraints.
fn num_constraints(&self) -> usize;
/// Returns the number of public inputs.
fn num_inputs(&self) -> usize;
/// Returns the number of witness variables.
fn num_witnesses(&self) -> usize;
}
/// Variable in the constraint system.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Variable(pub(crate) usize);
impl Variable {
/// Creates a new variable with the given index.
pub fn new(index: usize) -> Self {
Self(index)
}
/// Returns the variable index.
pub fn index(&self) -> usize {
self.0
}
}
/// Linear combination of variables: sum(coeff_i * var_i).
#[derive(Clone, Debug, Default)]
pub struct LinearCombination {
terms: Vec<(ScalarField, Variable)>,
}
impl LinearCombination {
/// Creates an empty linear combination.
pub fn new() -> Self {
Self { terms: Vec::new() }
}
/// Creates a linear combination from a single variable.
pub fn from_variable(var: Variable) -> Self {
let mut lc = Self::new();
lc.add_term(ScalarField::from(1u64), var);
lc
}
/// Creates a linear combination from a constant.
pub fn from_constant(value: ScalarField) -> Self {
let mut lc = Self::new();
lc.add_term(value, Variable(0)); // Variable 0 is the constant 1
lc
}
/// Adds a term (coefficient * variable) to the linear combination.
pub fn add_term(&mut self, coeff: ScalarField, var: Variable) {
self.terms.push((coeff, var));
}
/// Returns the terms of the linear combination.
pub fn terms(&self) -> &[(ScalarField, Variable)] {
&self.terms
}
}
/// Base trait for all circuits.
pub trait Circuit: Clone + Send + Sync {
/// Returns the circuit name.
fn name(&self) -> &str;
/// Returns the circuit configuration.
fn config(&self) -> &CircuitConfig;
/// Synthesizes the circuit constraints.
fn synthesize<CS: ConstraintSystem>(&self, cs: &mut CS) -> Result<(), CircuitError>;
/// Returns the public inputs for this circuit instance.
fn public_inputs(&self) -> Vec<ScalarField>;
}
/// Transfer circuit: validates a token transfer between accounts.
#[derive(Clone, Debug)]
pub struct TransferCircuit {
config: CircuitConfig,
/// Previous state root
pub old_root: StateRoot,
/// New state root after transfer
pub new_root: StateRoot,
/// Sender account index
pub sender_idx: u64,
/// Recipient account index
pub recipient_idx: u64,
/// Transfer amount
pub amount: u64,
/// Sender's signature (for witness)
pub signature: Vec<u8>,
/// Merkle proof for sender account
pub sender_proof: Vec<[u8; 32]>,
/// Merkle proof for recipient account
pub recipient_proof: Vec<[u8; 32]>,
}
impl TransferCircuit {
/// Creates a new transfer circuit.
pub fn new(
old_root: StateRoot,
new_root: StateRoot,
sender_idx: u64,
recipient_idx: u64,
amount: u64,
) -> Self {
Self {
config: CircuitConfig::default(),
old_root,
new_root,
sender_idx,
recipient_idx,
amount,
signature: Vec::new(),
sender_proof: Vec::new(),
recipient_proof: Vec::new(),
}
}
/// Sets the Merkle proofs for verification.
pub fn with_proofs(
mut self,
sender_proof: Vec<[u8; 32]>,
recipient_proof: Vec<[u8; 32]>,
) -> Self {
self.sender_proof = sender_proof;
self.recipient_proof = recipient_proof;
self
}
/// Sets the signature for verification.
pub fn with_signature(mut self, signature: Vec<u8>) -> Self {
self.signature = signature;
self
}
}
impl Circuit for TransferCircuit {
fn name(&self) -> &str {
"TransferCircuit"
}
fn config(&self) -> &CircuitConfig {
&self.config
}
fn synthesize<CS: ConstraintSystem>(&self, cs: &mut CS) -> Result<(), CircuitError> {
// Public inputs
let old_root_var = cs.alloc_input("old_root", field_from_hash(&self.old_root.0))?;
let new_root_var = cs.alloc_input("new_root", field_from_hash(&self.new_root.0))?;
// Private witnesses
let sender_idx_var = cs.alloc_witness("sender_idx", ScalarField::from(self.sender_idx))?;
let recipient_idx_var = cs.alloc_witness("recipient_idx", ScalarField::from(self.recipient_idx))?;
let amount_var = cs.alloc_witness("amount", ScalarField::from(self.amount))?;
// Constraint 1: Amount > 0
// We don't have a direct "greater than" constraint in R1CS,
// but we can enforce that amount is non-zero by requiring amount * amount_inv = 1
// This is a simplified version; real implementation would use range proofs
// Constraint 2: Merkle proof verification for sender
// Verify that sender account exists at sender_idx in old_root
// (Merkle path verification constraints)
// Constraint 3: Merkle proof verification for recipient
// Verify that recipient account exists at recipient_idx in old_root
// Constraint 4: Balance check
// sender.balance >= amount
// Constraint 5: State transition
// new_sender_balance = old_sender_balance - amount
// new_recipient_balance = old_recipient_balance + amount
// Constraint 6: New root computation
// Verify that new_root is correct after state updates
// Note: Full implementation would include all Merkle proof verification
// and hash constraints. This is a structural placeholder.
// Dummy constraint to ensure variables are used
let _lc = LinearCombination::from_variable(old_root_var);
let _lc2 = LinearCombination::from_variable(new_root_var);
let _lc3 = LinearCombination::from_variable(sender_idx_var);
let _lc4 = LinearCombination::from_variable(recipient_idx_var);
let _lc5 = LinearCombination::from_variable(amount_var);
Ok(())
}
fn public_inputs(&self) -> Vec<ScalarField> {
vec![
field_from_hash(&self.old_root.0),
field_from_hash(&self.new_root.0),
]
}
}
/// Batch circuit: aggregates multiple transfers into a single proof.
#[derive(Clone, Debug)]
pub struct BatchCircuit {
config: CircuitConfig,
/// Initial state root
pub initial_root: StateRoot,
/// Final state root after all transactions
pub final_root: StateRoot,
/// Individual transfer circuits
pub transfers: Vec<TransferCircuit>,
}
impl BatchCircuit {
/// Creates a new batch circuit.
pub fn new(initial_root: StateRoot, final_root: StateRoot) -> Self {
Self {
config: CircuitConfig::default(),
initial_root,
final_root,
transfers: Vec::new(),
}
}
/// Adds a transfer to the batch.
pub fn add_transfer(&mut self, transfer: TransferCircuit) {
self.transfers.push(transfer);
}
/// Returns the number of transfers in the batch.
pub fn num_transfers(&self) -> usize {
self.transfers.len()
}
}
impl Circuit for BatchCircuit {
fn name(&self) -> &str {
"BatchCircuit"
}
fn config(&self) -> &CircuitConfig {
&self.config
}
fn synthesize<CS: ConstraintSystem>(&self, cs: &mut CS) -> Result<(), CircuitError> {
// Public inputs: initial_root, final_root, batch_hash
let initial_root_var = cs.alloc_input("initial_root", field_from_hash(&self.initial_root.0))?;
let final_root_var = cs.alloc_input("final_root", field_from_hash(&self.final_root.0))?;
// For each transfer, verify:
// 1. The intermediate state roots chain correctly
// 2. Each individual transfer is valid
// In a real implementation, we would:
// - Synthesize each transfer circuit
// - Chain the state roots: root_i+1 = update(root_i, transfer_i)
// - Verify initial_root -> transfer_1 -> ... -> transfer_n -> final_root
let _lc = LinearCombination::from_variable(initial_root_var);
let _lc2 = LinearCombination::from_variable(final_root_var);
Ok(())
}
fn public_inputs(&self) -> Vec<ScalarField> {
vec![
field_from_hash(&self.initial_root.0),
field_from_hash(&self.final_root.0),
]
}
}
/// Deposit circuit: validates an L1 -> L2 deposit.
#[derive(Clone, Debug)]
pub struct DepositCircuit {
config: CircuitConfig,
/// Previous state root
pub old_root: StateRoot,
/// New state root after deposit
pub new_root: StateRoot,
/// L1 deposit transaction hash
pub l1_tx_hash: [u8; 32],
/// Recipient account index on L2
pub recipient_idx: u64,
/// Deposit amount
pub amount: u64,
}
impl Circuit for DepositCircuit {
fn name(&self) -> &str {
"DepositCircuit"
}
fn config(&self) -> &CircuitConfig {
&self.config
}
fn synthesize<CS: ConstraintSystem>(&self, cs: &mut CS) -> Result<(), CircuitError> {
let old_root_var = cs.alloc_input("old_root", field_from_hash(&self.old_root.0))?;
let new_root_var = cs.alloc_input("new_root", field_from_hash(&self.new_root.0))?;
let l1_tx_hash_var = cs.alloc_input("l1_tx_hash", field_from_hash(&self.l1_tx_hash))?;
let _lc = LinearCombination::from_variable(old_root_var);
let _lc2 = LinearCombination::from_variable(new_root_var);
let _lc3 = LinearCombination::from_variable(l1_tx_hash_var);
Ok(())
}
fn public_inputs(&self) -> Vec<ScalarField> {
vec![
field_from_hash(&self.old_root.0),
field_from_hash(&self.new_root.0),
field_from_hash(&self.l1_tx_hash),
]
}
}
/// Withdrawal circuit: validates an L2 -> L1 withdrawal.
#[derive(Clone, Debug)]
pub struct WithdrawCircuit {
config: CircuitConfig,
/// Previous state root
pub old_root: StateRoot,
/// New state root after withdrawal
pub new_root: StateRoot,
/// Sender account index on L2
pub sender_idx: u64,
/// L1 recipient address
pub l1_recipient: [u8; 20],
/// Withdrawal amount
pub amount: u64,
}
impl Circuit for WithdrawCircuit {
fn name(&self) -> &str {
"WithdrawCircuit"
}
fn config(&self) -> &CircuitConfig {
&self.config
}
fn synthesize<CS: ConstraintSystem>(&self, cs: &mut CS) -> Result<(), CircuitError> {
let old_root_var = cs.alloc_input("old_root", field_from_hash(&self.old_root.0))?;
let new_root_var = cs.alloc_input("new_root", field_from_hash(&self.new_root.0))?;
let _lc = LinearCombination::from_variable(old_root_var);
let _lc2 = LinearCombination::from_variable(new_root_var);
Ok(())
}
fn public_inputs(&self) -> Vec<ScalarField> {
vec![
field_from_hash(&self.old_root.0),
field_from_hash(&self.new_root.0),
]
}
}
/// Converts a 32-byte hash to a scalar field element.
fn field_from_hash(hash: &[u8; 32]) -> ScalarField {
// Take first 31 bytes to ensure it fits in the field
let mut bytes = [0u8; 32];
bytes[..31].copy_from_slice(&hash[..31]);
ScalarField::from_le_bytes_mod_order(&bytes)
}
/// Circuit errors.
#[derive(Debug, Error)]
pub enum CircuitError {
#[error("Synthesis error: {0}")]
SynthesisError(String),
#[error("Invalid witness: {0}")]
InvalidWitness(String),
#[error("Constraint violation: {0}")]
ConstraintViolation(String),
#[error("Circuit configuration error: {0}")]
ConfigError(String),
}
impl From<SynthesisError> for CircuitError {
fn from(e: SynthesisError) -> Self {
CircuitError::SynthesisError(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_circuit_config_default() {
let config = CircuitConfig::default();
assert_eq!(config.max_batch_size, crate::constants::MAX_BATCH_SIZE);
assert_eq!(config.tree_depth, crate::constants::STATE_TREE_DEPTH);
assert!(config.verify_signatures);
}
#[test]
fn test_linear_combination() {
let var = Variable::new(1);
let lc = LinearCombination::from_variable(var);
assert_eq!(lc.terms().len(), 1);
assert_eq!(lc.terms()[0].1, var);
}
#[test]
fn test_transfer_circuit_creation() {
let old_root = StateRoot([0u8; 32]);
let new_root = StateRoot([1u8; 32]);
let circuit = TransferCircuit::new(old_root, new_root, 0, 1, 100);
assert_eq!(circuit.name(), "TransferCircuit");
assert_eq!(circuit.sender_idx, 0);
assert_eq!(circuit.recipient_idx, 1);
assert_eq!(circuit.amount, 100);
}
#[test]
fn test_batch_circuit() {
let initial_root = StateRoot([0u8; 32]);
let final_root = StateRoot([2u8; 32]);
let mut batch = BatchCircuit::new(initial_root, final_root);
assert_eq!(batch.num_transfers(), 0);
let transfer = TransferCircuit::new(
StateRoot([0u8; 32]),
StateRoot([1u8; 32]),
0,
1,
100,
);
batch.add_transfer(transfer);
assert_eq!(batch.num_transfers(), 1);
}
#[test]
fn test_field_from_hash() {
let hash = [0xffu8; 32];
let field = field_from_hash(&hash);
// Field element should be valid (not panic)
let _ = field;
}
}