UNPKG

@iota/iota-names-sdk

Version:
718 lines (625 loc) 24.5 kB
// Copyright (c) Mysten Labs, Inc. // Modifications Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 import { bcs } from '@iota/iota-sdk/bcs'; import { IotaGraphQLClient } from '@iota/iota-sdk/graphql'; import { graphql } from '@iota/iota-sdk/graphql/schemas/2025.2'; import { fromB64, toB64 } from '@iota/iota-sdk/utils'; import { blake2b } from '@noble/hashes/blake2b'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { CouponBcs, CouponHouseBcs, DummyFieldBcs, NameBcs } from './bcs.js'; import { ALLOWED_METADATA, packages } from './constants.js'; import { applyCouponsToPrice, validateCoupons } from './coupons.js'; import { getConfigType, getCoreConfigType, getDenyListType, getNameType, getPricelistConfigType, getRegistryKeyType, getRenewalPricelistConfigType, getSubnamesConfigType, isSubname, validateYears, } from './helpers.js'; import type { Coupon, CouponHouse, IotaNamesClientConfig, IotaNamesCoreConfig, IotaNamesPriceList, IotaNamesSubnamesConfig, NameRecord, PackageInfo, } from './types.js'; import { isValidIotaName, normalizeIotaName, validateIotaName } from './utils.js'; /// The IotaNamesClient is the main entry point for the IotaNames SDK. /// It allows you to interact with IOTA-Names. export class IotaNamesClient { graphQlClient: IotaGraphQLClient; config: PackageInfo; constructor(config: IotaNamesClientConfig) { this.graphQlClient = config.graphQlClient; if ('network' in config) { this.config = packages[config.network as keyof typeof packages]; } else { this.config = config.packageInfo; } } async isMethodSupported( packageId: string, module: string, functionName: string, ): Promise<boolean> { const methodData = await this.graphQlClient.query<{ package: { module: { function: unknown } }; }>({ query: `query getMethod($package: IotaAddress!, $module: String!, $function: String!) { package(address: $package) { module(name: $module) { function(name: $function) { name } } } }`, variables: { package: packageId, module, function: functionName, }, }); return methodData.data?.package?.module?.function != null; } /** * Get the corresponding address for the given package. * Sometimes new versions might contain new types, * so its important to use the package they were created on when reading them in e.g GraphQL. * * - If the package does not have any versioning then the value is returned directly. * - If there are multiple versions available then the version passed as argument will be used to retrieve the package. * - If none of above returned a version then the latest will be picked. * * @argument {string} key - Package key. * @argument {string} [version=v1] - Optionally specify your desired version. */ getPackage(key: keyof PackageInfo, version: `v${number}` | 'latest' = 'latest'): string { const pkg = this.config[key]; // This package is not versioned at all if (typeof pkg === 'string') return pkg; // Select the versioned package, only if its not "latest" if (version !== 'latest' && version in pkg && typeof pkg[version] === 'string') { return pkg[version]; } // Get the latest available version for that given package const fallback = Object.values(pkg).toReversed()[0]; if (!fallback) throw new Error(`Package ${key} is not set`); return fallback; } /** * Returns the core config of IOTA Names. */ async getCoreConfig(): Promise<IotaNamesCoreConfig> { const iotaNamesObjectId = this.getPackage('iotaNamesObjectId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const coreConfigBcsB64 = toB64( DummyFieldBcs.serialize({ dummy_field: false, }).toBytes(), ); const coreConfigResponse: any = await this.graphQlClient.query({ query: graphql(` query getCoreConfig($parentId: IotaAddress!, $name: DynamicFieldName!) { owner(address: $parentId) { address dynamicField(name: $name) { value { ... on MoveValue { json } } } } } `), variables: { parentId: iotaNamesObjectId, name: { type: getConfigType(packageId, getCoreConfigType(packageId)), bcs: coreConfigBcsB64, }, }, }); const coreConfig = coreConfigResponse?.data?.owner?.dynamicField?.value?.json; if (!coreConfig) { throw new Error('Core config not found or is invalid'); } return coreConfig; } /** * Returns the subnames config of IOTA Names. */ async getSubnamesConfig(): Promise<IotaNamesSubnamesConfig> { const iotaNamesObjectId = this.getPackage('iotaNamesObjectId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const subnamesPackageId = this.getPackage('subnamesPackageId', 'v1'); const subnamesConfigBcsB64 = toB64( DummyFieldBcs.serialize({ dummy_field: false, }).toBytes(), ); const subnamesConfigResponse: any = await this.graphQlClient.query({ query: graphql(` query getSubnameConfig($parentId: IotaAddress!, $name: DynamicFieldName!) { owner(address: $parentId) { address dynamicField(name: $name) { value { ... on MoveValue { json } } } } } `), variables: { parentId: iotaNamesObjectId, name: { type: getConfigType(packageId, getSubnamesConfigType(subnamesPackageId)), bcs: subnamesConfigBcsB64, }, }, }); const subnamesConfig = subnamesConfigResponse?.data?.owner?.dynamicField?.value?.json; if (!subnamesConfig) { throw new Error('Subnames config not found or is invalid'); } return subnamesConfig; } /** * Returns the price list for IOTA names in the base asset. */ // Format: // { // [ 3, 3 ] => 500000000, // [ 4, 4 ] => 100000000, // [ 5, 63 ] => 20000000 // } async getPriceList(): Promise<IotaNamesPriceList> { const iotaNamesObjectId = this.getPackage('iotaNamesObjectId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const pricingConfigBcsB64 = toB64( DummyFieldBcs.serialize({ dummy_field: false, }).toBytes(), ); const priceListResponse: any = await this.graphQlClient.query({ query: graphql(` query getPriceList($parentId: IotaAddress!, $name: DynamicFieldName!) { owner(address: $parentId) { address dynamicField(name: $name) { value { ... on MoveValue { json } } } } } `), variables: { parentId: iotaNamesObjectId, name: { type: getConfigType(packageId, getPricelistConfigType(packageId)), bcs: pricingConfigBcsB64, }, }, }); const priceList = priceListResponse?.data?.owner?.dynamicField?.value?.json?.pricing; const contents = priceList?.contents; // Ensure the content exists if (!contents) { throw new Error('Price list not found or content is invalid'); } const priceMap = new Map(); for (const entry of contents) { const { pos0, pos1 } = entry.key; const key = [Number(pos0), Number(pos1)]; // Convert keys to numbers const value = Number(entry.value); // Convert value to a number priceMap.set(key, value); } return priceMap; } /** * Returns the renewal price list for IOTA names in the base asset. */ // Format: // { // [ 3, 3 ] => 500000000000, // [ 4, 4 ] => 250000000000, // [ 5, 63 ] => 50000000000 // } async getRenewalPriceList(): Promise<IotaNamesPriceList> { const iotaNamesObjectId = this.getPackage('iotaNamesObjectId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const pricingConfigBcsB64 = toB64( DummyFieldBcs.serialize({ dummy_field: false, }).toBytes(), ); const priceListResponse: any = await this.graphQlClient.query({ query: graphql(` query getRenewalPriceList($parentId: IotaAddress!, $name: DynamicFieldName!) { owner(address: $parentId) { address dynamicField(name: $name) { value { ... on MoveValue { json } } } } } `), variables: { parentId: iotaNamesObjectId, name: { type: getConfigType(packageId, getRenewalPricelistConfigType(packageId)), bcs: pricingConfigBcsB64, }, }, }); const priceList = priceListResponse?.data?.owner?.dynamicField?.value?.json?.config?.pricing; const contents = priceList?.contents; // Ensure the content exists if (!contents) { throw new Error('Price list not found or content is invalid'); } const priceMap = new Map(); for (const entry of contents) { const { pos0, pos1 } = entry.key; const key = [Number(pos0), Number(pos1)]; // Convert keys to numbers const value = Number(entry.value); // Convert value to a number priceMap.set(key, value); } return priceMap; } async getPublicName(address: string): Promise<string | null> { const response: any = await this.graphQlClient.query({ query: graphql(` query resolveNameServiceName($address: IotaAddress!, $nameFormat: NameFormat) { address(address: $address) { publicName: iotaNamesDefaultName(format: $nameFormat) } } `), variables: { address, }, }); const publicName = response?.data?.address?.publicName ?? null; return publicName; } async getDenyListTableIds(): Promise<{ reservedTableId: string | null; blockedTableId: string | null; }> { const iotaNamesObjectId = this.getPackage('iotaNamesObjectId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const denyListBcsB64 = toB64( DummyFieldBcs.serialize({ dummy_field: false, }).toBytes(), ); const response: any = await this.graphQlClient.query({ query: graphql(` query getDenyList($address: IotaAddress!, $type: String!, $bcs: Base64!) { owner(address: $address) { dynamicField(name: { type: $type, bcs: $bcs }) { name { type { repr } json } value { ... on MoveValue { type { repr } json } } } } } `), variables: { address: iotaNamesObjectId, type: getRegistryKeyType(packageId, getDenyListType(packageId)), bcs: denyListBcsB64, }, }); const denyList = response?.data?.owner?.dynamicField?.value?.json; return { reservedTableId: denyList?.reserved?.id ?? null, blockedTableId: denyList?.blocked?.id ?? null, }; } /** * Gets the full reserved/blocked list */ async getRestrictedList(type: 'reserved' | 'blocked'): Promise<string[]> { const { reservedTableId, blockedTableId } = await this.getDenyListTableIds(); if (type === 'reserved' && !reservedTableId) return []; if (type === 'blocked' && !blockedTableId) return []; const tableId = type === 'reserved' ? reservedTableId : blockedTableId; const results: string[] = []; let cursor: string | null = null; const pageSize = 50; let hasMorePages = true; while (hasMorePages) { const response: any = await this.graphQlClient.query({ query: graphql(` query getReservedListFromTable( $address: IotaAddress! $first: Int! $after: String ) { owner(address: $address) { dynamicFields(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { name { json } } } } } `), variables: { address: tableId, first: pageSize, after: cursor }, }); const df = response?.data?.owner?.dynamicFields; const nodes = df?.nodes ?? []; for (const n of nodes) { const json = n?.name?.json; if (typeof json === 'string' && json.length > 0) { results.push(json); continue; } } if (!df?.pageInfo?.hasNextPage) { hasMorePages = false; } else { cursor = df.pageInfo.endCursor; } cursor = df.pageInfo.endCursor; } return results; } async getNameRecord(name: string): Promise<NameRecord | null> { if (!isValidIotaName(name)) throw new Error('Invalid IOTA name'); const registryTableId = this.getPackage('registryTableId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const nameBcsB64 = toB64( NameBcs.serialize({ labels: normalizeIotaName(name, 'dot').split('.').reverse(), }).toBytes(), ); const nameRecordResponse: any = await this.graphQlClient.query({ query: graphql(` query getNameRecord($parentId: IotaAddress!, $name: DynamicFieldName!) { owner(address: $parentId) { address dynamicField(name: $name) { value { ... on MoveValue { json } } } } } `), variables: { parentId: registryTableId, name: { type: getNameType(packageId), bcs: nameBcsB64, }, }, }); const nameRecord = nameRecordResponse.data?.owner?.dynamicField?.value?.json; // in case the name record is not found, return null if (!nameRecord) return null; const nameRecordData = nameRecord.data?.contents; if (nameRecord.error || !nameRecordData) throw new Error('Name record not found. This name is not registered.'); const data: Record<string, string> = {}; if (nameRecordData) { nameRecordData.forEach((field: any) => { if (field.key) { data[field.key as string] = field.value; } }); } return { name, nftId: nameRecord?.nft_id, targetAddress: nameRecord?.target_address!, expirationDate: new Date(Number(nameRecord?.expiration_timestamp_ms)), data, avatar: data[ALLOWED_METADATA.avatar], }; } async getCouponHouse(): Promise<CouponHouse> { const iotaNamesObjectId = this.getPackage('iotaNamesObjectId', 'v1'); const packageId = this.getPackage('packageId', 'v1'); const couponsPackageId = this.getPackage('couponsPackageId', 'v1'); const DummyFieldB64 = DummyFieldBcs.serialize({ dummy_field: false }).toBase64(); const couponHouseResponse = await this.graphQlClient.query<{ owner: { dynamicField: { value: { bcs: string } } }; }>({ query: graphql(` query getIotaNamesCouponHouseRegistryKey( $parentId: IotaAddress! $name: DynamicFieldName! ) { owner(address: $parentId) { dynamicField(name: $name) { value { ... on MoveValue { bcs } } } } } `), variables: { parentId: iotaNamesObjectId, name: { type: `${packageId}::iota_names::RegistryKey<${couponsPackageId}::coupon_house::CouponHouse>`, bcs: DummyFieldB64, }, }, }); const couponsHouseDynamicFieldBcsValue = couponHouseResponse?.data?.owner?.dynamicField?.value?.bcs; if (!couponsHouseDynamicFieldBcsValue) { throw new Error('Coupon house not found or is invalid'); } return CouponHouseBcs.parse(fromB64(couponsHouseDynamicFieldBcsValue)); } async resolveCoupon(couponCode: string): Promise<Coupon | null> { const couponHouse = await this.getCouponHouse(); const couponsTableId = couponHouse?.coupons?.coupons?.id.id.bytes; if (!couponsTableId) { throw new Error('Coupons table ID not found in the coupon house'); } const couponCodeHash = bytesToHex(blake2b(couponCode, { dkLen: 32 })); const couponCodeBytes = hexToBytes(couponCodeHash); const couponCodeB64 = bcs.vector(bcs.u8()).serialize(couponCodeBytes).toBase64(); const couponResponse = await this.graphQlClient.query<{ owner: { dynamicField: { value: { bcs: string } } }; }>({ query: graphql(` query getCouponBcs($parentId: IotaAddress!, $name: DynamicFieldName!) { owner(address: $parentId) { dynamicField(name: $name) { value { ... on MoveValue { bcs } } } } } `), variables: { parentId: couponsTableId, name: { type: 'vector<u8>', bcs: couponCodeB64, }, }, }); const couponBcsBase64 = couponResponse?.data?.owner?.dynamicField?.value?.bcs; if (!couponBcsBase64) { return null; } const couponData = CouponBcs.parse(fromB64(couponBcsBase64)); return { ...couponData, couponCode }; } /** * Calculates the registration or renewal price for an SLN (Second Level Name). * It expects a name, the number of years and a `IotaNamesPriceList` object, * as returned from `iotaNamesClient.getPriceList()` function, or `iotaNames.getRenewalPriceList()` function. * * It throws an error: * 1. if the name is a subname * 2. if the name is not a valid IOTA name * 3. if the years are not between 1 and 5 */ async calculatePrice({ name, years, isRegistration = true, }: { name: string; years: number; isRegistration?: boolean; }) { if (!isValidIotaName(name)) { throw new Error('Invalid IOTA names'); } validateYears(years); if (isSubname(name)) { throw new Error('Subnames do not have a registration fee'); } const length = normalizeIotaName(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 calculateDiscountedPrice({ coupons, name, years, isRegistration = true, address, }: { coupons: Coupon[] | string[]; name: string; years: number; isRegistration?: boolean; address?: string; }) { if (coupons.every((coupon) => typeof coupon === 'string')) { const couponPromises = (coupons as string[]).map(async (couponCode) => { const coupon = await this.resolveCoupon(couponCode); if (!coupon) { throw new Error(`Coupon not found: ${couponCode}`); } return coupon; }); coupons = (await Promise.all(couponPromises)) as Coupon[]; } const normalizedName = normalizeIotaName(name, 'dot'); validateIotaName(normalizedName); const nameParts = normalizedName.split('.'); const firstNamePart = nameParts[0]; validateCoupons(coupons, years, firstNamePart.length, address); const standardPrice = await this.calculatePrice({ name, years, isRegistration, }); return applyCouponsToPrice(coupons, standardPrice); } }