Skip to main content

icydb_schema/
subaccount.rs

1//! Canonical fixed-width subaccount atom.
2
3use crate::{Principal, SchemaContractError};
4
5use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
6use std::fmt::{self, Display};
7
8//
9// Subaccount
10//
11
12type SubaccountBytes = [u8; 32];
13
14#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
15/// A canonical 32-byte ICRC account subaccount.
16pub struct Subaccount(SubaccountBytes);
17
18impl Subaccount {
19    /// The lexicographically smallest subaccount.
20    pub const MIN: Self = Self::from_array([0x00; 32]);
21    /// The lexicographically largest subaccount.
22    pub const MAX: Self = Self::from_array([0xFF; 32]);
23
24    /// Return the fixed-width byte array.
25    #[must_use]
26    pub const fn to_array(&self) -> [u8; 32] {
27        self.0
28    }
29
30    /// Construct from the exact fixed-width byte array.
31    #[must_use]
32    pub const fn from_array(array: [u8; 32]) -> Self {
33        Self(array)
34    }
35
36    /// Parse 64 ASCII hex digits, optionally prefixed by lowercase `0x`.
37    ///
38    /// # Errors
39    /// Returns [`SchemaContractError::InvalidLiteral`] for invalid width,
40    /// non-ASCII input or non-hex characters. No trimming is performed.
41    pub fn from_hex(text: &str) -> Result<Self, SchemaContractError> {
42        let text = text.strip_prefix("0x").unwrap_or(text);
43        if text.len() != 64 || !text.is_ascii() {
44            return Err(SchemaContractError::InvalidLiteral);
45        }
46        let mut bytes = [0; 32];
47        for (index, pair) in text.as_bytes().as_chunks::<2>().0.iter().enumerate() {
48            let pair =
49                std::str::from_utf8(pair).map_err(|_| SchemaContractError::InvalidLiteral)?;
50            bytes[index] =
51                u8::from_str_radix(pair, 16).map_err(|_| SchemaContractError::InvalidLiteral)?;
52        }
53        Ok(Self::from_array(bytes))
54    }
55
56    /// Borrow the fixed-width bytes.
57    #[must_use]
58    pub const fn as_slice(&self) -> &[u8] {
59        &self.0
60    }
61
62    /// Consume the value and return its fixed-width bytes.
63    #[must_use]
64    pub const fn to_bytes(self) -> [u8; 32] {
65        self.0
66    }
67}
68
69impl<'de> Deserialize<'de> for Subaccount {
70    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
71    where
72        D: Deserializer<'de>,
73    {
74        // Read into a fixed array and inspect at most one excess byte before
75        // rejecting an oversized payload.
76        struct SubaccountBytesVisitor;
77
78        impl<'de> serde::de::Visitor<'de> for SubaccountBytesVisitor {
79            type Value = Subaccount;
80
81            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82                formatter.write_str("exactly 32 subaccount bytes")
83            }
84
85            fn visit_seq<A: serde::de::SeqAccess<'de>>(
86                self,
87                mut sequence: A,
88            ) -> Result<Subaccount, A::Error> {
89                let mut bytes = [0; 32];
90                for (index, byte) in bytes.iter_mut().enumerate() {
91                    *byte = sequence
92                        .next_element()?
93                        .ok_or_else(|| A::Error::invalid_length(index, &self))?;
94                }
95                if sequence.next_element::<u8>()?.is_some() {
96                    return Err(A::Error::invalid_length(33, &self));
97                }
98                Ok(Subaccount::from_array(bytes))
99            }
100        }
101
102        if deserializer.is_human_readable() {
103            return SubaccountBytes::deserialize(deserializer).map(Self);
104        }
105
106        deserializer.deserialize_seq(SubaccountBytesVisitor)
107    }
108}
109
110impl Display for Subaccount {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        for byte in &self.0 {
113            write!(f, "{byte:02x}")?;
114        }
115
116        Ok(())
117    }
118}
119
120// code taken from
121// <https://docs.rs/ic-ledger-types/latest/src/ic_ledger_types/lib.rs.html#140-148>
122#[expect(clippy::cast_possible_truncation)]
123impl From<Principal> for Subaccount {
124    fn from(principal: Principal) -> Self {
125        let mut bytes = [0u8; 32];
126        let p = principal.as_slice();
127
128        // Defensive check: Principals are currently <= 29 bytes
129        let len = p.len().min(31); // reserve 1 byte for the length prefix
130        bytes[0] = len as u8;
131
132        // Copy safely without panic risk
133        bytes[1..=len].copy_from_slice(&p[..len]);
134
135        Self(bytes)
136    }
137}
138
139impl Serialize for Subaccount {
140    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
141    where
142        S: serde::Serializer,
143    {
144        if serializer.is_human_readable() {
145            self.0.serialize(serializer)
146        } else {
147            serializer.serialize_bytes(&self.0)
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::Account;
156
157    #[test]
158    fn hex_text_preserves_every_byte_with_optional_prefix() {
159        for byte in 0..=u8::MAX {
160            let expected = Subaccount::from_array([byte; 32]);
161            for text in [
162                format!("{byte:02x}").repeat(32),
163                format!("{byte:02X}").repeat(32),
164            ] {
165                assert_eq!(Subaccount::from_hex(&text), Ok(expected));
166                assert_eq!(Subaccount::from_hex(&format!("0x{text}")), Ok(expected));
167            }
168        }
169    }
170
171    #[test]
172    fn hex_text_rejects_width_unicode_and_nonhex_boundaries() {
173        for text in [
174            String::new(),
175            "0x".into(),
176            "0".repeat(63),
177            "0".repeat(65),
178            "g".repeat(64),
179            format!("aƩ{}", "0".repeat(61)),
180            format!(" {}", "0".repeat(64)),
181            format!("0X{}", "0".repeat(64)),
182        ] {
183            assert_eq!(
184                Subaccount::from_hex(&text),
185                Err(SchemaContractError::InvalidLiteral)
186            );
187        }
188    }
189
190    #[test]
191    fn binary_serde_and_wire_preserve_exact_subaccount_bytes() {
192        for byte in [0, 23, 24, 128, 255] {
193            let value = Subaccount::from_array([byte; 32]);
194            let mut encoded = Vec::new();
195            ciborium::into_writer(&value, &mut encoded).unwrap();
196            assert_eq!(encoded.len(), 34);
197            let wire: ciborium::Value = ciborium::from_reader(encoded.as_slice()).unwrap();
198            assert_eq!(wire, ciborium::Value::Bytes(value.to_bytes().to_vec()));
199            assert_eq!(
200                ciborium::from_reader::<Subaccount, _>(encoded.as_slice()).unwrap(),
201                value
202            );
203
204            let json = serde_json::to_string(&value).unwrap();
205            assert_eq!(json, serde_json::to_string(&value.to_array()).unwrap());
206            assert_eq!(serde_json::from_str::<Subaccount>(&json).unwrap(), value);
207
208            let wire = crate::wire::encode_one(value).unwrap();
209            assert_eq!(
210                wire,
211                crate::wire::encode_one(serde_bytes::Bytes::new(&value.to_array())).unwrap()
212            );
213            assert_eq!(wire.len(), 33);
214            assert_eq!(crate::wire::decode_one::<Subaccount>(&wire).unwrap(), value);
215
216            let batch = vec![value; 1_000];
217            encoded.clear();
218            ciborium::into_writer(&batch, &mut encoded).unwrap();
219            assert_eq!(encoded.len(), 34_003);
220            assert_eq!(
221                ciborium::from_reader::<Vec<Subaccount>, _>(encoded.as_slice()).unwrap(),
222                batch
223            );
224        }
225    }
226
227    #[test]
228    fn binary_subaccount_ingress_enforces_width_and_byte_elements() {
229        for len in [0, 31, 33, 1_024] {
230            let wire = crate::wire::encode_one(vec![0_u8; len]).unwrap();
231            assert!(crate::wire::decode_one::<Subaccount>(&wire).is_err());
232            let mut encoded = Vec::new();
233            ciborium::into_writer(&serde_bytes::Bytes::new(&vec![0; len]), &mut encoded).unwrap();
234            assert!(ciborium::from_reader::<Subaccount, _>(encoded.as_slice()).is_err());
235        }
236        // Postcard carries values without source integer-type tags: use a value
237        // outside the byte domain rather than zero, which also encodes as u8.
238        let wire = crate::wire::encode_one(vec![256_u16; 32]).unwrap();
239        assert!(crate::wire::decode_one::<Subaccount>(&wire).is_err());
240
241        let mut encoded = Vec::new();
242        ciborium::into_writer(&Subaccount::MAX, &mut encoded).unwrap();
243        for end in 0..encoded.len() {
244            assert!(ciborium::from_reader::<Subaccount, _>(&encoded[..end]).is_err());
245        }
246    }
247
248    #[test]
249    fn accounts_roundtrip_optional_subaccounts_in_all_transports() {
250        for subaccount in [None, Some(Subaccount::MIN), Some(Subaccount::MAX)] {
251            let account = Account::from_owner_and_subaccount(Principal::anonymous(), subaccount);
252            let mut encoded = Vec::new();
253            ciborium::into_writer(&account, &mut encoded).unwrap();
254            assert_eq!(encoded.len(), if subaccount.is_some() { 54 } else { 21 });
255            assert_eq!(
256                ciborium::from_reader::<Account, _>(encoded.as_slice()).unwrap(),
257                account
258            );
259            let json = serde_json::to_string(&account).unwrap();
260            assert_eq!(serde_json::from_str::<Account>(&json).unwrap(), account);
261            let wire = crate::wire::encode_one(account).unwrap();
262            // Native account projection is the ordered owner/optional-bytes pair.
263            assert_eq!(
264                wire,
265                crate::wire::encode_one((account.owner(), subaccount)).unwrap()
266            );
267            assert_eq!(crate::wire::decode_one::<Account>(&wire).unwrap(), account);
268        }
269    }
270}