sui_name_service/
lib.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fmt;
5use std::marker::PhantomData;
6use std::str::FromStr;
7
8use move_core_types::ident_str;
9use move_core_types::identifier::IdentStr;
10use move_core_types::language_storage::StructTag;
11use serde::{Deserialize, Serialize};
12use sui_types::TypeTag;
13use sui_types::base_types::{ObjectID, SuiAddress};
14use sui_types::collection_types::VecMap;
15use sui_types::dynamic_field::Field;
16use sui_types::id::{ID, UID};
17use sui_types::object::{MoveObject, Object};
18
19const NAME_SERVICE_DOMAIN_MODULE: &IdentStr = ident_str!("domain");
20const NAME_SERVICE_DOMAIN_STRUCT: &IdentStr = ident_str!("Domain");
21const LEAF_EXPIRATION_TIMESTAMP: u64 = 0;
22const DEFAULT_TLD: &str = "sui";
23const ACCEPTED_SEPARATORS: [char; 2] = ['.', '*'];
24const SUI_NEW_FORMAT_SEPARATOR: char = '@';
25
26#[derive(Debug, Serialize, Deserialize, Clone)]
27pub struct Registry {
28    /// The `registry` table maps `Domain` to `NameRecord`.
29    /// Added / replaced in the `add_record` function.
30    registry: Table<Domain, NameRecord>,
31    /// The `reverse_registry` table maps `address` to `domain_name`.
32    /// Updated in the `set_reverse_lookup` function.
33    reverse_registry: Table<SuiAddress, Domain>,
34}
35
36#[derive(Debug, Serialize, Deserialize, Clone, Eq, Hash, PartialEq)]
37pub struct Domain {
38    labels: Vec<String>,
39}
40
41/// A single record in the registry.
42#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
43pub struct NameRecord {
44    /// The ID of the `RegistrationNFT` assigned to this record.
45    ///
46    /// The owner of the corrisponding `RegistrationNFT` has the rights to
47    /// be able to change and adjust the `target_address` of this domain.
48    ///
49    /// It is possible that the ID changes if the record expires and is
50    /// purchased by someone else.
51    pub nft_id: ID,
52    /// Timestamp in milliseconds when the record expires.
53    pub expiration_timestamp_ms: u64,
54    /// The target address that this domain points to
55    pub target_address: Option<SuiAddress>,
56    /// Additional data which may be stored in a record
57    pub data: VecMap<String, String>,
58}
59
60/// A SuinsRegistration object to manage an SLD
61#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
62pub struct SuinsRegistration {
63    pub id: UID,
64    pub domain: Domain,
65    pub domain_name: String,
66    pub expiration_timestamp_ms: u64,
67    pub image_url: String,
68}
69
70/// A SubDomainRegistration object to manage a subdomain.
71#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
72pub struct SubDomainRegistration {
73    pub id: UID,
74    pub nft: SuinsRegistration,
75}
76
77/// Two different view options for a domain.
78/// `At` -> `test@example` | `Dot` -> `test.example.sui`
79#[derive(Clone, Eq, PartialEq, Debug)]
80pub enum DomainFormat {
81    At,
82    Dot,
83}
84
85#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
86#[serde(rename_all = "kebab-case")]
87pub struct NameServiceConfig {
88    pub package_address: SuiAddress,
89    pub registry_id: ObjectID,
90    pub reverse_registry_id: ObjectID,
91}
92
93/// Rust version of the Move sui::table::Table type.
94#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
95pub struct Table<K, V> {
96    pub id: ObjectID,
97    pub size: u64,
98
99    #[serde(skip)]
100    _key: PhantomData<K>,
101    #[serde(skip)]
102    _value: PhantomData<V>,
103}
104
105#[derive(thiserror::Error, Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
106pub enum NameServiceError {
107    #[error("Name Service: String length: {0} exceeds maximum allowed length: {1}")]
108    ExceedsMaxLength(usize, usize),
109    #[error("Name Service: String length: {0} outside of valid range: [{1}, {2}]")]
110    InvalidLength(usize, usize, usize),
111    #[error("Name Service: Hyphens are not allowed as the first or last character")]
112    InvalidHyphens,
113    #[error("Name Service: Only lowercase letters, numbers, and hyphens are allowed")]
114    InvalidUnderscore,
115    #[error("Name Service: Domain must contain at least one label")]
116    LabelsEmpty,
117    #[error("Name Service: Domain must include only one separator")]
118    InvalidSeparator,
119
120    #[error("Name Service: Name has expired.")]
121    NameExpired,
122    #[error("Name Service: Malformed object for {0}")]
123    MalformedObject(ObjectID),
124}
125
126impl Domain {
127    pub fn type_(package_address: SuiAddress) -> StructTag {
128        StructTag {
129            address: package_address.into(),
130            module: NAME_SERVICE_DOMAIN_MODULE.to_owned(),
131            name: NAME_SERVICE_DOMAIN_STRUCT.to_owned(),
132            type_params: vec![],
133        }
134    }
135
136    /// Derive the parent domain for a given domain
137    /// E.g. `test.example.sui` -> `example.sui`
138    ///
139    /// SAFETY: This is a safe operation because we only allow a
140    /// domain's label vector size to be >= 2 (see `Domain::from_str`)
141    pub fn parent(&self) -> Domain {
142        Domain {
143            labels: self.labels[0..(self.labels.len() - 1)].to_vec(),
144        }
145    }
146
147    pub fn is_subdomain(&self) -> bool {
148        self.depth() >= 3
149    }
150
151    /// Returns the depth for a name.
152    /// Depth is defined by the amount of labels in a domain, including TLD.
153    /// E.g. `test.example.sui` -> `3`
154    ///
155    /// SAFETY: We can safely cast to a u8 as the max depth is 235.
156    pub fn depth(&self) -> u8 {
157        self.labels.len() as u8
158    }
159
160    /// Formats a domain into a string based on the available output formats.
161    /// The default separator is `.`
162    pub fn format(&self, format: DomainFormat) -> String {
163        let mut labels = self.labels.clone();
164        let sep = &ACCEPTED_SEPARATORS[0].to_string();
165        labels.reverse();
166
167        if format == DomainFormat::Dot {
168            return labels.join(sep);
169        };
170
171        // SAFETY: This is a safe operation because we only allow a
172        // domain's label vector size to be >= 2 (see `Domain::from_str`)
173        let _tld = labels.pop();
174        let sld = labels.pop().unwrap();
175
176        format!("{}{}{}", labels.join(sep), SUI_NEW_FORMAT_SEPARATOR, sld)
177    }
178}
179
180impl NameServiceConfig {
181    pub fn new(
182        package_address: SuiAddress,
183        registry_id: ObjectID,
184        reverse_registry_id: ObjectID,
185    ) -> Self {
186        Self {
187            package_address,
188            registry_id,
189            reverse_registry_id,
190        }
191    }
192
193    pub fn record_field_id(&self, domain: &Domain) -> ObjectID {
194        let domain_type_tag = Domain::type_(self.package_address);
195        let domain_bytes = bcs::to_bytes(domain).unwrap();
196
197        sui_types::dynamic_field::derive_dynamic_field_id(
198            self.registry_id,
199            &TypeTag::Struct(Box::new(domain_type_tag)),
200            &domain_bytes,
201        )
202        .unwrap()
203    }
204
205    pub fn reverse_record_field_id(&self, address: &[u8]) -> ObjectID {
206        sui_types::dynamic_field::derive_dynamic_field_id(
207            self.reverse_registry_id,
208            &TypeTag::Address,
209            address,
210        )
211        .unwrap()
212    }
213
214    pub fn name_record_type(&self) -> StructTag {
215        StructTag {
216            address: self.package_address.into(),
217            module: ident_str!("name_record").to_owned(),
218            name: ident_str!("NameRecord").to_owned(),
219            type_params: vec![],
220        }
221    }
222
223    // Create a config based on the package and object ids published on mainnet
224    pub fn mainnet() -> Self {
225        const MAINNET_NS_PACKAGE_ADDRESS: &str =
226            "0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0";
227        const MAINNET_NS_REGISTRY_ID: &str =
228            "0xe64cd9db9f829c6cc405d9790bd71567ae07259855f4fba6f02c84f52298c106";
229        const MAINNET_NS_REVERSE_REGISTRY_ID: &str =
230            "0x2fd099e17a292d2bc541df474f9fafa595653848cbabb2d7a4656ec786a1969f";
231
232        let package_address = SuiAddress::from_str(MAINNET_NS_PACKAGE_ADDRESS).unwrap();
233        let registry_id = ObjectID::from_str(MAINNET_NS_REGISTRY_ID).unwrap();
234        let reverse_registry_id = ObjectID::from_str(MAINNET_NS_REVERSE_REGISTRY_ID).unwrap();
235
236        Self::new(package_address, registry_id, reverse_registry_id)
237    }
238
239    // Create a config based on the package and object ids published on testnet
240    pub fn testnet() -> Self {
241        const TESTNET_NS_PACKAGE_ADDRESS: &str =
242            "0x22fa05f21b1ad71442491220bb9338f7b7095fe35000ef88d5400d28523bdd93";
243        const TESTNET_NS_REGISTRY_ID: &str =
244            "0xb120c0d55432630fce61f7854795a3463deb6e3b443cc4ae72e1282073ff56e4";
245        const TESTNET_NS_REVERSE_REGISTRY_ID: &str =
246            "0xcee9dbb070db70936c3a374439a6adb16f3ba97eac5468d2e1e6fff6ed93e465";
247
248        let package_address = SuiAddress::from_str(TESTNET_NS_PACKAGE_ADDRESS).unwrap();
249        let registry_id = ObjectID::from_str(TESTNET_NS_REGISTRY_ID).unwrap();
250        let reverse_registry_id = ObjectID::from_str(TESTNET_NS_REVERSE_REGISTRY_ID).unwrap();
251
252        Self::new(package_address, registry_id, reverse_registry_id)
253    }
254}
255
256impl NameRecord {
257    /// Leaf records expire when their parent expires.
258    /// The `expiration_timestamp_ms` is set to `0` (on-chain) to indicate this.
259    pub fn is_leaf_record(&self) -> bool {
260        self.expiration_timestamp_ms == LEAF_EXPIRATION_TIMESTAMP
261    }
262
263    /// Validate that a `NameRecord` is a valid parent of a child `NameRecord`.
264    ///
265    /// WARNING: This only applies for `leaf` records
266    pub fn is_valid_leaf_parent(&self, child: &NameRecord) -> bool {
267        self.nft_id == child.nft_id
268    }
269
270    /// Checks if a `node` name record has expired.
271    /// Expects the latest checkpoint's timestamp.
272    pub fn is_node_expired(&self, checkpoint_timestamp_ms: u64) -> bool {
273        self.expiration_timestamp_ms < checkpoint_timestamp_ms
274    }
275}
276
277impl FromStr for Domain {
278    type Err = NameServiceError;
279
280    fn from_str(s: &str) -> Result<Self, Self::Err> {
281        /// The maximum length of a full domain
282        const MAX_DOMAIN_LENGTH: usize = 200;
283
284        if s.len() > MAX_DOMAIN_LENGTH {
285            return Err(NameServiceError::ExceedsMaxLength(
286                s.len(),
287                MAX_DOMAIN_LENGTH,
288            ));
289        }
290        let separator = separator(s)?;
291
292        let formatted_string = convert_from_new_format(s, &separator)?;
293
294        let labels = formatted_string
295            .split(separator)
296            .rev()
297            .map(validate_label)
298            .collect::<Result<Vec<_>, Self::Err>>()?;
299
300        // A valid domain in our system has at least a TLD and an SLD (len == 2).
301        if labels.len() < 2 {
302            return Err(NameServiceError::LabelsEmpty);
303        }
304
305        let labels = labels.into_iter().map(ToOwned::to_owned).collect();
306        Ok(Domain { labels })
307    }
308}
309
310impl fmt::Display for Domain {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        // We use to_string() to check on-chain state and parse on-chain data
313        // so we should always default to DOT format.
314        let output = self.format(DomainFormat::Dot);
315        f.write_str(&output)?;
316
317        Ok(())
318    }
319}
320
321impl Default for NameServiceConfig {
322    fn default() -> Self {
323        Self::mainnet()
324    }
325}
326
327impl TryFrom<Object> for NameRecord {
328    type Error = NameServiceError;
329
330    fn try_from(object: Object) -> Result<Self, NameServiceError> {
331        object
332            .to_rust::<Field<Domain, Self>>()
333            .map(|record| record.value)
334            .ok_or_else(|| NameServiceError::MalformedObject(object.id()))
335    }
336}
337
338impl TryFrom<MoveObject> for NameRecord {
339    type Error = NameServiceError;
340
341    fn try_from(object: MoveObject) -> Result<Self, NameServiceError> {
342        object
343            .to_rust::<Field<Domain, Self>>()
344            .map(|record| record.value)
345            .ok_or_else(|| NameServiceError::MalformedObject(object.id()))
346    }
347}
348
349/// Parses a separator from the domain string input.
350/// E.g.  `example.sui` -> `.` | example*sui -> `@` | `example*sui` -> `*`
351fn separator(s: &str) -> Result<char, NameServiceError> {
352    let mut domain_separator: Option<char> = None;
353
354    for separator in ACCEPTED_SEPARATORS.iter() {
355        if s.contains(*separator) {
356            if domain_separator.is_some() {
357                return Err(NameServiceError::InvalidSeparator);
358            }
359
360            domain_separator = Some(*separator);
361        }
362    }
363
364    match domain_separator {
365        Some(separator) => Ok(separator),
366        None => Ok(ACCEPTED_SEPARATORS[0]),
367    }
368}
369
370/// Converts @label ending to label{separator}sui ending.
371///
372/// E.g. `@example` -> `example.sui` | `test@example` -> `test.example.sui`
373fn convert_from_new_format(s: &str, separator: &char) -> Result<String, NameServiceError> {
374    let mut splits = s.split(SUI_NEW_FORMAT_SEPARATOR);
375
376    let Some(before) = splits.next() else {
377        return Err(NameServiceError::InvalidSeparator);
378    };
379
380    let Some(after) = splits.next() else {
381        return Ok(before.to_string());
382    };
383
384    if splits.next().is_some() || after.contains(*separator) || after.is_empty() {
385        return Err(NameServiceError::InvalidSeparator);
386    }
387
388    let mut parts = vec![];
389
390    if !before.is_empty() {
391        parts.push(before);
392    }
393
394    parts.push(after);
395    parts.push(DEFAULT_TLD);
396
397    Ok(parts.join(&separator.to_string()))
398}
399
400pub fn validate_label(label: &str) -> Result<&str, NameServiceError> {
401    const MIN_LABEL_LENGTH: usize = 1;
402    const MAX_LABEL_LENGTH: usize = 63;
403    let bytes = label.as_bytes();
404    let len = bytes.len();
405
406    if !(MIN_LABEL_LENGTH..=MAX_LABEL_LENGTH).contains(&len) {
407        return Err(NameServiceError::InvalidLength(
408            len,
409            MIN_LABEL_LENGTH,
410            MAX_LABEL_LENGTH,
411        ));
412    }
413
414    for (i, character) in bytes.iter().enumerate() {
415        let is_valid_character = match character {
416            b'a'..=b'z' => true,
417            b'0'..=b'9' => true,
418            b'-' if i != 0 && i != len - 1 => true,
419            _ => false,
420        };
421
422        if !is_valid_character {
423            match character {
424                b'-' => return Err(NameServiceError::InvalidHyphens),
425                _ => return Err(NameServiceError::InvalidUnderscore),
426            }
427        };
428    }
429    Ok(label)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_parent_extraction() {
438        let mut name = Domain::from_str("leaf.node.test.sui").unwrap();
439
440        assert_eq!(name.parent().to_string(), "node.test.sui");
441
442        name = Domain::from_str("node.test.sui").unwrap();
443
444        assert_eq!(name.parent().to_string(), "test.sui");
445    }
446
447    #[test]
448    fn test_expirations() {
449        let system_time: u64 = 100;
450
451        let mut name = NameRecord {
452            nft_id: sui_types::id::ID::new(ObjectID::random()),
453            data: VecMap { contents: vec![] },
454            target_address: Some(SuiAddress::random_for_testing_only()),
455            expiration_timestamp_ms: system_time + 10,
456        };
457
458        assert!(!name.is_node_expired(system_time));
459
460        name.expiration_timestamp_ms = system_time - 10;
461
462        assert!(name.is_node_expired(system_time));
463    }
464
465    #[test]
466    fn test_name_service_outputs() {
467        assert_eq!("@test".parse::<Domain>().unwrap().to_string(), "test.sui");
468        assert_eq!(
469            "test.sui".parse::<Domain>().unwrap().to_string(),
470            "test.sui"
471        );
472        assert_eq!(
473            "test@sld".parse::<Domain>().unwrap().to_string(),
474            "test.sld.sui"
475        );
476        assert_eq!(
477            "test.test@example".parse::<Domain>().unwrap().to_string(),
478            "test.test.example.sui"
479        );
480        assert_eq!(
481            "sui@sui".parse::<Domain>().unwrap().to_string(),
482            "sui.sui.sui"
483        );
484
485        assert_eq!("@sui".parse::<Domain>().unwrap().to_string(), "sui.sui");
486
487        assert_eq!(
488            "test*test@test".parse::<Domain>().unwrap().to_string(),
489            "test.test.test.sui"
490        );
491        assert_eq!(
492            "test.test.sui".parse::<Domain>().unwrap().to_string(),
493            "test.test.sui"
494        );
495        assert_eq!(
496            "test.test.test.sui".parse::<Domain>().unwrap().to_string(),
497            "test.test.test.sui"
498        );
499    }
500
501    #[test]
502    fn test_different_wildcard() {
503        assert_eq!("test.sui".parse::<Domain>(), "test*sui".parse::<Domain>(),);
504
505        assert_eq!("@test".parse::<Domain>(), "test*sui".parse::<Domain>(),);
506    }
507
508    #[test]
509    fn test_invalid_inputs() {
510        assert!("*".parse::<Domain>().is_err());
511        assert!(".".parse::<Domain>().is_err());
512        assert!("@".parse::<Domain>().is_err());
513        assert!("@inner.sui".parse::<Domain>().is_err());
514        assert!("@inner*sui".parse::<Domain>().is_err());
515        assert!("test@".parse::<Domain>().is_err());
516        assert!("sui".parse::<Domain>().is_err());
517        assert!("test.test@example.sui".parse::<Domain>().is_err());
518        assert!("test@test@example".parse::<Domain>().is_err());
519    }
520
521    #[test]
522    fn output_tests() {
523        let mut domain = "test.sui".parse::<Domain>().unwrap();
524        assert!(domain.format(DomainFormat::Dot) == "test.sui");
525        assert!(domain.format(DomainFormat::At) == "@test");
526
527        domain = "test.test.sui".parse::<Domain>().unwrap();
528        assert!(domain.format(DomainFormat::Dot) == "test.test.sui");
529        assert!(domain.format(DomainFormat::At) == "test@test");
530
531        domain = "test.test.test.sui".parse::<Domain>().unwrap();
532        assert!(domain.format(DomainFormat::Dot) == "test.test.test.sui");
533        assert!(domain.format(DomainFormat::At) == "test.test@test");
534
535        domain = "test.test.test.test.sui".parse::<Domain>().unwrap();
536        assert!(domain.format(DomainFormat::Dot) == "test.test.test.test.sui");
537        assert!(domain.format(DomainFormat::At) == "test.test.test@test");
538    }
539}