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