1use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34use crate::bond::{Bond, BondOutcome};
35use crate::route::Fill;
36
37const FIXED_SCALE: f64 = 1_000_000.0;
41
42const 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
68fn 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
79fn 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
105pub trait CommittableModel {
110 fn parameters(&self) -> Vec<f64>;
112 fn infer(&self, input: &[f64]) -> Vec<f64>;
114 fn architecture_tag(&self) -> String;
117}
118
119#[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 let param_root = merkle_root(¶ms);
134 let root = sha256(&[DOMAIN_NODE, ¶m_root, arch.as_bytes()]);
135 Self {
136 root: to_hex(&root),
137 num_params: params.len(),
138 architecture: arch,
139 }
140 }
141}
142
143#[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 pub nonce: u64,
155}
156
157impl InferenceAttestation {
158 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 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
193pub fn verify_by_reexecution<M: CommittableModel + ?Sized>(
197 att: &InferenceAttestation,
198 model: &M,
199 input: &[f64],
200) -> bool {
201 let commitment = ModelCommitment::of(model);
203 if commitment != att.model {
204 return false;
205 }
206 if to_hex(&hash_vec(DOMAIN_INPUT, input)) != att.input_hash {
208 return false;
209 }
210 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 att.is_internally_consistent()
222}
223
224#[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 pub fn verify<M: CommittableModel + ?Sized>(&self, model: &M, input: &[f64]) -> bool {
240 verify_by_reexecution(&self.attestation, model, input)
241 }
242
243 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
262pub trait InferenceProofSystem {
267 type Proof;
269 fn prove<M: CommittableModel + ?Sized>(&self, model: &M, input: &[f64], nonce: u64) -> Self::Proof;
271 fn verify(&self, proof: &Self::Proof, expected: &InferenceAttestation) -> bool;
273}
274
275pub 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#[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 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 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] }; 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; 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 assert_eq!(vbond.settle_verified(&m, &input, &good_fill), BondOutcome::Honored);
442
443 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}