Skip to main content

wickra_proof_core/
proof.rs

1//! `prove` / `verify` and the [`Prover`] command-JSON handle.
2
3use crate::canonical::{canonicalize, hash_value};
4use crate::error::{Error, Result};
5use crate::spec::ProofSpec;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8use std::collections::BTreeMap;
9use wickra_backtest_core::{run, version as engine_version, Candle, StrategySpec};
10
11/// The proof: the full backtest report plus the two canonical hashes and the
12/// exact engine version that produced it.
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
14pub struct Proof {
15    /// The full deterministic backtest report, embedded as JSON.
16    pub report: Value,
17    /// blake3 hex of `canonicalize(inputs)` where
18    /// `inputs = {strategy, dataset_ref, candles, engine_version}`.
19    pub inputs_hash: String,
20    /// blake3 hex of `canonicalize(report)`.
21    pub report_hash: String,
22    /// The exact backtest engine version that produced `report`.
23    pub engine_version: String,
24}
25
26/// Fold `(spec, data)` into a deterministic report and its canonical hashes.
27pub fn prove(spec: &ProofSpec, data: &BTreeMap<String, Vec<Candle>>) -> Result<Proof> {
28    let linked = engine_version().to_string();
29    if let Some(expected) = &spec.engine_version {
30        if expected != &linked {
31            return Err(Error::EngineMismatch {
32                expected: expected.clone(),
33                linked,
34            });
35        }
36    }
37
38    let strategy: StrategySpec =
39        serde_json::from_value(spec.strategy.clone()).map_err(|e| Error::BadSpec(e.to_string()))?;
40    let candles = data
41        .get(&strategy.symbol)
42        .ok_or_else(|| Error::Data(format!("no candles for symbol {}", strategy.symbol)))?;
43
44    let report = run(&strategy, candles).map_err(|e| Error::Backtest(e.to_string()))?;
45    let report_value = serde_json::to_value(&report)?;
46    let report_hash = hash_value(&report_value)?;
47
48    let inputs = json!({
49        "strategy": spec.strategy,
50        "dataset_ref": spec.dataset_ref,
51        "candles": serde_json::to_value(data)?,
52        "engine_version": linked,
53    });
54    let inputs_hash = hash_value(&inputs)?;
55
56    Ok(Proof {
57        report: report_value,
58        inputs_hash,
59        report_hash,
60        engine_version: linked,
61    })
62}
63
64/// Verify a proof by recomputing it from `(spec, data)` and comparing the
65/// canonical hashes and engine version. This is recomputation, not blind trust
66/// of a supplied hash, so a forged `report`+`hash` cannot pass.
67pub fn verify(
68    proof: &Proof,
69    spec: &ProofSpec,
70    data: &BTreeMap<String, Vec<Candle>>,
71) -> Result<bool> {
72    let fresh = prove(spec, data)?;
73    Ok(fresh.report_hash == proof.report_hash
74        && fresh.inputs_hash == proof.inputs_hash
75        && fresh.engine_version == proof.engine_version)
76}
77
78/// Stateless command-JSON handle. It holds nothing, but is handle-shaped so the
79/// ten language bindings share the same surface as screener/terminal.
80#[derive(Debug, Default, Clone, Copy)]
81pub struct Prover;
82
83#[derive(Deserialize)]
84struct ProveReq {
85    spec: ProofSpec,
86    data: BTreeMap<String, Vec<Candle>>,
87}
88
89#[derive(Deserialize)]
90struct VerifyReq {
91    proof: Proof,
92    spec: ProofSpec,
93    data: BTreeMap<String, Vec<Candle>>,
94}
95
96impl Prover {
97    /// Create a new (stateless) handle.
98    #[must_use]
99    pub fn new() -> Self {
100        Prover
101    }
102
103    /// The wickra-proof-core crate version.
104    #[must_use]
105    pub fn version() -> &'static str {
106        env!("CARGO_PKG_VERSION")
107    }
108
109    /// Dispatch a command envelope `{"cmd": ...}` and return a canonical JSON
110    /// string. Unknown commands and errors return an error envelope, never a
111    /// panic.
112    pub fn command_json(&mut self, cmd_json: &str) -> Result<String> {
113        let value = dispatch(cmd_json);
114        canonicalize(&value)
115    }
116}
117
118fn dispatch(cmd_json: &str) -> Value {
119    match dispatch_inner(cmd_json) {
120        Ok(v) => v,
121        Err(e) => json!({ "ok": false, "error": e.to_string() }),
122    }
123}
124
125fn dispatch_inner(cmd_json: &str) -> Result<Value> {
126    let env: Value = serde_json::from_str(cmd_json)?;
127    let cmd = env.get("cmd").and_then(Value::as_str).unwrap_or("");
128    match cmd {
129        "prove" => {
130            let req: ProveReq = serde_json::from_value(env)?;
131            Ok(serde_json::to_value(prove(&req.spec, &req.data)?)?)
132        }
133        "verify" => {
134            let req: VerifyReq = serde_json::from_value(env)?;
135            let valid = verify(&req.proof, &req.spec, &req.data)?;
136            Ok(json!({ "ok": true, "valid": valid }))
137        }
138        "canonicalize" => {
139            let value = env.get("value").cloned().unwrap_or(Value::Null);
140            Ok(json!({ "ok": true, "canonical": canonicalize(&value)? }))
141        }
142        "version" => Ok(json!({
143            "version": Prover::version(),
144            "engine_version": engine_version(),
145        })),
146        other => Err(Error::Parse(format!("unknown cmd: {other}"))),
147    }
148}