Skip to main content

icydb_schema/
account.rs

1//! Canonical account atom without storage authority.
2
3#[cfg(test)]
4mod tests;
5
6use std::{
7    fmt::{self, Display, Formatter},
8    str::FromStr,
9};
10
11use serde::{Deserialize, Serialize};
12
13use crate::{Principal, Subaccount};
14
15/// Canonical ICRC account atom.
16#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
17pub struct Account {
18    owner: Principal,
19    subaccount: Option<Subaccount>,
20}
21
22impl Account {
23    const PRINCIPAL_MAX_LEN: usize = Principal::MAX_LENGTH_IN_BYTES as usize;
24    // A maximum principal occupies 63 text bytes, followed by '-', seven
25    // checksum characters, '.', and at most 64 subaccount hex digits.
26    const MAX_TEXT_LEN: usize = 136;
27    const TAG_SUBACCOUNT: u8 = 0x80;
28
29    /// Construct from convertible owner and optional subaccount values.
30    pub fn new<P: Into<Principal>, S: Into<Subaccount>>(owner: P, subaccount: Option<S>) -> Self {
31        Self {
32            owner: owner.into(),
33            subaccount: subaccount.map(Into::into),
34        }
35    }
36
37    /// Construct from canonical components.
38    #[must_use]
39    pub const fn from_owner_and_subaccount(
40        owner: Principal,
41        subaccount: Option<Subaccount>,
42    ) -> Self {
43        Self { owner, subaccount }
44    }
45
46    /// Return the owner.
47    #[must_use]
48    pub const fn owner(&self) -> Principal {
49        self.owner
50    }
51
52    /// Return the optional subaccount.
53    #[must_use]
54    pub const fn subaccount(&self) -> Option<Subaccount> {
55        self.subaccount
56    }
57
58    // ICRC-1 text uses the CRC-32 of owner || subaccount in unpadded base32.
59    fn checksum(owner: Principal, subaccount: Subaccount) -> String {
60        let mut hasher = crc32fast::Hasher::new();
61        hasher.update(owner.as_slice());
62        hasher.update(subaccount.as_slice());
63        data_encoding::BASE32_NOPAD
64            .encode(&hasher.finalize().to_be_bytes())
65            .to_ascii_lowercase()
66    }
67
68    #[expect(clippy::cast_possible_truncation)]
69    fn ordering_tag(&self) -> u8 {
70        let mut tag = self.owner.as_slice().len().min(u8::MAX as usize) as u8;
71        if self.subaccount.is_some() {
72            tag |= Self::TAG_SUBACCOUNT;
73        }
74        tag
75    }
76}
77
78impl Display for Account {
79    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
80        let Some(subaccount) = self.subaccount.filter(|value| *value != Subaccount::MIN) else {
81            return Display::fmt(&self.owner, formatter);
82        };
83        let hex = subaccount.to_string();
84        write!(
85            formatter,
86            "{}-{}.{}",
87            self.owner,
88            Self::checksum(self.owner, subaccount),
89            hex.trim_start_matches('0')
90        )
91    }
92}
93
94impl From<Principal> for Account {
95    fn from(owner: Principal) -> Self {
96        Self {
97            owner,
98            subaccount: None,
99        }
100    }
101}
102
103impl FromStr for Account {
104    type Err = String;
105
106    fn from_str(input: &str) -> Result<Self, Self::Err> {
107        if input.len() > Self::MAX_TEXT_LEN || !input.is_ascii() {
108            return Err("invalid account text width or alphabet".into());
109        }
110        let Some((prefix, hex)) = input.split_once('.') else {
111            return input
112                .parse::<Principal>()
113                .map(Self::from)
114                .map_err(|error| error.to_string());
115        };
116        let (owner, checksum) = prefix
117            .rsplit_once('-')
118            .filter(|(_, checksum)| checksum.len() == 7)
119            .ok_or("missing account checksum")?;
120        if hex.is_empty() || hex.len() > 64 || hex.starts_with('0') {
121            return Err("invalid account subaccount width or leading zero".into());
122        }
123        let owner = owner
124            .parse::<Principal>()
125            .map_err(|error| error.to_string())?;
126        // Left padding admits odd hex widths without allocating from input length.
127        let mut padded = [b'0'; 64];
128        padded[64 - hex.len()..].copy_from_slice(hex.as_bytes());
129        let hex = std::str::from_utf8(&padded).map_err(|error| error.to_string())?;
130        let subaccount = Subaccount::from_hex(hex).map_err(|error| error.to_string())?;
131        if checksum != Self::checksum(owner, subaccount) {
132            return Err("invalid account checksum".into());
133        }
134        Ok(Self::from_owner_and_subaccount(owner, Some(subaccount)))
135    }
136}
137
138impl Ord for Account {
139    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
140        self.ordering_tag()
141            .cmp(&other.ordering_tag())
142            .then_with(|| {
143                let mut left = [0; Self::PRINCIPAL_MAX_LEN];
144                let left_bytes = self.owner.as_slice();
145                left[..left_bytes.len()].copy_from_slice(left_bytes);
146                let mut right = [0; Self::PRINCIPAL_MAX_LEN];
147                let right_bytes = other.owner.as_slice();
148                right[..right_bytes.len()].copy_from_slice(right_bytes);
149                left.cmp(&right)
150            })
151            .then_with(|| {
152                self.subaccount
153                    .unwrap_or(Subaccount::MIN)
154                    .cmp(&other.subaccount.unwrap_or(Subaccount::MIN))
155            })
156    }
157}
158
159impl PartialOrd for Account {
160    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
161        Some(self.cmp(other))
162    }
163}