Skip to main content

octra_sqlite/protocol/
tx.rs

1use serde::Serialize;
2
3#[derive(Clone, PartialEq, Serialize)]
4pub struct Tx {
5    pub from: String,
6    pub to_: String,
7    pub amount: String,
8    pub nonce: i64,
9    pub ou: String,
10    pub timestamp: f64,
11    pub op_type: String,
12    #[serde(skip_serializing_if = "String::is_empty")]
13    pub encrypted_data: String,
14    #[serde(skip_serializing_if = "String::is_empty")]
15    pub message: String,
16    pub signature: String,
17    pub public_key: String,
18}
19
20pub fn canonical_tx(tx: &Tx) -> String {
21    let mut s = String::new();
22    s.push_str("{\"from\":\"");
23    s.push_str(&escape_json_string(&tx.from));
24    s.push_str("\",\"to_\":\"");
25    s.push_str(&escape_json_string(&tx.to_));
26    s.push_str("\",\"amount\":\"");
27    s.push_str(&escape_json_string(&tx.amount));
28    s.push_str("\",\"nonce\":");
29    s.push_str(&tx.nonce.to_string());
30    s.push_str(",\"ou\":\"");
31    s.push_str(&escape_json_string(&tx.ou));
32    s.push_str("\",\"timestamp\":");
33    s.push_str(&canonical_timestamp(tx.timestamp));
34    s.push_str(",\"op_type\":\"");
35    s.push_str(&escape_json_string(&tx.op_type));
36    s.push('"');
37    if !tx.encrypted_data.is_empty() {
38        s.push_str(",\"encrypted_data\":\"");
39        s.push_str(&escape_json_string(&tx.encrypted_data));
40        s.push('"');
41    }
42    if !tx.message.is_empty() {
43        s.push_str(",\"message\":\"");
44        s.push_str(&escape_json_string(&tx.message));
45        s.push('"');
46    }
47    s.push('}');
48    s
49}
50
51fn canonical_timestamp(value: f64) -> String {
52    let mut text = serde_json::to_string(&value).unwrap_or_else(|_| format!("{value}"));
53    if !text.contains('.') && !text.contains('e') && !text.contains('E') {
54        text.push_str(".0");
55    }
56    text
57}
58
59fn escape_json_string(value: &str) -> String {
60    value
61        .replace('\\', "\\\\")
62        .replace('"', "\\\"")
63        .replace('\u{0008}', "\\b")
64        .replace('\u{000c}', "\\f")
65        .replace('\n', "\\n")
66        .replace('\r', "\\r")
67        .replace('\t', "\\t")
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn canonical_tx_omits_empty_optional_fields() {
76        let tx = Tx {
77            from: "octA".into(),
78            to_: "octB".into(),
79            amount: "0".into(),
80            nonce: 7,
81            ou: "200000".into(),
82            timestamp: 1.0,
83            op_type: "deploy_circle".into(),
84            encrypted_data: String::new(),
85            message: "{\"runtime\":\"wasm_v1\",\"code_b64\":\"QUJD\"}".into(),
86            signature: String::new(),
87            public_key: String::new(),
88        };
89        let canonical = canonical_tx(&tx);
90        assert!(!canonical.contains("encrypted_data"));
91        assert!(canonical.contains("\"op_type\":\"deploy_circle\""));
92        assert!(canonical.contains("\\\"runtime\\\":\\\"wasm_v1\\\""));
93    }
94
95    #[test]
96    fn wire_tx_omits_empty_optional_fields() {
97        let tx = Tx {
98            from: "octA".into(),
99            to_: "octB".into(),
100            amount: "0".into(),
101            nonce: 7,
102            ou: "200000".into(),
103            timestamp: 1.0,
104            op_type: "deploy_circle".into(),
105            encrypted_data: String::new(),
106            message: "{\"runtime\":\"wasm_v1\",\"code_b64\":\"QUJD\"}".into(),
107            signature: "sig".into(),
108            public_key: "pub".into(),
109        };
110        let wire = serde_json::to_value(tx).unwrap();
111        assert!(wire.get("encrypted_data").is_none());
112        assert!(wire.get("message").is_some());
113    }
114
115    #[test]
116    fn canonical_tx_matches_field_order() {
117        let tx = Tx {
118            from: "octA".into(),
119            to_: "octB".into(),
120            amount: "0".into(),
121            nonce: 7,
122            ou: "1000".into(),
123            timestamp: 1.0,
124            op_type: "circle_call".into(),
125            encrypted_data: "exec".into(),
126            message: "[\"select 1;\"]".into(),
127            signature: String::new(),
128            public_key: String::new(),
129        };
130        assert_eq!(
131            canonical_tx(&tx),
132            "{\"from\":\"octA\",\"to_\":\"octB\",\"amount\":\"0\",\"nonce\":7,\"ou\":\"1000\",\"timestamp\":1.0,\"op_type\":\"circle_call\",\"encrypted_data\":\"exec\",\"message\":\"[\\\"select 1;\\\"]\"}"
133        );
134    }
135}