@iota/iota-names-sdk
Version:
IOTA-Names SDK
718 lines (625 loc) • 24.5 kB
text/typescript
// 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);
}
}