Skip to main content

aptos_sdk/api/
ans.rs

1//! Aptos Names Service (ANS) client.
2//!
3//! Resolves `.apt` names to addresses and back, and builds the entry-function
4//! payloads needed to register names and manage their primary-name / target
5//! address records. This mirrors the read/write surface of the TypeScript
6//! SDK's `ans` namespace, talking to the on-chain `router` module via view
7//! functions and entry functions.
8//!
9//! # Networks
10//!
11//! ANS is only deployed on mainnet, testnet, and (for development) localnet.
12//! On those networks [`AnsClient::new`] picks the correct `router` contract
13//! address automatically. For devnet, custom networks, or a privately deployed
14//! ANS, construct the client with [`AnsClient::with_router_address`] and supply
15//! the contract address yourself.
16//!
17//! # Name format
18//!
19//! Names follow the ANS validation rules (see [`AnsName`]): each segment must
20//! be 3-63 characters of lowercase `a-z`, `0-9`, and hyphens, and may not start
21//! or end with a hyphen. A name may have at most two segments,
22//! `subdomain.domain`, and an optional trailing `.apt` which is stripped.
23//!
24//! # Example
25//!
26//! ```rust,no_run
27//! use aptos_sdk::api::{AnsClient, FullnodeClient};
28//! use aptos_sdk::config::AptosConfig;
29//!
30//! # async fn run() -> aptos_sdk::error::AptosResult<()> {
31//! let fullnode = FullnodeClient::new(AptosConfig::mainnet())?;
32//! let ans = AnsClient::new(fullnode);
33//!
34//! if let Some(address) = ans.get_target_address("greg.apt").await? {
35//!     println!("greg.apt resolves to {address}");
36//! }
37//! # Ok(())
38//! # }
39//! ```
40
41use crate::api::FullnodeClient;
42use crate::config::Network;
43use crate::error::{AptosError, AptosResult};
44use crate::transaction::EntryFunction;
45use crate::types::AccountAddress;
46
47/// `router` contract address on mainnet.
48const MAINNET_ROUTER: &str = "0x867ed1f6bf916171b1de3ee92849b8978b7d1b9e0a8cc982a3d19d535dfd9c0c";
49/// `router` contract address on testnet.
50const TESTNET_ROUTER: &str = "0xb1ae61606dfbe0ea5b5c45ffdb4fb08da0dba18c5125182ff63ab280a450ecf4";
51/// Default `router` contract address used by the local ANS test deployment
52/// (matches the TypeScript SDK's `LOCAL_ANS_ACCOUNT_ADDRESS`).
53const LOCAL_ROUTER: &str = "0x585fc9f0f0c54183b039ffc770ca282ebd87307916c215a3e692f2f8e4305e82";
54
55/// Minimum length of a single ANS name segment.
56const MIN_SEGMENT_LEN: usize = 3;
57/// Maximum length of a single ANS name segment.
58const MAX_SEGMENT_LEN: usize = 63;
59
60/// Human-readable description of the segment validation rules.
61const SEGMENT_RULES: &str = "a segment must be 3-63 characters of lowercase a-z, 0-9, and hyphens, \
62     and may not start or end with a hyphen";
63
64/// A validated ANS name, split into its domain and optional subdomain.
65///
66/// Construct one with [`AnsName::parse`], which enforces the ANS validation
67/// rules and strips a trailing `.apt`.
68///
69/// # Example
70///
71/// ```rust
72/// use aptos_sdk::api::ans::AnsName;
73///
74/// let name = AnsName::parse("alice.apt")?;
75/// assert_eq!(name.domain(), "alice");
76/// assert_eq!(name.subdomain(), None);
77///
78/// let sub = AnsName::parse("wallet.alice.apt")?;
79/// assert_eq!(sub.domain(), "alice");
80/// assert_eq!(sub.subdomain(), Some("wallet"));
81/// # Ok::<(), aptos_sdk::error::AptosError>(())
82/// ```
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct AnsName {
85    domain: String,
86    subdomain: Option<String>,
87}
88
89impl AnsName {
90    /// Parses and validates an ANS name.
91    ///
92    /// Accepts names with or without a trailing `.apt`, and either a bare
93    /// domain (`alice`) or a `subdomain.domain` (`wallet.alice`).
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the name has more than two segments or any segment
98    /// violates the ANS rules (3-63 characters of lowercase `a-z`, `0-9`, and
99    /// non-leading/trailing hyphens).
100    pub fn parse(name: &str) -> AptosResult<Self> {
101        let trimmed = name.strip_suffix(".apt").unwrap_or(name);
102        let mut parts = trimmed.split('.');
103        let first = parts.next().unwrap_or("");
104        let second = parts.next();
105        if parts.next().is_some() {
106            return Err(invalid_name(name, "a name may have at most two segments"));
107        }
108
109        if !is_valid_segment(first) {
110            return Err(invalid_name(first, SEGMENT_RULES));
111        }
112        if let Some(second) = second
113            && !is_valid_segment(second)
114        {
115            return Err(invalid_name(second, SEGMENT_RULES));
116        }
117
118        match second {
119            // "subdomain.domain": first is the subdomain, second the domain.
120            Some(domain) => Ok(Self {
121                domain: domain.to_string(),
122                subdomain: Some(first.to_string()),
123            }),
124            // "domain": no subdomain.
125            None => Ok(Self {
126                domain: first.to_string(),
127                subdomain: None,
128            }),
129        }
130    }
131
132    /// Returns the domain (top-level) segment, e.g. `alice` for `alice.apt`.
133    #[must_use]
134    pub fn domain(&self) -> &str {
135        &self.domain
136    }
137
138    /// Returns the subdomain segment, if any.
139    #[must_use]
140    pub fn subdomain(&self) -> Option<&str> {
141        self.subdomain.as_deref()
142    }
143
144    /// Returns `true` if this name has a subdomain.
145    #[must_use]
146    pub fn is_subdomain(&self) -> bool {
147        self.subdomain.is_some()
148    }
149
150    /// Returns the fully qualified name (without the `.apt` suffix), e.g.
151    /// `wallet.alice` or `alice`.
152    #[must_use]
153    pub fn full_name(&self) -> String {
154        match &self.subdomain {
155            Some(sub) => format!("{sub}.{}", self.domain),
156            None => self.domain.clone(),
157        }
158    }
159}
160
161impl std::fmt::Display for AnsName {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "{}.apt", self.full_name())
164    }
165}
166
167fn invalid_name(value: &str, reason: &str) -> AptosError {
168    AptosError::Other(anyhow::anyhow!("invalid ANS name '{value}': {reason}"))
169}
170
171/// Validates a single ANS segment against the ANS naming rules.
172fn is_valid_segment(segment: &str) -> bool {
173    let len = segment.len();
174    if !(MIN_SEGMENT_LEN..=MAX_SEGMENT_LEN).contains(&len) {
175        return false;
176    }
177    let bytes = segment.as_bytes();
178    let is_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
179    // First and last characters must be alphanumeric (no leading/trailing hyphen).
180    if !is_alnum(bytes[0]) || !is_alnum(bytes[len - 1]) {
181        return false;
182    }
183    bytes.iter().all(|&b| is_alnum(b) || b == b'-')
184}
185
186/// Builds the standard `(domain: String, subdomain: Option<String>)` argument
187/// pair used by most `router` functions.
188fn domain_subdomain_args(name: &AnsName) -> AptosResult<Vec<Vec<u8>>> {
189    Ok(vec![
190        bcs_string(name.domain())?,
191        bcs_option_string(name.subdomain())?,
192    ])
193}
194
195/// Client for the Aptos Names Service.
196///
197/// Cheap to clone (wraps a [`FullnodeClient`]). See the [module docs](self) for
198/// usage and network support.
199#[derive(Debug, Clone)]
200pub struct AnsClient {
201    fullnode: FullnodeClient,
202    /// Explicit router contract address. When `None`, the address is resolved
203    /// from the fullnode's configured network.
204    router_override: Option<AccountAddress>,
205}
206
207impl AnsClient {
208    /// Constructs an ANS client that resolves the `router` contract address
209    /// from the fullnode's configured network (mainnet / testnet / localnet).
210    ///
211    /// For networks without a built-in ANS deployment (devnet, custom), use
212    /// [`with_router_address`](Self::with_router_address) instead; calls on a
213    /// client built with [`new`](Self::new) for such a network return an error.
214    #[must_use]
215    pub fn new(fullnode: FullnodeClient) -> Self {
216        Self {
217            fullnode,
218            router_override: None,
219        }
220    }
221
222    /// Constructs an ANS client pinned to an explicit `router` contract
223    /// address.
224    ///
225    /// Use this for devnet, custom networks, or a privately deployed ANS where
226    /// the contract address is not built in.
227    #[must_use]
228    pub fn with_router_address(fullnode: FullnodeClient, router_address: AccountAddress) -> Self {
229        Self {
230            fullnode,
231            router_override: Some(router_address),
232        }
233    }
234
235    /// Returns the resolved `router` contract address for this client.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`AptosError::Config`] if no override was supplied and the
240    /// configured network has no known ANS deployment.
241    pub fn router_address(&self) -> AptosResult<AccountAddress> {
242        if let Some(addr) = self.router_override {
243            return Ok(addr);
244        }
245        let hex = match self.fullnode.config().network() {
246            Network::Mainnet => MAINNET_ROUTER,
247            Network::Testnet => TESTNET_ROUTER,
248            Network::Local => LOCAL_ROUTER,
249            other => {
250                return Err(AptosError::Config(format!(
251                    "ANS is not deployed on {}; construct the client with \
252                     AnsClient::with_router_address to supply the router contract address",
253                    other.as_str()
254                )));
255            }
256        };
257        AccountAddress::from_hex(hex)
258    }
259
260    // === Reads ===
261
262    /// Resolves the **target address** a name points to (where it resolves to),
263    /// or `None` if the name is unregistered or has no target set.
264    ///
265    /// This is the address callers usually want when "looking up a name". It is
266    /// distinct from the owner address (see
267    /// [`get_owner_address`](Self::get_owner_address)).
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the network has no ANS deployment, the name is
272    /// invalid, or the underlying view call fails.
273    pub async fn get_target_address(&self, name: &str) -> AptosResult<Option<AccountAddress>> {
274        let name = AnsName::parse(name)?;
275        let values = self
276            .view_router("get_target_addr", domain_subdomain_args(&name)?)
277            .await?;
278        option_address(values.first())
279    }
280
281    /// Resolves the **owner address** of a name (the account that controls the
282    /// registration), or `None` if the name is unregistered.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the network has no ANS deployment, the name is
287    /// invalid, or the underlying view call fails.
288    pub async fn get_owner_address(&self, name: &str) -> AptosResult<Option<AccountAddress>> {
289        let name = AnsName::parse(name)?;
290        let values = self
291            .view_router("get_owner_addr", domain_subdomain_args(&name)?)
292            .await?;
293        option_address(values.first())
294    }
295
296    /// Returns the primary name registered to an address (as a fully qualified
297    /// name without the `.apt` suffix, e.g. `wallet.alice` or `alice`), or
298    /// `None` if the address has no primary name.
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if the network has no ANS deployment or the underlying
303    /// view call fails.
304    pub async fn get_primary_name(&self, address: AccountAddress) -> AptosResult<Option<String>> {
305        let args = vec![bcs_address(address)?];
306        let values = self.view_router("get_primary_name", args).await?;
307
308        // The router returns exactly (Option<String> subdomain, Option<String>
309        // domain). A shorter response is a malformed reply, not "no primary
310        // name", so surface it rather than masking a node/router bug.
311        if values.len() < 2 {
312            return Err(AptosError::Internal(format!(
313                "ANS get_primary_name returned {} value(s); expected 2 (subdomain, domain)",
314                values.len()
315            )));
316        }
317        let subdomain = option_string(&values[0])?;
318        let domain = option_string(&values[1])?;
319
320        Ok(domain.map(|domain| match subdomain {
321            Some(sub) => format!("{sub}.{domain}"),
322            None => domain,
323        }))
324    }
325
326    /// Returns the expiration timestamp of a name in **seconds since the Unix
327    /// epoch**, or `None` if the name is unregistered.
328    ///
329    /// Note: the TypeScript SDK returns milliseconds; this returns the raw
330    /// on-chain value in seconds. Multiply by 1000 for millisecond parity.
331    ///
332    /// # Errors
333    ///
334    /// Returns an error if the network has no ANS deployment, the name is
335    /// invalid, or the view call fails. A missing name is reported as
336    /// `Ok(None)`, not an error: the Move view aborts, which the node surfaces
337    /// as an HTTP 400 carrying the abort code. Other API failures (rate limits,
338    /// 5xx, malformed requests) propagate so transient/operational problems are
339    /// not mistaken for an unregistered name.
340    pub async fn get_expiration(&self, name: &str) -> AptosResult<Option<u64>> {
341        let name = AnsName::parse(name)?;
342        let args = domain_subdomain_args(&name)?;
343        match self.view_router("get_expiration", args).await {
344            // A successful response must carry exactly one `u64`. The missing-
345            // name case is handled by the Move-abort branch below, so an empty
346            // or non-`u64` *successful* response is a malformed reply, not "no
347            // expiration".
348            Ok(values) => {
349                let raw = values.first().ok_or_else(|| {
350                    AptosError::Internal("ANS get_expiration returned no value".into())
351                })?;
352                let secs = parse_u64(raw).ok_or_else(|| {
353                    AptosError::Internal(format!(
354                        "ANS get_expiration returned a non-u64 value: {raw}"
355                    ))
356                })?;
357                Ok(Some(secs))
358            }
359            // A missing name aborts in the Move view, which the node surfaces as
360            // an HTTP 400 carrying the Move abort code (`vm_error_code`). Treat
361            // *only* that as "no expiration"; propagate everything else (rate
362            // limits, 5xx, malformed requests) so operational failures are not
363            // mistaken for an unregistered name.
364            Err(AptosError::Api {
365                status_code: 400,
366                vm_error_code: Some(_),
367                ..
368            }) => Ok(None),
369            Err(e) => Err(e),
370        }
371    }
372
373    /// Resolves a name to its target address, erroring if it does not resolve.
374    ///
375    /// This is a convenience wrapper over
376    /// [`get_target_address`](Self::get_target_address) for callers that treat
377    /// an unresolved name as an error.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`AptosError::NotFound`] if the name does not resolve to an
382    /// address, plus any error from
383    /// [`get_target_address`](Self::get_target_address).
384    pub async fn lookup(&self, name: &str) -> AptosResult<AccountAddress> {
385        self.get_target_address(name).await?.ok_or_else(|| {
386            AptosError::NotFound(format!("ANS name '{name}' does not resolve to an address"))
387        })
388    }
389
390    /// Returns the primary name registered to an address, if any.
391    ///
392    /// Alias for [`get_primary_name`](Self::get_primary_name).
393    ///
394    /// # Errors
395    ///
396    /// Returns an error if the network has no ANS deployment or the underlying
397    /// view call fails.
398    pub async fn reverse_lookup(&self, address: AccountAddress) -> AptosResult<Option<String>> {
399        self.get_primary_name(address).await
400    }
401
402    // === Writes (entry-function builders) ===
403
404    /// Builds the payload to register a top-level domain for the given
405    /// duration (in seconds).
406    ///
407    /// `target_address` is the address the name will resolve to (defaults to
408    /// the sender on-chain when `None`); `to_address` is the account that will
409    /// own the registration (defaults to the sender when `None`).
410    ///
411    /// # Errors
412    ///
413    /// Returns an error if the network has no ANS deployment, the name is
414    /// invalid, the name has a subdomain (use a subdomain-specific flow), or
415    /// argument encoding fails.
416    pub fn register_domain_payload(
417        &self,
418        name: &str,
419        registration_duration_secs: u64,
420        target_address: Option<AccountAddress>,
421        to_address: Option<AccountAddress>,
422    ) -> AptosResult<EntryFunction> {
423        let name = AnsName::parse(name)?;
424        if name.is_subdomain() {
425            return Err(invalid_name(
426                &name.full_name(),
427                "register_domain_payload expects a top-level domain, not a subdomain",
428            ));
429        }
430        let args = vec![
431            bcs_string(name.domain())?,
432            bcs_u64(registration_duration_secs)?,
433            bcs_option_address(target_address)?,
434            bcs_option_address(to_address)?,
435        ];
436        self.router_entry("register_domain", args)
437    }
438
439    /// Builds the payload to set the sender's primary name to `name`.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if the network has no ANS deployment, the name is
444    /// invalid, or argument encoding fails.
445    pub fn set_primary_name_payload(&self, name: &str) -> AptosResult<EntryFunction> {
446        let name = AnsName::parse(name)?;
447        self.router_entry("set_primary_name", domain_subdomain_args(&name)?)
448    }
449
450    /// Builds the payload to clear the sender's primary name.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if the network has no ANS deployment.
455    pub fn clear_primary_name_payload(&self) -> AptosResult<EntryFunction> {
456        self.router_entry("clear_primary_name", vec![])
457    }
458
459    /// Builds the payload to point `name` at `address` (set its target
460    /// address).
461    ///
462    /// # Errors
463    ///
464    /// Returns an error if the network has no ANS deployment, the name is
465    /// invalid, or argument encoding fails.
466    pub fn set_target_address_payload(
467        &self,
468        name: &str,
469        address: AccountAddress,
470    ) -> AptosResult<EntryFunction> {
471        let name = AnsName::parse(name)?;
472        let mut args = domain_subdomain_args(&name)?;
473        args.push(bcs_address(address)?);
474        self.router_entry("set_target_addr", args)
475    }
476
477    /// Builds the payload to clear the target address of `name`.
478    ///
479    /// # Errors
480    ///
481    /// Returns an error if the network has no ANS deployment, the name is
482    /// invalid, or argument encoding fails.
483    pub fn clear_target_address_payload(&self, name: &str) -> AptosResult<EntryFunction> {
484        let name = AnsName::parse(name)?;
485        self.router_entry("clear_target_addr", domain_subdomain_args(&name)?)
486    }
487
488    // === Internal helpers ===
489
490    /// Calls a `router::<function>` view function with BCS-encoded args.
491    async fn view_router(
492        &self,
493        function: &str,
494        args: Vec<Vec<u8>>,
495    ) -> AptosResult<Vec<serde_json::Value>> {
496        let router = self.router_address()?;
497        let function_id = format!("{router}::router::{function}");
498        let response = self
499            .fullnode
500            .view_bcs_args(&function_id, vec![], args)
501            .await?;
502        Ok(response.into_inner())
503    }
504
505    /// Builds an entry-function payload targeting `router::<function>`.
506    fn router_entry(&self, function: &str, args: Vec<Vec<u8>>) -> AptosResult<EntryFunction> {
507        let router = self.router_address()?;
508        let function_id = format!("{router}::router::{function}");
509        EntryFunction::from_function_id(&function_id, vec![], args)
510    }
511}
512
513// === BCS argument encoders ===
514
515fn bcs_string(value: &str) -> AptosResult<Vec<u8>> {
516    aptos_bcs::to_bytes(&value.to_string()).map_err(AptosError::bcs)
517}
518
519fn bcs_option_string(value: Option<&str>) -> AptosResult<Vec<u8>> {
520    aptos_bcs::to_bytes(&value.map(ToString::to_string)).map_err(AptosError::bcs)
521}
522
523fn bcs_address(value: AccountAddress) -> AptosResult<Vec<u8>> {
524    aptos_bcs::to_bytes(&value).map_err(AptosError::bcs)
525}
526
527fn bcs_option_address(value: Option<AccountAddress>) -> AptosResult<Vec<u8>> {
528    aptos_bcs::to_bytes(&value).map_err(AptosError::bcs)
529}
530
531fn bcs_u64(value: u64) -> AptosResult<Vec<u8>> {
532    aptos_bcs::to_bytes(&value).map_err(AptosError::bcs)
533}
534
535// === JSON result decoders ===
536
537/// Unwraps a Move `Option<T>` from its JSON form.
538///
539/// The node renders `0x1::option::Option<T>` as `{"vec": []}` (none) or
540/// `{"vec": [value]}` (some); a bare array is also accepted defensively.
541/// `Ok(None)` is a genuine `Option::none()`. A value that is not Option-shaped
542/// at all is a malformed reply and yields an error rather than being silently
543/// treated as "not found".
544fn unwrap_option(value: &serde_json::Value) -> AptosResult<Option<&serde_json::Value>> {
545    if let Some(vec) = value.get("vec").and_then(serde_json::Value::as_array) {
546        return Ok(vec.first());
547    }
548    if let Some(arr) = value.as_array() {
549        return Ok(arr.first());
550    }
551    Err(AptosError::Internal(format!(
552        "ANS view returned a value that is not a Move Option: {value}"
553    )))
554}
555
556/// Decodes an `Option<address>` view result into an [`AccountAddress`].
557///
558/// A Move `Option::none()` (an unregistered or unset record) yields `Ok(None)`.
559/// A malformed reply -- a missing return value, a non-Option value, a
560/// non-string entry, or an undecodable address -- is surfaced as an error
561/// rather than being silently treated as "not found".
562fn option_address(value: Option<&serde_json::Value>) -> AptosResult<Option<AccountAddress>> {
563    let value = value.ok_or_else(|| {
564        AptosError::Internal(
565            "ANS view returned no value where an Option<address> was expected".into(),
566        )
567    })?;
568    let Some(inner) = unwrap_option(value)? else {
569        return Ok(None);
570    };
571    let s = inner.as_str().ok_or_else(|| {
572        AptosError::Internal(format!("ANS view returned a non-string address: {inner}"))
573    })?;
574    let address = AccountAddress::from_hex(s).map_err(|e| {
575        AptosError::Internal(format!("ANS view returned a malformed address '{s}': {e}"))
576    })?;
577    Ok(Some(address))
578}
579
580/// Decodes an `Option<String>` view result, mapping an empty string to `None`.
581///
582/// A genuine `Option::none()` (or an empty string) yields `Ok(None)`; a
583/// non-Option or non-string value is a malformed reply and yields an error.
584fn option_string(value: &serde_json::Value) -> AptosResult<Option<String>> {
585    let Some(inner) = unwrap_option(value)? else {
586        return Ok(None);
587    };
588    let s = inner.as_str().ok_or_else(|| {
589        AptosError::Internal(format!("ANS view returned a non-string value: {inner}"))
590    })?;
591    Ok(if s.is_empty() {
592        None
593    } else {
594        Some(s.to_string())
595    })
596}
597
598/// Parses a `u64` view result, which the node renders as a JSON string.
599fn parse_u64(value: &serde_json::Value) -> Option<u64> {
600    if let Some(n) = value.as_u64() {
601        return Some(n);
602    }
603    value.as_str().and_then(|s| s.parse().ok())
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::config::AptosConfig;
610    use wiremock::matchers::{method, path};
611    use wiremock::{Mock, MockServer, ResponseTemplate};
612
613    fn ans_for(server: &MockServer) -> AnsClient {
614        let url = format!("{}/v1", server.uri());
615        let config = AptosConfig::custom(&url).unwrap().without_retry();
616        let fullnode = FullnodeClient::new(config).unwrap();
617        // Custom network has no built-in ANS deployment; pin a router address.
618        AnsClient::with_router_address(fullnode, AccountAddress::from_hex("0x1").unwrap())
619    }
620
621    #[test]
622    fn parse_plain_domain() {
623        let name = AnsName::parse("alice.apt").unwrap();
624        assert_eq!(name.domain(), "alice");
625        assert_eq!(name.subdomain(), None);
626        assert!(!name.is_subdomain());
627        assert_eq!(name.full_name(), "alice");
628        assert_eq!(name.to_string(), "alice.apt");
629    }
630
631    #[test]
632    fn parse_without_suffix() {
633        let name = AnsName::parse("alice").unwrap();
634        assert_eq!(name.domain(), "alice");
635        assert_eq!(name.subdomain(), None);
636    }
637
638    #[test]
639    fn parse_subdomain() {
640        let name = AnsName::parse("wallet.alice.apt").unwrap();
641        assert_eq!(name.domain(), "alice");
642        assert_eq!(name.subdomain(), Some("wallet"));
643        assert!(name.is_subdomain());
644        assert_eq!(name.full_name(), "wallet.alice");
645    }
646
647    #[test]
648    fn parse_rejects_invalid_names() {
649        // Too short.
650        assert!(AnsName::parse("ab").is_err());
651        // Leading hyphen.
652        assert!(AnsName::parse("-abc").is_err());
653        // Trailing hyphen.
654        assert!(AnsName::parse("abc-").is_err());
655        // Uppercase.
656        assert!(AnsName::parse("Alice").is_err());
657        // Three segments.
658        assert!(AnsName::parse("a.b.c").is_err());
659        // Invalid characters.
660        assert!(AnsName::parse("ali_ce").is_err());
661    }
662
663    #[test]
664    fn segment_validation_boundaries() {
665        assert!(is_valid_segment("abc"));
666        assert!(is_valid_segment("a-c"));
667        assert!(is_valid_segment("a1c"));
668        assert!(is_valid_segment(&"a".repeat(63)));
669        assert!(!is_valid_segment(&"a".repeat(64)));
670        assert!(!is_valid_segment("ab"));
671        assert!(!is_valid_segment("-bc"));
672        assert!(!is_valid_segment("ab-"));
673    }
674
675    #[test]
676    fn router_address_unsupported_network_errors() {
677        // Devnet has no built-in ANS deployment.
678        let fullnode = FullnodeClient::new(AptosConfig::devnet()).unwrap();
679        let ans = AnsClient::new(fullnode);
680        assert!(matches!(ans.router_address(), Err(AptosError::Config(_))));
681    }
682
683    #[test]
684    fn router_address_known_networks() {
685        // Compare against literals, not the constants: `from_hex` zero-pads
686        // short input, so a router constant that lost a digit would still parse
687        // (to a different address) and comparing it with itself would pass.
688        let mainnet = AnsClient::new(FullnodeClient::new(AptosConfig::mainnet()).unwrap());
689        assert_eq!(
690            mainnet.router_address().unwrap(),
691            AccountAddress::from_hex(
692                "0x867ed1f6bf916171b1de3ee92849b8978b7d1b9e0a8cc982a3d19d535dfd9c0c"
693            )
694            .unwrap()
695        );
696        let testnet = AnsClient::new(FullnodeClient::new(AptosConfig::testnet()).unwrap());
697        assert_eq!(
698            testnet.router_address().unwrap(),
699            AccountAddress::from_hex(
700                "0xb1ae61606dfbe0ea5b5c45ffdb4fb08da0dba18c5125182ff63ab280a450ecf4"
701            )
702            .unwrap()
703        );
704        let local = AnsClient::new(FullnodeClient::new(AptosConfig::local()).unwrap());
705        assert_eq!(
706            local.router_address().unwrap(),
707            AccountAddress::from_hex(
708                "0x585fc9f0f0c54183b039ffc770ca282ebd87307916c215a3e692f2f8e4305e82"
709            )
710            .unwrap()
711        );
712    }
713
714    #[tokio::test]
715    async fn get_target_address_resolves() {
716        let server = MockServer::start().await;
717        let addr = "0x0000000000000000000000000000000000000000000000000000000000000abc";
718        Mock::given(method("POST"))
719            .and(path("/v1/view"))
720            .respond_with(
721                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": [addr]}])),
722            )
723            .expect(1)
724            .mount(&server)
725            .await;
726
727        let ans = ans_for(&server);
728        let resolved = ans.get_target_address("alice.apt").await.unwrap();
729        assert_eq!(resolved, Some(AccountAddress::from_hex(addr).unwrap()));
730    }
731
732    #[tokio::test]
733    async fn get_target_address_none_when_unregistered() {
734        let server = MockServer::start().await;
735        Mock::given(method("POST"))
736            .and(path("/v1/view"))
737            .respond_with(
738                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": []}])),
739            )
740            .expect(1)
741            .mount(&server)
742            .await;
743
744        let ans = ans_for(&server);
745        let resolved = ans.get_target_address("alice.apt").await.unwrap();
746        assert_eq!(resolved, None);
747    }
748
749    #[tokio::test]
750    async fn get_primary_name_combines_subdomain_and_domain() {
751        let server = MockServer::start().await;
752        Mock::given(method("POST"))
753            .and(path("/v1/view"))
754            .respond_with(ResponseTemplate::new(200).set_body_json(
755                // (subdomain, domain)
756                serde_json::json!([{"vec": ["wallet"]}, {"vec": ["alice"]}]),
757            ))
758            .expect(1)
759            .mount(&server)
760            .await;
761
762        let ans = ans_for(&server);
763        let name = ans.get_primary_name(AccountAddress::ONE).await.unwrap();
764        assert_eq!(name, Some("wallet.alice".to_string()));
765    }
766
767    #[tokio::test]
768    async fn get_primary_name_domain_only() {
769        let server = MockServer::start().await;
770        Mock::given(method("POST"))
771            .and(path("/v1/view"))
772            .respond_with(
773                ResponseTemplate::new(200)
774                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": ["alice"]}])),
775            )
776            .expect(1)
777            .mount(&server)
778            .await;
779
780        let ans = ans_for(&server);
781        let name = ans.get_primary_name(AccountAddress::ONE).await.unwrap();
782        assert_eq!(name, Some("alice".to_string()));
783    }
784
785    #[tokio::test]
786    async fn get_primary_name_none() {
787        let server = MockServer::start().await;
788        Mock::given(method("POST"))
789            .and(path("/v1/view"))
790            .respond_with(
791                ResponseTemplate::new(200)
792                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": []}])),
793            )
794            .expect(1)
795            .mount(&server)
796            .await;
797
798        let ans = ans_for(&server);
799        let name = ans.get_primary_name(AccountAddress::ONE).await.unwrap();
800        assert_eq!(name, None);
801    }
802
803    #[tokio::test]
804    async fn get_expiration_parses_seconds() {
805        let server = MockServer::start().await;
806        Mock::given(method("POST"))
807            .and(path("/v1/view"))
808            .respond_with(
809                ResponseTemplate::new(200).set_body_json(serde_json::json!(["1700000000"])),
810            )
811            .expect(1)
812            .mount(&server)
813            .await;
814
815        let ans = ans_for(&server);
816        let expiration = ans.get_expiration("alice.apt").await.unwrap();
817        assert_eq!(expiration, Some(1_700_000_000));
818    }
819
820    #[tokio::test]
821    async fn get_expiration_none_on_move_abort() {
822        let server = MockServer::start().await;
823        Mock::given(method("POST"))
824            .and(path("/v1/view"))
825            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
826                "message": "Move abort",
827                "error_code": "invalid_input",
828                "vm_error_code": 196609
829            })))
830            .expect(1)
831            .mount(&server)
832            .await;
833
834        let ans = ans_for(&server);
835        let expiration = ans.get_expiration("alice.apt").await.unwrap();
836        assert_eq!(expiration, None);
837    }
838
839    #[tokio::test]
840    async fn lookup_errors_when_unresolved() {
841        let server = MockServer::start().await;
842        Mock::given(method("POST"))
843            .and(path("/v1/view"))
844            .respond_with(
845                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": []}])),
846            )
847            .mount(&server)
848            .await;
849
850        let ans = ans_for(&server);
851        let err = ans.lookup("alice.apt").await.unwrap_err();
852        assert!(matches!(err, AptosError::NotFound(_)));
853    }
854
855    #[test]
856    fn set_primary_name_payload_encodes_arguments() {
857        let router = AccountAddress::from_hex("0x1").unwrap();
858        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
859        let ans = AnsClient::with_router_address(fullnode, router);
860
861        let payload = ans.set_primary_name_payload("alice.apt").unwrap();
862        assert_eq!(payload.module.address, router);
863        assert_eq!(payload.module.name.as_str(), "router");
864        assert_eq!(payload.function, "set_primary_name");
865        // (domain: String "alice", subdomain: Option<String>::None)
866        assert_eq!(payload.args.len(), 2);
867        assert_eq!(
868            payload.args[0],
869            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
870        );
871        assert_eq!(
872            payload.args[1],
873            aptos_bcs::to_bytes(&Option::<String>::None).unwrap()
874        );
875    }
876
877    #[test]
878    fn set_target_address_payload_encodes_arguments() {
879        let router = AccountAddress::from_hex("0x1").unwrap();
880        let target = AccountAddress::from_hex("0xabc").unwrap();
881        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
882        let ans = AnsClient::with_router_address(fullnode, router);
883
884        let payload = ans
885            .set_target_address_payload("wallet.alice.apt", target)
886            .unwrap();
887        assert_eq!(payload.function, "set_target_addr");
888        // (domain "alice", subdomain Some("wallet"), address target)
889        assert_eq!(payload.args.len(), 3);
890        assert_eq!(
891            payload.args[0],
892            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
893        );
894        assert_eq!(
895            payload.args[1],
896            aptos_bcs::to_bytes(&Some("wallet".to_string())).unwrap()
897        );
898        assert_eq!(payload.args[2], aptos_bcs::to_bytes(&target).unwrap());
899    }
900
901    #[test]
902    fn register_domain_payload_rejects_subdomain() {
903        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
904        let ans =
905            AnsClient::with_router_address(fullnode, AccountAddress::from_hex("0x1").unwrap());
906        let result = ans.register_domain_payload("wallet.alice.apt", 31_536_000, None, None);
907        assert!(matches!(result, Err(AptosError::Other(_))));
908    }
909
910    #[test]
911    fn register_domain_payload_encodes_arguments() {
912        let router = AccountAddress::from_hex("0x1").unwrap();
913        let target = AccountAddress::from_hex("0xabc").unwrap();
914        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
915        let ans = AnsClient::with_router_address(fullnode, router);
916
917        let payload = ans
918            .register_domain_payload("alice.apt", 31_536_000, Some(target), None)
919            .unwrap();
920        assert_eq!(payload.function, "register_domain");
921        // (domain "alice", duration u64, target Some(addr), to None)
922        assert_eq!(payload.args.len(), 4);
923        assert_eq!(
924            payload.args[0],
925            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
926        );
927        assert_eq!(
928            payload.args[1],
929            aptos_bcs::to_bytes(&31_536_000u64).unwrap()
930        );
931        assert_eq!(payload.args[2], aptos_bcs::to_bytes(&Some(target)).unwrap());
932        assert_eq!(
933            payload.args[3],
934            aptos_bcs::to_bytes(&Option::<AccountAddress>::None).unwrap()
935        );
936    }
937
938    #[test]
939    fn clear_primary_name_payload_has_no_args() {
940        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
941        let ans =
942            AnsClient::with_router_address(fullnode, AccountAddress::from_hex("0x1").unwrap());
943        let payload = ans.clear_primary_name_payload().unwrap();
944        assert_eq!(payload.function, "clear_primary_name");
945        assert!(payload.args.is_empty());
946    }
947
948    #[tokio::test]
949    async fn get_owner_address_resolves() {
950        let server = MockServer::start().await;
951        let addr = "0x0000000000000000000000000000000000000000000000000000000000000abc";
952        Mock::given(method("POST"))
953            .and(path("/v1/view"))
954            .respond_with(
955                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": [addr]}])),
956            )
957            .expect(1)
958            .mount(&server)
959            .await;
960
961        let ans = ans_for(&server);
962        let owner = ans.get_owner_address("alice.apt").await.unwrap();
963        assert_eq!(owner, Some(AccountAddress::from_hex(addr).unwrap()));
964    }
965
966    #[tokio::test]
967    async fn lookup_resolves_to_target_address() {
968        let server = MockServer::start().await;
969        let addr = "0x0000000000000000000000000000000000000000000000000000000000000abc";
970        Mock::given(method("POST"))
971            .and(path("/v1/view"))
972            .respond_with(
973                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": [addr]}])),
974            )
975            .mount(&server)
976            .await;
977
978        let ans = ans_for(&server);
979        let resolved = ans.lookup("alice.apt").await.unwrap();
980        assert_eq!(resolved, AccountAddress::from_hex(addr).unwrap());
981    }
982
983    #[tokio::test]
984    async fn reverse_lookup_returns_primary_name() {
985        let server = MockServer::start().await;
986        Mock::given(method("POST"))
987            .and(path("/v1/view"))
988            .respond_with(
989                ResponseTemplate::new(200)
990                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": ["alice"]}])),
991            )
992            .mount(&server)
993            .await;
994
995        let ans = ans_for(&server);
996        let name = ans.reverse_lookup(AccountAddress::ONE).await.unwrap();
997        assert_eq!(name, Some("alice".to_string()));
998    }
999
1000    #[test]
1001    fn clear_target_address_payload_encodes_arguments() {
1002        let router = AccountAddress::from_hex("0x1").unwrap();
1003        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
1004        let ans = AnsClient::with_router_address(fullnode, router);
1005
1006        let payload = ans.clear_target_address_payload("alice.apt").unwrap();
1007        assert_eq!(payload.function, "clear_target_addr");
1008        // (domain "alice", subdomain Option<String>::None)
1009        assert_eq!(payload.args.len(), 2);
1010        assert_eq!(
1011            payload.args[0],
1012            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
1013        );
1014        assert_eq!(
1015            payload.args[1],
1016            aptos_bcs::to_bytes(&Option::<String>::None).unwrap()
1017        );
1018    }
1019
1020    #[tokio::test]
1021    async fn get_expiration_propagates_server_error() {
1022        let server = MockServer::start().await;
1023        Mock::given(method("POST"))
1024            .and(path("/v1/view"))
1025            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
1026                "message": "internal error"
1027            })))
1028            .mount(&server)
1029            .await;
1030
1031        let ans = ans_for(&server);
1032        // A 5xx (or any non-abort API error) must surface as an error rather
1033        // than being silently coerced to `None`.
1034        let result = ans.get_expiration("alice.apt").await;
1035        assert!(matches!(
1036            result,
1037            Err(AptosError::Api {
1038                status_code: 500,
1039                ..
1040            })
1041        ));
1042    }
1043
1044    #[tokio::test]
1045    async fn get_target_address_errors_on_malformed_address() {
1046        let server = MockServer::start().await;
1047        // A present-but-undecodable address must surface as an error, not be
1048        // silently treated as "unregistered" (which would let `lookup` report
1049        // a spurious NotFound).
1050        Mock::given(method("POST"))
1051            .and(path("/v1/view"))
1052            .respond_with(
1053                ResponseTemplate::new(200)
1054                    .set_body_json(serde_json::json!([{"vec": ["not-an-address"]}])),
1055            )
1056            .mount(&server)
1057            .await;
1058
1059        let ans = ans_for(&server);
1060        let err = ans.get_target_address("alice.apt").await.unwrap_err();
1061        assert!(matches!(err, AptosError::Internal(_)));
1062    }
1063
1064    #[tokio::test]
1065    async fn get_expiration_errors_on_malformed_success() {
1066        let server = MockServer::start().await;
1067        // A successful response with no return value is malformed and must not
1068        // be confused with a missing name (which arrives as a Move-abort 400).
1069        Mock::given(method("POST"))
1070            .and(path("/v1/view"))
1071            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1072            .mount(&server)
1073            .await;
1074
1075        let ans = ans_for(&server);
1076        let err = ans.get_expiration("alice.apt").await.unwrap_err();
1077        assert!(matches!(err, AptosError::Internal(_)));
1078    }
1079
1080    #[tokio::test]
1081    async fn get_target_address_none_on_move_option_none() {
1082        let server = MockServer::start().await;
1083        // A genuine Move `Option::none()` (empty `vec`) is an unset record and
1084        // must remain `Ok(None)`.
1085        Mock::given(method("POST"))
1086            .and(path("/v1/view"))
1087            .respond_with(
1088                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": []}])),
1089            )
1090            .mount(&server)
1091            .await;
1092
1093        let ans = ans_for(&server);
1094        assert_eq!(ans.get_target_address("alice.apt").await.unwrap(), None);
1095    }
1096
1097    #[tokio::test]
1098    async fn get_target_address_errors_on_non_option_value() {
1099        let server = MockServer::start().await;
1100        // A value that is not Move-Option-shaped (here a bare string instead of
1101        // `{"vec": [...]}`) is a malformed reply and must error, not be coerced
1102        // to "unregistered".
1103        Mock::given(method("POST"))
1104            .and(path("/v1/view"))
1105            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["0x1"])))
1106            .mount(&server)
1107            .await;
1108
1109        let ans = ans_for(&server);
1110        let err = ans.get_target_address("alice.apt").await.unwrap_err();
1111        assert!(matches!(err, AptosError::Internal(_)));
1112    }
1113
1114    #[tokio::test]
1115    async fn get_primary_name_errors_on_short_response() {
1116        let server = MockServer::start().await;
1117        // The router view returns two values; a one-value response is malformed
1118        // and must surface as an error rather than "no primary name".
1119        Mock::given(method("POST"))
1120            .and(path("/v1/view"))
1121            .respond_with(
1122                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": ["alice"]}])),
1123            )
1124            .mount(&server)
1125            .await;
1126
1127        let ans = ans_for(&server);
1128        let err = ans.get_primary_name(AccountAddress::ONE).await.unwrap_err();
1129        assert!(matches!(err, AptosError::Internal(_)));
1130    }
1131
1132    #[tokio::test]
1133    async fn get_primary_name_errors_on_non_string_value() {
1134        let server = MockServer::start().await;
1135        // A non-string inside the domain Option is a malformed reply.
1136        Mock::given(method("POST"))
1137            .and(path("/v1/view"))
1138            .respond_with(
1139                ResponseTemplate::new(200)
1140                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": [42]}])),
1141            )
1142            .mount(&server)
1143            .await;
1144
1145        let ans = ans_for(&server);
1146        let err = ans.get_primary_name(AccountAddress::ONE).await.unwrap_err();
1147        assert!(matches!(err, AptosError::Internal(_)));
1148    }
1149}