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/// Canonical ICRC account atom for database identity.
16///
17/// An absent and an explicit all-zero subaccount denote the same account and
18/// are stored as absence. Constructors, deserialization and Platform conversion
19/// share that normalization; nonzero subaccounts retain all 32 bytes. Platform
20/// owns the shared account text grammar.
21#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
22#[cfg_attr(
23    all(feature = "wire-schema", not(target_family = "wasm")),
24    derive(ic_wire::Wire)
25)]
26pub struct Account {
27    owner: Principal,
28    subaccount: Option<Subaccount>,
29}
30
31impl Account {
32    const PRINCIPAL_MAX_LEN: usize = Principal::MAX_LENGTH_IN_BYTES as usize;
33    const TAG_SUBACCOUNT: u8 = 0x80;
34
35    /// Construct from convertible owner and optional subaccount values.
36    pub fn new<P: Into<Principal>, S: Into<Subaccount>>(owner: P, subaccount: Option<S>) -> Self {
37        Self::from_owner_and_subaccount(owner.into(), subaccount.map(Into::into))
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 {
47            owner,
48            subaccount: Self::canonical_subaccount(subaccount),
49        }
50    }
51
52    // Keep one representation for the ICRC default account without allocating
53    // or making const construction depend on non-const trait comparison.
54    const fn canonical_subaccount(subaccount: Option<Subaccount>) -> Option<Subaccount> {
55        let Some(value) = subaccount else {
56            return None;
57        };
58        let bytes = value.to_bytes();
59        let mut index = 0;
60        while index < bytes.len() {
61            if bytes[index] != 0 {
62                return Some(value);
63            }
64            index += 1;
65        }
66        None
67    }
68
69    /// Return the owner.
70    #[must_use]
71    pub const fn owner(&self) -> Principal {
72        self.owner
73    }
74
75    /// Return the optional subaccount.
76    #[must_use]
77    pub const fn subaccount(&self) -> Option<Subaccount> {
78        self.subaccount
79    }
80
81    #[expect(clippy::cast_possible_truncation)]
82    fn ordering_tag(&self) -> u8 {
83        let mut tag = self.owner.as_slice().len().min(u8::MAX as usize) as u8;
84        if self.subaccount.is_some() {
85            tag |= Self::TAG_SUBACCOUNT;
86        }
87        tag
88    }
89}
90
91// Decode the same two fields as the derived serializer. Normalization belongs
92// to construction, so a decoder cannot create a second default identity.
93impl<'de> Deserialize<'de> for Account {
94    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95    where
96        D: serde::Deserializer<'de>,
97    {
98        #[derive(Deserialize)]
99        #[serde(rename = "Account")]
100        struct Fields {
101            owner: Principal,
102            subaccount: Option<Subaccount>,
103        }
104        let fields = Fields::deserialize(deserializer)?;
105        Ok(Self::from_owner_and_subaccount(
106            fields.owner,
107            fields.subaccount,
108        ))
109    }
110}
111
112impl Display for Account {
113    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
114        Display::fmt(&ic_platform_types::Account::from(*self), formatter)
115    }
116}
117
118// Normalize Platform's explicit default representation at the database boundary.
119impl From<ic_platform_types::Account> for Account {
120    fn from(value: ic_platform_types::Account) -> Self {
121        Self::from_owner_and_subaccount(
122            value.owner.into(),
123            value.subaccount.map(Subaccount::from_array),
124        )
125    }
126}
127
128impl From<Account> for ic_platform_types::Account {
129    fn from(value: Account) -> Self {
130        Self {
131            owner: value.owner.into(),
132            subaccount: value.subaccount.map(Subaccount::to_bytes),
133        }
134    }
135}
136
137impl From<Principal> for Account {
138    fn from(owner: Principal) -> Self {
139        Self {
140            owner,
141            subaccount: None,
142        }
143    }
144}
145
146impl FromStr for Account {
147    type Err = String;
148
149    fn from_str(input: &str) -> Result<Self, Self::Err> {
150        if input.len() > ic_platform_types::Account::MAX_LENGTH_IN_TEXT || !input.is_ascii() {
151            return Err("invalid account text width or alphabet".into());
152        }
153        // Keep IcyDB's refusal precedence: after a checksum-shaped prefix,
154        // empty/oversized subaccounts reject before attempting the owner parse.
155        // Platform otherwise owns the grammar, checksum and hex decoding.
156        if let Some((prefix, hex)) = input.split_once('.')
157            && prefix
158                .rsplit_once('-')
159                .is_some_and(|(_, checksum)| checksum.len() == 7)
160            && (hex.is_empty() || hex.len() > 64)
161        {
162            return Err("invalid account subaccount width or leading zero".into());
163        }
164        input
165            .parse::<ic_platform_types::Account>()
166            .map(Self::from)
167            .map_err(|error| match error {
168                ic_platform_types::AccountError::TextTooLong => {
169                    "invalid account text width or alphabet".into()
170                }
171                ic_platform_types::AccountError::InvalidPrincipal(_) => {
172                    PrincipalError::InvalidText.to_string()
173                }
174                ic_platform_types::AccountError::MissingChecksum => {
175                    "missing account checksum".into()
176                }
177                ic_platform_types::AccountError::InvalidChecksum => {
178                    "invalid account checksum".into()
179                }
180                ic_platform_types::AccountError::InvalidSubaccount => {
181                    SchemaContractError::InvalidLiteral.to_string()
182                }
183                ic_platform_types::AccountError::LeadingZeroesInSubaccount
184                | ic_platform_types::AccountError::DefaultSubaccountShouldBeOmitted => {
185                    "invalid account subaccount width or leading zero".into()
186                }
187            })
188    }
189}
190
191impl Ord for Account {
192    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
193        self.ordering_tag()
194            .cmp(&other.ordering_tag())
195            .then_with(|| {
196                let mut left = [0; Self::PRINCIPAL_MAX_LEN];
197                let left_bytes = self.owner.as_slice();
198                left[..left_bytes.len()].copy_from_slice(left_bytes);
199                let mut right = [0; Self::PRINCIPAL_MAX_LEN];
200                let right_bytes = other.owner.as_slice();
201                right[..right_bytes.len()].copy_from_slice(right_bytes);
202                left.cmp(&right)
203            })
204            .then_with(|| {
205                self.subaccount
206                    .unwrap_or(Subaccount::MIN)
207                    .cmp(&other.subaccount.unwrap_or(Subaccount::MIN))
208            })
209    }
210}
211
212impl PartialOrd for Account {
213    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
214        Some(self.cmp(other))
215    }
216}