UNPKG

@mysten/suins

Version:
361 lines (307 loc) 10.7 kB
// Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 import type { SuiClient } from '@mysten/sui/client'; import type { Transaction } from '@mysten/sui/transactions'; import { isValidSuiNSName, normalizeSuiNSName } from '@mysten/sui/utils'; import { mainPackage } from './constants.js'; import { getCoinDiscountConfigType, getConfigType, getDomainType, getPricelistConfigType, getRenewalPricelistConfigType, isSubName, validateYears, } from './helpers.js'; import { SuiPriceServiceConnection, SuiPythClient } from './pyth/pyth.js'; import type { CoinTypeDiscount, NameRecord, Network, PackageInfo, SuinsClientConfig, SuinsPriceList, } from './types.js'; /// The SuinsClient is the main entry point for the Suins SDK. /// It allows you to interact with SuiNS. export class SuinsClient { client: SuiClient; network: Network; config: PackageInfo; constructor(config: SuinsClientConfig) { this.client = config.client; this.network = config.network || 'mainnet'; if (this.network === 'mainnet') { this.config = mainPackage.mainnet; } else if (this.network === 'testnet') { this.config = mainPackage.testnet; } else { throw new Error('Invalid network'); } } /** * Returns the price list for SuiNS names in the base asset. */ // Format: // { // [ 3, 3 ] => 500000000, // [ 4, 4 ] => 100000000, // [ 5, 63 ] => 20000000 // } async getPriceList(): Promise<SuinsPriceList> { if (!this.config.suins) throw new Error('Suins object ID is not set'); if (!this.config.packageId) throw new Error('Price list config not found'); const priceList = await this.client.getDynamicFieldObject({ parentId: this.config.suins, name: { type: getConfigType( this.config.packageIdV1, getPricelistConfigType(this.config.packageIdPricing), ), value: { dummy_field: false }, }, }); // Ensure the content exists and is a MoveStruct with expected fields if ( !priceList?.data?.content || priceList.data.content.dataType !== 'moveObject' || !('fields' in priceList.data.content) ) { throw new Error('Price list not found or content is invalid'); } // Safely extract fields const fields = priceList.data.content.fields as Record<string, any>; if (!fields.value || !fields.value.fields || !fields.value.fields.pricing) { throw new Error('Pricing fields not found in the price list'); } const contentArray = fields.value.fields.pricing.fields.contents; const priceMap = new Map(); for (const entry of contentArray) { const keyFields = entry.fields.key.fields; const key = [Number(keyFields.pos0), Number(keyFields.pos1)]; // Convert keys to numbers const value = Number(entry.fields.value); // Convert value to a number priceMap.set(key, value); } return priceMap; } /** * Returns the renewal price list for SuiNS names in the base asset. */ // Format: // { // [ 3, 3 ] => 500000000, // [ 4, 4 ] => 100000000, // [ 5, 63 ] => 20000000 // } async getRenewalPriceList(): Promise<SuinsPriceList> { if (!this.config.suins) throw new Error('Suins object ID is not set'); if (!this.config.packageId) throw new Error('Price list config not found'); const priceList = await this.client.getDynamicFieldObject({ parentId: this.config.suins, name: { type: getConfigType( this.config.packageIdV1, getRenewalPricelistConfigType(this.config.packageIdPricing), ), value: { dummy_field: false }, }, }); if ( !priceList || !priceList.data || !priceList.data.content || priceList.data.content.dataType !== 'moveObject' || !('fields' in priceList.data.content) ) { throw new Error('Price list not found or content structure is invalid'); } // Safely extract fields const fields = priceList.data.content.fields as Record<string, any>; if ( !fields.value || !fields.value.fields || !fields.value.fields.config || !fields.value.fields.config.fields.pricing || !fields.value.fields.config.fields.pricing.fields.contents ) { throw new Error('Pricing fields not found in the price list'); } const contentArray = fields.value.fields.config.fields.pricing.fields.contents; const priceMap = new Map(); for (const entry of contentArray) { const keyFields = entry.fields.key.fields; const key = [Number(keyFields.pos0), Number(keyFields.pos1)]; // Convert keys to numbers const value = Number(entry.fields.value); // Convert value to a number priceMap.set(key, value); } return priceMap; } /** * Returns the coin discount list for SuiNS names. */ // Format: // { // 'b48aac3f53bab328e1eb4c5b3c34f55e760f2fb3f2305ee1a474878d80f650f0::TESTUSDC::TESTUSDC' => 0, // '0000000000000000000000000000000000000000000000000000000000000002::sui::SUI' => 0, // 'b48aac3f53bab328e1eb4c5b3c34f55e760f2fb3f2305ee1a474878d80f650f0::TESTNS::TESTNS' => 25 // } async getCoinTypeDiscount(): Promise<CoinTypeDiscount> { if (!this.config.suins) throw new Error('Suins object ID is not set'); if (!this.config.packageId) throw new Error('Price list config not found'); const dfValue = await this.client.getDynamicFieldObject({ parentId: this.config.suins, name: { type: getConfigType( this.config.packageIdV1, getCoinDiscountConfigType(this.config.payments.packageId), ), value: { dummy_field: false }, }, }); if ( !dfValue || !dfValue.data || !dfValue.data.content || dfValue.data.content.dataType !== 'moveObject' || !('fields' in dfValue.data.content) ) { throw new Error('dfValue not found or content structure is invalid'); } // Safely extract fields const fields = dfValue.data.content.fields as Record<string, any>; if ( !fields.value || !fields.value.fields || !fields.value.fields.base_currency || !fields.value.fields.base_currency.fields || !fields.value.fields.base_currency.fields.name || !fields.value.fields.currencies || !fields.value.fields.currencies.fields || !fields.value.fields.currencies.fields.contents ) { throw new Error('Required fields are missing in dfValue'); } // Safely extract content const content = fields.value.fields; const currencyDiscounts = content.currencies.fields.contents; const discountMap = new Map(); for (const entry of currencyDiscounts) { const key = entry.fields.key.fields.name; const value = Number(entry.fields.value.fields.discount_percentage); discountMap.set(key, value); } return discountMap; } async getNameRecord(name: string): Promise<NameRecord | null> { if (!isValidSuiNSName(name)) throw new Error('Invalid SuiNS name'); if (!this.config.registryTableId) throw new Error('Suins package ID is not set'); const nameRecord = await this.client.getDynamicFieldObject({ parentId: this.config.registryTableId, name: { type: getDomainType(this.config.packageIdV1), value: normalizeSuiNSName(name, 'dot').split('.').reverse(), }, }); const fields = nameRecord.data?.content; // in case the name record is not found, return null if (nameRecord.error?.code === 'dynamicFieldNotFound') return null; if (nameRecord.error || !fields || fields.dataType !== 'moveObject') throw new Error('Name record not found. This domain is not registered.'); const content = fields.fields as Record<string, any>; const data: Record<string, string> = {}; content.value.fields.data.fields.contents.forEach((item: any) => { // @ts-ignore-next-line data[item.fields.key as string] = item.fields.value; }); return { name, nftId: content.value.fields?.nft_id, targetAddress: content.value.fields?.target_address!, expirationTimestampMs: content.value.fields?.expiration_timestamp_ms, data, avatar: data.avatar, contentHash: data.content_hash, walrusSiteId: data.walrus_site_id, }; } /** * Calculates the registration or renewal price for an SLD (Second Level Domain). * It expects a domain name, the number of years and a `SuinsPriceList` object, * as returned from `suinsClient.getPriceList()` function, or `suins.getRenewalPriceList()` function. * * It throws an error: * 1. if the name is a subdomain * 2. if the name is not a valid SuiNS name * 3. if the years are not between 1 and 5 */ async calculatePrice({ name, years, isRegistration = true, }: { name: string; years: number; isRegistration?: boolean; }) { if (!isValidSuiNSName(name)) { throw new Error('Invalid SuiNS name'); } validateYears(years); if (isSubName(name)) { throw new Error('Subdomains do not have a registration fee'); } const length = normalizeSuiNSName(name, 'dot').split('.')[0].length; const priceList = await this.getPriceList(); const renewalPriceList = await this.getRenewalPriceList(); let yearsRemain = years; let price = 0; if (isRegistration) { for (const [[minLength, maxLength], pricePerYear] of priceList.entries()) { if (length >= minLength && length <= maxLength) { price += pricePerYear; // Registration is always 1 year yearsRemain -= 1; break; } } } for (const [[minLength, maxLength], pricePerYear] of renewalPriceList.entries()) { if (length >= minLength && length <= maxLength) { price += yearsRemain * pricePerYear; break; } } return price; } async getPriceInfoObject(tx: Transaction, feed: string) { // Initialize connection to the Sui Price Service const endpoint = this.network === 'testnet' ? 'https://hermes-beta.pyth.network' : 'https://hermes.pyth.network'; const connection = new SuiPriceServiceConnection(endpoint); // List of price feed IDs const priceIDs = [ feed, // ASSET/USD price ID ]; // Fetch price feed update data const priceUpdateData = await connection.getPriceFeedsUpdateData(priceIDs); // Initialize Sui Client and Pyth Client const wormholeStateId = this.config.pyth.wormholeStateId; const pythStateId = this.config.pyth.pythStateId; const client = new SuiPythClient(this.client, pythStateId, wormholeStateId); return await client.updatePriceFeeds(tx, priceUpdateData, priceIDs); // returns priceInfoObjectIds } async getObjectType(objectId: string) { // Fetch the object details from the Sui client const objectResponse = await this.client.getObject({ id: objectId, options: { showType: true }, }); // Extract and return the type if available if (objectResponse && objectResponse.data && objectResponse.data.type) { return objectResponse.data.type; } // Throw an error if the type is not found throw new Error(`Type information not found for object ID: ${objectId}`); } }