Skip to main content

scemadex_sdk/
zkbond.rs

1//! zkML-verified conviction bonds (Primitive I) — the capstone of the
2//! Adversarial Layer.
3//!
4//! A [`crate::bond::Bond`] makes a *paid black-box inference* trustworthy by
5//! giving the agent slashable skin in the game. But it still trusts the agent's
6//! word that the quote came from the model it claims. This module removes that
7//! last trust assumption: it binds a bond to a **cryptographic commitment that a
8//! specific model produced a specific inference**, so a lie is *provable* and the
9//! bond is slashed on proof — not on reputation.
10//!
11//! ## Trust model
12//!
13//! The Scematica Deep Q* net is pure-Rust and **deterministic** (no
14//! ML-framework nondeterminism), which is exactly what makes verifiable
15//! inference tractable here:
16//!
17//! 1. **Commit** — the model is committed to as a SHA-256 Merkle root over its
18//!    quantised parameters (`ModelCommitment`). Publishing the root fixes the
19//!    weights without revealing them up front.
20//! 2. **Attest** — for an inference, the agent publishes an
21//!    [`InferenceAttestation`] binding `model_root · hash(input) · output`.
22//! 3. **Verify** — any challenger holding the model + input **re-executes** the
23//!    forward pass and checks the binding. A mismatch is a fraud proof; the bond
24//!    slashes regardless of the realised fill ([`VerifiedBond::settle_verified`]).
25//!
26//! This is *optimistic* verification (trustless-by-refutation), the same shape as
27//! an optimistic rollup. [`InferenceProofSystem`] is the seam to drop in a
28//! **succinct** zk backend (risc0 / SP1 / halo2) later without changing the bond
29//! API: `ReexecutionProofSystem` is the reference implementation shipping today.
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34use crate::bond::{Bond, BondOutcome};
35use crate::route::Fill;
36
37/// Fixed-point scale for deterministic float→integer quantisation. Two f64s that
38/// agree to ~6 decimals commit to the same bytes, sidestepping float-formatting
39/// ambiguity across platforms.
40const FIXED_SCALE: f64 = 1_000_000.0;
41
42/// Domain-separation tags so leaf / node / input / output hashes can never collide.
43const DOMAIN_LEAF: &[u8] = b"scemadex.zkbond.v1.leaf";
44const DOMAIN_NODE: &[u8] = b"scemadex.zkbond.v1.node";
45const DOMAIN_INPUT: &[u8] = b"scemadex.zkbond.v1.input";
46const DOMAIN_OUTPUT: &[u8] = b"scemadex.zkbond.v1.output";
47
48fn quantise(x: f64) -> i64 {
49    (x * FIXED_SCALE).round() as i64
50}
51
52fn to_hex(bytes: &[u8]) -> String {
53    let mut s = String::with_capacity(bytes.len() * 2);
54    for b in bytes {
55        s.push_str(&format!("{b:02x}"));
56    }
57    s
58}
59
60fn sha256(parts: &[&[u8]]) -> [u8; 32] {
61    let mut h = Sha256::new();
62    for p in parts {
63        h.update(p);
64    }
65    h.finalize().into()
66}
67
68/// Hash a quantised vector under a domain tag (used for input & output binding).
69fn hash_vec(domain: &[u8], v: &[f64]) -> [u8; 32] {
70    let mut h = Sha256::new();
71    h.update(domain);
72    h.update((v.len() as u64).to_le_bytes());
73    for &x in v {
74        h.update(quantise(x).to_le_bytes());
75    }
76    h.finalize().into()
77}
78
79/// Merkle root over quantised parameters. Leaves are `H(LEAF · i · q(w_i))`;
80/// internal nodes are `H(NODE · left · right)`, duplicating the last node on odd
81/// levels. Empty models commit to `H(LEAF · "empty")`.
82fn merkle_root(params: &[f64]) -> [u8; 32] {
83    if params.is_empty() {
84        return sha256(&[DOMAIN_LEAF, b"empty"]);
85    }
86    let mut level: Vec<[u8; 32]> = params
87        .iter()
88        .enumerate()
89        .map(|(i, &w)| sha256(&[DOMAIN_LEAF, &(i as u64).to_le_bytes(), &quantise(w).to_le_bytes()]))
90        .collect();
91    while level.len() > 1 {
92        let mut next = Vec::with_capacity(level.len().div_ceil(2));
93        let mut i = 0;
94        while i < level.len() {
95            let left = level[i];
96            let right = if i + 1 < level.len() { level[i + 1] } else { level[i] };
97            next.push(sha256(&[DOMAIN_NODE, &left, &right]));
98            i += 2;
99        }
100        level = next;
101    }
102    level[0]
103}
104
105/// A model whose inference can be committed to and re-executed deterministically.
106///
107/// The Scematica agent implements this behind the `scematica` feature (see
108/// [`nn_models`]); tests and third-party models can implement it directly.
109pub trait CommittableModel {
110    /// Canonical, stable-ordered flattening of every parameter (weights + biases).
111    fn parameters(&self) -> Vec<f64>;
112    /// Deterministic forward pass producing the public output (e.g. Q-values).
113    fn infer(&self, input: &[f64]) -> Vec<f64>;
114    /// Architecture identifier bound into the commitment (guards against a model
115    /// with coincidentally-equal weights but a different shape).
116    fn architecture_tag(&self) -> String;
117}
118
119/// A commitment to a model: a Merkle root over its quantised parameters plus its
120/// architecture tag and parameter count.
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ModelCommitment {
123    pub root: String,
124    pub num_params: usize,
125    pub architecture: String,
126}
127
128impl ModelCommitment {
129    pub fn of<M: CommittableModel + ?Sized>(model: &M) -> Self {
130        let params = model.parameters();
131        let arch = model.architecture_tag();
132        // Fold the arch tag into the committed root so shape is bound too.
133        let param_root = merkle_root(&params);
134        let root = sha256(&[DOMAIN_NODE, &param_root, arch.as_bytes()]);
135        Self {
136            root: to_hex(&root),
137            num_params: params.len(),
138            architecture: arch,
139        }
140    }
141}
142
143/// A binding proof that the committed model produced `output` for a specific
144/// input. `commitment` = `H(OUTPUT · model_root · input_hash · q(output) · nonce)`.
145// No `Eq`: `output: Vec<f64>` isn't `Eq`. Equality is by the committed hash
146// fields anyway (see `PartialEq`), which is exact.
147#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
148pub struct InferenceAttestation {
149    pub model: ModelCommitment,
150    pub input_hash: String,
151    pub output: Vec<f64>,
152    pub commitment: String,
153    /// Anti-replay / freshness nonce (e.g. the intent digest folded to a u64).
154    pub nonce: u64,
155}
156
157impl InferenceAttestation {
158    /// Produce an attestation for `model.infer(input)`.
159    pub fn create<M: CommittableModel + ?Sized>(model: &M, input: &[f64], nonce: u64) -> Self {
160        let commitment = ModelCommitment::of(model);
161        let output = model.infer(input);
162        let input_hash = to_hex(&hash_vec(DOMAIN_INPUT, input));
163        let commitment_hash = Self::bind(&commitment.root, &input_hash, &output, nonce);
164        Self {
165            model: commitment,
166            input_hash,
167            output,
168            commitment: commitment_hash,
169            nonce,
170        }
171    }
172
173    fn bind(model_root: &str, input_hash: &str, output: &[f64], nonce: u64) -> String {
174        let out_hash = hash_vec(DOMAIN_OUTPUT, output);
175        to_hex(&sha256(&[
176            DOMAIN_OUTPUT,
177            model_root.as_bytes(),
178            input_hash.as_bytes(),
179            &out_hash,
180            &nonce.to_le_bytes(),
181        ]))
182    }
183
184    /// Recompute the binding from the attestation's own fields and check internal
185    /// consistency (cheap; does not prove the *model* — see
186    /// [`verify_by_reexecution`]).
187    pub fn is_internally_consistent(&self) -> bool {
188        let expected = Self::bind(&self.model.root, &self.input_hash, &self.output, self.nonce);
189        expected == self.commitment
190    }
191}
192
193/// The fraud proof: re-execute `model` on `input` and check the attestation binds
194/// to that exact `(model, input, output)`. Any honest party holding the model and
195/// input can call this to refute a false attestation.
196pub fn verify_by_reexecution<M: CommittableModel + ?Sized>(
197    att: &InferenceAttestation,
198    model: &M,
199    input: &[f64],
200) -> bool {
201    // 1. The committed model must be the one we hold.
202    let commitment = ModelCommitment::of(model);
203    if commitment != att.model {
204        return false;
205    }
206    // 2. The input must be the one attested.
207    if to_hex(&hash_vec(DOMAIN_INPUT, input)) != att.input_hash {
208        return false;
209    }
210    // 3. Re-execution must reproduce the attested output (quantised equality).
211    let recomputed = model.infer(input);
212    if recomputed.len() != att.output.len()
213        || recomputed
214            .iter()
215            .zip(&att.output)
216            .any(|(a, b)| quantise(*a) != quantise(*b))
217    {
218        return false;
219    }
220    // 4. And the published binding must be correct.
221    att.is_internally_consistent()
222}
223
224/// A conviction bond bound to a verifiable inference. Settlement honours the bond
225/// only if the inference is proven *and* the fill meets the guarantee; a
226/// fraudulent attestation is slashed regardless of the fill.
227#[derive(Clone, Debug, Serialize, Deserialize)]
228pub struct VerifiedBond {
229    pub bond: Bond,
230    pub attestation: InferenceAttestation,
231}
232
233impl VerifiedBond {
234    pub fn new(bond: Bond, attestation: InferenceAttestation) -> Self {
235        Self { bond, attestation }
236    }
237
238    /// Re-execute and verify the bound inference.
239    pub fn verify<M: CommittableModel + ?Sized>(&self, model: &M, input: &[f64]) -> bool {
240        verify_by_reexecution(&self.attestation, model, input)
241    }
242
243    /// Settle with verification: a proven-false inference slashes unconditionally;
244    /// otherwise the usual guaranteed-min-output rule applies.
245    pub fn settle_verified<M: CommittableModel + ?Sized>(
246        &self,
247        model: &M,
248        input: &[f64],
249        fill: &Fill,
250    ) -> BondOutcome {
251        if !self.verify(model, input) {
252            return BondOutcome::Slashed;
253        }
254        if fill.amount_out.raw >= self.bond.min_out_raw {
255            BondOutcome::Honored
256        } else {
257            BondOutcome::Slashed
258        }
259    }
260}
261
262/// Seam for pluggable inference-proof backends. The reference implementation
263/// ([`ReexecutionProofSystem`]) proves by deterministic re-execution; a future
264/// implementation can produce a succinct zk-SNARK over the same attestation
265/// without changing [`VerifiedBond`].
266pub trait InferenceProofSystem {
267    /// Opaque proof artefact.
268    type Proof;
269    /// Produce a proof that `model` yields its inference on `input`.
270    fn prove<M: CommittableModel + ?Sized>(&self, model: &M, input: &[f64], nonce: u64) -> Self::Proof;
271    /// Verify a proof binds to `expected`.
272    fn verify(&self, proof: &Self::Proof, expected: &InferenceAttestation) -> bool;
273}
274
275/// Reference proof system: the "proof" is the attestation itself, verified by
276/// re-execution at settlement time. Trustless-by-refutation, zero extra deps.
277pub struct ReexecutionProofSystem;
278
279impl InferenceProofSystem for ReexecutionProofSystem {
280    type Proof = InferenceAttestation;
281
282    fn prove<M: CommittableModel + ?Sized>(
283        &self,
284        model: &M,
285        input: &[f64],
286        nonce: u64,
287    ) -> Self::Proof {
288        InferenceAttestation::create(model, input, nonce)
289    }
290
291    fn verify(&self, proof: &Self::Proof, expected: &InferenceAttestation) -> bool {
292        proof == expected && proof.is_internally_consistent()
293    }
294}
295
296/// `CommittableModel` implementations for the real Scematica Deep Q* networks.
297/// Only compiled with the `scematica` feature.
298#[cfg(feature = "scematica")]
299#[cfg_attr(docsrs, doc(cfg(feature = "scematica")))]
300pub mod nn_models {
301    use super::CommittableModel;
302    use scematica_nn::network::QNetwork;
303    use scematica_nn::QuantileNetwork;
304
305    fn linear_params(l: &scematica_nn::network::Linear, out: &mut Vec<f64>) {
306        for row in &l.weights {
307            out.extend_from_slice(row);
308        }
309        out.extend_from_slice(&l.biases);
310    }
311
312    impl CommittableModel for QNetwork {
313        fn parameters(&self) -> Vec<f64> {
314            let mut p = Vec::new();
315            for l in &self.layers {
316                linear_params(l, &mut p);
317            }
318            if let Some(v) = &self.value_head {
319                linear_params(v, &mut p);
320            }
321            if let Some(a) = &self.advantage_head {
322                linear_params(a, &mut p);
323            }
324            p
325        }
326        fn infer(&self, input: &[f64]) -> Vec<f64> {
327            self.forward(input)
328        }
329        fn architecture_tag(&self) -> String {
330            format!("scematica-qnet:{:?}", self.layer_sizes)
331        }
332    }
333
334    impl CommittableModel for QuantileNetwork {
335        fn parameters(&self) -> Vec<f64> {
336            let mut p = Vec::new();
337            for l in &self.layers {
338                linear_params(l, &mut p);
339            }
340            linear_params(&self.value_head, &mut p);
341            linear_params(&self.advantage_head, &mut p);
342            p
343        }
344        fn infer(&self, input: &[f64]) -> Vec<f64> {
345            // Public output is the mean-of-quantiles Q-vector.
346            self.q_values(input)
347        }
348        fn architecture_tag(&self) -> String {
349            format!(
350                "scematica-qrdqn:{:?}:q{}:a{}",
351                self.layer_sizes, self.n_quantiles, self.action_dim
352            )
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::bond::Bond;
361    use crate::primitives::{Amount, Usdc};
362
363    /// A tiny deterministic model for exercising the commitment/attestation logic
364    /// without the `scematica` feature.
365    struct MockModel {
366        params: Vec<f64>,
367    }
368    impl CommittableModel for MockModel {
369        fn parameters(&self) -> Vec<f64> {
370            self.params.clone()
371        }
372        fn infer(&self, input: &[f64]) -> Vec<f64> {
373            let bias: f64 = self.params.iter().sum();
374            vec![input.iter().sum::<f64>() + bias, bias]
375        }
376        fn architecture_tag(&self) -> String {
377            "mock-1".into()
378        }
379    }
380
381    fn model() -> MockModel {
382        MockModel { params: vec![0.1, -0.2, 0.3, 0.4] }
383    }
384
385    #[test]
386    fn attestation_verifies_against_same_model_and_input() {
387        let m = model();
388        let input = [1.0, 2.0, 3.0];
389        let att = InferenceAttestation::create(&m, &input, 42);
390        assert!(att.is_internally_consistent());
391        assert!(verify_by_reexecution(&att, &m, &input));
392    }
393
394    #[test]
395    fn verify_fails_if_model_weights_differ() {
396        let att = InferenceAttestation::create(&model(), &[1.0, 2.0, 3.0], 1);
397        let tampered = MockModel { params: vec![0.1, -0.2, 0.3, 0.5] }; // last weight changed
398        assert!(!verify_by_reexecution(&att, &tampered, &[1.0, 2.0, 3.0]));
399    }
400
401    #[test]
402    fn verify_fails_if_input_differs() {
403        let m = model();
404        let att = InferenceAttestation::create(&m, &[1.0, 2.0, 3.0], 1);
405        assert!(!verify_by_reexecution(&att, &m, &[1.0, 2.0, 3.5]));
406    }
407
408    #[test]
409    fn verify_fails_if_output_is_tampered() {
410        let m = model();
411        let mut att = InferenceAttestation::create(&m, &[1.0, 2.0, 3.0], 1);
412        att.output[0] += 1.0; // claim a different inference
413        // Internal consistency now broken AND re-execution disagrees.
414        assert!(!att.is_internally_consistent());
415        assert!(!verify_by_reexecution(&att, &m, &[1.0, 2.0, 3.0]));
416    }
417
418    #[test]
419    fn model_commitment_is_deterministic() {
420        let a = ModelCommitment::of(&model());
421        let b = ModelCommitment::of(&model());
422        assert_eq!(a, b);
423        assert_eq!(a.num_params, 4);
424    }
425
426    #[test]
427    fn fraudulent_inference_slashes_regardless_of_fill() {
428        let m = model();
429        let input = [1.0, 2.0, 3.0];
430        let att = InferenceAttestation::create(&m, &input, 7);
431        let bond = Bond {
432            intent_digest: "d".into(),
433            amount: Usdc::from_usdc(5.0),
434            min_out_raw: 1_000_000,
435            deadline_unix: 0,
436        };
437        let vbond = VerifiedBond::new(bond, att);
438        let good_fill = Fill { amount_out: Amount::new(1_000_000, 6), executed_unix: 0 };
439
440        // Honest model + meeting fill → honored.
441        assert_eq!(vbond.settle_verified(&m, &input, &good_fill), BondOutcome::Honored);
442
443        // A different model can't satisfy the commitment → slashed even though the
444        // fill met the guarantee (the inference provenance is what failed).
445        let liar = MockModel { params: vec![9.9, 9.9, 9.9, 9.9] };
446        assert_eq!(vbond.settle_verified(&liar, &input, &good_fill), BondOutcome::Slashed);
447    }
448
449    #[test]
450    fn reexecution_proof_system_roundtrips() {
451        let m = model();
452        let input = [0.5, 0.5];
453        let ps = ReexecutionProofSystem;
454        let proof = ps.prove(&m, &input, 3);
455        let expected = InferenceAttestation::create(&m, &input, 3);
456        assert!(ps.verify(&proof, &expected));
457    }
458}