wickra-proof-core 0.1.0

Deterministic Proof-of-Backtest core: fold a (spec, data) pair into a wickra-backtest report and a canonical blake3 hash reproducible byte-for-byte across ten languages.
Documentation
//! Inline tests for wickra-proof-core: canonicalization vectors, prove/verify
//! round-trips, tamper detection, and the engine-version pin.

use crate::{
    canonicalize, hash_candles, hash_report, hash_value, prove, verify, Config, Error, Proof,
    ProofSpec, Prover,
};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use wickra_backtest_core::Candle;

/// A small, valid EMA-cross strategy (the shape wickra-backtest accepts).
fn strategy() -> Value {
    json!({
        "symbol": "BTCUSDT",
        "timeframe": "1h",
        "indicators": {
            "ema_fast": { "type": "Ema", "params": [5] },
            "ema_slow": { "type": "Ema", "params": [15] }
        },
        "entry": { "cross_above": ["ema_fast", "ema_slow"] },
        "exit": { "cross_below": ["ema_fast", "ema_slow"] },
        "sizing": { "type": "fixed_fraction", "fraction": 0.95 },
        "costs": { "taker_bps": 5, "slippage": { "type": "fixed_bps", "bps": 2 } },
        "risk": { "trailing_stop_pct": 5.0 }
    })
}

/// A deterministic oscillating candle series long enough to warm up EMA(15) and
/// produce at least one crossing.
fn candles() -> Vec<Candle> {
    (0..40)
        .map(|i| {
            let t = f64::from(i);
            let base = 100.0 + (t * 0.4).sin() * 8.0;
            Candle {
                time: 1_700_000_000 + i64::from(i) * 3600,
                open: base,
                high: base + 1.0,
                low: base - 1.0,
                close: base + 0.5,
                volume: 1000.0,
            }
        })
        .collect()
}

fn data() -> BTreeMap<String, Vec<Candle>> {
    let mut m = BTreeMap::new();
    m.insert("BTCUSDT".to_string(), candles());
    m
}

fn spec() -> ProofSpec {
    ProofSpec {
        strategy: strategy(),
        dataset_ref: "BTCUSDT/1h/test".to_string(),
        engine_version: None,
    }
}

#[test]
fn canonicalize_sorts_keys_and_strips_whitespace() {
    let v = json!({ "z": 1, "a": 2, "m": { "y": 3, "b": 4 } });
    assert_eq!(
        canonicalize(&v).unwrap(),
        "{\"a\":2,\"m\":{\"b\":4,\"y\":3},\"z\":1}"
    );
}

#[test]
fn canonicalize_rounds_floats_and_keeps_integers() {
    // A float is quantized to 1e-8; a whole value collapses to its integer token
    // (so `1.0` and `1` are indistinguishable across languages), a fractional one
    // keeps its digits.
    let v = json!({ "b": 1.000_000_000_4, "a": 2, "c": 1.5 });
    assert_eq!(canonicalize(&v).unwrap(), "{\"a\":2,\"b\":1,\"c\":1.5}");
}

#[test]
fn canonicalize_preserves_array_order() {
    let v = json!([3, 1, 2]);
    assert_eq!(canonicalize(&v).unwrap(), "[3,1,2]");
}

#[test]
fn canonicalize_is_deterministic() {
    let v = json!({ "report": [1.5, 2.5], "hash": "x" });
    assert_eq!(canonicalize(&v).unwrap(), canonicalize(&v).unwrap());
}

#[test]
fn prove_then_verify_is_true() {
    let s = spec();
    let d = data();
    let proof = prove(&s, &d).unwrap();
    assert_eq!(proof.report_hash.len(), 64);
    assert_eq!(proof.inputs_hash.len(), 64);
    assert_eq!(proof.engine_version, wickra_backtest_core::version());
    assert!(verify(&proof, &s, &d).unwrap());
}

#[test]
fn prove_is_reproducible() {
    let s = spec();
    let d = data();
    let a = prove(&s, &d).unwrap();
    let b = prove(&s, &d).unwrap();
    assert_eq!(a.report_hash, b.report_hash);
    assert_eq!(a.inputs_hash, b.inputs_hash);
}

#[test]
fn tampered_report_fails_verification() {
    let s = spec();
    let d = data();
    let genuine = prove(&s, &d).unwrap();
    let forged = Proof {
        report: genuine.report.clone(),
        // Swap in a plausible-looking but wrong hash.
        report_hash: "0".repeat(64),
        inputs_hash: genuine.inputs_hash.clone(),
        engine_version: genuine.engine_version.clone(),
    };
    assert!(!verify(&forged, &s, &d).unwrap());
}

#[test]
fn engine_version_mismatch_is_rejected() {
    let mut s = spec();
    s.engine_version = Some("9.9.9-nonexistent".to_string());
    match prove(&s, &data()) {
        Err(Error::EngineMismatch { expected, .. }) => assert_eq!(expected, "9.9.9-nonexistent"),
        other => panic!("expected EngineMismatch, got {other:?}"),
    }
}

#[test]
fn missing_symbol_data_is_reported() {
    let s = spec();
    let empty: BTreeMap<String, Vec<Candle>> = BTreeMap::new();
    assert!(matches!(prove(&s, &empty), Err(Error::Data(_))));
}

#[test]
fn bad_spec_is_rejected() {
    // A strategy that is not a JSON object fails validation.
    let json_str = r#"{"strategy": 42, "dataset_ref": "x"}"#;
    assert!(matches!(
        ProofSpec::from_json(json_str),
        Err(Error::BadSpec(_))
    ));
}

#[test]
fn config_roundtrips_from_json() {
    let cfg_json = json!({ "spec": spec() }).to_string();
    let cfg = Config::from_json(&cfg_json).unwrap();
    assert_eq!(cfg.spec, spec());
}

#[test]
fn command_json_prove_matches_direct_prove() {
    let s = spec();
    let d = data();
    let direct = prove(&s, &d).unwrap();
    let req = json!({ "cmd": "prove", "spec": s, "data": d }).to_string();
    let mut prover = Prover::new();
    let out = prover.command_json(&req).unwrap();
    let parsed: Proof = serde_json::from_str(&out).unwrap();
    assert_eq!(parsed.report_hash, direct.report_hash);
    assert_eq!(parsed.inputs_hash, direct.inputs_hash);
}

#[test]
fn command_json_verify_reports_valid() {
    let s = spec();
    let d = data();
    let proof = prove(&s, &d).unwrap();
    let req = json!({ "cmd": "verify", "proof": proof, "spec": s, "data": d }).to_string();
    let mut prover = Prover::new();
    let out = prover.command_json(&req).unwrap();
    let v: Value = serde_json::from_str(&out).unwrap();
    assert_eq!(v["ok"], json!(true));
    assert_eq!(v["valid"], json!(true));
}

#[test]
fn command_json_version_reports_both_versions() {
    let mut prover = Prover::new();
    let out = prover.command_json(r#"{"cmd":"version"}"#).unwrap();
    let v: Value = serde_json::from_str(&out).unwrap();
    assert_eq!(v["version"], json!(crate::version()));
    assert_eq!(v["engine_version"], json!(wickra_backtest_core::version()));
}

#[test]
fn command_json_canonicalize_exposes_the_string() {
    let mut prover = Prover::new();
    let out = prover
        .command_json(r#"{"cmd":"canonicalize","value":{"b":1,"a":2}}"#)
        .unwrap();
    let v: Value = serde_json::from_str(&out).unwrap();
    assert_eq!(v["ok"], json!(true));
    assert_eq!(v["canonical"], json!("{\"a\":2,\"b\":1}"));
}

#[test]
fn command_json_unknown_cmd_returns_error_envelope() {
    let mut prover = Prover::new();
    let out = prover.command_json(r#"{"cmd":"nope"}"#).unwrap();
    let v: Value = serde_json::from_str(&out).unwrap();
    assert_eq!(v["ok"], json!(false));
    assert!(v["error"].as_str().unwrap().contains("nope"));
}

#[test]
fn hash_report_equals_the_hash_prove_reports() {
    // The claim the zkVM guest and every binding rest on: recomputing the report
    // hash outside the prover lands on the same 64 hex characters the prover
    // published. `prove` calls `hash_report` rather than repeating it, and this
    // is what holds that arrangement in place.
    let report =
        wickra_backtest_core::run(&serde_json::from_value(strategy()).unwrap(), &candles())
            .unwrap();
    let proof = prove(&spec(), &data()).unwrap();
    assert_eq!(hash_report(&report).unwrap(), proof.report_hash);
}

#[test]
fn hash_value_is_the_hash_of_the_canonical_form() {
    // hash_value is blake3(canonicalize(v)) and nothing else, so two values that
    // canonicalize alike hash alike -- key order is not part of the identity.
    let a = json!({ "b": 1, "a": 2 });
    let b = json!({ "a": 2, "b": 1 });
    assert_eq!(hash_value(&a).unwrap(), hash_value(&b).unwrap());
    assert_eq!(hash_value(&a).unwrap().len(), 64);
}

#[test]
fn hash_candles_is_deterministic_and_order_sensitive() {
    let series = candles();
    assert_eq!(
        hash_candles(&series).unwrap(),
        hash_candles(&series).unwrap()
    );

    // Order is part of the commitment: a reordered series is a different series,
    // or the dataset commitment would not bind anything.
    let mut shuffled = series.clone();
    shuffled.reverse();
    assert_ne!(
        hash_candles(&series).unwrap(),
        hash_candles(&shuffled).unwrap()
    );
}

#[test]
fn hash_candles_differs_from_the_inputs_hash() {
    // `inputs_hash` covers {strategy, dataset_ref, candles, engine_version}.
    // The dataset commitment covers the candles alone. Conflating the two would
    // let a proof over one strategy pass as a commitment to the data.
    let proof = prove(&spec(), &data()).unwrap();
    assert_ne!(hash_candles(&candles()).unwrap(), proof.inputs_hash);
}

#[test]
fn an_edited_candle_changes_the_commitment() {
    let series = candles();
    let mut edited = series.clone();
    edited[7].close += 1e-6;
    assert_ne!(
        hash_candles(&series).unwrap(),
        hash_candles(&edited).unwrap()
    );
}