Skip to main content

octra_sqlite/protocol/
tx.rs

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