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, PrincipalError, SchemaContractError, Subaccount};
14
15/// IcyDB account atom with representation-preserving identity.
16///
17/// An absent subaccount and an explicit zero subaccount remain distinct database
18/// identities. Explicit conversions to and from [`ic_platform::Account`] preserve
19/// those fields; Platform compares both representations as the same ICRC account.
20/// Each type retains its own binary serialization contract. Platform owns the
21/// shared ICRC account text format; database equality remains local.
22#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
23pub struct Account {
24    owner: Principal,
25    subaccount: Option<Subaccount>,
26}
27
28impl Account {
29    const PRINCIPAL_MAX_LEN: usize = Principal::MAX_LENGTH_IN_BYTES as usize;
30    const TAG_SUBACCOUNT: u8 = 0x80;
31
32    /// Construct from convertible owner and optional subaccount values.
33    pub fn new<P: Into<Principal>, S: Into<Subaccount>>(owner: P, subaccount: Option<S>) -> Self {
34        Self {
35            owner: owner.into(),
36            subaccount: subaccount.map(Into::into),
37        }
38    }
39
40    /// Construct from canonical components.
41    #[must_use]
42    pub const fn from_owner_and_subaccount(
43        owner: Principal,
44        subaccount: Option<Subaccount>,
45    ) -> Self {
46        Self { owner, subaccount }
47    }
48
49    /// Return the owner.
50    #[must_use]
51    pub const fn owner(&self) -> Principal {
52        self.owner
53    }
54
55    /// Return the optional subaccount.
56    #[must_use]
57    pub const fn subaccount(&self) -> Option<Subaccount> {
58        self.subaccount
59    }
60
61    #[expect(clippy::cast_possible_truncation)]
62    fn ordering_tag(&self) -> u8 {
63        let mut tag = self.owner.as_slice().len().min(u8::MAX as usize) as u8;
64        if self.subaccount.is_some() {
65            tag |= Self::TAG_SUBACCOUNT;
66        }
67        tag
68    }
69}
70
71impl Display for Account {
72    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
73        Display::fmt(&ic_platform::Account::from(*self), formatter)
74    }
75}
76
77// Preserve the optional field itself, not Platform's effective subaccount.
78impl From<ic_platform::Account> for Account {
79    fn from(value: ic_platform::Account) -> Self {
80        Self::from_owner_and_subaccount(
81            value.owner.into(),
82            value.subaccount.map(Subaccount::from_array),
83        )
84    }
85}
86
87impl From<Account> for ic_platform::Account {
88    fn from(value: Account) -> Self {
89        Self {
90            owner: value.owner.into(),
91            subaccount: value.subaccount.map(Subaccount::to_bytes),
92        }
93    }
94}
95
96impl From<Principal> for Account {
97    fn from(owner: Principal) -> Self {
98        Self {
99            owner,
100            subaccount: None,
101        }
102    }
103}
104
105impl FromStr for Account {
106    type Err = String;
107
108    fn from_str(input: &str) -> Result<Self, Self::Err> {
109        if input.len() > ic_platform::Account::MAX_LENGTH_IN_TEXT || !input.is_ascii() {
110            return Err("invalid account text width or alphabet".into());
111        }
112        // Keep IcyDB's refusal precedence: after a checksum-shaped prefix,
113        // empty/oversized subaccounts reject before attempting the owner parse.
114        // Platform otherwise owns the grammar, checksum and hex decoding.
115        if let Some((prefix, hex)) = input.split_once('.')
116            && prefix
117                .rsplit_once('-')
118                .is_some_and(|(_, checksum)| checksum.len() == 7)
119            && (hex.is_empty() || hex.len() > 64)
120        {
121            return Err("invalid account subaccount width or leading zero".into());
122        }
123        input
124            .parse::<ic_platform::Account>()
125            .map(Self::from)
126            .map_err(|error| match error {
127                ic_platform::AccountError::TextTooLong => {
128                    "invalid account text width or alphabet".into()
129                }
130                ic_platform::AccountError::InvalidPrincipal(_) => {
131                    PrincipalError::InvalidText.to_string()
132                }
133                ic_platform::AccountError::MissingChecksum => "missing account checksum".into(),
134                ic_platform::AccountError::InvalidChecksum => "invalid account checksum".into(),
135                ic_platform::AccountError::InvalidSubaccount => {
136                    SchemaContractError::InvalidLiteral.to_string()
137                }
138                ic_platform::AccountError::LeadingZeroesInSubaccount
139                | ic_platform::AccountError::DefaultSubaccountShouldBeOmitted => {
140                    "invalid account subaccount width or leading zero".into()
141                }
142            })
143    }
144}
145
146impl Ord for Account {
147    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
148        self.ordering_tag()
149            .cmp(&other.ordering_tag())
150            .then_with(|| {
151                let mut left = [0; Self::PRINCIPAL_MAX_LEN];
152                let left_bytes = self.owner.as_slice();
153                left[..left_bytes.len()].copy_from_slice(left_bytes);
154                let mut right = [0; Self::PRINCIPAL_MAX_LEN];
155                let right_bytes = other.owner.as_slice();
156                right[..right_bytes.len()].copy_from_slice(right_bytes);
157                left.cmp(&right)
158            })
159            .then_with(|| {
160                self.subaccount
161                    .unwrap_or(Subaccount::MIN)
162                    .cmp(&other.subaccount.unwrap_or(Subaccount::MIN))
163            })
164    }
165}
166
167impl PartialOrd for Account {
168    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
169        Some(self.cmp(other))
170    }
171}