Skip to main content

wickra_proof_core/
canonical.rs

1//! Deterministic JSON canonicalization — the single source of the hash's
2//! stability across all ten language bindings.
3//!
4//! The rules (each normative):
5//! 1. Object keys are sorted ascending by Unicode code point (`BTreeMap`).
6//! 2. No whitespace: `,` and `:` separators, nothing else.
7//! 3. Every float is quantized to 1e-8 by decimal rounding (`{:.8}`), trailing
8//!    zeros trimmed. A whole-valued float collapses to its integer token
9//!    (`1.0` -> `"1"`), because JSON in most host languages (JavaScript above
10//!    all) cannot preserve the `.0`: `JSON.stringify` emits `1`, and the hash
11//!    must be byte-identical regardless of which language loaded the spec.
12//!    Integers stay integers by the same token. Magnitudes at or above
13//!    `2^52 * 1e-8` (where the f64 ULP reaches the grid) instead use the
14//!    shortest round-trippable form, keeping canonicalization a fixed point.
15//! 4. `NaN` / `±inf` cannot occur: `serde_json` rejects them at parse time, so
16//!    every `Value` number is a finite integer or float by construction.
17//! 5. Strings use `serde_json`'s standard escaping.
18//! 6. Array order is preserved (it is meaning-bearing).
19
20use crate::error::Result;
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::fmt::Write as _;
24use wickra_backtest_core::{BacktestReport, Candle};
25
26/// Format a float in a fixed, cross-language-stable, idempotent decimal form:
27/// eight fractional digits, trailing zeros trimmed, and a whole value collapsed
28/// to its bare integer token (`1.0` -> `"1"`). The integer collapse is essential:
29/// a host language cannot distinguish `1.0` from `1` in JSON — `JSON.stringify`
30/// emits `1` — so the only representation every language can reproduce for a
31/// whole number is the integer one. Negative zero normalizes to `0`.
32///
33/// Quantization to 1e-8 is done purely by the `{:.8}` decimal rounding — no
34/// separate binary-grid step. That matters for the moat's load-bearing property:
35/// canonicalization must be a fixed point (canonicalize -> parse -> canonicalize
36/// yields the same bytes). Rounding to eight decimals is a fixed point only while
37/// the 1e-8 grid is coarser than the f64 ULP, i.e. `|x| < 2^52 * 1e-8`; a binary
38/// `(x*1e8).round()/1e8` grid disagrees with the decimal one right at that
39/// boundary and used to drift (found by the canonicalize fuzz target on inputs
40/// like `44447444.444...` and `5e55`).
41///
42/// At or above that magnitude the 1e-8 grid is finer than f64 can represent, so
43/// `{:.8}` is meaningless and unstable; emit the shortest round-trippable form
44/// (`Display`, always positional for f64, never scientific) instead, which
45/// re-parses to the same value under `serde_json`'s `float_roundtrip` parser.
46fn format_f64(x: f64) -> String {
47    // 2^52 * 1e-8: the magnitude at which the f64 ULP first reaches the 1e-8
48    // grid. Below it, decimal rounding to eight places is a fixed point.
49    const GRID_RESOLUTION_LIMIT: f64 = 45_035_996.273_704_96;
50    let x = if x == 0.0 { 0.0 } else { x };
51    if x.abs() >= GRID_RESOLUTION_LIMIT {
52        return format!("{x}");
53    }
54    let mut s = format!("{x:.8}");
55    if s.contains('.') {
56        while s.ends_with('0') {
57            s.pop();
58        }
59        if s.ends_with('.') {
60            s.pop();
61        }
62    }
63    // A small negative that rounds to zero at eight decimals formats as
64    // `-0.00000000`, which trims to `-0`. Re-parsing `-0` yields `-0.0`, which
65    // the `x == 0.0` guard above collapses to `0` — so the signed form would
66    // break idempotence. Drop the sign whenever the rounded value is zero.
67    if s == "-0" {
68        s.clear();
69        s.push('0');
70    }
71    s
72}
73
74fn write_number(out: &mut String, n: &serde_json::Number) {
75    if let Some(i) = n.as_i64() {
76        write!(out, "{i}").expect("writing to a String is infallible");
77    } else if let Some(u) = n.as_u64() {
78        write!(out, "{u}").expect("writing to a String is infallible");
79    } else {
80        // A JSON number that is neither i64 nor u64 is a finite f64: serde_json
81        // rejects NaN/inf at parse time and never yields an unrepresentable
82        // number here.
83        let f = n.as_f64().unwrap_or(0.0);
84        out.push_str(&format_f64(f));
85    }
86}
87
88fn write_string(out: &mut String, s: &str) {
89    // serde_json's string serializer is the reference escaping; reuse it.
90    let encoded = Value::String(s.to_string()).to_string();
91    out.push_str(&encoded);
92}
93
94fn write_value(out: &mut String, value: &Value) {
95    match value {
96        Value::Null => out.push_str("null"),
97        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
98        Value::Number(n) => write_number(out, n),
99        Value::String(s) => write_string(out, s),
100        Value::Array(items) => {
101            out.push('[');
102            for (i, item) in items.iter().enumerate() {
103                if i > 0 {
104                    out.push(',');
105                }
106                write_value(out, item);
107            }
108            out.push(']');
109        }
110        Value::Object(map) => {
111            let sorted: BTreeMap<&String, &Value> = map.iter().collect();
112            out.push('{');
113            for (i, (key, val)) in sorted.iter().enumerate() {
114                if i > 0 {
115                    out.push(',');
116                }
117                write_string(out, key);
118                out.push(':');
119                write_value(out, val);
120            }
121            out.push('}');
122        }
123    }
124}
125
126/// Produce the canonical, whitespace-free, key-sorted string form of `value`.
127/// Byte-identical across languages; the input to the blake3 hash.
128///
129/// The `Result` return keeps a uniform signature with the rest of the API and
130/// the language bindings; canonicalization of a `serde_json::Value` is total.
131pub fn canonicalize(value: &Value) -> Result<String> {
132    let mut out = String::new();
133    write_value(&mut out, value);
134    Ok(out)
135}
136
137/// The lowercase 64-hex blake3 of a canonical string (no prefix).
138pub(crate) fn blake3_hex(canonical: &str) -> String {
139    blake3::hash(canonical.as_bytes()).to_hex().to_string()
140}
141
142/// The canonical hash of any JSON value: the blake3 of its canonical form.
143///
144/// This is the crate's only definition of "the hash". [`prove`](crate::prove)
145/// reports both of its hashes through it, and [`hash_candles`] and
146/// [`hash_report`] are it applied to the two shapes a caller outside the prover
147/// needs. Anything that recomputes a hash independently -- a zkVM guest, a
148/// language binding -- has to agree with this function, or the proof it
149/// produces says nothing.
150///
151/// # Errors
152/// Propagates a canonicalization failure. Canonicalizing a `Value` is total, so
153/// the variant exists for signature uniformity with the rest of the API.
154pub fn hash_value(value: &Value) -> Result<String> {
155    Ok(blake3_hex(&canonicalize(value)?))
156}
157
158/// The canonical hash of a candle series -- the dataset commitment.
159///
160/// Binds a proof to the data it was computed over: the same candles in the same
161/// order produce the same 64-hex string in every language, so a verifier can
162/// tell whether two proofs used the same series without being shown it.
163///
164/// Note what this is *not*: [`prove`](crate::prove) hashes
165/// `{strategy, dataset_ref, candles, engine_version}` as one block into
166/// `inputs_hash`. This hashes the candles alone.
167///
168/// # Errors
169/// Returns [`Error`](crate::Error) if the candles cannot be represented as JSON.
170pub fn hash_candles(candles: &[Candle]) -> Result<String> {
171    hash_value(&serde_json::to_value(candles)?)
172}
173
174/// The canonical hash of a backtest report.
175///
176/// Byte-identical to the `report_hash` that [`prove`](crate::prove) reports for
177/// the same report -- `prove` calls this rather than repeating it, so the two
178/// cannot drift apart.
179///
180/// # Errors
181/// Returns [`Error`](crate::Error) if the report cannot be represented as JSON.
182pub fn hash_report(report: &BacktestReport) -> Result<String> {
183    hash_value(&serde_json::to_value(report)?)
184}