Skip to main content

ic_platform/account/
mod.rs

1//! Owned ICRC account identity, independent of Candid and ledger clients.
2//!
3//! Account semantics and textual checksum derive from icrc-ledger-types 0.2.0;
4//! see NOTICE.md. Serialization preserves absent versus explicit zero subaccounts,
5//! although equality, ordering and hashing treat them as the same account.
6//!
7//! Convert to `ExternalAccount` before encoding external Candid DTOs:
8//!
9//! ```compile_fail
10//! let account = ic_platform::Account::from(ic_platform::Principal::anonymous());
11//! ic_platform::candid::contract::encode_one(account).unwrap();
12//! ```
13
14use crate::{Principal, PrincipalError};
15use serde::{Deserialize, Serialize};
16use std::{fmt, hash::Hash, str::FromStr};
17
18/// The 32-byte ledger subaccount; no principal or Candid marker is embedded.
19pub type Subaccount = [u8; 32];
20
21/// Effective subaccount when the optional field is absent.
22pub const DEFAULT_SUBACCOUNT: &Subaccount = &[0; 32];
23
24/// An owned account for application protocols. External ledger DTOs remain separate.
25#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
26pub struct Account {
27    /// Account owner.
28    pub owner: Principal,
29    /// Explicit subaccount, or the effective all-zero default.
30    pub subaccount: Option<Subaccount>,
31}
32
33impl Account {
34    /// Maximum canonical account text length, including checksum and subaccount.
35    pub const MAX_LENGTH_IN_TEXT: usize = Principal::MAX_LENGTH_IN_TEXT + 1 + 7 + 1 + 64;
36
37    /// Return the explicit subaccount or the all-zero default.
38    pub fn effective_subaccount(&self) -> &Subaccount {
39        self.subaccount.as_ref().unwrap_or(DEFAULT_SUBACCOUNT)
40    }
41}
42
43impl PartialEq for Account {
44    fn eq(&self, other: &Self) -> bool {
45        self.owner == other.owner && self.effective_subaccount() == other.effective_subaccount()
46    }
47}
48impl Eq for Account {}
49impl PartialOrd for Account {
50    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
51        Some(self.cmp(other))
52    }
53}
54impl Ord for Account {
55    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
56        self.owner.cmp(&other.owner).then_with(|| {
57            self.effective_subaccount()
58                .cmp(other.effective_subaccount())
59        })
60    }
61}
62impl Hash for Account {
63    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
64        self.owner.hash(state);
65        self.effective_subaccount().hash(state);
66    }
67}
68impl From<Principal> for Account {
69    fn from(owner: Principal) -> Self {
70        Self {
71            owner,
72            subaccount: None,
73        }
74    }
75}
76
77// Preserve the upstream CRC32(owner || subaccount) textual checksum algorithm.
78fn checksum(owner: &[u8], subaccount: &Subaccount) -> [u8; 7] {
79    let mut hasher = crc32fast::Hasher::new();
80    hasher.update(owner);
81    hasher.update(subaccount);
82    let mut encoded = [0; 7];
83    data_encoding::BASE32_NOPAD.encode_mut(&hasher.finalize().to_be_bytes(), &mut encoded);
84    encoded.make_ascii_lowercase();
85    encoded
86}
87
88impl fmt::Display for Account {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{}", self.owner)?;
91        let subaccount = self.effective_subaccount();
92        if subaccount == DEFAULT_SUBACCOUNT {
93            return Ok(());
94        }
95        let checksum = checksum(self.owner.as_slice(), subaccount);
96        let checksum = std::str::from_utf8(&checksum).map_err(|_| fmt::Error)?;
97        write!(f, "-{checksum}.")?;
98        let first = subaccount
99            .iter()
100            .position(|byte| *byte != 0)
101            .ok_or(fmt::Error)?;
102        // Encode once into a fixed buffer, omitting only the first leading nibble.
103        // Repeated integer formatting here dominates non-default account Display.
104        const HEX: &[u8; 16] = b"0123456789abcdef";
105        let mut hex = [0u8; 64];
106        let mut written = 0;
107        for (index, &byte) in subaccount[first..].iter().enumerate() {
108            if index != 0 || byte >= 16 {
109                hex[written] = HEX[(byte >> 4) as usize];
110                written += 1;
111            }
112            hex[written] = HEX[(byte & 15) as usize];
113            written += 1;
114        }
115        f.write_str(std::str::from_utf8(&hex[..written]).map_err(|_| fmt::Error)?)
116    }
117}
118
119/// Failures admitting canonical account text. No input strings are retained.
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub enum AccountError {
122    /// Input exceeds the account grammar's maximum length.
123    TextTooLong,
124    /// The owner is not a valid principal.
125    InvalidPrincipal(PrincipalError),
126    /// A non-default subaccount needs a seven-character checksum.
127    MissingChecksum,
128    /// The checksum differs from CRC32(owner || subaccount).
129    InvalidChecksum,
130    /// Subaccount contains non-hex characters or exceeds 64 hex digits.
131    InvalidSubaccount,
132    /// Canonical subaccount text must omit leading zeroes.
133    LeadingZeroesInSubaccount,
134    /// Default subaccounts must be represented by the owner alone.
135    DefaultSubaccountShouldBeOmitted,
136}
137impl fmt::Display for AccountError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self {
140            Self::TextTooLong => f.write_str("account text is too long"),
141            Self::InvalidPrincipal(error) => write!(f, "invalid principal: {error}"),
142            Self::MissingChecksum => f.write_str("missing account checksum"),
143            Self::InvalidChecksum => f.write_str("invalid account checksum"),
144            Self::InvalidSubaccount => f.write_str("subaccount must contain at most 64 hex digits"),
145            Self::LeadingZeroesInSubaccount => {
146                f.write_str("subaccount should not have leading zeroes")
147            }
148            Self::DefaultSubaccountShouldBeOmitted => {
149                f.write_str("default subaccount should be omitted")
150            }
151        }
152    }
153}
154impl std::error::Error for AccountError {
155    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156        match self {
157            Self::InvalidPrincipal(error) => Some(error),
158            _ => None,
159        }
160    }
161}
162impl FromStr for Account {
163    type Err = AccountError;
164
165    fn from_str(text: &str) -> Result<Self, Self::Err> {
166        if text.len() > Self::MAX_LENGTH_IN_TEXT {
167            return Err(AccountError::TextTooLong);
168        }
169        let Some((prefix, hex)) = text.split_once('.') else {
170            return text
171                .parse::<Principal>()
172                .map(Self::from)
173                .map_err(AccountError::InvalidPrincipal);
174        };
175        let (owner, supplied_checksum) = prefix
176            .rsplit_once('-')
177            .filter(|(_, sum)| sum.len() == 7)
178            .ok_or(AccountError::MissingChecksum)?;
179        if hex.starts_with('0') {
180            return Err(AccountError::LeadingZeroesInSubaccount);
181        }
182        let owner: Principal = owner
183            .parse::<Principal>()
184            .map_err(AccountError::InvalidPrincipal)?;
185        if hex.len() > 64 {
186            return Err(AccountError::InvalidSubaccount);
187        }
188        if hex.is_empty() {
189            return Err(AccountError::DefaultSubaccountShouldBeOmitted);
190        }
191        let mut subaccount = [0; 32];
192        for (index, byte) in hex.bytes().enumerate() {
193            let nibble = match byte {
194                b'0'..=b'9' => byte - b'0',
195                b'a'..=b'f' => byte - b'a' + 10,
196                b'A'..=b'F' => byte - b'A' + 10,
197                _ => return Err(AccountError::InvalidSubaccount),
198            };
199            let offset = 64 - hex.len() + index;
200            subaccount[offset / 2] |= nibble << (if offset % 2 == 0 { 4 } else { 0 });
201        }
202        if supplied_checksum.as_bytes() != checksum(owner.as_slice(), &subaccount) {
203            return Err(AccountError::InvalidChecksum);
204        }
205        Ok(Self {
206            owner,
207            subaccount: Some(subaccount),
208        })
209    }
210}
211
212/// Original Candid ledger DTO identity, available only with ledger integration.
213#[cfg(feature = "icrc-ledger-types")]
214pub use icrc_ledger_types::icrc1::account::Account as ExternalAccount;
215
216#[cfg(feature = "icrc-ledger-types")]
217impl From<ExternalAccount> for Account {
218    fn from(value: ExternalAccount) -> Self {
219        Self {
220            owner: value.owner.into(),
221            subaccount: value.subaccount,
222        }
223    }
224}
225#[cfg(feature = "icrc-ledger-types")]
226impl From<Account> for ExternalAccount {
227    fn from(value: Account) -> Self {
228        Self {
229            owner: value.owner.into(),
230            subaccount: value.subaccount,
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests;