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
12pub type Subaccount = [u8; 32];
17
18pub const DEFAULT_SUBACCOUNT: &Subaccount = &[0; 32];
19
20#[derive(CandidType, Clone, Copy, Debug, Deserialize, Serialize)]
25pub struct Account {
26 pub owner: Principal,
27 pub subaccount: Option<Subaccount>,
28}
29
30impl Account {
31 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 #[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 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 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 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 fn from(owner: Principal) -> Self {
120 Self {
121 owner,
122 subaccount: None,
123 }
124 }
125}
126
127impl Hash for Account {
128 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 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 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
148 Some(self.cmp(other))
149 }
150}
151
152fn 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
164fn 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
174fn 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
191fn 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
198const 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 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 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 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 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 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 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}