Skip to main content

accumulate_client/codec/
canonical.rs

1//! Canonical JSON encoding for Accumulate protocol
2//!
3//! Provides deterministic JSON serialization with alphabetically ordered keys
4//! to ensure byte-for-byte compatibility with TypeScript SDK
5
6// Allow unwrap in this module - serialization of valid JSON values cannot fail
7#![allow(clippy::unwrap_used)]
8
9use serde_json::{Map, Value};
10use std::collections::BTreeMap;
11
12/// Canonical JSON encoder for Accumulate protocol
13#[derive(Debug, Clone, Copy)]
14pub struct CanonicalEncoder;
15
16impl CanonicalEncoder {
17    /// Encode a JSON value to canonical string format
18    pub fn encode(value: &Value) -> String {
19        Self::encode_value(value)
20    }
21
22    fn encode_value(value: &Value) -> String {
23        match value {
24            Value::Null => "null".to_string(),
25            Value::Bool(b) => b.to_string(),
26            Value::Number(n) => n.to_string(),
27            Value::String(s) => serde_json::to_string(s).unwrap(),
28            Value::Array(arr) => Self::encode_array(arr),
29            Value::Object(obj) => Self::encode_object(obj),
30        }
31    }
32
33    fn encode_array(arr: &[Value]) -> String {
34        let elements: Vec<String> = arr.iter().map(Self::encode_value).collect();
35        format!("[{}]", elements.join(","))
36    }
37
38    fn encode_object(obj: &Map<String, Value>) -> String {
39        // Sort keys alphabetically for deterministic output
40        let mut sorted_keys: Vec<&String> = obj.keys().collect();
41        sorted_keys.sort();
42
43        let pairs: Vec<String> = sorted_keys
44            .iter()
45            .map(|key| {
46                let key_json = serde_json::to_string(key).unwrap();
47                let value_json = Self::encode_value(obj.get(*key).unwrap());
48                format!("{}:{}", key_json, value_json)
49            })
50            .collect();
51
52        format!("{{{}}}", pairs.join(","))
53    }
54
55    /// Pre-process a JSON value to ensure all objects have sorted keys
56    pub fn canonicalize(value: &Value) -> Value {
57        match value {
58            Value::Object(map) => {
59                let mut btree: BTreeMap<String, Value> = BTreeMap::new();
60                for (k, v) in map {
61                    btree.insert(k.clone(), Self::canonicalize(v));
62                }
63                Value::Object(Map::from_iter(btree.into_iter()))
64            }
65            Value::Array(arr) => Value::Array(arr.iter().map(Self::canonicalize).collect()),
66            _ => value.clone(),
67        }
68    }
69}
70
71/// Convenience function for canonical JSON encoding
72pub fn to_canonical_string(value: &Value) -> String {
73    CanonicalEncoder::encode(value)
74}
75
76/// Convenience function for canonical JSON preprocessing
77pub fn canonicalize(value: &Value) -> Value {
78    CanonicalEncoder::canonicalize(value)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use serde_json::json;
85
86    #[test]
87    fn test_simple_object_ordering() {
88        let value = json!({ "z": 3, "a": 1, "m": 2 });
89        let canonical = to_canonical_string(&value);
90        assert_eq!(canonical, r#"{"a":1,"m":2,"z":3}"#);
91    }
92
93    #[test]
94    fn test_nested_object_ordering() {
95        let value = json!({
96            "outer": {
97                "z": "last",
98                "a": "first",
99                "m": "middle"
100            },
101            "simple": 42
102        });
103        let canonical = to_canonical_string(&value);
104        assert_eq!(
105            canonical,
106            r#"{"outer":{"a":"first","m":"middle","z":"last"},"simple":42}"#
107        );
108    }
109
110    #[test]
111    fn test_array_preservation() {
112        let value = json!({
113            "array": [
114                { "b": 2, "a": 1 },
115                { "d": 4, "c": 3 }
116            ]
117        });
118        let canonical = to_canonical_string(&value);
119        assert_eq!(canonical, r#"{"array":[{"a":1,"b":2},{"c":3,"d":4}]}"#);
120    }
121
122    #[test]
123    fn test_all_json_types() {
124        let value = json!({
125            "string": "hello",
126            "number": 42.5,
127            "integer": 123,
128            "boolean": true,
129            "null_value": null,
130            "array": [1, 2, 3],
131            "object": { "nested": "value" }
132        });
133
134        let canonical = to_canonical_string(&value);
135        let expected = r#"{"array":[1,2,3],"boolean":true,"integer":123,"null_value":null,"number":42.5,"object":{"nested":"value"},"string":"hello"}"#;
136        assert_eq!(canonical, expected);
137    }
138
139    #[test]
140    fn test_deep_nesting() {
141        let value = json!({
142            "level1": {
143                "z_last": {
144                    "z_deeply_nested": "value",
145                    "a_deeply_nested": "another"
146                },
147                "a_first": "simple"
148            }
149        });
150
151        let canonical = to_canonical_string(&value);
152        let expected = r#"{"level1":{"a_first":"simple","z_last":{"a_deeply_nested":"another","z_deeply_nested":"value"}}}"#;
153        assert_eq!(canonical, expected);
154    }
155
156    #[test]
157    fn test_empty_structures() {
158        let value = json!({
159            "empty_object": {},
160            "empty_array": [],
161            "filled": { "key": "value" }
162        });
163
164        let canonical = to_canonical_string(&value);
165        let expected = r#"{"empty_array":[],"empty_object":{},"filled":{"key":"value"}}"#;
166        assert_eq!(canonical, expected);
167    }
168
169    #[test]
170    fn test_unicode_strings() {
171        let value = json!({
172            "unicode": "Hello world",
173            "multibyte": "cafe resume",
174            "escape": "line1\nline2\ttab"
175        });
176
177        let canonical = to_canonical_string(&value);
178        // Note: serde_json escapes unicode and control characters
179        assert!(canonical.contains(r#""unicode":"Hello world""#));
180        assert!(canonical.contains(r#""multibyte":"cafe resume""#));
181        assert!(canonical.contains(r#""escape":"line1\nline2\ttab""#));
182    }
183}