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    // Create a config based on the package and object ids published on mainnet
215    pub fn mainnet() -> Self {
216        const MAINNET_NS_PACKAGE_ADDRESS: &str =
217            "0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0";
218        const MAINNET_NS_REGISTRY_ID: &str =
219            "0xe64cd9db9f829c6cc405d9790bd71567ae07259855f4fba6f02c84f52298c106";
220        const MAINNET_NS_REVERSE_REGISTRY_ID: &str =
221            "0x2fd099e17a292d2bc541df474f9fafa595653848cbabb2d7a4656ec786a1969f";
222
223        let package_address = SuiAddress::from_str(MAINNET_NS_PACKAGE_ADDRESS).unwrap();
224        let registry_id = ObjectID::from_str(MAINNET_NS_REGISTRY_ID).unwrap();
225        let reverse_registry_id = ObjectID::from_str(MAINNET_NS_REVERSE_REGISTRY_ID).unwrap();
226
227        Self::new(package_address, registry_id, reverse_registry_id)
228    }
229
230    // Create a config based on the package and object ids published on testnet
231    pub fn testnet() -> Self {
232        const TESTNET_NS_PACKAGE_ADDRESS: &str =
233            "0x22fa05f21b1ad71442491220bb9338f7b7095fe35000ef88d5400d28523bdd93";
234        const TESTNET_NS_REGISTRY_ID: &str =
235            "0xb120c0d55432630fce61f7854795a3463deb6e3b443cc4ae72e1282073ff56e4";
236        const TESTNET_NS_REVERSE_REGISTRY_ID: &str =
237            "0xcee9dbb070db70936c3a374439a6adb16f3ba97eac5468d2e1e6fff6ed93e465";
238
239        let package_address = SuiAddress::from_str(TESTNET_NS_PACKAGE_ADDRESS).unwrap();
240        let registry_id = ObjectID::from_str(TESTNET_NS_REGISTRY_ID).unwrap();
241        let reverse_registry_id = ObjectID::from_str(TESTNET_NS_REVERSE_REGISTRY_ID).unwrap();
242
243        Self::new(package_address, registry_id, reverse_registry_id)
244    }
245}
246
247impl NameRecord {
248    /// Leaf records expire when their parent expires.
249    /// The `expiration_timestamp_ms` is set to `0` (on-chain) to indicate this.
250    pub fn is_leaf_record(&self) -> bool {
251        self.expiration_timestamp_ms == LEAF_EXPIRATION_TIMESTAMP
252    }
253
254    /// Validate that a `NameRecord` is a valid parent of a child `NameRecord`.
255    ///
256    /// WARNING: This only applies for `leaf` records
257    pub fn is_valid_leaf_parent(&self, child: &NameRecord) -> bool {
258        self.nft_id == child.nft_id
259    }
260
261    /// Checks if a `node` name record has expired.
262    /// Expects the latest checkpoint's timestamp.
263    pub fn is_node_expired(&self, checkpoint_timestamp_ms: u64) -> bool {
264        self.expiration_timestamp_ms < checkpoint_timestamp_ms
265    }
266}
267
268impl FromStr for Domain {
269    type Err = NameServiceError;
270
271    fn from_str(s: &str) -> Result<Self, Self::Err> {
272        /// The maximum length of a full domain
273        const MAX_DOMAIN_LENGTH: usize = 200;
274
275        if s.len() > MAX_DOMAIN_LENGTH {
276            return Err(NameServiceError::ExceedsMaxLength(
277                s.len(),
278                MAX_DOMAIN_LENGTH,
279            ));
280        }
281        let separator = separator(s)?;
282
283        let formatted_string = convert_from_new_format(s, &separator)?;
284
285        let labels = formatted_string
286            .split(separator)
287            .rev()
288            .map(validate_label)
289            .collect::<Result<Vec<_>, Self::Err>>()?;
290
291        // A valid domain in our system has at least a TLD and an SLD (len == 2).
292        if labels.len() < 2 {
293            return Err(NameServiceError::LabelsEmpty);
294        }
295
296        let labels = labels.into_iter().map(ToOwned::to_owned).collect();
297        Ok(Domain { labels })
298    }
299}
300
301impl fmt::Display for Domain {
302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
303        // We use to_string() to check on-chain state and parse on-chain data
304        // so we should always default to DOT format.
305        let output = self.format(DomainFormat::Dot);
306        f.write_str(&output)?;
307
308        Ok(())
309    }
310}
311
312impl Default for NameServiceConfig {
313    fn default() -> Self {
314        Self::mainnet()
315    }
316}
317
318impl TryFrom<Object> for NameRecord {
319    type Error = NameServiceError;
320
321    fn try_from(object: Object) -> Result<Self, NameServiceError> {
322        object
323            .to_rust::<Field<Domain, Self>>()
324            .map(|record| record.value)
325            .ok_or_else(|| NameServiceError::MalformedObject(object.id()))
326    }
327}
328
329impl TryFrom<MoveObject> for NameRecord {
330    type Error = NameServiceError;
331
332    fn try_from(object: MoveObject) -> Result<Self, NameServiceError> {
333        object
334            .to_rust::<Field<Domain, Self>>()
335            .map(|record| record.value)
336            .ok_or_else(|| NameServiceError::MalformedObject(object.id()))
337    }
338}
339
340/// Parses a separator from the domain string input.
341/// E.g.  `example.sui` -> `.` | example*sui -> `@` | `example*sui` -> `*`
342fn separator(s: &str) -> Result<char, NameServiceError> {
343    let mut domain_separator: Option<char> = None;
344
345    for separator in ACCEPTED_SEPARATORS.iter() {
346        if s.contains(*separator) {
347            if domain_separator.is_some() {
348                return Err(NameServiceError::InvalidSeparator);
349            }
350
351            domain_separator = Some(*separator);
352        }
353    }
354
355    match domain_separator {
356        Some(separator) => Ok(separator),
357        None => Ok(ACCEPTED_SEPARATORS[0]),
358    }
359}
360
361/// Converts @label ending to label{separator}sui ending.
362///
363/// E.g. `@example` -> `example.sui` | `test@example` -> `test.example.sui`
364fn convert_from_new_format(s: &str, separator: &char) -> Result<String, NameServiceError> {
365    let mut splits = s.split(SUI_NEW_FORMAT_SEPARATOR);
366
367    let Some(before) = splits.next() else {
368        return Err(NameServiceError::InvalidSeparator);
369    };
370
371    let Some(after) = splits.next() else {
372        return Ok(before.to_string());
373    };
374
375    if splits.next().is_some() || after.contains(*separator) || after.is_empty() {
376        return Err(NameServiceError::InvalidSeparator);
377    }
378
379    let mut parts = vec![];
380
381    if !before.is_empty() {
382        parts.push(before);
383    }
384
385    parts.push(after);
386    parts.push(DEFAULT_TLD);
387
388    Ok(parts.join(&separator.to_string()))
389}
390
391pub fn validate_label(label: &str) -> Result<&str, NameServiceError> {
392    const MIN_LABEL_LENGTH: usize = 1;
393    const MAX_LABEL_LENGTH: usize = 63;
394    let bytes = label.as_bytes();
395    let len = bytes.len();
396
397    if !(MIN_LABEL_LENGTH..=MAX_LABEL_LENGTH).contains(&len) {
398        return Err(NameServiceError::InvalidLength(
399            len,
400            MIN_LABEL_LENGTH,
401            MAX_LABEL_LENGTH,
402        ));
403    }
404
405    for (i, character) in bytes.iter().enumerate() {
406        let is_valid_character = match character {
407            b'a'..=b'z' => true,
408            b'0'..=b'9' => true,
409            b'-' if i != 0 && i != len - 1 => true,
410            _ => false,
411        };
412
413        if !is_valid_character {
414            match character {
415                b'-' => return Err(NameServiceError::InvalidHyphens),
416                _ => return Err(NameServiceError::InvalidUnderscore),
417            }
418        };
419    }
420    Ok(label)
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_parent_extraction() {
429        let mut name = Domain::from_str("leaf.node.test.sui").unwrap();
430
431        assert_eq!(name.parent().to_string(), "node.test.sui");
432
433        name = Domain::from_str("node.test.sui").unwrap();
434
435        assert_eq!(name.parent().to_string(), "test.sui");
436    }
437
438    #[test]
439    fn test_expirations() {
440        let system_time: u64 = 100;
441
442        let mut name = NameRecord {
443            nft_id: sui_types::id::ID::new(ObjectID::random()),
444            data: VecMap { contents: vec![] },
445            target_address: Some(SuiAddress::random_for_testing_only()),
446            expiration_timestamp_ms: system_time + 10,
447        };
448
449        assert!(!name.is_node_expired(system_time));
450
451        name.expiration_timestamp_ms = system_time - 10;
452
453        assert!(name.is_node_expired(system_time));
454    }
455
456    #[test]
457    fn test_name_service_outputs() {
458        assert_eq!("@test".parse::<Domain>().unwrap().to_string(), "test.sui");
459        assert_eq!(
460            "test.sui".parse::<Domain>().unwrap().to_string(),
461            "test.sui"
462        );
463        assert_eq!(
464            "test@sld".parse::<Domain>().unwrap().to_string(),
465            "test.sld.sui"
466        );
467        assert_eq!(
468            "test.test@example".parse::<Domain>().unwrap().to_string(),
469            "test.test.example.sui"
470        );
471        assert_eq!(
472            "sui@sui".parse::<Domain>().unwrap().to_string(),
473            "sui.sui.sui"
474        );
475
476        assert_eq!("@sui".parse::<Domain>().unwrap().to_string(), "sui.sui");
477
478        assert_eq!(
479            "test*test@test".parse::<Domain>().unwrap().to_string(),
480            "test.test.test.sui"
481        );
482        assert_eq!(
483            "test.test.sui".parse::<Domain>().unwrap().to_string(),
484            "test.test.sui"
485        );
486        assert_eq!(
487            "test.test.test.sui".parse::<Domain>().unwrap().to_string(),
488            "test.test.test.sui"
489        );
490    }
491
492    #[test]
493    fn test_different_wildcard() {
494        assert_eq!("test.sui".parse::<Domain>(), "test*sui".parse::<Domain>(),);
495
496        assert_eq!("@test".parse::<Domain>(), "test*sui".parse::<Domain>(),);
497    }
498
499    #[test]
500    fn test_invalid_inputs() {
501        assert!("*".parse::<Domain>().is_err());
502        assert!(".".parse::<Domain>().is_err());
503        assert!("@".parse::<Domain>().is_err());
504        assert!("@inner.sui".parse::<Domain>().is_err());
505        assert!("@inner*sui".parse::<Domain>().is_err());
506        assert!("test@".parse::<Domain>().is_err());
507        assert!("sui".parse::<Domain>().is_err());
508        assert!("test.test@example.sui".parse::<Domain>().is_err());
509        assert!("test@test@example".parse::<Domain>().is_err());
510    }
511
512    #[test]
513    fn output_tests() {
514        let mut domain = "test.sui".parse::<Domain>().unwrap();
515        assert!(domain.format(DomainFormat::Dot) == "test.sui");
516        assert!(domain.format(DomainFormat::At) == "@test");
517
518        domain = "test.test.sui".parse::<Domain>().unwrap();
519        assert!(domain.format(DomainFormat::Dot) == "test.test.sui");
520        assert!(domain.format(DomainFormat::At) == "test@test");
521
522        domain = "test.test.test.sui".parse::<Domain>().unwrap();
523        assert!(domain.format(DomainFormat::Dot) == "test.test.test.sui");
524        assert!(domain.format(DomainFormat::At) == "test.test@test");
525
526        domain = "test.test.test.test.sui".parse::<Domain>().unwrap();
527        assert!(domain.format(DomainFormat::Dot) == "test.test.test.test.sui");
528        assert!(domain.format(DomainFormat::At) == "test.test.test@test");
529    }
530}