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 = "0x5f8fd2347449685cf41d4db97926ec3a096eaf381332be4f1318ad4d16a8497c";
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        let mainnet = AnsClient::new(FullnodeClient::new(AptosConfig::mainnet()).unwrap());
686        assert_eq!(
687            mainnet.router_address().unwrap(),
688            AccountAddress::from_hex(MAINNET_ROUTER).unwrap()
689        );
690        let testnet = AnsClient::new(FullnodeClient::new(AptosConfig::testnet()).unwrap());
691        assert_eq!(
692            testnet.router_address().unwrap(),
693            AccountAddress::from_hex(TESTNET_ROUTER).unwrap()
694        );
695    }
696
697    #[tokio::test]
698    async fn get_target_address_resolves() {
699        let server = MockServer::start().await;
700        let addr = "0x0000000000000000000000000000000000000000000000000000000000000abc";
701        Mock::given(method("POST"))
702            .and(path("/v1/view"))
703            .respond_with(
704                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": [addr]}])),
705            )
706            .expect(1)
707            .mount(&server)
708            .await;
709
710        let ans = ans_for(&server);
711        let resolved = ans.get_target_address("alice.apt").await.unwrap();
712        assert_eq!(resolved, Some(AccountAddress::from_hex(addr).unwrap()));
713    }
714
715    #[tokio::test]
716    async fn get_target_address_none_when_unregistered() {
717        let server = MockServer::start().await;
718        Mock::given(method("POST"))
719            .and(path("/v1/view"))
720            .respond_with(
721                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": []}])),
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, None);
730    }
731
732    #[tokio::test]
733    async fn get_primary_name_combines_subdomain_and_domain() {
734        let server = MockServer::start().await;
735        Mock::given(method("POST"))
736            .and(path("/v1/view"))
737            .respond_with(ResponseTemplate::new(200).set_body_json(
738                // (subdomain, domain)
739                serde_json::json!([{"vec": ["wallet"]}, {"vec": ["alice"]}]),
740            ))
741            .expect(1)
742            .mount(&server)
743            .await;
744
745        let ans = ans_for(&server);
746        let name = ans.get_primary_name(AccountAddress::ONE).await.unwrap();
747        assert_eq!(name, Some("wallet.alice".to_string()));
748    }
749
750    #[tokio::test]
751    async fn get_primary_name_domain_only() {
752        let server = MockServer::start().await;
753        Mock::given(method("POST"))
754            .and(path("/v1/view"))
755            .respond_with(
756                ResponseTemplate::new(200)
757                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": ["alice"]}])),
758            )
759            .expect(1)
760            .mount(&server)
761            .await;
762
763        let ans = ans_for(&server);
764        let name = ans.get_primary_name(AccountAddress::ONE).await.unwrap();
765        assert_eq!(name, Some("alice".to_string()));
766    }
767
768    #[tokio::test]
769    async fn get_primary_name_none() {
770        let server = MockServer::start().await;
771        Mock::given(method("POST"))
772            .and(path("/v1/view"))
773            .respond_with(
774                ResponseTemplate::new(200)
775                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": []}])),
776            )
777            .expect(1)
778            .mount(&server)
779            .await;
780
781        let ans = ans_for(&server);
782        let name = ans.get_primary_name(AccountAddress::ONE).await.unwrap();
783        assert_eq!(name, None);
784    }
785
786    #[tokio::test]
787    async fn get_expiration_parses_seconds() {
788        let server = MockServer::start().await;
789        Mock::given(method("POST"))
790            .and(path("/v1/view"))
791            .respond_with(
792                ResponseTemplate::new(200).set_body_json(serde_json::json!(["1700000000"])),
793            )
794            .expect(1)
795            .mount(&server)
796            .await;
797
798        let ans = ans_for(&server);
799        let expiration = ans.get_expiration("alice.apt").await.unwrap();
800        assert_eq!(expiration, Some(1_700_000_000));
801    }
802
803    #[tokio::test]
804    async fn get_expiration_none_on_move_abort() {
805        let server = MockServer::start().await;
806        Mock::given(method("POST"))
807            .and(path("/v1/view"))
808            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
809                "message": "Move abort",
810                "error_code": "invalid_input",
811                "vm_error_code": 196609
812            })))
813            .expect(1)
814            .mount(&server)
815            .await;
816
817        let ans = ans_for(&server);
818        let expiration = ans.get_expiration("alice.apt").await.unwrap();
819        assert_eq!(expiration, None);
820    }
821
822    #[tokio::test]
823    async fn lookup_errors_when_unresolved() {
824        let server = MockServer::start().await;
825        Mock::given(method("POST"))
826            .and(path("/v1/view"))
827            .respond_with(
828                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": []}])),
829            )
830            .mount(&server)
831            .await;
832
833        let ans = ans_for(&server);
834        let err = ans.lookup("alice.apt").await.unwrap_err();
835        assert!(matches!(err, AptosError::NotFound(_)));
836    }
837
838    #[test]
839    fn set_primary_name_payload_encodes_arguments() {
840        let router = AccountAddress::from_hex("0x1").unwrap();
841        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
842        let ans = AnsClient::with_router_address(fullnode, router);
843
844        let payload = ans.set_primary_name_payload("alice.apt").unwrap();
845        assert_eq!(payload.module.address, router);
846        assert_eq!(payload.module.name.as_str(), "router");
847        assert_eq!(payload.function, "set_primary_name");
848        // (domain: String "alice", subdomain: Option<String>::None)
849        assert_eq!(payload.args.len(), 2);
850        assert_eq!(
851            payload.args[0],
852            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
853        );
854        assert_eq!(
855            payload.args[1],
856            aptos_bcs::to_bytes(&Option::<String>::None).unwrap()
857        );
858    }
859
860    #[test]
861    fn set_target_address_payload_encodes_arguments() {
862        let router = AccountAddress::from_hex("0x1").unwrap();
863        let target = AccountAddress::from_hex("0xabc").unwrap();
864        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
865        let ans = AnsClient::with_router_address(fullnode, router);
866
867        let payload = ans
868            .set_target_address_payload("wallet.alice.apt", target)
869            .unwrap();
870        assert_eq!(payload.function, "set_target_addr");
871        // (domain "alice", subdomain Some("wallet"), address target)
872        assert_eq!(payload.args.len(), 3);
873        assert_eq!(
874            payload.args[0],
875            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
876        );
877        assert_eq!(
878            payload.args[1],
879            aptos_bcs::to_bytes(&Some("wallet".to_string())).unwrap()
880        );
881        assert_eq!(payload.args[2], aptos_bcs::to_bytes(&target).unwrap());
882    }
883
884    #[test]
885    fn register_domain_payload_rejects_subdomain() {
886        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
887        let ans =
888            AnsClient::with_router_address(fullnode, AccountAddress::from_hex("0x1").unwrap());
889        let result = ans.register_domain_payload("wallet.alice.apt", 31_536_000, None, None);
890        assert!(matches!(result, Err(AptosError::Other(_))));
891    }
892
893    #[test]
894    fn register_domain_payload_encodes_arguments() {
895        let router = AccountAddress::from_hex("0x1").unwrap();
896        let target = AccountAddress::from_hex("0xabc").unwrap();
897        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
898        let ans = AnsClient::with_router_address(fullnode, router);
899
900        let payload = ans
901            .register_domain_payload("alice.apt", 31_536_000, Some(target), None)
902            .unwrap();
903        assert_eq!(payload.function, "register_domain");
904        // (domain "alice", duration u64, target Some(addr), to None)
905        assert_eq!(payload.args.len(), 4);
906        assert_eq!(
907            payload.args[0],
908            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
909        );
910        assert_eq!(
911            payload.args[1],
912            aptos_bcs::to_bytes(&31_536_000u64).unwrap()
913        );
914        assert_eq!(payload.args[2], aptos_bcs::to_bytes(&Some(target)).unwrap());
915        assert_eq!(
916            payload.args[3],
917            aptos_bcs::to_bytes(&Option::<AccountAddress>::None).unwrap()
918        );
919    }
920
921    #[test]
922    fn clear_primary_name_payload_has_no_args() {
923        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
924        let ans =
925            AnsClient::with_router_address(fullnode, AccountAddress::from_hex("0x1").unwrap());
926        let payload = ans.clear_primary_name_payload().unwrap();
927        assert_eq!(payload.function, "clear_primary_name");
928        assert!(payload.args.is_empty());
929    }
930
931    #[tokio::test]
932    async fn get_owner_address_resolves() {
933        let server = MockServer::start().await;
934        let addr = "0x0000000000000000000000000000000000000000000000000000000000000abc";
935        Mock::given(method("POST"))
936            .and(path("/v1/view"))
937            .respond_with(
938                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": [addr]}])),
939            )
940            .expect(1)
941            .mount(&server)
942            .await;
943
944        let ans = ans_for(&server);
945        let owner = ans.get_owner_address("alice.apt").await.unwrap();
946        assert_eq!(owner, Some(AccountAddress::from_hex(addr).unwrap()));
947    }
948
949    #[tokio::test]
950    async fn lookup_resolves_to_target_address() {
951        let server = MockServer::start().await;
952        let addr = "0x0000000000000000000000000000000000000000000000000000000000000abc";
953        Mock::given(method("POST"))
954            .and(path("/v1/view"))
955            .respond_with(
956                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": [addr]}])),
957            )
958            .mount(&server)
959            .await;
960
961        let ans = ans_for(&server);
962        let resolved = ans.lookup("alice.apt").await.unwrap();
963        assert_eq!(resolved, AccountAddress::from_hex(addr).unwrap());
964    }
965
966    #[tokio::test]
967    async fn reverse_lookup_returns_primary_name() {
968        let server = MockServer::start().await;
969        Mock::given(method("POST"))
970            .and(path("/v1/view"))
971            .respond_with(
972                ResponseTemplate::new(200)
973                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": ["alice"]}])),
974            )
975            .mount(&server)
976            .await;
977
978        let ans = ans_for(&server);
979        let name = ans.reverse_lookup(AccountAddress::ONE).await.unwrap();
980        assert_eq!(name, Some("alice".to_string()));
981    }
982
983    #[test]
984    fn clear_target_address_payload_encodes_arguments() {
985        let router = AccountAddress::from_hex("0x1").unwrap();
986        let fullnode = FullnodeClient::new(AptosConfig::mainnet()).unwrap();
987        let ans = AnsClient::with_router_address(fullnode, router);
988
989        let payload = ans.clear_target_address_payload("alice.apt").unwrap();
990        assert_eq!(payload.function, "clear_target_addr");
991        // (domain "alice", subdomain Option<String>::None)
992        assert_eq!(payload.args.len(), 2);
993        assert_eq!(
994            payload.args[0],
995            aptos_bcs::to_bytes(&"alice".to_string()).unwrap()
996        );
997        assert_eq!(
998            payload.args[1],
999            aptos_bcs::to_bytes(&Option::<String>::None).unwrap()
1000        );
1001    }
1002
1003    #[tokio::test]
1004    async fn get_expiration_propagates_server_error() {
1005        let server = MockServer::start().await;
1006        Mock::given(method("POST"))
1007            .and(path("/v1/view"))
1008            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
1009                "message": "internal error"
1010            })))
1011            .mount(&server)
1012            .await;
1013
1014        let ans = ans_for(&server);
1015        // A 5xx (or any non-abort API error) must surface as an error rather
1016        // than being silently coerced to `None`.
1017        let result = ans.get_expiration("alice.apt").await;
1018        assert!(matches!(
1019            result,
1020            Err(AptosError::Api {
1021                status_code: 500,
1022                ..
1023            })
1024        ));
1025    }
1026
1027    #[tokio::test]
1028    async fn get_target_address_errors_on_malformed_address() {
1029        let server = MockServer::start().await;
1030        // A present-but-undecodable address must surface as an error, not be
1031        // silently treated as "unregistered" (which would let `lookup` report
1032        // a spurious NotFound).
1033        Mock::given(method("POST"))
1034            .and(path("/v1/view"))
1035            .respond_with(
1036                ResponseTemplate::new(200)
1037                    .set_body_json(serde_json::json!([{"vec": ["not-an-address"]}])),
1038            )
1039            .mount(&server)
1040            .await;
1041
1042        let ans = ans_for(&server);
1043        let err = ans.get_target_address("alice.apt").await.unwrap_err();
1044        assert!(matches!(err, AptosError::Internal(_)));
1045    }
1046
1047    #[tokio::test]
1048    async fn get_expiration_errors_on_malformed_success() {
1049        let server = MockServer::start().await;
1050        // A successful response with no return value is malformed and must not
1051        // be confused with a missing name (which arrives as a Move-abort 400).
1052        Mock::given(method("POST"))
1053            .and(path("/v1/view"))
1054            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1055            .mount(&server)
1056            .await;
1057
1058        let ans = ans_for(&server);
1059        let err = ans.get_expiration("alice.apt").await.unwrap_err();
1060        assert!(matches!(err, AptosError::Internal(_)));
1061    }
1062
1063    #[tokio::test]
1064    async fn get_target_address_none_on_move_option_none() {
1065        let server = MockServer::start().await;
1066        // A genuine Move `Option::none()` (empty `vec`) is an unset record and
1067        // must remain `Ok(None)`.
1068        Mock::given(method("POST"))
1069            .and(path("/v1/view"))
1070            .respond_with(
1071                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": []}])),
1072            )
1073            .mount(&server)
1074            .await;
1075
1076        let ans = ans_for(&server);
1077        assert_eq!(ans.get_target_address("alice.apt").await.unwrap(), None);
1078    }
1079
1080    #[tokio::test]
1081    async fn get_target_address_errors_on_non_option_value() {
1082        let server = MockServer::start().await;
1083        // A value that is not Move-Option-shaped (here a bare string instead of
1084        // `{"vec": [...]}`) is a malformed reply and must error, not be coerced
1085        // to "unregistered".
1086        Mock::given(method("POST"))
1087            .and(path("/v1/view"))
1088            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(["0x1"])))
1089            .mount(&server)
1090            .await;
1091
1092        let ans = ans_for(&server);
1093        let err = ans.get_target_address("alice.apt").await.unwrap_err();
1094        assert!(matches!(err, AptosError::Internal(_)));
1095    }
1096
1097    #[tokio::test]
1098    async fn get_primary_name_errors_on_short_response() {
1099        let server = MockServer::start().await;
1100        // The router view returns two values; a one-value response is malformed
1101        // and must surface as an error rather than "no primary name".
1102        Mock::given(method("POST"))
1103            .and(path("/v1/view"))
1104            .respond_with(
1105                ResponseTemplate::new(200).set_body_json(serde_json::json!([{"vec": ["alice"]}])),
1106            )
1107            .mount(&server)
1108            .await;
1109
1110        let ans = ans_for(&server);
1111        let err = ans.get_primary_name(AccountAddress::ONE).await.unwrap_err();
1112        assert!(matches!(err, AptosError::Internal(_)));
1113    }
1114
1115    #[tokio::test]
1116    async fn get_primary_name_errors_on_non_string_value() {
1117        let server = MockServer::start().await;
1118        // A non-string inside the domain Option is a malformed reply.
1119        Mock::given(method("POST"))
1120            .and(path("/v1/view"))
1121            .respond_with(
1122                ResponseTemplate::new(200)
1123                    .set_body_json(serde_json::json!([{"vec": []}, {"vec": [42]}])),
1124            )
1125            .mount(&server)
1126            .await;
1127
1128        let ans = ans_for(&server);
1129        let err = ans.get_primary_name(AccountAddress::ONE).await.unwrap_err();
1130        assert!(matches!(err, AptosError::Internal(_)));
1131    }
1132}