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