Skip to main content

near_kit/types/
account.rs

1//! NEAR account ID type with validation.
2
3use std::fmt::{self, Display};
4use std::str::FromStr;
5
6use borsh::{BorshDeserialize, BorshSerialize};
7use serde::{Deserialize, Serialize};
8
9use crate::error::ParseAccountIdError;
10
11/// A NEAR account identifier.
12///
13/// Valid account IDs:
14/// - Named: "alice.near", "bob.testnet", "sub.account.near"
15/// - Implicit (64 hex chars): "0123456789abcdef..."
16/// - EVM implicit (0x + 40 hex chars): "0x1234..."
17///
18/// # Examples
19///
20/// ```
21/// use near_kit::AccountId;
22///
23/// let named: AccountId = "alice.testnet".parse().unwrap();
24/// assert!(named.is_named());
25///
26/// let implicit = "0".repeat(64).parse::<AccountId>().unwrap();
27/// assert!(implicit.is_implicit());
28/// ```
29#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct AccountId(String);
32
33impl AccountId {
34    /// Parse and validate an account ID.
35    pub fn new(s: impl Into<String>) -> Result<Self, ParseAccountIdError> {
36        let s = s.into();
37        Self::validate(&s)?;
38        Ok(Self(s))
39    }
40
41    /// Create without validation (for internal use / testing).
42    #[doc(hidden)]
43    pub fn new_unchecked(s: impl Into<String>) -> Self {
44        Self(s.into())
45    }
46
47    /// Parse an account ID, falling back to unchecked if validation fails.
48    ///
49    /// This is a convenience method for APIs that accept user input where
50    /// we want to be lenient. If parsing fails, the string is used as-is.
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// use near_kit::AccountId;
56    ///
57    /// // Valid account parses normally
58    /// let valid = AccountId::parse_lenient("alice.near");
59    /// assert_eq!(valid.as_str(), "alice.near");
60    ///
61    /// // Invalid account is used as-is (no error)
62    /// let invalid = AccountId::parse_lenient("INVALID");
63    /// assert_eq!(invalid.as_str(), "INVALID");
64    /// ```
65    pub fn parse_lenient(s: impl AsRef<str>) -> Self {
66        s.as_ref()
67            .parse()
68            .unwrap_or_else(|_| Self::new_unchecked(s.as_ref()))
69    }
70
71    /// Validate an account ID string.
72    fn validate(s: &str) -> Result<(), ParseAccountIdError> {
73        if s.is_empty() {
74            return Err(ParseAccountIdError::Empty);
75        }
76
77        if s.len() > 64 {
78            return Err(ParseAccountIdError::TooLong(s.to_string()));
79        }
80
81        // Check for EVM implicit account (0x prefix)
82        if let Some(hex_part) = s.strip_prefix("0x") {
83            if s.len() != 42 {
84                return Err(ParseAccountIdError::InvalidFormat(s.to_string()));
85            }
86            // Validate hex characters after 0x
87            for c in hex_part.chars() {
88                if !c.is_ascii_hexdigit() {
89                    return Err(ParseAccountIdError::InvalidChar(s.to_string(), c));
90                }
91            }
92            return Ok(());
93        }
94
95        // Check for implicit account (64 hex chars)
96        if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) {
97            return Ok(());
98        }
99
100        // Named account validation
101        if s.len() < 2 {
102            return Err(ParseAccountIdError::TooShort(s.to_string()));
103        }
104
105        // Check each character
106        for c in s.chars() {
107            if !matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.') {
108                return Err(ParseAccountIdError::InvalidChar(s.to_string(), c));
109            }
110        }
111
112        // Check for valid structure (no leading/trailing dots, no consecutive dots)
113        if s.starts_with('.') || s.ends_with('.') || s.contains("..") {
114            return Err(ParseAccountIdError::InvalidFormat(s.to_string()));
115        }
116
117        // Check for valid structure (no leading/trailing hyphens or underscores per segment)
118        for part in s.split('.') {
119            if part.is_empty() {
120                return Err(ParseAccountIdError::InvalidFormat(s.to_string()));
121            }
122            if part.starts_with('-') || part.ends_with('-') {
123                return Err(ParseAccountIdError::InvalidFormat(s.to_string()));
124            }
125            if part.starts_with('_') || part.ends_with('_') {
126                return Err(ParseAccountIdError::InvalidFormat(s.to_string()));
127            }
128        }
129
130        Ok(())
131    }
132
133    /// Check if this is an implicit account (64 hex chars).
134    pub fn is_implicit(&self) -> bool {
135        self.0.len() == 64 && self.0.chars().all(|c| c.is_ascii_hexdigit())
136    }
137
138    /// Check if this is an EVM implicit account (0x prefix).
139    pub fn is_evm_implicit(&self) -> bool {
140        self.0.starts_with("0x") && self.0.len() == 42
141    }
142
143    /// Check if this is a named account.
144    pub fn is_named(&self) -> bool {
145        !self.is_implicit() && !self.is_evm_implicit()
146    }
147
148    /// Check if this is a top-level account (no dots, like "near" or "testnet").
149    pub fn is_top_level(&self) -> bool {
150        self.is_named() && !self.0.contains('.')
151    }
152
153    /// Check if this is a subaccount of another account.
154    pub fn is_sub_account_of(&self, parent: &AccountId) -> bool {
155        if !self.is_named() || !parent.is_named() {
156            return false;
157        }
158        self.0.ends_with(&format!(".{}", parent.0)) && self.0.len() > parent.0.len() + 1
159    }
160
161    /// Get the parent account (e.g., "sub.alice.near" → "alice.near").
162    pub fn parent(&self) -> Option<AccountId> {
163        if !self.is_named() {
164            return None;
165        }
166        self.0
167            .find('.')
168            .map(|i| AccountId(self.0[i + 1..].to_string()))
169    }
170
171    /// Get as string slice.
172    pub fn as_str(&self) -> &str {
173        &self.0
174    }
175}
176
177impl FromStr for AccountId {
178    type Err = ParseAccountIdError;
179
180    fn from_str(s: &str) -> Result<Self, Self::Err> {
181        Self::new(s)
182    }
183}
184
185impl TryFrom<&str> for AccountId {
186    type Error = ParseAccountIdError;
187
188    fn try_from(s: &str) -> Result<Self, Self::Error> {
189        Self::new(s)
190    }
191}
192
193impl TryFrom<String> for AccountId {
194    type Error = ParseAccountIdError;
195
196    fn try_from(s: String) -> Result<Self, Self::Error> {
197        Self::new(s)
198    }
199}
200
201impl Display for AccountId {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.0)
204    }
205}
206
207impl AsRef<str> for AccountId {
208    fn as_ref(&self) -> &str {
209        &self.0
210    }
211}
212
213impl BorshSerialize for AccountId {
214    fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
215        borsh::BorshSerialize::serialize(&self.0, writer)
216    }
217}
218
219impl BorshDeserialize for AccountId {
220    fn deserialize_reader<R: std::io::Read>(reader: &mut R) -> std::io::Result<Self> {
221        let s = String::deserialize_reader(reader)?;
222        Ok(Self(s))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_valid_named_accounts() {
232        assert!("alice.testnet".parse::<AccountId>().is_ok());
233        assert!("bob.near".parse::<AccountId>().is_ok());
234        assert!("sub.alice.testnet".parse::<AccountId>().is_ok());
235        assert!("a1.b2.c3.testnet".parse::<AccountId>().is_ok());
236        assert!("test_account.near".parse::<AccountId>().is_ok());
237        assert!("test-account.near".parse::<AccountId>().is_ok());
238    }
239
240    #[test]
241    fn test_valid_implicit_accounts() {
242        let hex64 = "0".repeat(64);
243        let account: AccountId = hex64.parse().unwrap();
244        assert!(account.is_implicit());
245        assert!(!account.is_named());
246    }
247
248    #[test]
249    fn test_valid_evm_accounts() {
250        let evm = format!("0x{}", "a".repeat(40));
251        let account: AccountId = evm.parse().unwrap();
252        assert!(account.is_evm_implicit());
253        assert!(!account.is_named());
254    }
255
256    #[test]
257    fn test_invalid_accounts() {
258        assert!("".parse::<AccountId>().is_err());
259        assert!("a".parse::<AccountId>().is_err()); // too short
260        assert!("A.near".parse::<AccountId>().is_err()); // uppercase
261        assert!(".alice.near".parse::<AccountId>().is_err()); // leading dot
262        assert!("alice.near.".parse::<AccountId>().is_err()); // trailing dot
263        assert!("alice..near".parse::<AccountId>().is_err()); // consecutive dots
264        assert!("-alice.near".parse::<AccountId>().is_err()); // leading hyphen
265    }
266
267    #[test]
268    fn test_parent() {
269        let account: AccountId = "sub.alice.testnet".parse().unwrap();
270        let parent = account.parent().unwrap();
271        assert_eq!(parent.as_str(), "alice.testnet");
272
273        let top: AccountId = "testnet".parse().unwrap();
274        assert!(top.parent().is_none());
275    }
276
277    #[test]
278    fn test_is_sub_account_of() {
279        let sub: AccountId = "sub.alice.testnet".parse().unwrap();
280        let parent: AccountId = "alice.testnet".parse().unwrap();
281        let testnet: AccountId = "testnet".parse().unwrap();
282
283        assert!(sub.is_sub_account_of(&parent));
284        assert!(parent.is_sub_account_of(&testnet));
285    }
286}