openzeppelin_relayer/models/transaction/stellar/
memo.rs

1//! Memo types and conversions for Stellar transactions
2
3use std::convert::TryFrom;
4
5use serde::{Deserialize, Serialize};
6use soroban_rs::xdr::{Hash, Memo, StringM};
7use utoipa::ToSchema;
8
9use crate::models::SignerError;
10use crate::utils::{deserialize_u64, serialize_u64};
11
12#[derive(Debug, Clone, Serialize, PartialEq, Deserialize, ToSchema)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum MemoSpec {
15    None,
16    Text {
17        value: String,
18    }, // ≤ 28 UTF-8 bytes
19    Id {
20        /// Non-negative integer (memo ID) encoded as a decimal string to preserve precision.
21        // String serde keeps large integers precise through storage
22        // serialization; `value_type = String` syncs the OpenAPI schema.
23        // See `serialize_u64`.
24        #[serde(serialize_with = "serialize_u64", deserialize_with = "deserialize_u64")]
25        #[schema(value_type = String, pattern = "^[0-9]+$")]
26        value: u64,
27    },
28    Hash {
29        #[serde(with = "hex::serde")]
30        value: [u8; 32],
31    },
32    Return {
33        #[serde(with = "hex::serde")]
34        value: [u8; 32],
35    },
36}
37
38impl TryFrom<MemoSpec> for Memo {
39    type Error = SignerError;
40    fn try_from(m: MemoSpec) -> Result<Self, Self::Error> {
41        Ok(match m {
42            MemoSpec::None => Memo::None,
43            MemoSpec::Text { value } => {
44                let text = StringM::<28>::try_from(value.as_str())
45                    .map_err(|e| SignerError::ConversionError(format!("Invalid memo text: {e}")))?;
46                Memo::Text(text)
47            }
48            MemoSpec::Id { value } => Memo::Id(value),
49            MemoSpec::Hash { value } => Memo::Hash(Hash(value)),
50            MemoSpec::Return { value } => Memo::Return(Hash(value)),
51        })
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    // MemoSpec::Id.value is a u64 stored in Redis and re-encoded by the
60    // partial_update Lua script, so it must serialize as a cjson-safe string.
61    #[test]
62    fn test_memo_id_serializes_as_string() {
63        let memo = MemoSpec::Id {
64            value: 18446744073709551615,
65        };
66        let json = serde_json::to_string(&memo).unwrap();
67        assert!(
68            json.contains(r#""value":"18446744073709551615""#),
69            "memo id should serialize as a string, got: {json}"
70        );
71    }
72
73    #[test]
74    fn test_memo_id_deserializes_from_corrupted_float() {
75        let mut value = serde_json::to_value(MemoSpec::Id {
76            value: 1099511627776,
77        })
78        .unwrap();
79        value["value"] = serde_json::json!(1099511627776.0);
80        let json = serde_json::to_string(&value).unwrap();
81
82        let parsed: MemoSpec = serde_json::from_str(&json).unwrap();
83        match parsed {
84            MemoSpec::Id { value } => assert_eq!(value, 1099511627776),
85            other => panic!("expected Id, got {other:?}"),
86        }
87    }
88
89    #[test]
90    fn test_memo_none() {
91        let spec = MemoSpec::None;
92        let memo = Memo::try_from(spec).unwrap();
93        assert!(matches!(memo, Memo::None));
94    }
95
96    #[test]
97    fn test_memo_text() {
98        let spec = MemoSpec::Text {
99            value: "Hello World".to_string(),
100        };
101        let memo = Memo::try_from(spec).unwrap();
102        assert!(matches!(memo, Memo::Text(_)));
103    }
104
105    #[test]
106    fn test_memo_id() {
107        let spec = MemoSpec::Id { value: 12345 };
108        let memo = Memo::try_from(spec).unwrap();
109        assert!(matches!(memo, Memo::Id(12345)));
110    }
111
112    #[test]
113    fn test_memo_spec_serde() {
114        let spec = MemoSpec::Text {
115            value: "hello".to_string(),
116        };
117        let json = serde_json::to_string(&spec).unwrap();
118        assert!(json.contains("text"));
119        assert!(json.contains("hello"));
120
121        let deserialized: MemoSpec = serde_json::from_str(&json).unwrap();
122        assert_eq!(spec, deserialized);
123    }
124
125    #[test]
126    fn test_memo_spec_json_format() {
127        // Test None
128        let none = MemoSpec::None;
129        let none_json = serde_json::to_value(&none).unwrap();
130        assert_eq!(none_json, serde_json::json!({"type": "none"}));
131
132        // Test Text
133        let text = MemoSpec::Text {
134            value: "hello".to_string(),
135        };
136        let text_json = serde_json::to_value(&text).unwrap();
137        assert_eq!(
138            text_json,
139            serde_json::json!({"type": "text", "value": "hello"})
140        );
141
142        // Test Id — value is serialized as a string so large memo IDs survive
143        // the Redis/Lua cjson round-trip (deserialization still accepts the
144        // legacy integer form for backward compatibility).
145        let id = MemoSpec::Id { value: 12345 };
146        let id_json = serde_json::to_value(&id).unwrap();
147        assert_eq!(id_json, serde_json::json!({"type": "id", "value": "12345"}));
148
149        // Test Hash
150        let hash = MemoSpec::Hash { value: [0x42; 32] };
151        let hash_json = serde_json::to_value(&hash).unwrap();
152        assert_eq!(hash_json["type"], "hash");
153        assert!(hash_json["value"].is_string()); // hex encoded
154
155        // Test Return
156        let ret = MemoSpec::Return { value: [0x42; 32] };
157        let ret_json = serde_json::to_value(&ret).unwrap();
158        assert_eq!(ret_json["type"], "return");
159        assert!(ret_json["value"].is_string()); // hex encoded
160    }
161}