Skip to main content

mx_proto/
json.rs

1use std::{fmt, string::FromUtf8Error};
2
3use base64::Engine;
4use bech32::{Bech32, Hrp};
5use num_bigint::{BigInt, BigUint, Sign};
6use prost::bytes::Bytes;
7use serde::ser::{SerializeMap, Serializer as _};
8use serde::{Deserialize, Serialize};
9
10use crate::generated::proto::Transaction as ProtoTransaction;
11
12const ERD_HRP: Hrp = Hrp::parse_unchecked("erd");
13
14/// JSON transaction shape used by gateway and signing APIs.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct Transaction {
18    /// The sender's nonce (transaction counter).
19    pub nonce: u64,
20    /// The value to transfer in atomic units (EGLD wei).
21    pub value: String,
22    /// The receiver's bech32 address.
23    pub receiver: String,
24    /// The sender's bech32 address.
25    pub sender: String,
26    /// Optional base64-encoded sender username.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub sender_username: Option<String>,
29    /// Optional base64-encoded receiver username.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub receiver_username: Option<String>,
32    /// Gas price in atomic units.
33    #[serde(rename = "gasPrice")]
34    pub gas_price: u64,
35    /// Maximum gas units to consume.
36    #[serde(rename = "gasLimit")]
37    pub gas_limit: u64,
38    /// Optional transaction data/payload (base64 or hex encoded on input, base64 on output).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub data: Option<String>,
41    /// Chain identifier (e.g., "1" for mainnet, "D" for devnet).
42    #[serde(rename = "chainID")]
43    pub chain_id: String,
44    /// Transaction version (typically 1 or 2).
45    pub version: u32,
46    /// Optional transaction options flags.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub options: Option<u32>,
49    /// Optional guardian address.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub guardian: Option<String>,
52    /// Optional guardian signature (hex-encoded).
53    #[serde(
54        rename = "guardianSignature",
55        default,
56        skip_serializing_if = "Option::is_none"
57    )]
58    pub guardian_signature: Option<String>,
59    /// Optional sender signature (hex-encoded).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub signature: Option<String>,
62    /// Optional relayer address.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub relayer: Option<String>,
65    /// Optional relayer signature (hex-encoded).
66    #[serde(
67        rename = "relayerSignature",
68        default,
69        skip_serializing_if = "Option::is_none"
70    )]
71    pub relayer_signature: Option<String>,
72}
73
74/// Errors returned when converting between JSON and protobuf transaction forms.
75#[derive(Debug)]
76pub enum ConversionError {
77    InvalidBech32(String),
78    InvalidAddressLength(usize),
79    InvalidNumeric(String),
80    InvalidHex(String),
81    InvalidBase64(String),
82    InvalidUtf8(FromUtf8Error),
83    InvalidBigIntEncoding(String),
84    Serialization(String),
85    Bech32Encode(bech32::EncodeError),
86}
87
88impl fmt::Display for ConversionError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::InvalidBech32(err) => write!(f, "invalid bech32 address: {err}"),
92            Self::InvalidAddressLength(len) => {
93                write!(f, "invalid address length: expected 32 bytes, got {len}")
94            }
95            Self::InvalidNumeric(value) => write!(f, "invalid numeric value: {value}"),
96            Self::InvalidHex(err) => write!(f, "invalid hex: {err}"),
97            Self::InvalidBase64(err) => write!(f, "invalid base64: {err}"),
98            Self::InvalidUtf8(err) => write!(f, "invalid utf-8: {err}"),
99            Self::InvalidBigIntEncoding(err) => write!(f, "invalid BigIntCaster encoding: {err}"),
100            Self::Serialization(err) => write!(f, "serialization failed: {err}"),
101            Self::Bech32Encode(err) => write!(f, "bech32 encode failed: {err}"),
102        }
103    }
104}
105
106impl std::error::Error for ConversionError {}
107
108impl From<FromUtf8Error> for ConversionError {
109    fn from(value: FromUtf8Error) -> Self {
110        Self::InvalidUtf8(value)
111    }
112}
113
114impl From<bech32::EncodeError> for ConversionError {
115    fn from(value: bech32::EncodeError) -> Self {
116        Self::Bech32Encode(value)
117    }
118}
119
120impl TryFrom<&Transaction> for ProtoTransaction {
121    type Error = ConversionError;
122
123    fn try_from(tx: &Transaction) -> Result<Self, Self::Error> {
124        Ok(Self {
125            nonce: tx.nonce,
126            value: parse_big_uint(&tx.value)?,
127            rcv_addr: decode_bech32(&tx.receiver)?,
128            rcv_user_name: decode_optional_base64(tx.receiver_username.as_deref())?,
129            snd_addr: decode_bech32(&tx.sender)?,
130            snd_user_name: decode_optional_base64(tx.sender_username.as_deref())?,
131            gas_price: tx.gas_price,
132            gas_limit: tx.gas_limit,
133            data: decode_data_field(tx.data.as_deref())?,
134            chain_id: Bytes::copy_from_slice(tx.chain_id.as_bytes()),
135            version: tx.version,
136            signature: decode_optional_hex(tx.signature.as_deref())?,
137            options: tx.options.unwrap_or_default(),
138            guardian_addr: decode_optional_bech32(tx.guardian.as_deref())?,
139            guardian_signature: decode_optional_hex(tx.guardian_signature.as_deref())?,
140            relayer_addr: decode_optional_bech32(tx.relayer.as_deref())?,
141            relayer_signature: decode_optional_hex(tx.relayer_signature.as_deref())?,
142        })
143    }
144}
145
146impl TryFrom<Transaction> for ProtoTransaction {
147    type Error = ConversionError;
148
149    fn try_from(tx: Transaction) -> Result<Self, Self::Error> {
150        Self::try_from(&tx)
151    }
152}
153
154impl Transaction {
155    /// Serializes the transaction into the canonical JSON payload used for signing.
156    pub fn signing_bytes(&self) -> Result<Vec<u8>, ConversionError> {
157        let data_len = self.data.as_ref().map_or(0, String::len);
158        let mut buf = Vec::with_capacity(256 + data_len);
159        let mut serializer = serde_json::Serializer::new(&mut buf);
160        let mut map = serializer.serialize_map(None).map_err(serialize_err)?;
161
162        map.serialize_entry("nonce", &self.nonce)
163            .map_err(serialize_err)?;
164        map.serialize_entry("value", &self.value)
165            .map_err(serialize_err)?;
166        map.serialize_entry("receiver", &self.receiver)
167            .map_err(serialize_err)?;
168        map.serialize_entry("sender", &self.sender)
169            .map_err(serialize_err)?;
170
171        if let Some(sender_username) = &self.sender_username
172            && !sender_username.is_empty()
173        {
174            map.serialize_entry("senderUsername", sender_username)
175                .map_err(serialize_err)?;
176        }
177        if let Some(receiver_username) = &self.receiver_username
178            && !receiver_username.is_empty()
179        {
180            map.serialize_entry("receiverUsername", receiver_username)
181                .map_err(serialize_err)?;
182        }
183
184        map.serialize_entry("gasPrice", &self.gas_price)
185            .map_err(serialize_err)?;
186        map.serialize_entry("gasLimit", &self.gas_limit)
187            .map_err(serialize_err)?;
188        if let Some(data) = &self.data {
189            map.serialize_entry("data", data).map_err(serialize_err)?;
190        }
191        map.serialize_entry("chainID", &self.chain_id)
192            .map_err(serialize_err)?;
193        map.serialize_entry("version", &self.version)
194            .map_err(serialize_err)?;
195        if let Some(options) = &self.options {
196            map.serialize_entry("options", options)
197                .map_err(serialize_err)?;
198        }
199        if let Some(guardian) = &self.guardian {
200            map.serialize_entry("guardian", guardian)
201                .map_err(serialize_err)?;
202        }
203        if let Some(relayer) = &self.relayer {
204            map.serialize_entry("relayer", relayer)
205                .map_err(serialize_err)?;
206        }
207        map.end().map_err(serialize_err)?;
208
209        Ok(buf)
210    }
211}
212
213impl TryFrom<&ProtoTransaction> for Transaction {
214    type Error = ConversionError;
215
216    fn try_from(tx: &ProtoTransaction) -> Result<Self, Self::Error> {
217        let value = decode_big_int_caster(&tx.value)?
218            .map_or_else(|| "0".to_owned(), |value| value.to_str_radix(10));
219
220        Ok(Self {
221            nonce: tx.nonce,
222            value,
223            receiver: encode_required_bech32(&tx.rcv_addr)?,
224            sender: encode_required_bech32(&tx.snd_addr)?,
225            sender_username: encode_optional_base64(&tx.snd_user_name),
226            receiver_username: encode_optional_base64(&tx.rcv_user_name),
227            gas_price: tx.gas_price,
228            gas_limit: tx.gas_limit,
229            data: encode_optional_base64(&tx.data),
230            chain_id: String::from_utf8(tx.chain_id.to_vec())?,
231            version: tx.version,
232            options: (tx.options != 0).then_some(tx.options),
233            guardian: encode_optional_bech32(&tx.guardian_addr)?,
234            guardian_signature: encode_optional_hex(&tx.guardian_signature),
235            signature: encode_optional_hex(&tx.signature),
236            relayer: encode_optional_bech32(&tx.relayer_addr)?,
237            relayer_signature: encode_optional_hex(&tx.relayer_signature),
238        })
239    }
240}
241
242impl TryFrom<ProtoTransaction> for Transaction {
243    type Error = ConversionError;
244
245    fn try_from(tx: ProtoTransaction) -> Result<Self, Self::Error> {
246        Self::try_from(&tx)
247    }
248}
249
250impl ProtoTransaction {
251    /// Serializes a protobuf transaction into the canonical JSON payload used for signing.
252    pub fn signing_bytes(&self) -> Result<Vec<u8>, ConversionError> {
253        Transaction::try_from(self)?.signing_bytes()
254    }
255}
256
257fn parse_big_uint(value: &str) -> Result<Bytes, ConversionError> {
258    let trimmed = value.trim();
259    let number = if let Some(hex_body) = trimmed.strip_prefix("0x") {
260        BigUint::parse_bytes(hex_body.as_bytes(), 16)
261    } else {
262        BigUint::parse_bytes(trimmed.as_bytes(), 10)
263    };
264    let num = number.ok_or_else(|| ConversionError::InvalidNumeric(value.to_owned()))?;
265    Ok(encode_big_int_caster(&BigInt::from_biguint(
266        Sign::Plus,
267        num,
268    )))
269}
270
271fn encode_big_int_caster(value: &BigInt) -> Bytes {
272    let (sign, magnitude) = value.to_bytes_be();
273    if magnitude.is_empty() {
274        return Bytes::from_static(&[0, 0]);
275    }
276
277    let mut encoded = Vec::with_capacity(magnitude.len() + 1);
278    encoded.push(match sign {
279        Sign::Minus => 1,
280        Sign::NoSign | Sign::Plus => 0,
281    });
282    encoded.extend_from_slice(&magnitude);
283    Bytes::from(encoded)
284}
285
286fn decode_big_int_caster(bytes: &[u8]) -> Result<Option<BigInt>, ConversionError> {
287    match bytes.len() {
288        0 => Err(ConversionError::InvalidBigIntEncoding(
289            "empty buffer is not a valid BigIntCaster value".to_owned(),
290        )),
291        1 => {
292            if bytes[0] == 0 {
293                Ok(None)
294            } else {
295                Err(ConversionError::InvalidBigIntEncoding(format!(
296                    "single-byte encoding must be nil marker 0x00, got 0x{:02x}",
297                    bytes[0]
298                )))
299            }
300        }
301        _ => {
302            let magnitude = BigUint::from_bytes_be(&bytes[1..]);
303            let value = match bytes[0] {
304                0 => BigInt::from_biguint(Sign::Plus, magnitude),
305                1 => BigInt::from_biguint(Sign::Minus, magnitude),
306                sign => {
307                    return Err(ConversionError::InvalidBigIntEncoding(format!(
308                        "invalid sign byte 0x{sign:02x}"
309                    )));
310                }
311            };
312            Ok(Some(value))
313        }
314    }
315}
316
317fn decode_bech32(addr: &str) -> Result<Bytes, ConversionError> {
318    let (_hrp, raw) =
319        bech32::decode(addr).map_err(|e| ConversionError::InvalidBech32(e.to_string()))?;
320    if raw.len() != 32 {
321        return Err(ConversionError::InvalidAddressLength(raw.len()));
322    }
323    Ok(Bytes::from(raw))
324}
325
326fn decode_optional_bech32(addr: Option<&str>) -> Result<Bytes, ConversionError> {
327    match addr {
328        Some(a) if !a.trim().is_empty() => decode_bech32(a),
329        _ => Ok(Bytes::new()),
330    }
331}
332
333fn decode_optional_hex(value: Option<&str>) -> Result<Bytes, ConversionError> {
334    match value {
335        Some(s) if !s.trim().is_empty() => hex::decode(s.trim())
336            .map(Bytes::from)
337            .map_err(|e| ConversionError::InvalidHex(e.to_string())),
338        _ => Ok(Bytes::new()),
339    }
340}
341
342fn decode_optional_base64(value: Option<&str>) -> Result<Bytes, ConversionError> {
343    match value {
344        Some(s) if !s.trim().is_empty() => base64::engine::general_purpose::STANDARD
345            .decode(s.trim())
346            .map(Bytes::from)
347            .map_err(|e| ConversionError::InvalidBase64(e.to_string())),
348        _ => Ok(Bytes::new()),
349    }
350}
351
352fn decode_data_field(value: Option<&str>) -> Result<Bytes, ConversionError> {
353    match value {
354        Some(s) => match base64::engine::general_purpose::STANDARD.decode(s.trim()) {
355            Ok(bytes) => Ok(Bytes::from(bytes)),
356            Err(_) => hex::decode(s.trim())
357                .map(Bytes::from)
358                .map_err(|e| ConversionError::InvalidHex(e.to_string())),
359        },
360        None => Ok(Bytes::new()),
361    }
362}
363
364fn encode_required_bech32(bytes: &[u8]) -> Result<String, ConversionError> {
365    if bytes.len() != 32 {
366        return Err(ConversionError::InvalidAddressLength(bytes.len()));
367    }
368    Ok(bech32::encode::<Bech32>(ERD_HRP, bytes)?)
369}
370
371fn encode_optional_bech32(bytes: &[u8]) -> Result<Option<String>, ConversionError> {
372    if bytes.is_empty() {
373        return Ok(None);
374    }
375    encode_required_bech32(bytes).map(Some)
376}
377
378fn encode_optional_hex(bytes: &[u8]) -> Option<String> {
379    (!bytes.is_empty()).then(|| hex::encode(bytes))
380}
381
382fn encode_optional_base64(bytes: &[u8]) -> Option<String> {
383    (!bytes.is_empty()).then(|| base64::engine::general_purpose::STANDARD.encode(bytes))
384}
385
386fn serialize_err(err: serde_json::Error) -> ConversionError {
387    ConversionError::Serialization(err.to_string())
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn make_json_tx() -> Transaction {
395        Transaction {
396            nonce: 42,
397            value: "1000000000000000000".to_owned(),
398            receiver: "erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplllst77y4l".to_owned(),
399            sender: "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th".to_owned(),
400            sender_username: Some("c2VuZGVy".to_owned()),
401            receiver_username: Some("cmVjZWl2ZXI=".to_owned()),
402            gas_price: 1_000_000_000,
403            gas_limit: 50_000,
404            data: Some("dGVzdA==".to_owned()),
405            chain_id: "1".to_owned(),
406            version: 2,
407            options: Some(1),
408            guardian: None,
409            guardian_signature: None,
410            signature: Some("ab".repeat(64)),
411            relayer: None,
412            relayer_signature: None,
413        }
414    }
415
416    #[test]
417    fn json_to_proto_converts_fields() {
418        let tx = make_json_tx();
419        let proto = ProtoTransaction::try_from(&tx).unwrap();
420
421        assert_eq!(proto.nonce, 42);
422        assert_eq!(proto.gas_price, 1_000_000_000);
423        assert_eq!(proto.gas_limit, 50_000);
424        assert_eq!(proto.chain_id.as_ref(), b"1");
425        assert_eq!(proto.data.as_ref(), b"test");
426        assert_eq!(proto.snd_addr.len(), 32);
427        assert_eq!(proto.rcv_addr.len(), 32);
428        assert_eq!(proto.value[0], 0);
429    }
430
431    #[test]
432    fn proto_to_json_roundtrip() {
433        let tx = make_json_tx();
434        let proto = ProtoTransaction::try_from(&tx).unwrap();
435        let roundtrip = Transaction::try_from(&proto).unwrap();
436
437        assert_eq!(roundtrip.nonce, tx.nonce);
438        assert_eq!(roundtrip.value, tx.value);
439        assert_eq!(roundtrip.receiver, tx.receiver);
440        assert_eq!(roundtrip.sender, tx.sender);
441        assert_eq!(roundtrip.chain_id, tx.chain_id);
442        assert_eq!(roundtrip.data, Some("dGVzdA==".to_owned()));
443        assert_eq!(roundtrip.signature, tx.signature);
444    }
445
446    #[test]
447    fn proto_to_json_zero_value_maps_to_zero_string() {
448        let proto = ProtoTransaction {
449            value: Bytes::from_static(&[0, 0]),
450            chain_id: Bytes::from_static(b"1"),
451            rcv_addr: decode_bech32(
452                "erd1qqqqqqqqqqqqqqqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplllst77y4l",
453            )
454            .unwrap(),
455            snd_addr: decode_bech32(
456                "erd1qyu5wthldzr8wx5c9ucg8kjagg0jfs53s8nr3zpz3hypefsdd8ssycr6th",
457            )
458            .unwrap(),
459            ..Default::default()
460        };
461
462        let json = Transaction::try_from(&proto).unwrap();
463        assert_eq!(json.value, "0");
464    }
465
466    #[test]
467    fn signing_bytes_field_order_matches_protocol() {
468        let tx = make_json_tx();
469        let json_str = String::from_utf8(tx.signing_bytes().unwrap()).unwrap();
470
471        let fields: Vec<&str> = json_str
472            .trim_matches(|c| c == '{' || c == '}')
473            .split(',')
474            .map(|s| s.split(':').next().unwrap().trim().trim_matches('"'))
475            .collect();
476
477        assert_eq!(fields[0], "nonce");
478        assert_eq!(fields[1], "value");
479        assert_eq!(fields[2], "receiver");
480        assert_eq!(fields[3], "sender");
481        assert_eq!(fields[4], "senderUsername");
482        assert_eq!(fields[5], "receiverUsername");
483        assert_eq!(fields[6], "gasPrice");
484        assert_eq!(fields[7], "gasLimit");
485        assert_eq!(fields[8], "data");
486        assert_eq!(fields[9], "chainID");
487        assert_eq!(fields[10], "version");
488        assert_eq!(fields[11], "options");
489    }
490
491    #[test]
492    fn signing_bytes_omit_signatures_and_include_relayer_when_present() {
493        let mut tx = make_json_tx();
494        tx.guardian =
495            Some("erd1k2s324ww2g0yj38qn2ch2jwctdy8mnfxep94q9arncc6xecg3xaq6mjse8".to_owned());
496        tx.guardian_signature = Some("cd".repeat(64));
497        tx.relayer =
498            Some("erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7znyq426ca4qznv276".to_owned());
499        tx.relayer_signature = Some("ef".repeat(64));
500
501        let json_str = String::from_utf8(tx.signing_bytes().unwrap()).unwrap();
502
503        assert!(json_str.contains("\"relayer\":"));
504        assert!(json_str.contains("\"guardian\":"));
505        assert!(!json_str.contains("signature"));
506        assert!(!json_str.contains("guardianSignature"));
507        assert!(!json_str.contains("relayerSignature"));
508    }
509
510    #[test]
511    fn signing_bytes_omits_empty_usernames() {
512        let mut tx = make_json_tx();
513        tx.sender_username = Some(String::new());
514        tx.receiver_username = Some(String::new());
515        tx.relayer =
516            Some("erd1spyavw0956vq68xj8y4tenjpq2wd5a9p2c6j8gsz7znyq426ca4qznv276".to_owned());
517
518        let json_str = String::from_utf8(tx.signing_bytes().unwrap()).unwrap();
519
520        assert!(json_str.contains("\"relayer\":"));
521        assert!(!json_str.contains("senderUsername"));
522        assert!(!json_str.contains("receiverUsername"));
523    }
524
525    #[test]
526    fn proto_signing_bytes_match_json_signing_bytes() {
527        let tx = make_json_tx();
528        let proto = ProtoTransaction::try_from(&tx).unwrap();
529
530        assert_eq!(proto.signing_bytes().unwrap(), tx.signing_bytes().unwrap());
531    }
532}