1use crate::ProtocolError;
20
21pub const MEMO_SIZE: usize = 36;
23
24pub const MEMO_PAYLOAD_SIZE: usize = MEMO_SIZE - 4;
26
27const KIND_EMPTY: u32 = 0;
29const KIND_TEXT: u32 = 1;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ShieldedMemo {
41 Empty,
43 Text(String),
45 Other {
49 kind: u32,
51 payload: [u8; MEMO_PAYLOAD_SIZE],
53 },
54}
55
56impl ShieldedMemo {
57 pub fn text(s: impl Into<String>) -> Result<Self, ProtocolError> {
60 let s = s.into();
61 if s.len() > MEMO_PAYLOAD_SIZE {
62 return Err(ProtocolError::Generic(format!(
63 "shielded text memo is {} bytes, exceeds the {MEMO_PAYLOAD_SIZE}-byte limit",
64 s.len()
65 )));
66 }
67 Ok(ShieldedMemo::Text(s))
68 }
69
70 pub fn to_bytes(&self) -> [u8; MEMO_SIZE] {
72 let mut out = [0u8; MEMO_SIZE];
73 let (kind, payload): (u32, [u8; MEMO_PAYLOAD_SIZE]) = match self {
74 ShieldedMemo::Empty => (KIND_EMPTY, [0u8; MEMO_PAYLOAD_SIZE]),
75 ShieldedMemo::Text(s) => {
76 let mut payload = [0u8; MEMO_PAYLOAD_SIZE];
80 let bytes = s.as_bytes();
81 let n = bytes.len().min(MEMO_PAYLOAD_SIZE);
82 payload[..n].copy_from_slice(&bytes[..n]);
83 (KIND_TEXT, payload)
84 }
85 ShieldedMemo::Other { kind, payload } => (*kind, *payload),
86 };
87 out[0..4].copy_from_slice(&kind.to_le_bytes());
88 out[4..MEMO_SIZE].copy_from_slice(&payload);
89 out
90 }
91
92 pub fn from_bytes(bytes: &[u8; MEMO_SIZE]) -> Self {
98 let kind = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
99 let mut payload = [0u8; MEMO_PAYLOAD_SIZE];
100 payload.copy_from_slice(&bytes[4..MEMO_SIZE]);
101
102 match kind {
103 KIND_EMPTY => {
104 if payload == [0u8; MEMO_PAYLOAD_SIZE] {
105 ShieldedMemo::Empty
106 } else {
107 ShieldedMemo::Other { kind, payload }
110 }
111 }
112 KIND_TEXT => {
113 let end = payload.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
115 match std::str::from_utf8(&payload[..end]) {
116 Ok(s) => ShieldedMemo::Text(s.to_string()),
117 Err(_) => ShieldedMemo::Other { kind, payload },
118 }
119 }
120 _ => ShieldedMemo::Other { kind, payload },
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn empty_round_trips() {
131 let memo = ShieldedMemo::Empty;
132 let bytes = memo.to_bytes();
133 assert_eq!(bytes, [0u8; MEMO_SIZE], "empty memo must be all-zero bytes");
134 assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Empty);
135 }
136
137 #[test]
138 fn legacy_all_zero_memo_decodes_as_empty() {
139 assert_eq!(
141 ShieldedMemo::from_bytes(&[0u8; MEMO_SIZE]),
142 ShieldedMemo::Empty
143 );
144 }
145
146 #[test]
147 fn text_round_trips() {
148 let memo = ShieldedMemo::text("thanks for lunch").expect("within limit");
149 let bytes = memo.to_bytes();
150 assert_eq!(
151 ShieldedMemo::from_bytes(&bytes),
152 ShieldedMemo::Text("thanks for lunch".to_string())
153 );
154 }
155
156 #[test]
157 fn empty_string_text_round_trips() {
158 let memo = ShieldedMemo::text("").expect("empty string is within limit");
159 let bytes = memo.to_bytes();
160 assert_eq!(
162 ShieldedMemo::from_bytes(&bytes),
163 ShieldedMemo::Text(String::new())
164 );
165 }
166
167 #[test]
168 fn multibyte_utf8_exactly_at_limit_round_trips() {
169 let s = "🍕".repeat(8);
171 assert_eq!(
172 s.len(),
173 MEMO_PAYLOAD_SIZE,
174 "fixture must be exactly 32 bytes"
175 );
176 let memo = ShieldedMemo::text(s.clone()).expect("32 bytes is within limit");
177 let bytes = memo.to_bytes();
178 assert_eq!(ShieldedMemo::from_bytes(&bytes), ShieldedMemo::Text(s));
179 }
180
181 #[test]
182 fn over_limit_text_is_rejected() {
183 let s = "a".repeat(MEMO_PAYLOAD_SIZE + 1);
185 assert!(
186 ShieldedMemo::text(s).is_err(),
187 "a 33-byte text memo must be rejected"
188 );
189 }
190
191 #[test]
192 fn multibyte_over_limit_is_rejected() {
193 let s = "🍕".repeat(9);
195 assert!(s.len() > MEMO_PAYLOAD_SIZE);
196 assert!(
197 ShieldedMemo::text(s).is_err(),
198 "a 36-byte multibyte memo must be rejected"
199 );
200 }
201
202 #[test]
203 fn unknown_kind_round_trips() {
204 let payload = [7u8; MEMO_PAYLOAD_SIZE];
205 let memo = ShieldedMemo::Other { kind: 42, payload };
206 let bytes = memo.to_bytes();
207 assert_eq!(
208 u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
209 42
210 );
211 assert_eq!(ShieldedMemo::from_bytes(&bytes), memo);
212 }
213
214 #[test]
215 fn kind_text_with_invalid_utf8_falls_back_to_other() {
216 let mut bytes = [0u8; MEMO_SIZE];
217 bytes[0..4].copy_from_slice(&KIND_TEXT.to_le_bytes());
218 bytes[4] = 0xFF;
220 match ShieldedMemo::from_bytes(&bytes) {
221 ShieldedMemo::Other { kind, payload } => {
222 assert_eq!(kind, KIND_TEXT);
223 assert_eq!(payload[0], 0xFF);
224 }
225 other => panic!("expected Other for invalid UTF-8, got {other:?}"),
226 }
227 }
228
229 #[test]
230 fn kind_empty_with_nonzero_payload_falls_back_to_other() {
231 let mut bytes = [0u8; MEMO_SIZE];
232 bytes[4] = 0x01;
234 match ShieldedMemo::from_bytes(&bytes) {
235 ShieldedMemo::Other { kind, payload } => {
236 assert_eq!(kind, KIND_EMPTY);
237 assert_eq!(payload[0], 0x01);
238 }
239 other => panic!("expected Other for non-zero kind-0 payload, got {other:?}"),
240 }
241 }
242
243 #[test]
244 fn text_with_embedded_then_trailing_zeros_trims_only_trailing() {
245 let memo = ShieldedMemo::text("ab").expect("within limit");
248 let bytes = memo.to_bytes();
249 assert_eq!(
251 ShieldedMemo::from_bytes(&bytes),
252 ShieldedMemo::Text("ab".to_string())
253 );
254 }
255}