Skip to main content

dpp/shielded/
memo.rs

1//! Structured 36-byte shielded note memo (`DashMemo`).
2//!
3//! Every shielded output carries a fixed 36-byte memo
4//! ([`MEMO_SIZE`]). The dashpay Orchard fork pins the memo to 36
5//! bytes (Zcash's is 512) by making the memo size a type parameter;
6//! the on-chain note ciphertext reserves exactly those bytes. This
7//! type imposes a small self-describing structure on those bytes so
8//! the wallet can attach an optional text note to a transfer and
9//! recover it on the receive side:
10//!
11//!   * bytes `[0..4]` — `kind` as a little-endian `u32`.
12//!   * bytes `[4..36]` — a 32-byte payload whose meaning depends on
13//!     `kind`.
14//!
15//! Two kinds are defined today; unknown kinds round-trip verbatim as
16//! [`ShieldedMemo::Other`] so a future writer's memo is never
17//! corrupted by an older reader.
18
19use crate::ProtocolError;
20
21/// Total memo length in bytes (`DashMemo = [u8; 36]`).
22pub const MEMO_SIZE: usize = 36;
23
24/// Length of the payload that follows the 4-byte kind tag.
25pub const MEMO_PAYLOAD_SIZE: usize = MEMO_SIZE - 4;
26
27/// `kind` tag for an empty memo (the all-zero 36 bytes today's senders write).
28const KIND_EMPTY: u32 = 0;
29/// `kind` tag for a UTF-8 text memo (zero-padded payload).
30const KIND_TEXT: u32 = 1;
31
32/// A decoded shielded note memo.
33///
34/// `to_bytes`/`from_bytes` are exact inverses for every variant: a
35/// memo written by [`to_bytes`](Self::to_bytes) decodes back to an
36/// equal value, and any 36 bytes decode to *some* variant (unknown
37/// kinds and malformed text fall back to [`Self::Other`]), so decoding
38/// is total and never loses bytes.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ShieldedMemo {
41    /// No memo: the all-zero 36 bytes. Today's `[0u8; 36]` memos decode to this.
42    Empty,
43    /// A UTF-8 text memo whose byte length is at most [`MEMO_PAYLOAD_SIZE`].
44    Text(String),
45    /// Any memo whose `kind` this version does not interpret (or a
46    /// structurally-invalid known kind). Retained verbatim so it
47    /// round-trips unchanged.
48    Other {
49        /// The raw 4-byte `kind` tag.
50        kind: u32,
51        /// The raw 32-byte payload.
52        payload: [u8; MEMO_PAYLOAD_SIZE],
53    },
54}
55
56impl ShieldedMemo {
57    /// Build a text memo, validating that `s` is at most
58    /// [`MEMO_PAYLOAD_SIZE`] bytes when UTF-8 encoded.
59    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    /// Encode to the on-chain 36-byte memo layout.
71    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                // `text()` is the only constructor and enforces the length bound; a
77                // directly-built `Text` longer than the payload is truncated rather than
78                // panicking (the surplus simply cannot fit the fixed-size memo).
79                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    /// Decode from the on-chain 36-byte memo layout.
93    ///
94    /// Decoding is total: a `KIND_TEXT` memo whose payload is not valid
95    /// UTF-8, or a `KIND_EMPTY` memo with a non-zero payload, falls back
96    /// to [`Self::Other`] so the raw bytes are preserved rather than lost.
97    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                    // kind 0 is defined as the all-zero memo; a non-zero payload under
108                    // kind 0 is not something we wrote, so keep it verbatim.
109                    ShieldedMemo::Other { kind, payload }
110                }
111            }
112            KIND_TEXT => {
113                // Trailing zero padding is not part of the text; trim it before decoding.
114                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        // Every memo written before this type existed was `[0u8; 36]`.
140        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        // An empty text payload is all-zero, but the kind tag is 1, so it stays Text("").
161        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        // 8 × 🍕 (U+1F355) = 8 × 4 = 32 bytes, exactly MEMO_PAYLOAD_SIZE.
170        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        // 33 ASCII bytes — one over the 32-byte payload.
184        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        // 9 × 🍕 = 36 bytes > 32.
194        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        // 0xFF is never a valid UTF-8 byte.
219        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        // kind 0 but a non-zero payload — not something we wrote.
233        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        // Build a memo whose payload has an interior zero followed by data,
246        // to confirm we trim trailing zeros only (rposition of last non-zero).
247        let memo = ShieldedMemo::text("ab").expect("within limit");
248        let bytes = memo.to_bytes();
249        // payload = [b'a', b'b', 0, 0, ...]; decoding trims to "ab".
250        assert_eq!(
251            ShieldedMemo::from_bytes(&bytes),
252            ShieldedMemo::Text("ab".to_string())
253        );
254    }
255}