use ark_bn254::{Bn254, Fr};
use ark_ff::PrimeField;
use ark_groth16::{Groth16, Proof, ProvingKey, VerifyingKey};
use ark_r1cs_std::{
alloc::AllocVar,
boolean::Boolean,
eq::EqGadget,
fields::fp::FpVar,
fields::FieldVar,
R1CSVar,
};
use ark_relations::r1cs::{ConstraintSynthesizer, ConstraintSystemRef, SynthesisError};
use ark_serialize::{CanonicalDeserialize, CanonicalSerialize};
use ark_snark::SNARK;
use ark_std::rand::{CryptoRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::ScemaDexError;
use crate::zkbackend::TracedMlp;
use crate::zkproof::{InferenceProof, SNARK_SOUNDNESS};
#[derive(Clone, Copy, Debug)]
pub struct SnarkConfig {
pub scale: f64,
pub acc_bits: usize,
}
impl Default for SnarkConfig {
fn default() -> Self {
Self { scale: 1024.0, acc_bits: 64 }
}
}
fn quantize_input(input: &[f64], scale: f64) -> Vec<i64> {
input.iter().map(|&x| (x * scale).round() as i64).collect()
}
type QuantizedModel = (Vec<Vec<Vec<i64>>>, Vec<Vec<i128>>, Vec<bool>, f64);
fn quantize_model(model: &TracedMlp, scale: f64) -> QuantizedModel {
let mut weights_q = Vec::with_capacity(model.layers.len());
let mut biases_q = Vec::with_capacity(model.layers.len());
let mut relu = Vec::with_capacity(model.layers.len());
let mut in_scale = scale; for layer in &model.layers {
let w: Vec<Vec<i64>> = layer
.weights
.iter()
.map(|row| row.iter().map(|&w| (w * scale).round() as i64).collect())
.collect();
let out_scale = scale * in_scale; let b: Vec<i128> = layer.biases.iter().map(|&bb| (bb * out_scale).round() as i128).collect();
weights_q.push(w);
biases_q.push(b);
relu.push(layer.relu);
in_scale = out_scale;
}
(weights_q, biases_q, relu, in_scale)
}
fn forward_i128(
weights_q: &[Vec<Vec<i64>>],
biases_q: &[Vec<i128>],
relu: &[bool],
input_q: &[i64],
) -> Vec<i128> {
let mut prev: Vec<i128> = input_q.iter().map(|&x| x as i128).collect();
for l in 0..weights_q.len() {
let mut acts = Vec::with_capacity(biases_q[l].len());
for o in 0..weights_q[l].len() {
let mut acc = biases_q[l][o];
for (i, &w) in weights_q[l][o].iter().enumerate() {
acc += w as i128 * prev[i];
}
if relu[l] && acc < 0 {
acc = 0;
}
acts.push(acc);
}
prev = acts;
}
prev
}
fn fr_from_i128(n: i128) -> Fr {
if n >= 0 {
Fr::from(n as u128)
} else {
-Fr::from((-n) as u128)
}
}
fn fr_to_u128(f: Fr) -> Option<u128> {
let limbs = f.into_bigint().0; if limbs[2] != 0 || limbs[3] != 0 {
return None;
}
Some((limbs[0] as u128) | ((limbs[1] as u128) << 64))
}
fn fr_to_i128(f: Fr) -> i128 {
if let Some(v) = fr_to_u128(f) {
return v as i128;
}
if let Some(v) = fr_to_u128(-f) {
return -(v as i128);
}
panic!("field element out of i128 range — quantisation bound exceeded");
}
fn enforce_range(
cs: ConstraintSystemRef<Fr>,
var: &FpVar<Fr>,
bits: usize,
) -> Result<(), SynthesisError> {
let val = var.value().ok().map(|f| fr_to_u128(f).expect("range value fits u128"));
let mut bit_vars = Vec::with_capacity(bits);
for i in 0..bits {
let b = val.map(|v| (v >> i) & 1 == 1);
bit_vars.push(Boolean::new_witness(cs.clone(), || {
b.ok_or(SynthesisError::AssignmentMissing)
})?);
}
let recon = Boolean::le_bits_to_fp_var(&bit_vars)?;
recon.enforce_equal(var)
}
fn relu_gadget(
cs: ConstraintSystemRef<Fr>,
acc: &FpVar<Fr>,
bits: usize,
) -> Result<FpVar<Fr>, SynthesisError> {
let (y_val, neg_val) = match acc.value() {
Ok(a) => {
let s = fr_to_i128(a);
(Some(fr_from_i128(s.max(0))), Some(fr_from_i128((-s).max(0))))
}
Err(_) => (None, None),
};
let y = FpVar::new_witness(cs.clone(), || y_val.ok_or(SynthesisError::AssignmentMissing))?;
let neg = FpVar::new_witness(cs.clone(), || neg_val.ok_or(SynthesisError::AssignmentMissing))?;
enforce_range(cs.clone(), &y, bits)?;
enforce_range(cs.clone(), &neg, bits)?;
acc.enforce_equal(&(&y - &neg))?;
(&y * &neg).enforce_equal(&FpVar::zero())?;
Ok(y)
}
#[derive(Clone)]
struct MlpCircuit {
weights_q: Vec<Vec<Vec<i64>>>,
biases_q: Vec<Vec<i128>>,
relu: Vec<bool>,
input_dim: usize,
acc_bits: usize,
input_q: Option<Vec<i64>>,
output_q: Option<Vec<i128>>,
}
impl ConstraintSynthesizer<Fr> for MlpCircuit {
fn generate_constraints(self, cs: ConstraintSystemRef<Fr>) -> Result<(), SynthesisError> {
let mut prev: Vec<FpVar<Fr>> = Vec::with_capacity(self.input_dim);
for i in 0..self.input_dim {
let x = FpVar::new_input(cs.clone(), || {
self.input_q
.as_ref()
.map(|v| fr_from_i128(v[i] as i128))
.ok_or(SynthesisError::AssignmentMissing)
})?;
prev.push(x);
}
for l in 0..self.weights_q.len() {
let mut acts = Vec::with_capacity(self.biases_q[l].len());
for o in 0..self.weights_q[l].len() {
let mut acc = FpVar::constant(fr_from_i128(self.biases_q[l][o]));
for (i, &w) in self.weights_q[l][o].iter().enumerate() {
acc += &FpVar::constant(fr_from_i128(w as i128)) * &prev[i];
}
if self.relu[l] {
acts.push(relu_gadget(cs.clone(), &acc, self.acc_bits)?);
} else {
acts.push(acc);
}
}
prev = acts;
}
for (o, act) in prev.iter().enumerate() {
let y = FpVar::new_input(cs.clone(), || {
self.output_q
.as_ref()
.map(|v| fr_from_i128(v[o]))
.ok_or(SynthesisError::AssignmentMissing)
})?;
act.enforce_equal(&y)?;
}
Ok(())
}
}
pub struct ProvenModel {
pk: ProvingKey<Bn254>,
vk_bytes: Vec<u8>,
weights_q: Vec<Vec<Vec<i64>>>,
biases_q: Vec<Vec<i128>>,
relu: Vec<bool>,
input_dim: usize,
acc_bits: usize,
scale: f64,
output_scale: f64,
}
impl ProvenModel {
pub fn setup<R: RngCore + CryptoRng>(
model: &TracedMlp,
cfg: SnarkConfig,
rng: &mut R,
) -> Result<Self, ScemaDexError> {
let (weights_q, biases_q, relu, output_scale) = quantize_model(model, cfg.scale);
let circuit = MlpCircuit {
weights_q: weights_q.clone(),
biases_q: biases_q.clone(),
relu: relu.clone(),
input_dim: model.input_dim,
acc_bits: cfg.acc_bits,
input_q: None,
output_q: None,
};
let (pk, vk) = Groth16::<Bn254>::circuit_specific_setup(circuit, rng)
.map_err(|e| ScemaDexError::Other(format!("groth16 setup: {e}")))?;
let mut vk_bytes = Vec::new();
vk.serialize_compressed(&mut vk_bytes)
.map_err(|e| ScemaDexError::Other(format!("vk serialize: {e}")))?;
Ok(Self {
pk,
vk_bytes,
weights_q,
biases_q,
relu,
input_dim: model.input_dim,
acc_bits: cfg.acc_bits,
scale: cfg.scale,
output_scale,
})
}
pub fn prove<R: RngCore + CryptoRng>(
&self,
input: &[f64],
rng: &mut R,
) -> Result<SnarkInferenceProof, ScemaDexError> {
if input.len() != self.input_dim {
return Err(ScemaDexError::Other(format!(
"input width {} != model {}",
input.len(),
self.input_dim
)));
}
let input_q = quantize_input(input, self.scale);
let output_i128 = forward_i128(&self.weights_q, &self.biases_q, &self.relu, &input_q);
let output_q: Vec<i64> = output_i128
.iter()
.map(|&y| {
i64::try_from(y)
.map_err(|_| ScemaDexError::Other("output exceeds i64 — lower scale".into()))
})
.collect::<Result<_, _>>()?;
let circuit = MlpCircuit {
weights_q: self.weights_q.clone(),
biases_q: self.biases_q.clone(),
relu: self.relu.clone(),
input_dim: self.input_dim,
acc_bits: self.acc_bits,
input_q: Some(input_q),
output_q: Some(output_i128),
};
let proof = Groth16::<Bn254>::prove(&self.pk, circuit, rng)
.map_err(|e| ScemaDexError::Other(format!("groth16 prove: {e}")))?;
let mut proof_bytes = Vec::new();
proof
.serialize_compressed(&mut proof_bytes)
.map_err(|e| ScemaDexError::Other(format!("proof serialize: {e}")))?;
let output: Vec<f64> = output_q.iter().map(|&y| y as f64 / self.output_scale).collect();
Ok(SnarkInferenceProof {
proof_bytes,
vk_bytes: self.vk_bytes.clone(),
scale: self.scale,
input_dim: self.input_dim,
output_q,
output,
})
}
pub fn model_id(&self) -> [u8; 32] {
Sha256::digest(&self.vk_bytes).into()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SnarkInferenceProof {
pub proof_bytes: Vec<u8>,
pub vk_bytes: Vec<u8>,
pub scale: f64,
pub input_dim: usize,
pub output_q: Vec<i64>,
pub output: Vec<f64>,
}
impl SnarkInferenceProof {
pub fn model_id(&self) -> [u8; 32] {
Sha256::digest(&self.vk_bytes).into()
}
fn verify_groth16(&self, input: &[f64]) -> bool {
if input.len() != self.input_dim {
return false;
}
let vk = match VerifyingKey::<Bn254>::deserialize_compressed(&self.vk_bytes[..]) {
Ok(v) => v,
Err(_) => return false,
};
let proof = match Proof::<Bn254>::deserialize_compressed(&self.proof_bytes[..]) {
Ok(p) => p,
Err(_) => return false,
};
let input_q = quantize_input(input, self.scale);
let mut public = Vec::with_capacity(self.input_dim + self.output_q.len());
for &x in &input_q {
public.push(fr_from_i128(x as i128));
}
for &y in &self.output_q {
public.push(fr_from_i128(y as i128));
}
Groth16::<Bn254>::verify(&vk, &public, &proof).unwrap_or(false)
}
}
impl InferenceProof for SnarkInferenceProof {
fn claimed_output(&self) -> &[f64] {
&self.output
}
fn soundness(&self) -> usize {
SNARK_SOUNDNESS
}
fn verify_inference(&self, input: &[f64], min_soundness: usize) -> bool {
min_soundness <= SNARK_SOUNDNESS && self.verify_groth16(input)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::zkbackend::DenseLayer;
use ark_std::rand::{rngs::StdRng, SeedableRng};
fn test_rng() -> StdRng {
StdRng::seed_from_u64(0x5CE_11A)
}
fn net() -> TracedMlp {
TracedMlp::new(
3,
vec![
DenseLayer::new(
vec![
vec![0.5, -0.2, 0.1],
vec![-0.3, 0.4, 0.2],
vec![0.1, 0.1, -0.5],
vec![0.2, -0.1, 0.3],
],
vec![0.05, -0.05, 0.0, 0.1],
true,
),
DenseLayer::new(
vec![vec![0.3, -0.2, 0.5, 0.1], vec![-0.1, 0.4, -0.3, 0.2]],
vec![0.0, 0.01],
false,
),
],
)
}
#[test]
fn honest_proof_verifies() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let input = [0.7, -1.2, 0.4];
let proof = pm.prove(&input, &mut rng).unwrap();
assert!(proof.verify_inference(&input, SNARK_SOUNDNESS));
assert_eq!(proof.output.len(), 2);
assert!(proof.proof_bytes.len() < 256);
}
#[test]
fn dequantised_output_tracks_a_float_forward_pass() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let input = [0.7, -1.2, 0.4];
let proof = pm.prove(&input, &mut rng).unwrap();
let m = net();
let mut prev = input.to_vec();
for layer in &m.layers {
let mut acts = Vec::new();
for (o, row) in layer.weights.iter().enumerate() {
let mut acc = layer.biases[o];
for (i, &w) in row.iter().enumerate() {
acc += w * prev[i];
}
if layer.relu && acc < 0.0 {
acc = 0.0;
}
acts.push(acc);
}
prev = acts;
}
for (got, want) in proof.output.iter().zip(prev.iter()) {
assert!((got - want).abs() < 1e-2, "snark {got} vs float {want}");
}
}
#[test]
fn wrong_input_is_rejected() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let proof = pm.prove(&[0.7, -1.2, 0.4], &mut rng).unwrap();
assert!(!proof.verify_inference(&[0.7, -1.2, 0.5], SNARK_SOUNDNESS));
}
#[test]
fn tampered_output_is_rejected() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let input = [0.7, -1.2, 0.4];
let mut proof = pm.prove(&input, &mut rng).unwrap();
proof.output_q[0] += 1; assert!(!proof.verify_inference(&input, SNARK_SOUNDNESS));
}
#[test]
fn soundness_floor_above_snark_is_refused() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let input = [0.7, -1.2, 0.4];
let proof = pm.prove(&input, &mut rng).unwrap();
assert!(proof.verify_inference(&input, SNARK_SOUNDNESS));
assert!(!proof.verify_inference(&input, SNARK_SOUNDNESS + 1));
}
#[test]
fn model_id_is_the_vk_hash_and_stable() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let input = [0.1, 0.2, 0.3];
let proof = pm.prove(&input, &mut rng).unwrap();
assert_eq!(pm.model_id(), proof.model_id());
}
#[test]
fn proof_is_usable_through_the_trait_object() {
let mut rng = test_rng();
let pm = ProvenModel::setup(&net(), SnarkConfig::default(), &mut rng).unwrap();
let input = [0.2, 0.9, -0.3];
let proof = pm.prove(&input, &mut rng).unwrap();
let dyn_proof: &dyn InferenceProof = &proof;
assert!(dyn_proof.verify_inference(&input, 64));
assert_eq!(dyn_proof.soundness(), SNARK_SOUNDNESS);
assert_eq!(dyn_proof.claimed_output().len(), 2);
}
}