diff --git a/packages/messaging/package.json b/packages/messaging/package.json index 5e771c8..a920162 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -56,6 +56,7 @@ "@mysten/bcs": "^1.9.2", "@mysten/seal": "^0.9.6", "@mysten/sui": "^1.45.2", + "@mysten/suins": "^0.9.13", "@mysten/walrus": "^0.8.6" }, "devDependencies": { diff --git a/packages/messaging/src/client.ts b/packages/messaging/src/client.ts index d2a0353..1c9ead5 100644 --- a/packages/messaging/src/client.ts +++ b/packages/messaging/src/client.ts @@ -61,6 +61,8 @@ import type { EncryptedSymmetricKey, SealConfig } from './encryption/types.js'; import { EnvelopeEncryption } from './encryption/envelopeEncryption.js'; import type { RawTransactionArgument } from './contracts/utils/index.js'; +import type { AddressResolver } from './utils/addressResolution.js'; +import type { ChannelNameResolver } from './utils/channelResolution.js'; import { CreatorCap, transferToSender as transferCreatorCap, @@ -79,6 +81,8 @@ export class SuiStackMessagingClient { #storage: (client: MessagingCompatibleClient) => StorageAdapter; #envelopeEncryption: EnvelopeEncryption; #sealConfig: SealConfig; + #addressResolver?: AddressResolver; + #channelResolver?: ChannelNameResolver; // TODO: Leave the responsibility of caching to the caller // #encryptedChannelDEKCache: Map = new Map(); // channelId --> EncryptedSymmetricKey // #channelMessagesTableIdCache: Map = new Map(); // channelId --> messagesTableId @@ -125,6 +129,12 @@ export class SuiStackMessagingClient { sessionKeyConfig: options.sessionKeyConfig, sealConfig: this.#sealConfig, }); + + // Initialize address resolver if provided + this.#addressResolver = options.addressResolver; + + // Initialize channel resolver if provided + this.#channelResolver = options.channelResolver; } /** @deprecated use `messaging()` instead */ @@ -180,6 +190,8 @@ export class SuiStackMessagingClient { sessionKey: 'sessionKey' in options ? options.sessionKey : undefined, sessionKeyConfig: 'sessionKeyConfig' in options ? options.sessionKeyConfig : undefined, sealConfig: options.sealConfig, + addressResolver: options.addressResolver, + channelResolver: options.channelResolver, }); }, }; @@ -187,6 +199,32 @@ export class SuiStackMessagingClient { // ===== Private Helper Methods ===== + /** + * Resolve addresses using the address resolver if available. + * If no resolver is configured, returns the input unchanged. + * @param addresses - Array of addresses or SuiNS names to resolve + * @returns Array of resolved addresses + */ + async #resolveAddresses(addresses: string[]): Promise { + if (!this.#addressResolver || addresses.length === 0) { + return addresses; + } + return this.#addressResolver.resolveMany(addresses); + } + + /** + * Resolve a channel name or ID to a channel object ID. + * If no resolver is configured, returns the input unchanged. + * @param channelNameOrId - A channel name (e.g., "#general") or channel object ID + * @returns The resolved channel object ID + */ + async #resolveChannelId(channelNameOrId: string): Promise { + if (!this.#channelResolver) { + return channelNameOrId; + } + return this.#channelResolver.resolve(channelNameOrId); + } + /** * Get user's member cap ID for a specific channel. * @param userAddress - The user's address @@ -504,12 +542,13 @@ export class SuiStackMessagingClient { /** * Get all members of a channel - * @param channelId - The channel ID + * @param channelNameOrId - The channel ID or name (e.g., "#general" if channelResolver is configured) * @returns Channel members with addresses and member cap IDs */ - async getChannelMembers(channelId: string): Promise { + async getChannelMembers(channelNameOrId: string): Promise { + const channelId = await this.#resolveChannelId(channelNameOrId); const logger = getLogger(LOG_CATEGORIES.CLIENT_READS); - logger.debug('Fetching channel members', { channelId }); + logger.debug('Fetching channel members', { channelId, originalInput: channelNameOrId }); // 1. Get the channel object to access the auth structure const channelObjectsRes = await this.#suiClient.core.getObjects({ @@ -592,18 +631,19 @@ export class SuiStackMessagingClient { /** * Get messages from a channel with pagination (returns decrypted messages) - * @param request - Request parameters including channelId, userAddress, cursor, limit, and direction + * @param request - Request parameters including channelId (or channel name), userAddress, cursor, limit, and direction * @returns Decrypted messages with pagination info */ async getChannelMessages({ - channelId, + channelId: channelNameOrId, userAddress, cursor = null, limit = 50, direction = 'backward', }: GetChannelMessagesRequest): Promise { + const channelId = await this.#resolveChannelId(channelNameOrId); const logger = getLogger(LOG_CATEGORIES.CLIENT_READS); - logger.debug('Fetching channel messages', { channelId, userAddress, cursor, limit, direction }); + logger.debug('Fetching channel messages', { channelId, originalInput: channelNameOrId, userAddress, cursor, limit, direction }); // 1. Get channel metadata (we need the raw channel object for metadata, not decrypted) const channelObjectsRes = await this.#suiClient.core.getObjects({ @@ -698,15 +738,16 @@ export class SuiStackMessagingClient { /** * Get new messages since last polling state (returns decrypted messages) - * @param request - Request with channelId, userAddress, pollingState, and limit + * @param request - Request with channelId (or channel name), userAddress, pollingState, and limit * @returns New decrypted messages since last poll */ async getLatestMessages({ - channelId, + channelId: channelNameOrId, userAddress, pollingState, limit = 50, }: GetLatestMessagesRequest): Promise { + const channelId = await this.#resolveChannelId(channelNameOrId); // 1. Get current channel state to check for new messages const channelObjectsRes = await this.#suiClient.core.getObjects({ objectIds: [channelId], @@ -753,7 +794,7 @@ export class SuiStackMessagingClient { * * @usage * ``` - * const flow = client.createChannelFlow(); + * const flow = await client.createChannelFlow(); * * // Step-by-step execution * // 1. build @@ -766,13 +807,18 @@ export class SuiStackMessagingClient { * const { channelId, encryptedKeyBytes } = await flow.getGeneratedEncryptionKey({ creatorCap, encryptedKeyBytes }); * ``` * - * @param opts - Options including creator address and initial members + * @param opts - Options including creator address and initial members (can include SuiNS names if addressResolver is configured) * @returns Channel creation flow with step-by-step methods */ - createChannelFlow({ + async createChannelFlow({ creatorAddress, initialMemberAddresses, - }: CreateChannelFlowOpts): CreateChannelFlow { + }: CreateChannelFlowOpts): Promise { + // Resolve SuiNS names to addresses if resolver is configured + const resolvedInitialMemberAddresses = initialMemberAddresses + ? await this.#resolveAddresses(initialMemberAddresses) + : undefined; + const build = () => { const logger = getLogger(LOG_CATEGORIES.CLIENT_WRITES); const tx = new Transaction(); @@ -782,14 +828,14 @@ export class SuiStackMessagingClient { // Add initial members if provided // Deduplicate addresses and filter out creator (who already gets a MemberCap automatically) const uniqueAddresses = - initialMemberAddresses && initialMemberAddresses.length > 0 - ? this.#deduplicateAddresses(initialMemberAddresses, creatorAddress) + resolvedInitialMemberAddresses && resolvedInitialMemberAddresses.length > 0 + ? this.#deduplicateAddresses(resolvedInitialMemberAddresses, creatorAddress) : []; - if (initialMemberAddresses && uniqueAddresses.length !== initialMemberAddresses.length) { + if (resolvedInitialMemberAddresses && uniqueAddresses.length !== resolvedInitialMemberAddresses.length) { logger.warn( 'Duplicate addresses or creator address detected in initialMemberAddresses. Creator automatically receives a MemberCap. Using unique non-creator addresses only.', { - originalCount: initialMemberAddresses?.length, + originalCount: resolvedInitialMemberAddresses?.length, uniqueCount: uniqueAddresses.length, creatorAddress, }, @@ -1053,12 +1099,12 @@ export class SuiStackMessagingClient { /** * Execute a send message transaction - * @param params - Transaction parameters including signer, channelId, memberCapId, message, and encryptedKey + * @param params - Transaction parameters including signer, channelId (or channel name), memberCapId, message, and encryptedKey * @returns Transaction digest and message ID */ async executeSendMessageTransaction({ signer, - channelId, + channelId: channelNameOrId, memberCapId, message, attachments, @@ -1070,10 +1116,12 @@ export class SuiStackMessagingClient { encryptedKey: EncryptedSymmetricKey; attachments?: File[]; } & { signer: Signer }): Promise<{ digest: string; messageId: string }> { + const channelId = await this.#resolveChannelId(channelNameOrId); const logger = getLogger(LOG_CATEGORIES.CLIENT_WRITES); const senderAddress = signer.toSuiAddress(); logger.debug('Sending message', { channelId, + originalInput: channelNameOrId, memberCapId, senderAddress, messageLength: message.length, @@ -1118,7 +1166,7 @@ export class SuiStackMessagingClient { * tx.add(client.addMembers({ * channelId, * memberCapId, - * newMemberAddresses: ['0xabc...', '0xdef...'], + * newMemberAddresses: ['0xabc...', '0xdef...', 'alice.sui'], * creatorCapId * })); * @@ -1126,21 +1174,26 @@ export class SuiStackMessagingClient { * tx.add(client.addMembers({ * channelId, * memberCapId, - * newMemberAddresses: ['0xabc...', '0xdef...'], + * newMemberAddresses: ['0xabc...', '0xdef...', 'bob.sui'], * address: signer.toSuiAddress() * })); * ``` */ addMembers(options: AddMembersOptions) { return async (tx: Transaction) => { - const { channelId, memberCapId, newMemberAddresses } = options; + const { memberCapId, newMemberAddresses } = options; + // Resolve channel name to ID if resolver is configured + const channelId = await this.#resolveChannelId(options.channelId); const logger = getLogger(LOG_CATEGORIES.CLIENT_WRITES); - const uniqueAddresses = this.#deduplicateAddresses(newMemberAddresses); - if (uniqueAddresses.length !== newMemberAddresses.length) { + // Resolve SuiNS names to addresses if resolver is configured + const resolvedAddresses = await this.#resolveAddresses(newMemberAddresses); + + const uniqueAddresses = this.#deduplicateAddresses(resolvedAddresses); + if (uniqueAddresses.length !== resolvedAddresses.length) { logger.warn('Duplicate addresses removed from newMemberAddresses.', { channelId, - originalCount: newMemberAddresses.length, + originalCount: resolvedAddresses.length, uniqueCount: uniqueAddresses.length, }); } @@ -1259,14 +1312,17 @@ export class SuiStackMessagingClient { digest: string; addedMembers: AddedMemberCap[]; }> { + // Resolve channel name to ID if resolver is configured + const channelId = await this.#resolveChannelId(options.channelId); const logger = getLogger(LOG_CATEGORIES.CLIENT_WRITES); logger.debug('Adding members to channel', { - channelId: options.channelId, + channelId, + originalInput: options.channelId, newMemberAddresses: options.newMemberAddresses, }); // If creatorCapId is not provided, use signer's address to fetch it - const { channelId, memberCapId, newMemberAddresses, creatorCapId } = options; + const { memberCapId, newMemberAddresses, creatorCapId } = options; const addMembersOptions: AddMembersOptions = creatorCapId ? { channelId, memberCapId, newMemberAddresses, creatorCapId } : { channelId, memberCapId, newMemberAddresses, address: signer.toSuiAddress() }; @@ -1353,7 +1409,7 @@ export class SuiStackMessagingClient { initialMemberCount: initialMembers?.length ?? 0, }); - const flow = this.createChannelFlow({ + const flow = await this.createChannelFlow({ creatorAddress, initialMemberAddresses: initialMembers, }); @@ -1909,6 +1965,8 @@ export function messaging(options: MessagingClientExtensionOptions) { sessionKey: 'sessionKey' in options ? options.sessionKey : undefined, sessionKeyConfig: 'sessionKeyConfig' in options ? options.sessionKeyConfig : undefined, sealConfig: options.sealConfig, + addressResolver: options.addressResolver, + channelResolver: options.channelResolver, }); }, }; diff --git a/packages/messaging/src/index.ts b/packages/messaging/src/index.ts index d2e0d6c..69c2612 100644 --- a/packages/messaging/src/index.ts +++ b/packages/messaging/src/index.ts @@ -35,3 +35,17 @@ export type * from './storage/adapters/walrus/types.js'; // Logging utilities (optional - requires @logtape/logtape peer dependency) export { getLogger, LOG_CATEGORIES } from './logging/index.js'; + +// Address resolution utilities (for SuiNS name resolution) +export { SuiNSResolver, isSuiNSName } from './utils/addressResolution.js'; +export type { AddressResolver } from './utils/addressResolution.js'; + +// Channel name resolution utilities +export { + LocalChannelRegistry, + PersistentChannelRegistry, + isChannelName, + normalizeChannelName, + formatChannelName, +} from './utils/channelResolution.js'; +export type { ChannelNameResolver } from './utils/channelResolution.js'; diff --git a/packages/messaging/src/types.ts b/packages/messaging/src/types.ts index 8b3a76c..26e6fce 100644 --- a/packages/messaging/src/types.ts +++ b/packages/messaging/src/types.ts @@ -22,6 +22,8 @@ import type { import type { MemberCap } from './contracts/sui_stack_messaging/member_cap.js'; import type { CreatorCap } from './contracts/sui_stack_messaging/creator_cap.js'; import type { StorageAdapter, StorageConfig } from './storage/adapters/storage.js'; +import type { AddressResolver } from './utils/addressResolution.js'; +import type { ChannelNameResolver } from './utils/channelResolution.js'; import type { Channel } from './contracts/sui_stack_messaging/channel.js'; import type { Message } from './contracts/sui_stack_messaging/message.js'; @@ -34,6 +36,18 @@ interface BaseMessagingClientExtensionOptions { * Key servers are configured separately via SealClient.asClientExtension() */ sealConfig?: SealConfig; + /** + * Optional address resolver for resolving SuiNS names to addresses. + * When provided, methods like createChannelFlow() and addMembers() will automatically + * resolve SuiNS names (e.g., "alice.sui") to their corresponding addresses. + */ + addressResolver?: AddressResolver; + /** + * Optional channel name resolver for resolving human-readable channel names to IDs. + * When provided, methods can accept channel names (e.g., "#general") instead of + * raw channel object IDs. + */ + channelResolver?: ChannelNameResolver; } // Storage variants (mutually exclusive) @@ -56,6 +70,8 @@ export interface MessagingClientOptions { sessionKeyConfig?: SessionKeyConfig; sessionKey?: SessionKey; sealConfig?: SealConfig; + addressResolver?: AddressResolver; + channelResolver?: ChannelNameResolver; } // Create Channel Flow interfaces diff --git a/packages/messaging/src/utils/addressResolution.ts b/packages/messaging/src/utils/addressResolution.ts new file mode 100644 index 0000000..f943430 --- /dev/null +++ b/packages/messaging/src/utils/addressResolution.ts @@ -0,0 +1,106 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { SuinsClient } from '@mysten/suins'; + +/** + * Interface for address resolution. + * Implementations can resolve SuiNS names to addresses and perform reverse lookups. + */ +export interface AddressResolver { + /** + * Resolve a SuiNS name or address to an address. + * If the input is already a valid address, return it unchanged. + * If the input is a SuiNS name (e.g., "alice.sui"), resolve it to an address. + * @param nameOrAddress - A SuiNS name or Sui address + * @returns The resolved address + * @throws Error if the name cannot be resolved + */ + resolve(nameOrAddress: string): Promise; + + /** + * Resolve multiple SuiNS names or addresses to addresses. + * @param namesOrAddresses - Array of SuiNS names or Sui addresses + * @returns Array of resolved addresses in the same order + * @throws Error if any name cannot be resolved + */ + resolveMany(namesOrAddresses: string[]): Promise; + + /** + * Perform a reverse lookup to get the default SuiNS name for an address. + * @param address - A Sui address + * @returns The default SuiNS name or null if not found + */ + reverseLookup(address: string): Promise; +} + +/** + * Check if a string is a SuiNS name (ends with .sui). + * @param input - The string to check + * @returns True if the input is a SuiNS name + */ +export function isSuiNSName(input: string): boolean { + return input.toLowerCase().endsWith('.sui'); +} + +/** + * SuiNS-based address resolver implementation. + * Uses the @mysten/suins SuinsClient to resolve names to addresses. + */ +export class SuiNSResolver implements AddressResolver { + #suinsClient: SuinsClient; + + /** + * Create a new SuiNSResolver. + * @param suinsClient - An initialized SuinsClient instance + */ + constructor(suinsClient: SuinsClient) { + this.#suinsClient = suinsClient; + } + + /** + * Resolve a SuiNS name or address to an address. + * @param nameOrAddress - A SuiNS name or Sui address + * @returns The resolved address + * @throws Error if the name cannot be resolved + */ + async resolve(nameOrAddress: string): Promise { + // If it's not a SuiNS name, return as-is (assume it's an address) + if (!isSuiNSName(nameOrAddress)) { + return nameOrAddress; + } + + // Resolve SuiNS name to address + const record = await this.#suinsClient.getNameRecord(nameOrAddress); + + if (!record || !record.targetAddress) { + throw new Error(`Failed to resolve SuiNS name: ${nameOrAddress}`); + } + + return record.targetAddress; + } + + /** + * Resolve multiple SuiNS names or addresses to addresses. + * @param namesOrAddresses - Array of SuiNS names or Sui addresses + * @returns Array of resolved addresses in the same order + * @throws Error if any name cannot be resolved + */ + async resolveMany(namesOrAddresses: string[]): Promise { + return Promise.all(namesOrAddresses.map((nameOrAddress) => this.resolve(nameOrAddress))); + } + + /** + * Perform a reverse lookup to get the default SuiNS name for an address. + * Note: Reverse lookup is not currently supported by the @mysten/suins SDK. + * This method always returns null. Future versions may implement this + * feature when the SDK adds support for it. + * @param address - A Sui address + * @returns Always returns null (reverse lookup not supported) + */ + async reverseLookup(_address: string): Promise { + // Reverse lookup (address → name) is not currently supported by @mysten/suins + // The SDK only supports forward lookup (name → address) via getNameRecord() + return null; + } +} diff --git a/packages/messaging/src/utils/channelResolution.ts b/packages/messaging/src/utils/channelResolution.ts new file mode 100644 index 0000000..6d0263b --- /dev/null +++ b/packages/messaging/src/utils/channelResolution.ts @@ -0,0 +1,313 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Interface for channel name resolution. + * Implementations can resolve human-readable channel names (e.g., "#general") + * to channel object IDs and perform reverse lookups. + */ +export interface ChannelNameResolver { + /** + * Resolve a channel name or ID to a channel object ID. + * If the input is already a valid channel ID (0x...), return it unchanged. + * If the input is a channel name (e.g., "#general"), resolve it to a channel ID. + * @param nameOrId - A channel name (with or without #) or channel object ID + * @returns The resolved channel object ID + * @throws Error if the name cannot be resolved + */ + resolve(nameOrId: string): Promise; + + /** + * Resolve multiple channel names or IDs to channel object IDs. + * @param namesOrIds - Array of channel names or channel object IDs + * @returns Array of resolved channel object IDs in the same order + * @throws Error if any name cannot be resolved + */ + resolveMany(namesOrIds: string[]): Promise; + + /** + * Perform a reverse lookup to get the channel name for a channel ID. + * @param channelId - A channel object ID + * @returns The channel name or null if not found/registered + */ + reverseLookup(channelId: string): Promise; + + /** + * Register a channel name mapping. + * @param name - The human-readable channel name (with or without #) + * @param channelId - The channel object ID + */ + register(name: string, channelId: string): Promise; + + /** + * Unregister a channel name mapping. + * @param name - The channel name to unregister + */ + unregister(name: string): Promise; + + /** + * List all registered channel name mappings. + * @returns Map of channel names to channel IDs + */ + list(): Promise>; +} + +/** + * Check if a string is a channel name (starts with # or doesn't start with 0x). + * @param input - The string to check + * @returns True if the input appears to be a channel name + */ +export function isChannelName(input: string): boolean { + if (!input || input.length === 0) { + return false; + } + // Explicit channel name prefix + if (input.startsWith('#')) { + return true; + } + // If it looks like an address/ID (starts with 0x), it's not a name + if (input.startsWith('0x')) { + return false; + } + // Otherwise, treat as a name (e.g., "general" without #) + return true; +} + +/** + * Normalize a channel name by removing the # prefix if present and converting to lowercase. + * @param name - The channel name to normalize + * @returns The normalized channel name + */ +export function normalizeChannelName(name: string): string { + let normalized = name.trim().toLowerCase(); + if (normalized.startsWith('#')) { + normalized = normalized.slice(1); + } + return normalized; +} + +/** + * Format a channel name with the # prefix. + * @param name - The channel name (with or without #) + * @returns The formatted channel name with # prefix + */ +export function formatChannelName(name: string): string { + const normalized = normalizeChannelName(name); + return `#${normalized}`; +} + +/** + * In-memory channel name registry implementation. + * Useful for local development, testing, and single-session usage. + * Names are stored in memory and lost when the process exits. + */ +export class LocalChannelRegistry implements ChannelNameResolver { + #nameToId: Map = new Map(); + #idToName: Map = new Map(); + + /** + * Create a new LocalChannelRegistry with optional initial mappings. + * @param initialMappings - Optional initial name-to-ID mappings + */ + constructor(initialMappings?: Record | Map) { + if (initialMappings) { + const entries = + initialMappings instanceof Map + ? initialMappings.entries() + : Object.entries(initialMappings); + for (const [name, channelId] of entries) { + const normalized = normalizeChannelName(name); + this.#nameToId.set(normalized, channelId); + this.#idToName.set(channelId, normalized); + } + } + } + + async resolve(nameOrId: string): Promise { + // If it's not a channel name, assume it's already an ID + if (!isChannelName(nameOrId)) { + return nameOrId; + } + + const normalized = normalizeChannelName(nameOrId); + const channelId = this.#nameToId.get(normalized); + + if (!channelId) { + throw new Error(`Channel name not found: ${formatChannelName(nameOrId)}`); + } + + return channelId; + } + + async resolveMany(namesOrIds: string[]): Promise { + return Promise.all(namesOrIds.map((nameOrId) => this.resolve(nameOrId))); + } + + async reverseLookup(channelId: string): Promise { + const name = this.#idToName.get(channelId); + return name ? formatChannelName(name) : null; + } + + async register(name: string, channelId: string): Promise { + const normalized = normalizeChannelName(name); + + // Check if name is already registered to a different channel + const existingId = this.#nameToId.get(normalized); + if (existingId && existingId !== channelId) { + throw new Error( + `Channel name ${formatChannelName(name)} is already registered to ${existingId}`, + ); + } + + // Remove any existing name for this channel ID + const existingName = this.#idToName.get(channelId); + if (existingName && existingName !== normalized) { + this.#nameToId.delete(existingName); + } + + this.#nameToId.set(normalized, channelId); + this.#idToName.set(channelId, normalized); + } + + async unregister(name: string): Promise { + const normalized = normalizeChannelName(name); + const channelId = this.#nameToId.get(normalized); + + if (channelId) { + this.#nameToId.delete(normalized); + this.#idToName.delete(channelId); + } + } + + async list(): Promise> { + const result = new Map(); + for (const [name, channelId] of this.#nameToId) { + result.set(formatChannelName(name), channelId); + } + return result; + } + + /** + * Export the registry data for persistence. + * @returns JSON-serializable object of name-to-ID mappings + */ + export(): Record { + const result: Record = {}; + for (const [name, channelId] of this.#nameToId) { + result[name] = channelId; + } + return result; + } + + /** + * Import registry data from a previously exported object. + * @param data - The exported registry data + * @param merge - If true, merge with existing data; if false, replace + */ + import(data: Record, merge: boolean = true): void { + if (!merge) { + this.#nameToId.clear(); + this.#idToName.clear(); + } + + for (const [name, channelId] of Object.entries(data)) { + const normalized = normalizeChannelName(name); + this.#nameToId.set(normalized, channelId); + this.#idToName.set(channelId, normalized); + } + } + + /** + * Clear all registered channel names. + */ + clear(): void { + this.#nameToId.clear(); + this.#idToName.clear(); + } + + /** + * Get the number of registered channel names. + */ + get size(): number { + return this.#nameToId.size; + } +} + +/** + * Persistent channel name registry that stores mappings in localStorage (browser) + * or a file (Node.js). Extends LocalChannelRegistry with persistence. + */ +export class PersistentChannelRegistry extends LocalChannelRegistry { + #storageKey: string; + #storage: Storage | null; + + /** + * Create a new PersistentChannelRegistry. + * @param storageKey - The key to use for storage (default: 'sui-messaging-channels') + */ + constructor(storageKey: string = 'sui-messaging-channels') { + // Try to load existing data from localStorage + let initialData: Record | undefined; + + // Check if localStorage is available (browser environment) + const storage = typeof localStorage !== 'undefined' ? localStorage : null; + + if (storage) { + try { + const stored = storage.getItem(storageKey); + if (stored) { + initialData = JSON.parse(stored); + } + } catch { + // Ignore parse errors, start fresh + } + } + + super(initialData); + this.#storageKey = storageKey; + this.#storage = storage; + } + + async register(name: string, channelId: string): Promise { + await super.register(name, channelId); + this.#persist(); + } + + async unregister(name: string): Promise { + await super.unregister(name); + this.#persist(); + } + + #persist(): void { + if (this.#storage) { + try { + this.#storage.setItem(this.#storageKey, JSON.stringify(this.export())); + } catch { + // Ignore storage errors (e.g., quota exceeded) + } + } + } + + /** + * Force a save to storage. + */ + save(): void { + this.#persist(); + } + + /** + * Reload data from storage, discarding any unsaved changes. + */ + reload(): void { + if (this.#storage) { + try { + const stored = this.#storage.getItem(this.#storageKey); + if (stored) { + this.import(JSON.parse(stored), false); + } + } catch { + // Ignore parse errors + } + } + } +} diff --git a/packages/messaging/test/unit/addressResolution.test.ts b/packages/messaging/test/unit/addressResolution.test.ts new file mode 100644 index 0000000..865f20d --- /dev/null +++ b/packages/messaging/test/unit/addressResolution.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + SuiNSResolver, + isSuiNSName, + type AddressResolver, +} from '../../src/utils/addressResolution.js'; + +describe('isSuiNSName', () => { + it('detects .sui names (lowercase)', () => { + expect(isSuiNSName('alice.sui')).toBe(true); + expect(isSuiNSName('bob.sui')).toBe(true); + expect(isSuiNSName('my-name.sui')).toBe(true); + }); + + it('detects .sui names (case insensitive)', () => { + expect(isSuiNSName('ALICE.SUI')).toBe(true); + expect(isSuiNSName('Alice.Sui')).toBe(true); + expect(isSuiNSName('BOB.sui')).toBe(true); + }); + + it('detects subdomains', () => { + expect(isSuiNSName('sub.alice.sui')).toBe(true); + expect(isSuiNSName('deep.sub.alice.sui')).toBe(true); + }); + + it('rejects non-.sui strings', () => { + expect(isSuiNSName('0x1234567890abcdef')).toBe(false); + expect(isSuiNSName('alice.eth')).toBe(false); + expect(isSuiNSName('alice.sol')).toBe(false); + expect(isSuiNSName('alice')).toBe(false); + expect(isSuiNSName('')).toBe(false); + expect(isSuiNSName('.sui')).toBe(true); // Edge case: technically ends with .sui + }); + + it('rejects addresses that happen to contain sui', () => { + expect(isSuiNSName('0xsui1234')).toBe(false); + expect(isSuiNSName('suiaddress')).toBe(false); + }); +}); + +describe('SuiNSResolver', () => { + let mockSuinsClient: { + getNameRecord: ReturnType; + }; + let resolver: SuiNSResolver; + + beforeEach(() => { + mockSuinsClient = { + getNameRecord: vi.fn(), + }; + resolver = new SuiNSResolver(mockSuinsClient as any); + }); + + describe('resolve', () => { + it('passes through addresses unchanged', async () => { + const address = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + + const result = await resolver.resolve(address); + + expect(result).toBe(address); + expect(mockSuinsClient.getNameRecord).not.toHaveBeenCalled(); + }); + + it('passes through short addresses', async () => { + const address = '0x2'; + + const result = await resolver.resolve(address); + + expect(result).toBe(address); + expect(mockSuinsClient.getNameRecord).not.toHaveBeenCalled(); + }); + + it('resolves SuiNS names to addresses', async () => { + const expectedAddress = '0xresolvedaddress123'; + mockSuinsClient.getNameRecord.mockResolvedValue({ + targetAddress: expectedAddress, + }); + + const result = await resolver.resolve('alice.sui'); + + expect(result).toBe(expectedAddress); + expect(mockSuinsClient.getNameRecord).toHaveBeenCalledWith('alice.sui'); + }); + + it('resolves subdomain names', async () => { + const expectedAddress = '0xsubdomainaddress'; + mockSuinsClient.getNameRecord.mockResolvedValue({ + targetAddress: expectedAddress, + }); + + const result = await resolver.resolve('treasury.dao.sui'); + + expect(result).toBe(expectedAddress); + expect(mockSuinsClient.getNameRecord).toHaveBeenCalledWith('treasury.dao.sui'); + }); + + it('throws on null record', async () => { + mockSuinsClient.getNameRecord.mockResolvedValue(null); + + await expect(resolver.resolve('nonexistent.sui')).rejects.toThrow( + 'Failed to resolve SuiNS name: nonexistent.sui', + ); + }); + + it('throws on record without targetAddress', async () => { + mockSuinsClient.getNameRecord.mockResolvedValue({ + targetAddress: null, + }); + + await expect(resolver.resolve('notarget.sui')).rejects.toThrow( + 'Failed to resolve SuiNS name: notarget.sui', + ); + }); + + it('throws on undefined targetAddress', async () => { + mockSuinsClient.getNameRecord.mockResolvedValue({}); + + await expect(resolver.resolve('undefined.sui')).rejects.toThrow( + 'Failed to resolve SuiNS name: undefined.sui', + ); + }); + }); + + describe('resolveMany', () => { + it('resolves empty array', async () => { + const result = await resolver.resolveMany([]); + + expect(result).toEqual([]); + expect(mockSuinsClient.getNameRecord).not.toHaveBeenCalled(); + }); + + it('resolves array of addresses (no SuiNS calls)', async () => { + const addresses = ['0xabc', '0xdef', '0x123']; + + const result = await resolver.resolveMany(addresses); + + expect(result).toEqual(addresses); + expect(mockSuinsClient.getNameRecord).not.toHaveBeenCalled(); + }); + + it('resolves array of SuiNS names', async () => { + mockSuinsClient.getNameRecord + .mockResolvedValueOnce({ targetAddress: '0xalice' }) + .mockResolvedValueOnce({ targetAddress: '0xbob' }) + .mockResolvedValueOnce({ targetAddress: '0xcharlie' }); + + const result = await resolver.resolveMany(['alice.sui', 'bob.sui', 'charlie.sui']); + + expect(result).toEqual(['0xalice', '0xbob', '0xcharlie']); + expect(mockSuinsClient.getNameRecord).toHaveBeenCalledTimes(3); + }); + + it('resolves mixed array (names and addresses)', async () => { + mockSuinsClient.getNameRecord + .mockResolvedValueOnce({ targetAddress: '0xalice' }) + .mockResolvedValueOnce({ targetAddress: '0xcharlie' }); + + const result = await resolver.resolveMany([ + 'alice.sui', + '0xbob_address', + 'charlie.sui', + '0xdave_address', + ]); + + expect(result).toEqual(['0xalice', '0xbob_address', '0xcharlie', '0xdave_address']); + expect(mockSuinsClient.getNameRecord).toHaveBeenCalledTimes(2); + expect(mockSuinsClient.getNameRecord).toHaveBeenCalledWith('alice.sui'); + expect(mockSuinsClient.getNameRecord).toHaveBeenCalledWith('charlie.sui'); + }); + + it('preserves order in mixed array', async () => { + mockSuinsClient.getNameRecord.mockResolvedValueOnce({ targetAddress: '0xresolved' }); + + const result = await resolver.resolveMany(['0xfirst', 'middle.sui', '0xlast']); + + expect(result).toEqual(['0xfirst', '0xresolved', '0xlast']); + }); + + it('throws if any name fails to resolve', async () => { + mockSuinsClient.getNameRecord + .mockResolvedValueOnce({ targetAddress: '0xalice' }) + .mockResolvedValueOnce(null); // Second name fails + + await expect(resolver.resolveMany(['alice.sui', 'fails.sui'])).rejects.toThrow( + 'Failed to resolve SuiNS name: fails.sui', + ); + }); + }); + + describe('reverseLookup', () => { + it('returns null (not implemented)', async () => { + const result = await resolver.reverseLookup('0x123'); + + expect(result).toBeNull(); + }); + + it('returns null for any address', async () => { + const result = await resolver.reverseLookup( + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ); + + expect(result).toBeNull(); + }); + }); +}); + +describe('AddressResolver interface', () => { + it('can create a custom implementation', async () => { + // Example of custom resolver (e.g., for testing or alternative naming systems) + const customResolver: AddressResolver = { + resolve: async (nameOrAddress) => { + if (nameOrAddress === 'test.custom') { + return '0xcustom_resolved'; + } + return nameOrAddress; + }, + resolveMany: async (inputs) => { + return Promise.all(inputs.map((input) => customResolver.resolve(input))); + }, + reverseLookup: async () => null, + }; + + expect(await customResolver.resolve('test.custom')).toBe('0xcustom_resolved'); + expect(await customResolver.resolve('0xpassthrough')).toBe('0xpassthrough'); + expect(await customResolver.resolveMany(['test.custom', '0xaddr'])).toEqual([ + '0xcustom_resolved', + '0xaddr', + ]); + }); +}); diff --git a/packages/messaging/test/unit/channelResolution.test.ts b/packages/messaging/test/unit/channelResolution.test.ts new file mode 100644 index 0000000..2db047d --- /dev/null +++ b/packages/messaging/test/unit/channelResolution.test.ts @@ -0,0 +1,333 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + LocalChannelRegistry, + isChannelName, + normalizeChannelName, + formatChannelName, + type ChannelNameResolver, +} from '../../src/utils/channelResolution.js'; + +describe('isChannelName', () => { + it('detects names with # prefix', () => { + expect(isChannelName('#general')).toBe(true); + expect(isChannelName('#random')).toBe(true); + expect(isChannelName('#my-channel')).toBe(true); + expect(isChannelName('#123')).toBe(true); + }); + + it('detects names without # prefix (not starting with 0x)', () => { + expect(isChannelName('general')).toBe(true); + expect(isChannelName('random')).toBe(true); + expect(isChannelName('my-channel')).toBe(true); + }); + + it('rejects addresses (starting with 0x)', () => { + expect(isChannelName('0x123')).toBe(false); + expect(isChannelName('0xabcdef1234567890')).toBe(false); + expect(isChannelName('0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef')).toBe( + false, + ); + }); + + it('rejects empty strings', () => { + expect(isChannelName('')).toBe(false); + }); +}); + +describe('normalizeChannelName', () => { + it('removes # prefix', () => { + expect(normalizeChannelName('#general')).toBe('general'); + expect(normalizeChannelName('#RANDOM')).toBe('random'); + }); + + it('converts to lowercase', () => { + expect(normalizeChannelName('General')).toBe('general'); + expect(normalizeChannelName('RANDOM')).toBe('random'); + expect(normalizeChannelName('#MyChannel')).toBe('mychannel'); + }); + + it('trims whitespace', () => { + expect(normalizeChannelName(' general ')).toBe('general'); + expect(normalizeChannelName(' #random ')).toBe('random'); + }); + + it('handles already normalized names', () => { + expect(normalizeChannelName('general')).toBe('general'); + expect(normalizeChannelName('random')).toBe('random'); + }); +}); + +describe('formatChannelName', () => { + it('adds # prefix', () => { + expect(formatChannelName('general')).toBe('#general'); + expect(formatChannelName('random')).toBe('#random'); + }); + + it('normalizes and adds prefix', () => { + expect(formatChannelName('GENERAL')).toBe('#general'); + expect(formatChannelName('#Random')).toBe('#random'); + expect(formatChannelName(' #MyChannel ')).toBe('#mychannel'); + }); +}); + +describe('LocalChannelRegistry', () => { + let registry: LocalChannelRegistry; + + beforeEach(() => { + registry = new LocalChannelRegistry(); + }); + + describe('constructor', () => { + it('creates empty registry', () => { + expect(registry.size).toBe(0); + }); + + it('initializes with Record mappings', () => { + const initialRegistry = new LocalChannelRegistry({ + general: '0xchannel1', + random: '0xchannel2', + }); + expect(initialRegistry.size).toBe(2); + }); + + it('initializes with Map mappings', () => { + const map = new Map([ + ['general', '0xchannel1'], + ['random', '0xchannel2'], + ]); + const initialRegistry = new LocalChannelRegistry(map); + expect(initialRegistry.size).toBe(2); + }); + + it('normalizes names during initialization', async () => { + const initialRegistry = new LocalChannelRegistry({ + '#General': '0xchannel1', + RANDOM: '0xchannel2', + }); + expect(await initialRegistry.resolve('#general')).toBe('0xchannel1'); + expect(await initialRegistry.resolve('random')).toBe('0xchannel2'); + }); + }); + + describe('resolve', () => { + beforeEach(async () => { + await registry.register('general', '0xchannel1'); + await registry.register('random', '0xchannel2'); + }); + + it('resolves registered channel names', async () => { + expect(await registry.resolve('#general')).toBe('0xchannel1'); + expect(await registry.resolve('general')).toBe('0xchannel1'); + expect(await registry.resolve('#random')).toBe('0xchannel2'); + }); + + it('resolves case-insensitively', async () => { + expect(await registry.resolve('#GENERAL')).toBe('0xchannel1'); + expect(await registry.resolve('General')).toBe('0xchannel1'); + expect(await registry.resolve('#RaNdOm')).toBe('0xchannel2'); + }); + + it('passes through channel IDs unchanged', async () => { + expect(await registry.resolve('0xchannel1')).toBe('0xchannel1'); + expect(await registry.resolve('0xunknown')).toBe('0xunknown'); + }); + + it('throws for unregistered names', async () => { + await expect(registry.resolve('#unknown')).rejects.toThrow('Channel name not found: #unknown'); + await expect(registry.resolve('notfound')).rejects.toThrow( + 'Channel name not found: #notfound', + ); + }); + }); + + describe('resolveMany', () => { + beforeEach(async () => { + await registry.register('general', '0xchannel1'); + await registry.register('random', '0xchannel2'); + }); + + it('resolves multiple names', async () => { + const result = await registry.resolveMany(['#general', '#random']); + expect(result).toEqual(['0xchannel1', '0xchannel2']); + }); + + it('resolves mixed names and IDs', async () => { + const result = await registry.resolveMany(['#general', '0xother', 'random']); + expect(result).toEqual(['0xchannel1', '0xother', '0xchannel2']); + }); + + it('resolves empty array', async () => { + const result = await registry.resolveMany([]); + expect(result).toEqual([]); + }); + + it('throws if any name is not found', async () => { + await expect(registry.resolveMany(['#general', '#unknown'])).rejects.toThrow( + 'Channel name not found: #unknown', + ); + }); + }); + + describe('reverseLookup', () => { + beforeEach(async () => { + await registry.register('general', '0xchannel1'); + }); + + it('returns channel name for registered ID', async () => { + expect(await registry.reverseLookup('0xchannel1')).toBe('#general'); + }); + + it('returns null for unregistered ID', async () => { + expect(await registry.reverseLookup('0xunknown')).toBeNull(); + }); + }); + + describe('register', () => { + it('registers new channel name', async () => { + await registry.register('general', '0xchannel1'); + expect(await registry.resolve('#general')).toBe('0xchannel1'); + }); + + it('normalizes channel name', async () => { + await registry.register('#GENERAL', '0xchannel1'); + expect(await registry.resolve('general')).toBe('0xchannel1'); + }); + + it('allows updating same name to same channel', async () => { + await registry.register('general', '0xchannel1'); + await registry.register('general', '0xchannel1'); // Should not throw + expect(await registry.resolve('#general')).toBe('0xchannel1'); + }); + + it('throws when name is already registered to different channel', async () => { + await registry.register('general', '0xchannel1'); + await expect(registry.register('general', '0xchannel2')).rejects.toThrow( + 'Channel name #general is already registered to 0xchannel1', + ); + }); + + it('replaces name when channel ID gets new name', async () => { + await registry.register('general', '0xchannel1'); + await registry.register('main', '0xchannel1'); // Same channel, new name + expect(await registry.reverseLookup('0xchannel1')).toBe('#main'); + await expect(registry.resolve('#general')).rejects.toThrow(); + }); + }); + + describe('unregister', () => { + beforeEach(async () => { + await registry.register('general', '0xchannel1'); + }); + + it('removes registered channel name', async () => { + await registry.unregister('general'); + await expect(registry.resolve('#general')).rejects.toThrow(); + }); + + it('removes reverse lookup', async () => { + await registry.unregister('general'); + expect(await registry.reverseLookup('0xchannel1')).toBeNull(); + }); + + it('handles unregistering non-existent name', async () => { + await registry.unregister('unknown'); // Should not throw + }); + + it('normalizes name before unregistering', async () => { + await registry.unregister('#GENERAL'); + await expect(registry.resolve('general')).rejects.toThrow(); + }); + }); + + describe('list', () => { + it('returns empty map for empty registry', async () => { + const result = await registry.list(); + expect(result.size).toBe(0); + }); + + it('returns all registered names', async () => { + await registry.register('general', '0xchannel1'); + await registry.register('random', '0xchannel2'); + const result = await registry.list(); + expect(result.size).toBe(2); + expect(result.get('#general')).toBe('0xchannel1'); + expect(result.get('#random')).toBe('0xchannel2'); + }); + }); + + describe('export/import', () => { + it('exports registry data', async () => { + await registry.register('general', '0xchannel1'); + await registry.register('random', '0xchannel2'); + const exported = registry.export(); + expect(exported).toEqual({ + general: '0xchannel1', + random: '0xchannel2', + }); + }); + + it('imports registry data (merge)', async () => { + await registry.register('general', '0xchannel1'); + registry.import({ random: '0xchannel2' }, true); + expect(await registry.resolve('#general')).toBe('0xchannel1'); + expect(await registry.resolve('#random')).toBe('0xchannel2'); + }); + + it('imports registry data (replace)', async () => { + await registry.register('general', '0xchannel1'); + registry.import({ random: '0xchannel2' }, false); + await expect(registry.resolve('#general')).rejects.toThrow(); + expect(await registry.resolve('#random')).toBe('0xchannel2'); + }); + }); + + describe('clear', () => { + it('removes all registrations', async () => { + await registry.register('general', '0xchannel1'); + await registry.register('random', '0xchannel2'); + registry.clear(); + expect(registry.size).toBe(0); + await expect(registry.resolve('#general')).rejects.toThrow(); + }); + }); +}); + +describe('ChannelNameResolver interface', () => { + it('can create a custom implementation', async () => { + // Example of custom resolver (e.g., on-chain registry) + const customResolver: ChannelNameResolver = { + resolve: async (nameOrId) => { + if (nameOrId === '#test') { + return '0xcustom_resolved'; + } + if (nameOrId.startsWith('0x')) { + return nameOrId; + } + throw new Error(`Unknown channel: ${nameOrId}`); + }, + resolveMany: async (inputs) => { + return Promise.all(inputs.map((input) => customResolver.resolve(input))); + }, + reverseLookup: async (channelId) => { + if (channelId === '0xcustom_resolved') { + return '#test'; + } + return null; + }, + register: async () => { + throw new Error('Registration not supported'); + }, + unregister: async () => { + throw new Error('Unregistration not supported'); + }, + list: async () => new Map([['#test', '0xcustom_resolved']]), + }; + + expect(await customResolver.resolve('#test')).toBe('0xcustom_resolved'); + expect(await customResolver.resolve('0xpassthrough')).toBe('0xpassthrough'); + expect(await customResolver.reverseLookup('0xcustom_resolved')).toBe('#test'); + }); +}); diff --git a/packages/pnpm-lock.yaml b/packages/pnpm-lock.yaml index 9b4752c..19663ff 100644 --- a/packages/pnpm-lock.yaml +++ b/packages/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@mysten/sui': specifier: ^1.45.2 version: 1.45.2(typescript@5.9.3) + '@mysten/suins': + specifier: ^0.9.13 + version: 0.9.13(typescript@5.9.3) '@mysten/walrus': specifier: ^0.8.6 version: 0.8.6(typescript@5.9.3) @@ -1367,6 +1370,10 @@ packages: resolution: {integrity: sha512-gftf7fNpFSiXyfXpbtP2afVEnhc7p2m/MEYc/SO5pov92dacGKOpQIF7etZsGDI1Wvhv+dpph+ulRNpnYSs7Bg==} engines: {node: '>=18'} + '@mysten/suins@0.9.13': + resolution: {integrity: sha512-3sGMBmqcAY3GXGtnATcGhpY7+ZRbiBZFpfcyin+uHoFuyyXWrNFz63NjvADzbKGCSMS9lA6GPhsI0yGsDmJVZQ==} + engines: {node: '>=16'} + '@mysten/utils@0.2.0': resolution: {integrity: sha512-CM6kJcJHX365cK6aXfFRLBiuyXc5WSBHQ43t94jqlCAIRw8umgNcTb5EnEA9n31wPAQgLDGgbG/rCUISCTJ66w==} @@ -2088,6 +2095,9 @@ packages: async@3.2.2: resolution: {integrity: sha512-H0E+qZaDEfx/FY4t7iLRv1W2fFI6+pyCeTw1uN20AQPiwqwM6ojPxHxdLv4z8hi2DtnW9BOckSspLucW7pIE5g==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -2103,6 +2113,14 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: ^0.30.0 + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -2295,6 +2313,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -2456,6 +2478,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2862,6 +2888,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2870,6 +2905,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -3171,6 +3210,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -3394,6 +3437,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3766,6 +3817,9 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -6090,6 +6144,17 @@ snapshots: - '@gql.tada/vue-support' - typescript + '@mysten/suins@0.9.13(typescript@5.9.3)': + dependencies: + '@mysten/sui': 1.45.2(typescript@5.9.3) + axios: 1.13.2 + axios-retry: 4.5.0(axios@1.13.2) + transitivePeerDependencies: + - '@gql.tada/svelte-support' + - '@gql.tada/vue-support' + - debug + - typescript + '@mysten/utils@0.2.0': dependencies: '@scure/base': 1.2.6 @@ -6905,6 +6970,8 @@ snapshots: async@3.2.2: {} + asynckit@0.4.0: {} + autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.27.0 @@ -6921,6 +6988,19 @@ snapshots: axe-core@4.11.0: {} + axios-retry@4.5.0(axios@1.13.2): + dependencies: + axios: 1.13.2 + is-retry-allowed: 2.2.0 + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} b4a@1.7.3: {} @@ -7144,6 +7224,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -7299,6 +7383,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + detect-indent@6.1.0: {} detect-indent@7.0.2: {} @@ -7910,6 +7996,8 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -7919,6 +8007,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -8235,6 +8331,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + is-retry-allowed@2.2.0: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: @@ -8432,6 +8530,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -8738,6 +8842,8 @@ snapshots: '@types/node': 24.9.2 long: 5.3.2 + proxy-from-env@1.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5