Skip to main content

canic_cdk/types/
account.rs

1use base32::Alphabet;
2use candid::{CandidType, Principal};
3use crc32fast::Hasher as Crc32Hasher;
4use serde::{Deserialize, Serialize};
5use std::{
6    cmp::Ordering,
7    fmt::{self, Display, Write},
8    hash::{Hash, Hasher},
9    str::FromStr,
10};
11
12//
13// Subaccount
14//
15
16pub type Subaccount = [u8; 32];
17
18pub const DEFAULT_SUBACCOUNT: &Subaccount = &[0; 32];
19
20//
21// Account
22//
23
24#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
25pub struct Account {
26    pub owner: Principal,
27    pub subaccount: Option<Subaccount>,
28}
29
30impl Account {
31    // Construct one account from an owner and optional subaccount payload.
32    pub fn new<P: Into<Principal>, S: Into<Subaccount>>(owner: P, subaccount: Option<S>) -> Self {
33        Self {
34            owner: owner.into(),
35            subaccount: subaccount.map(Into::into),
36        }
37    }
38
39    // The effective subaccount of an account is the configured subaccount or
40    // the all-zero default when none is present.
41    #[must_use]
42    pub fn effective_subaccount(&self) -> &Subaccount {
43        self.subaccount.as_ref().unwrap_or(DEFAULT_SUBACCOUNT)
44    }
45}
46
47impl Display for Account {
48    // Render the canonical ICRC account text form with checksum-bearing subaccounts.
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match &self.subaccount {
51            None => Display::fmt(&self.owner, f),
52            Some(subaccount) if subaccount == DEFAULT_SUBACCOUNT => Display::fmt(&self.owner, f),
53            Some(subaccount) => write!(
54                f,
55                "{}-{}.{}",
56                self.owner,
57                full_account_checksum(self.owner.as_slice(), subaccount),
58                encode_trimmed_hex(subaccount),
59            ),
60        }
61    }
62}
63
64impl Eq for Account {}
65
66impl FromStr for Account {
67    type Err = String;
68
69    // Parse the canonical ICRC account text form into owner and subaccount fields.
70    fn from_str(s: &str) -> Result<Self, Self::Err> {
71        match s.split_once('.') {
72            Some((principal_checksum, subaccount)) => {
73                let (principal, checksum) = match principal_checksum.rsplit_once('-') {
74                    Some((_, checksum)) if checksum.len() != 7 => {
75                        return Err("missing checksum".to_string());
76                    }
77                    Some(parts) => parts,
78                    None => return Err("missing checksum".to_string()),
79                };
80
81                if subaccount.starts_with('0') {
82                    return Err("subaccount should not have leading zeroes".to_string());
83                }
84
85                let owner = Principal::from_str(principal)
86                    .map_err(|err| format!("invalid principal: {err}"))?;
87                let subaccount = decode_subaccount(subaccount)?;
88
89                if &subaccount == DEFAULT_SUBACCOUNT {
90                    return Err("default subaccount should be omitted".to_string());
91                }
92
93                let expected_checksum = full_account_checksum(owner.as_slice(), &subaccount);
94                if checksum != expected_checksum {
95                    return Err(format!("invalid checksum (expected: {expected_checksum})"));
96                }
97
98                Ok(Self {
99                    owner,
100                    subaccount: Some(subaccount),
101                })
102            }
103            None => Principal::from_str(s)
104                .map(Self::from)
105                .map_err(|err| format!("invalid principal: {err}")),
106        }
107    }
108}
109
110impl PartialEq for Account {
111    // Compare accounts by owner and effective subaccount semantics.
112    fn eq(&self, other: &Self) -> bool {
113        self.owner == other.owner && self.effective_subaccount() == other.effective_subaccount()
114    }
115}
116
117impl From<Principal> for Account {
118    // Promote one principal into its default account representation.
119    fn from(owner: Principal) -> Self {
120        Self {
121            owner,
122            subaccount: None,
123        }
124    }
125}
126
127impl Hash for Account {
128    // Hash the owner plus effective subaccount so omitted defaults stay equivalent.
129    fn hash<H: Hasher>(&self, state: &mut H) {
130        self.owner.hash(state);
131        self.effective_subaccount().hash(state);
132    }
133}
134
135impl Ord for Account {
136    // Order accounts by owner first, then by effective subaccount bytes.
137    fn cmp(&self, other: &Self) -> Ordering {
138        self.owner.cmp(&other.owner).then_with(|| {
139            self.effective_subaccount()
140                .cmp(other.effective_subaccount())
141        })
142    }
143}
144
145impl PartialOrd for Account {
146    // Delegate partial ordering to the total ordering implementation.
147    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
148        Some(self.cmp(other))
149    }
150}
151
152// Compute the textual checksum over owner and subaccount bytes.
153fn full_account_checksum(owner: &[u8], subaccount: &[u8]) -> String {
154    let mut hasher = Crc32Hasher::new();
155    hasher.update(owner);
156    hasher.update(subaccount);
157
158    base32::encode(
159        Alphabet::Rfc4648Lower { padding: false },
160        &hasher.finalize().to_be_bytes(),
161    )
162}
163
164// Encode one subaccount as lowercase hex without leading zeroes.
165fn encode_trimmed_hex(subaccount: &Subaccount) -> String {
166    let mut encoded = String::with_capacity(64);
167    for &byte in subaccount {
168        let _ = write!(encoded, "{byte:02x}");
169    }
170
171    encoded.trim_start_matches('0').to_string()
172}
173
174// Decode one possibly trimmed lowercase or uppercase hex subaccount string.
175fn decode_subaccount(encoded: &str) -> Result<Subaccount, String> {
176    if encoded.len() > 64 {
177        return Err("invalid subaccount: subaccount is longer than 32 bytes".to_string());
178    }
179
180    let padded = format!("{encoded:0>64}");
181    let mut out = [0_u8; 32];
182
183    for (index, chunk) in padded.as_bytes().chunks_exact(2).enumerate() {
184        out[index] = decode_hex_byte(chunk)
185            .ok_or_else(|| "invalid subaccount: subaccount is not hex-encoded".to_string())?;
186    }
187
188    Ok(out)
189}
190
191// Decode one ASCII hex byte pair into its binary representation.
192fn decode_hex_byte(pair: &[u8]) -> Option<u8> {
193    let high = decode_hex_nibble(pair.first().copied()?)?;
194    let low = decode_hex_nibble(pair.get(1).copied()?)?;
195    Some((high << 4) | low)
196}
197
198// Decode one ASCII hex nibble into its numeric value.
199const fn decode_hex_nibble(byte: u8) -> Option<u8> {
200    match byte {
201        b'0'..=b'9' => Some(byte - b'0'),
202        b'a'..=b'f' => Some(byte - b'a' + 10),
203        b'A'..=b'F' => Some(byte - b'A' + 10),
204        _ => None,
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::Account;
211    use candid::Principal;
212    use std::str::FromStr;
213
214    #[test]
215    // Default accounts should serialize as the principal only.
216    fn account_display_omits_default_subaccount() {
217        let owner = Principal::anonymous();
218        let account = Account::from(owner);
219
220        assert_eq!(account.to_string(), owner.to_string());
221    }
222
223    #[test]
224    // Non-default subaccounts should trim leading zeroes in text form.
225    fn account_display_trims_subaccount_hex() {
226        let owner =
227            Principal::from_text("k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae")
228                .unwrap();
229        let subaccount = Some([
230            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
231            0, 0, 1,
232        ]);
233        let account = Account { owner, subaccount };
234
235        assert_eq!(
236            account.to_string(),
237            "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.1"
238        );
239    }
240
241    #[test]
242    // Bare principals should parse into default accounts.
243    fn account_from_str_accepts_principal_only() {
244        let text = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae";
245
246        assert_eq!(
247            Account::from_str(text),
248            Ok(Account::from(Principal::from_str(text).unwrap()))
249        );
250    }
251
252    #[test]
253    // Canonical text parsing should reject subaccounts with leading zeroes.
254    fn account_from_str_rejects_leading_zeroes() {
255        let text = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-6cc627i.01";
256
257        assert_eq!(
258            Account::from_str(text),
259            Err("subaccount should not have leading zeroes".to_string())
260        );
261    }
262
263    #[test]
264    // Dot-qualified account strings must include the checksum segment.
265    fn account_from_str_rejects_missing_checksum() {
266        let text = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae.1";
267
268        assert_eq!(Account::from_str(text), Err("missing checksum".to_string()));
269    }
270
271    #[test]
272    // Non-default account text should round-trip through parse and display.
273    fn account_from_str_round_trips_non_default_subaccount() {
274        let text = "k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae-dfxgiyy.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
275        let owner =
276            Principal::from_str("k2t6j-2nvnp-4zjm3-25dtz-6xhaa-c7boj-5gayf-oj3xs-i43lp-teztq-6ae")
277                .unwrap();
278        let subaccount = Some([
279            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
280            0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c,
281            0x1d, 0x1e, 0x1f, 0x20,
282        ]);
283
284        assert_eq!(Account::from_str(text), Ok(Account { owner, subaccount }));
285        assert_eq!(Account::from_str(text).unwrap().to_string(), text);
286    }
287}