UNPKG

@mysten/suins

Version:
324 lines (271 loc) 9.76 kB
// Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 import type { ClientWithCoreApi, SuiClientTypes } from '@mysten/sui/client'; import type { Transaction, TransactionObjectArgument } from '@mysten/sui/transactions'; import { isValidSuiNSName, normalizeSuiNSName } from '@mysten/sui/utils'; import { mainPackage } from './constants.js'; import { isSubName, validateYears } from './helpers.js'; import { SuiPriceServiceConnection, SuiPythClient } from './pyth/pyth.js'; import type { CoinTypeDiscount, NameRecord, PackageInfo, SuinsClientConfig, SuinsPriceList, } from './types.js'; import { ConfigKey } from './contracts/suins/suins.js'; import { Domain } from './contracts/suins/domain.js'; import { NameRecord as NameRecordBcs } from './contracts/suins/name_record.js'; import { PricingConfig, RenewalConfig } from './contracts/suins/pricing_config.js'; import { PaymentsConfig } from './contracts/suins_payments/payments.js'; export type SuinsExtensionOptions<Name extends string = 'suins'> = { name?: Name; packageInfo?: PackageInfo; }; /** * Creates a SuiNS client extension that can be used with `client.$extend()`. * * @example * ```ts * import { SuiJsonRpcClient, getJsonRpcFullnodeUrl } from '@mysten/sui/jsonRpc'; * import { suins } from '@mysten/suins'; * * const client = new SuiJsonRpcClient({ * url: getJsonRpcFullnodeUrl('mainnet'), * network: 'mainnet', * }).$extend(suins()); * * const nameRecord = await client.suins.getNameRecord('example.sui'); * ``` */ export function suins<const Name extends string = 'suins'>({ name = 'suins' as Name, packageInfo, }: SuinsExtensionOptions<Name> = {}) { return { name, register: (client: ClientWithCoreApi) => { return new SuinsClient({ client, network: client.network, packageInfo, }); }, }; } export class SuinsClient { client: ClientWithCoreApi; network: SuiClientTypes.Network; config: PackageInfo; constructor(config: SuinsClientConfig) { this.client = config.client; this.network = config.network || 'mainnet'; if (config.packageInfo) { this.config = config.packageInfo; } else 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 configType = `${this.config.packageIdV1}::suins::ConfigKey<${this.config.packageIdPricing}::pricing_config::PricingConfig>`; const nameBytes = ConfigKey.serialize({ dummy_field: false }).toBytes(); const result = await this.client.core.getDynamicField({ parentId: this.config.suins, name: { type: configType, bcs: nameBytes }, }); if (!result.dynamicField?.value?.bcs) { throw new Error('Price list not found or content is invalid'); } const pricingConfig = PricingConfig.parse(result.dynamicField.value.bcs); const priceMap = new Map(); for (const entry of pricingConfig.pricing.contents) { const key = [Number(entry.key[0]), Number(entry.key[1])]; const value = Number(entry.value); 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 configType = `${this.config.packageIdV1}::suins::ConfigKey<${this.config.packageIdPricing}::pricing_config::RenewalConfig>`; const nameBytes = ConfigKey.serialize({ dummy_field: false }).toBytes(); const result = await this.client.core.getDynamicField({ parentId: this.config.suins, name: { type: configType, bcs: nameBytes }, }); if (!result.dynamicField?.value?.bcs) { throw new Error('Price list not found or content structure is invalid'); } const renewalConfig = RenewalConfig.parse(result.dynamicField.value.bcs); const priceMap = new Map(); for (const entry of renewalConfig.config.pricing.contents) { const key = [Number(entry.key[0]), Number(entry.key[1])]; const value = Number(entry.value); 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 configType = `${this.config.packageIdV1}::suins::ConfigKey<${this.config.payments.packageId}::payments::PaymentsConfig>`; const result = await this.client.core.getDynamicField({ parentId: this.config.suins, name: { type: configType, bcs: ConfigKey.serialize({ dummy_field: false }).toBytes() }, }); if (!result.dynamicField?.value?.bcs) { throw new Error('Payments config not found or content structure is invalid'); } const paymentsConfig = PaymentsConfig.parse(result.dynamicField.value.bcs); const discountMap = new Map(); for (const entry of paymentsConfig.currencies.contents) { const key = entry.key.name; const value = Number(entry.value.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 labels = normalizeSuiNSName(name, 'dot').split('.').reverse(); const result = await this.client.core.getDynamicField({ parentId: this.config.registryTableId, name: { type: `${this.config.packageIdV1}::domain::Domain`, bcs: Domain.serialize({ labels }).toBytes(), }, }); if (!result.dynamicField) return null; if (!result.dynamicField.value?.bcs) { throw new Error('Name record not found. This domain is not registered.'); } const record = NameRecordBcs.parse(result.dynamicField.value.bcs); const data: Record<string, string> = {}; for (const entry of record.data.contents) { data[entry.key] = entry.value; } return { name, nftId: record.nft_id, targetAddress: record.target_address ?? '', expirationTimestampMs: Number(record.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, feeCoin?: TransactionObjectArgument) { const endpoint = this.network === 'testnet' ? 'https://hermes-beta.pyth.network' : 'https://hermes.pyth.network'; const connection = new SuiPriceServiceConnection(endpoint); const priceIDs = [feed]; const priceUpdateData = await connection.getPriceFeedsUpdateData(priceIDs); const client = new SuiPythClient( this.client, this.config.pyth.pythStateId, this.config.pyth.wormholeStateId, ); return client.updatePriceFeeds(tx, priceUpdateData, priceIDs, feeCoin); } async getPythBaseUpdateFee(): Promise<number> { const client = new SuiPythClient( this.client, this.config.pyth.pythStateId, this.config.pyth.wormholeStateId, ); return client.getBaseUpdateFee(); } async getObjectType(objectId: string) { const result = await this.client.core.getObject({ objectId }); if (result.object?.type) { return result.object.type; } throw new Error(`Type information not found for object ID: ${objectId}`); } }