@mysten/suins
Version:
361 lines (307 loc) • 10.7 kB
text/typescript
// 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}`);
}
}