suins_indexer/
indexer.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::{HashMap, HashSet},
6    str::FromStr,
7};
8
9use move_core_types::language_storage::StructTag;
10use sui_name_service::{Domain, NameRecord, SubDomainRegistration};
11use sui_types::{
12    base_types::{ObjectID, SuiAddress},
13    dynamic_field::Field,
14    full_checkpoint_content::{CheckpointData, CheckpointTransaction},
15    object::Object,
16};
17
18use crate::models::VerifiedDomain;
19
20/// We default to mainnet for both.
21const REGISTRY_TABLE_ID: &str =
22    "0xe64cd9db9f829c6cc405d9790bd71567ae07259855f4fba6f02c84f52298c106";
23const NAME_RECORD_TYPE: &str = "0x2::dynamic_field::Field<0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0::domain::Domain,0xd22b24490e0bae52676651b4f56660a5ff8022a2576e0089f79b3c88d44e08f0::name_record::NameRecord>";
24const SUBDOMAIN_REGISTRATION_TYPE: &str = "0x00c2f85e07181b90c140b15c5ce27d863f93c4d9159d2a4e7bdaeb40e286d6f5::subdomain_registration::SubDomainRegistration";
25
26#[derive(Debug, Clone)]
27pub struct NameRecordChange(Field<Domain, NameRecord>);
28
29pub struct SuinsIndexer {
30    registry_table_id: SuiAddress,
31    subdomain_wrapper_type: StructTag,
32    name_record_type: StructTag,
33}
34
35impl std::default::Default for SuinsIndexer {
36    fn default() -> Self {
37        Self::new(
38            REGISTRY_TABLE_ID.to_owned(),
39            SUBDOMAIN_REGISTRATION_TYPE.to_owned(),
40            NAME_RECORD_TYPE.to_owned(),
41        )
42    }
43}
44
45impl SuinsIndexer {
46    /// Create a new config by passing the table ID + subdomain wrapper type.
47    /// Useful for testing or custom environments.
48    pub fn new(registry_address: String, wrapper_type: String, record_type: String) -> Self {
49        let registry_table_id = SuiAddress::from_str(&registry_address).unwrap();
50        let name_record_type = StructTag::from_str(&record_type).unwrap();
51        let subdomain_wrapper_type = StructTag::from_str(&wrapper_type).unwrap();
52
53        Self {
54            registry_table_id,
55            name_record_type,
56            subdomain_wrapper_type,
57        }
58    }
59
60    /// Checks if the object referenced is a subdomain wrapper.
61    /// For subdomain wrappers, we're saving the ID of the wrapper object,
62    /// to make it easy to locate the NFT (since the base NFT gets wrapped and indexing won't work there).
63    pub fn is_subdomain_wrapper(&self, object: &Object) -> bool {
64        object
65            .struct_tag()
66            .is_some_and(|tag| tag == self.subdomain_wrapper_type)
67    }
68
69    // Filter by the dynamic field value type.
70    // A valid name record for an object has the type `Field<Domain,NameRecord>,
71    // and the parent of it is the `registry` table id.
72    pub fn is_name_record(&self, object: &Object) -> bool {
73        object
74            .get_single_owner()
75            .is_some_and(|owner| owner == self.registry_table_id)
76            && object
77                .struct_tag()
78                .is_some_and(|tag| tag == self.name_record_type)
79    }
80
81    /// Processes a checkpoint and produces a list of `updates` and a list of `removals`
82    ///
83    /// We can then use these to execute our DB bulk insertions + bulk deletions.
84    ///
85    /// Returns
86    /// - `Vec<VerifiedDomain>`: A list of NameRecord updates for the database (including sequence number)
87    /// - `Vec<String>`: A list of IDs to be deleted from the database (`field_id` is the matching column)
88    pub fn process_checkpoint(&self, data: &CheckpointData) -> (Vec<VerifiedDomain>, Vec<String>) {
89        let mut checkpoint = SuinsIndexerCheckpoint::new(data.checkpoint_summary.sequence_number);
90
91        // loop through all the transactions in the checkpoint
92        // Since the transactions are sequenced inside the checkpoint, we can safely assume
93        // that we have the latest data for each name record in the end of the loop.
94        for transaction in &data.transactions {
95            // Add all name record changes to the name_records HashMap.
96            // Remove any removals that got re-created.
97            checkpoint.parse_record_changes(self, &transaction.output_objects);
98
99            // Gather all removals from the transaction,
100            // and delete any name records from the name_records if it got deleted.
101            checkpoint.parse_record_deletions(self, transaction);
102        }
103
104        (
105            // Convert our name_records & wrappers into a list of updates for the DB.
106            checkpoint.prepare_db_updates(),
107            checkpoint
108                .removals
109                .into_iter()
110                .map(|id| id.to_string())
111                .collect(),
112        )
113    }
114}
115
116pub struct SuinsIndexerCheckpoint {
117    /// A list of name records that have been updated in the checkpoint.
118    name_records: HashMap<ObjectID, NameRecordChange>,
119    /// A list of subdomain wrappers that have been created in the checkpoint.
120    subdomain_wrappers: HashMap<String, String>,
121    /// A list of name records that have been deleted in the checkpoint.
122    removals: HashSet<ObjectID>,
123    /// The sequence number of the checkpoint.
124    checkpoint_sequence_number: u64,
125}
126
127impl SuinsIndexerCheckpoint {
128    pub fn new(checkpoint_sequence_number: u64) -> Self {
129        Self {
130            name_records: HashMap::new(),
131            subdomain_wrappers: HashMap::new(),
132            removals: HashSet::new(),
133            checkpoint_sequence_number,
134        }
135    }
136
137    /// Parses the name record changes + subdomain wraps.
138    /// and pushes them into the supplied vector + hashmap.
139    ///
140    /// It is implemented in a way to do just a single iteration over the objects.
141    pub fn parse_record_changes(&mut self, config: &SuinsIndexer, objects: &[Object]) {
142        for object in objects {
143            // Parse all the changes to a `NameRecord`
144            if config.is_name_record(object) {
145                let name_record: Field<Domain, NameRecord> = object
146                    .to_rust()
147                    .unwrap_or_else(|| panic!("Failed to parse name record for {:?}", object));
148
149                let id = object.id();
150
151                // Remove from the removals list if it's there.
152                // The reason it might have been there is that the same name record might have been
153                // deleted in a previous transaction in the same checkpoint, and now it got re-created.
154                self.removals.remove(&id);
155
156                self.name_records.insert(id, NameRecordChange(name_record));
157            }
158            // Parse subdomain wrappers and save them in our hashmap.
159            // Later, we'll save the id of the wrapper in the name record.
160            // NameRecords & their equivalent SubdomainWrappers are always created in the same PTB, so we can safely assume
161            // that the wrapper will be created on the same checkpoint as the name record and vice versa.
162            if config.is_subdomain_wrapper(object) {
163                let sub_domain: SubDomainRegistration = object.to_rust().unwrap();
164                self.subdomain_wrappers.insert(
165                    sub_domain.nft.domain_name,
166                    sub_domain.id.id.bytes.to_string(),
167                );
168            };
169        }
170    }
171
172    /// Parses a list of the deletions in the checkpoint and adds them to the removals list.
173    /// Also removes any name records from the updates, if they ended up being deleted in the same checkpoint.
174    pub fn parse_record_deletions(
175        &mut self,
176        config: &SuinsIndexer,
177        transaction: &CheckpointTransaction,
178    ) {
179        // a list of all the deleted objects in the transaction.
180        let deleted_objects: HashSet<_> = transaction
181            .effects
182            .all_tombstones()
183            .into_iter()
184            .map(|(id, _)| id)
185            .collect();
186
187        for input in transaction.input_objects.iter() {
188            if config.is_name_record(input) && deleted_objects.contains(&input.id()) {
189                // since this record was deleted, we need to remove it from the name_records hashmap.
190                // that catches a case where a name record was edited on a previous transaction in the checkpoint
191                // and deleted from a different tx later in the checkpoint.
192                self.name_records.remove(&input.id());
193
194                // add it in the list of removals
195                self.removals.insert(input.id());
196            }
197        }
198    }
199
200    /// Prepares a vector of `VerifiedDomain`s to be inserted into the DB, taking in account
201    /// the list of subdomain wrappers created as well as the checkpoint's sequence number.
202    pub fn prepare_db_updates(&self) -> Vec<VerifiedDomain> {
203        let mut updates: Vec<VerifiedDomain> = vec![];
204
205        for (field_id, name_record_change) in self.name_records.iter() {
206            let name_record = &name_record_change.0;
207
208            let parent = name_record.name.parent().to_string();
209            let nft_id = name_record.value.nft_id.bytes.to_string();
210
211            updates.push(VerifiedDomain {
212                field_id: field_id.to_string(),
213                name: name_record.name.to_string(),
214                parent,
215                expiration_timestamp_ms: name_record.value.expiration_timestamp_ms as i64,
216                nft_id,
217                target_address: if name_record.value.target_address.is_some() {
218                    Some(SuiAddress::to_string(
219                        &name_record.value.target_address.unwrap(),
220                    ))
221                } else {
222                    None
223                },
224                // unwrapping must be safe as `value.data` is an on-chain value with VecMap<String,String> type.
225                data: serde_json::to_value(&name_record.value.data).unwrap(),
226                last_checkpoint_updated: self.checkpoint_sequence_number as i64,
227                subdomain_wrapper_id: self
228                    .subdomain_wrappers
229                    .get(&name_record.name.to_string())
230                    .cloned(),
231            });
232        }
233
234        updates
235    }
236}
237
238/// Allows us to format a SuiNS specific query for updating the DB entries
239/// only if the checkpoint is newer than the last checkpoint we have in the DB.
240/// Doing that, we do not care about the order of execution and we can use multiple threads
241/// to commit from later checkpoints to the DB.
242///
243/// WARNING: This can easily be SQL-injected, so make sure to use it only with trusted inputs.
244pub fn format_update_field_query(field: &str) -> String {
245    format!(
246        "CASE WHEN excluded.last_checkpoint_updated > domains.last_checkpoint_updated THEN excluded.{field} ELSE domains.{field} END"
247    )
248}
249
250/// Update the subdomain wrapper ID only if it is part of the checkpoint.
251pub fn format_update_subdomain_wrapper_query() -> String {
252    "CASE WHEN excluded.subdomain_wrapper_id IS NOT NULL THEN excluded.subdomain_wrapper_id ELSE domains.subdomain_wrapper_id END".to_string()
253}