diff --git a/README.md b/README.md index a7fa1ff..34faf81 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,25 @@ try { } ``` +### Stellar `.wraith` Name Resolution + +`resolveStellarName` simulates `resolve(name)` against the configured Soroban `wraith-names` contract and decodes the 64-byte `(spendingPubKey || viewingPubKey)` payload into a validated `st:xlm:...` stealth meta-address. Both `@wraith-protocol/sdk-react` and `@wraith-protocol/sdk-svelte` delegate `useStellarName` to this core resolver with `AbortSignal` cancellation and typed errors. + +- **Name normalization (`normalizeStellarName`)**: Strips a trailing `.wraith` suffix and validates `3..=32` characters, lowercase ASCII alphanumeric labels (`a-z`, `0-9`), and at most one subdomain level (`sub.parent`). Malformed inputs reject before any RPC call with `InvalidNameError`. +- **Network semantics**: Accepts `'testnet'` or `'stellar'` (pointing to the configured Stellar testnet deployment). Requesting an unconfigured network or a mismatched `networkPassphrase` rejects with `WalletWrongNetworkError`. +- **Cache semantics**: Positive resolutions (`st:xlm:...`) are cached in memory for `DEFAULT_STELLAR_NAME_TTL_MS` (5 minutes) keyed by `network:rpcUrl:contractId:normalizedName`. Unregistered names (`null`) and errors are never cached so newly registered names resolve immediately. Pass `{ bypassCache: true }`, `{ ttlMs: 0 }`, or call `clearStellarNameCache()` to force a fresh Soroban RPC read. +- **Cancellation**: Pass `{ signal }` to cancel an in-flight simulation; aborted requests reject with `RPCAbortedError`. + +```ts +import { resolveStellarName, clearStellarNameCache } from '@wraith-protocol/sdk/chains/stellar'; + +const metaAddress = await resolveStellarName('alice.wraith', 'testnet', { + bypassCache: false, + signal: controller.signal, +}); +// => "st:xlm:..." or null if unregistered +``` + ## CKB (Nervos) Stealth Addresses Stealth address cryptography for CKB using secp256k1 with blake2b hashing. CKB uses a UTXO-based Cell model where the Cell itself is the announcement. No separate announcer contract needed. diff --git a/etc/sdk-stellar.api.md b/etc/sdk-stellar.api.md index dcf672a..bdbc5eb 100644 --- a/etc/sdk-stellar.api.md +++ b/etc/sdk-stellar.api.md @@ -291,6 +291,9 @@ export function checkStealthAddress(ephemeralPubKey: Uint8Array, viewingKey: Uin // @public export function clearAssetMetadataCache(): void; +// @public +export function clearStellarNameCache(): void; + // @public export function computeAnnouncementViewTag(ephemeralPubKey: Uint8Array, viewingPubKey: Uint8Array): number; @@ -325,6 +328,9 @@ export const DEFAULT_BASE_FEE = 100; // @public export const DEFAULT_BATCH_SENDER_THRESHOLD = 10; +// @public +export const DEFAULT_STELLAR_NAME_TTL_MS: number; + // @public export const DEPLOYMENTS: Record; @@ -438,6 +444,9 @@ export function getAssetMetadataResult(contractId: string, network?: Network, op // @public export function getDeployment(chain: string): StellarChainDeployment; +// @public +export function getStellarNameDefaultTtl(): number; + // @public export const HASH_MEMO_BYTES = 32; @@ -552,6 +561,9 @@ export const META_ADDRESS_PREFIX = "st:xlm:"; // @public export type Network = 'testnet' | 'mainnet'; +// @public +export function normalizeStellarName(name: string): string; + // Warning: (ae-internal-missing-underscore) The name "parseAnnouncementEvent" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -581,6 +593,20 @@ export interface RequestTimeouts { requestMs?: number; } +// @public +export function resolveStellarName(name: string, chain?: string, options?: ResolveStellarNameOptions): Promise; + +// @public +export interface ResolveStellarNameOptions { + bypassCache?: boolean; + contractId?: string; + networkPassphrase?: string; + rpcUrl?: string; + signal?: AbortSignal; + throwOnNotFound?: boolean; + ttlMs?: number; +} + // @public (undocumented) export class RetentionExceededError extends Error { constructor(requestedLedger: number, oldestAvailableLedger: number); @@ -677,6 +703,9 @@ export const SCHEME_ID_V2 = 2; // @public export function seedToScalar(seed: Uint8Array): bigint; +// @public +export function setStellarNameDefaultTtl(ttlMs: number): void; + // @public export function signStellarTransaction(transactionHash: Uint8Array, stealthScalar: bigint, stealthPubKey: Uint8Array): Uint8Array; diff --git a/package.json b/package.json index 86308ea..1605f4f 100644 --- a/package.json +++ b/package.json @@ -130,12 +130,12 @@ "name": "Stellar ESM (import *)", "path": "dist/chains/stellar/index.js", "import": "*", - "limit": "31 KB" + "limit": "33 KB" }, { "name": "Stellar CJS (require)", "path": "dist/chains/stellar/index.cjs", - "limit": "39.5 KB" + "limit": "41 KB" } ], "dependencies": { diff --git a/packages/sdk-react/src/hooks.ts b/packages/sdk-react/src/hooks.ts index 2795eb6..dbf479f 100644 --- a/packages/sdk-react/src/hooks.ts +++ b/packages/sdk-react/src/hooks.ts @@ -5,12 +5,14 @@ import { deriveStealthKeys, deriveStealthKeysFromSigner, buildStealthPayment, - getDeployment, + resolveStellarName, type StealthKeys, type StellarStealthSigner, type BuildStealthPaymentOptions, + type ResolveStellarNameOptions, } from '@wraith-protocol/sdk/chains/stellar'; import { Horizon } from '@stellar/stellar-sdk'; +import { getUnknownErrorMessage } from '@wraith-protocol/sdk'; import { getPlatform, setPlatform, type Platform } from './platform'; export { getPlatform, setPlatform }; @@ -48,8 +50,8 @@ export function useStellarSendStealthPayment() { try { const result = await buildStealthPayment(options); return result; - } catch (err: any) { - setError(err); + } catch (err) { + setError(err instanceof Error ? err : new Error(getUnknownErrorMessage(err))); throw err; } finally { setBuilding(false); @@ -74,7 +76,7 @@ export function useStellarBalance(publicKey: string, rpcUrl = 'https://horizon.s .loadAccount(publicKey) .then((acc) => { if (!mounted) return; - const xlmBalance = acc.balances.find((b: any) => b.asset_type === 'native'); + const xlmBalance = acc.balances.find((balance) => balance.asset_type === 'native'); setBalance(xlmBalance ? xlmBalance.balance : '0'); }) .catch(() => { @@ -92,29 +94,69 @@ export function useStellarBalance(publicKey: string, rpcUrl = 'https://horizon.s return { balance, loading }; } +export function useStellarName( + name: string | undefined, + chain?: string, + options?: ResolveStellarNameOptions, +): { address: string | null; loading: boolean; error: Error | null }; /** Hook to resolve a Wraith name on Stellar */ -export function useStellarName(name: string | undefined, chain = 'testnet') { +export function useStellarName(name: string | undefined, chain = 'stellar') { const [address, setAddress] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // eslint-disable-next-line prefer-rest-params + const options = arguments[2] as ResolveStellarNameOptions | undefined; + const rpcUrl = options?.rpcUrl; + const contractId = options?.contractId; + const networkPassphrase = options?.networkPassphrase; + const bypassCache = options?.bypassCache; + const ttlMs = options?.ttlMs; + const throwOnNotFound = options?.throwOnNotFound; + const callerSignal = options?.signal; useEffect(() => { - if (!name) return; + if (name === undefined) { + setAddress(null); + setLoading(false); + setError(null); + return; + } let mounted = true; + const controller = new AbortController(); + const onCallerAbort = () => controller.abort(callerSignal?.reason); + + if (callerSignal) { + if (callerSignal.aborted) { + controller.abort(callerSignal.reason); + } else { + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + } async function resolve() { setLoading(true); setError(null); + setAddress(null); try { - const deployment = getDeployment(chain); - // Stub for resolving name using the names contract. - // Integrators will fill in with actual logic if they need it, - // or this can be updated once the resolve logic is added to the SDK. - await new Promise((resolve) => setTimeout(resolve, 500)); + const resolved = await resolveStellarName(name!, chain, { + rpcUrl, + contractId, + networkPassphrase, + bypassCache, + ttlMs, + throwOnNotFound, + signal: controller.signal, + }); + if (mounted && resolved !== null) { + setAddress(resolved); + return; + } if (!mounted) return; setAddress(null); // Return null or resolved meta-address - } catch (err: any) { - if (mounted) setError(err); + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err : new Error(getUnknownErrorMessage(err))); + } } finally { if (mounted) setLoading(false); } @@ -123,8 +165,20 @@ export function useStellarName(name: string | undefined, chain = 'testnet') { resolve(); return () => { mounted = false; + callerSignal?.removeEventListener('abort', onCallerAbort); + controller.abort(); }; - }, [name, chain]); + }, [ + name, + chain, + rpcUrl, + contractId, + networkPassphrase, + bypassCache, + ttlMs, + throwOnNotFound, + callerSignal, + ]); return { address, loading, error }; } diff --git a/packages/sdk-react/test/hooks.test.tsx b/packages/sdk-react/test/hooks.test.tsx index ca6db4e..6809965 100644 --- a/packages/sdk-react/test/hooks.test.tsx +++ b/packages/sdk-react/test/hooks.test.tsx @@ -1,10 +1,12 @@ // @vitest-environment jsdom import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { useStellarStealthKeys, useStellarBalance } from '../src/hooks'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useStellarStealthKeys, useStellarBalance, useStellarName } from '../src/hooks'; +import { InvalidNameError, RPCAbortedError, WalletWrongNetworkError } from '@wraith-protocol/sdk'; import { deriveStealthKeys, deriveStealthKeysFromSigner, + resolveStellarName, } from '@wraith-protocol/sdk/chains/stellar'; vi.mock('@wraith-protocol/sdk/chains/stellar', async () => { @@ -17,7 +19,7 @@ vi.mock('@wraith-protocol/sdk/chains/stellar', async () => { ), fetchAnnouncements: vi.fn(() => Promise.resolve({ announcements: [] })), buildStealthPayment: vi.fn(() => Promise.resolve({})), - getDeployment: vi.fn(() => ({ rpcUrl: 'https://rpc', contracts: { names: 'foo' } })), + resolveStellarName: vi.fn(), }; }); @@ -36,6 +38,10 @@ vi.mock('@stellar/stellar-sdk', async () => { }); describe('useStellarStealthKeys', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('generates keys correctly', () => { const { result } = renderHook(() => useStellarStealthKeys()); @@ -72,7 +78,6 @@ describe('useStellarBalance', () => { expect(result.current.loading).toBe(true); expect(result.current.balance).toBeNull(); - // wait for effect to resolve await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -81,3 +86,110 @@ describe('useStellarBalance', () => { expect(result.current.balance).toBe('10.5'); }); }); + +describe('useStellarName', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('stays idle when name is undefined', () => { + const { result } = renderHook(() => useStellarName(undefined)); + expect(result.current.address).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(resolveStellarName).not.toHaveBeenCalled(); + }); + + it('resolves a found name and clears loading', async () => { + const expected = `st:xlm:${'a'.repeat(128)}`; + vi.mocked(resolveStellarName).mockResolvedValueOnce(expected); + + const { result } = renderHook(() => useStellarName('alice.wraith', 'testnet')); + expect(result.current.loading).toBe(true); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.address).toBe(expected); + expect(result.current.error).toBeNull(); + }); + + it('handles missing names by setting address to null without error', async () => { + vi.mocked(resolveStellarName).mockResolvedValueOnce(null); + + const { result } = renderHook(() => useStellarName('missing.wraith', 'testnet')); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('surfaces typed InvalidNameError for malformed names', async () => { + const malformedErr = new InvalidNameError('ab', 'Too short'); + vi.mocked(resolveStellarName).mockRejectedValueOnce(malformedErr); + + const { result } = renderHook(() => useStellarName('ab', 'testnet')); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.error).toBe(malformedErr); + }); + + it('surfaces typed WalletWrongNetworkError for wrong-network requests', async () => { + const wrongNetErr = new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: 'testnet', + actualNetwork: 'mainnet', + }); + vi.mocked(resolveStellarName).mockRejectedValueOnce(wrongNetErr); + + const { result } = renderHook(() => useStellarName('alice.wraith', 'mainnet')); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.error).toBe(wrongNetErr); + }); + + it('aborts in-flight requests on unmount and surfaces RPCAbortedError for caller signal aborts', async () => { + let capturedSignal: AbortSignal | undefined; + vi.mocked(resolveStellarName).mockImplementationOnce((_name, _chain, opts) => { + capturedSignal = opts?.signal; + return new Promise(() => {}); + }); + + const { unmount } = renderHook(() => useStellarName('alice.wraith', 'testnet')); + expect(capturedSignal?.aborted).toBe(false); + unmount(); + expect(capturedSignal?.aborted).toBe(true); + + const abortedErr = new RPCAbortedError('https://soroban-testnet.stellar.org'); + const callerController = new AbortController(); + callerController.abort(); + vi.mocked(resolveStellarName).mockRejectedValueOnce(abortedErr); + + const { result } = renderHook(() => + useStellarName('alice.wraith', 'testnet', { signal: callerController.signal }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(abortedErr); + }); +}); diff --git a/packages/sdk-svelte/README.md b/packages/sdk-svelte/README.md index 24a4be5..47c8499 100644 --- a/packages/sdk-svelte/README.md +++ b/packages/sdk-svelte/README.md @@ -61,6 +61,28 @@ Reactive Wraith API client. {/if} ``` +### `useStellarName` + +Resolves a Stellar `.wraith` name through the core `resolveStellarName` helper against the configured Soroban `wraith-names` contract, with positive-only in-memory TTL caching (5 minutes by default), `AbortController` cancellation (`abort()` / `dispose()`), and typed `WraithError` instances (`InvalidNameError`, `InvalidMetaAddressError`, `WalletWrongNetworkError`, `RPCAbortedError`). + +```svelte + + +{#if $loading} +

Resolving...

+{:else if $error} +

Error: {$error.message}

+{:else} +

Meta-address: {$address ?? 'Not registered'}

+{/if} +``` + ### `useEvmStealthKeys` ```svelte diff --git a/packages/sdk-svelte/src/primitives/useStellarName.ts b/packages/sdk-svelte/src/primitives/useStellarName.ts index b2321d2..865d6b8 100644 --- a/packages/sdk-svelte/src/primitives/useStellarName.ts +++ b/packages/sdk-svelte/src/primitives/useStellarName.ts @@ -1,34 +1,103 @@ -import { getDeployment } from '@wraith-protocol/sdk/chains/stellar'; +import { + resolveStellarName, + type ResolveStellarNameOptions, +} from '@wraith-protocol/sdk/chains/stellar'; import { readonly, writable } from 'svelte/store'; /** Store primitive for resolving a Wraith name on Stellar. */ -export function useStellarName(name: string | undefined, chain = 'testnet') { +export function useStellarName( + name: string | undefined, + chain = 'testnet', + options: ResolveStellarNameOptions = {}, +) { const _address = writable(null); const _loading = writable(false); const _error = writable(null); - async function resolve() { - if (!name) return; + let currentId = 0; + let activeController: AbortController | null = null; + let cleanupCallerSignal: (() => void) | null = null; + + function abort(reason?: unknown) { + currentId += 1; + cleanupCallerSignal?.(); + cleanupCallerSignal = null; + if (activeController) { + activeController.abort(reason); + activeController = null; + } + _loading.set(false); + } + + async function resolve( + nextName: string | undefined = name, + nextChain: string = chain, + nextOptions: ResolveStellarNameOptions = options, + ): Promise { + abort(); + + if (nextName === undefined) { + _address.set(null); + _error.set(null); + _loading.set(false); + return null; + } + + const requestId = ++currentId; + const controller = new AbortController(); + activeController = controller; + + const callerSignal = nextOptions.signal; + if (callerSignal) { + const onCallerAbort = () => controller.abort(callerSignal.reason); + if (callerSignal.aborted) { + controller.abort(callerSignal.reason); + } else { + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + cleanupCallerSignal = () => callerSignal.removeEventListener('abort', onCallerAbort); + } + } _loading.set(true); _error.set(null); + _address.set(null); + try { - getDeployment(chain); - // Name resolution remains a placeholder until the core SDK exposes it. - await new Promise((resolveDelay) => setTimeout(resolveDelay, 500)); - _address.set(null); + const resolved = await resolveStellarName(nextName, nextChain, { + ...nextOptions, + signal: controller.signal, + }); + if (requestId === currentId) { + _address.set(resolved); + } + return resolved; } catch (cause) { - _error.set(cause instanceof Error ? cause : new Error(String(cause))); + const err = cause instanceof Error ? cause : new Error(String(cause)); + if (requestId === currentId) { + _address.set(null); + _error.set(err); + } + return null; } finally { - _loading.set(false); + if (requestId === currentId) { + cleanupCallerSignal?.(); + cleanupCallerSignal = null; + activeController = null; + _loading.set(false); + } } } - void resolve(); + if (name !== undefined) { + void resolve(name, chain, options); + } return { address: readonly(_address), loading: readonly(_loading), error: readonly(_error), + resolve, + abort, + dispose: abort, }; } diff --git a/packages/sdk-svelte/test/stellarParity.test.ts b/packages/sdk-svelte/test/stellarParity.test.ts index 9e7ba7d..61290fa 100644 --- a/packages/sdk-svelte/test/stellarParity.test.ts +++ b/packages/sdk-svelte/test/stellarParity.test.ts @@ -9,7 +9,12 @@ import { useStellarSendStealthPayment, useStellarStealthKeys, } from '../src/index.js'; -import { buildStealthPayment, fetchAnnouncementsStream } from '@wraith-protocol/sdk/chains/stellar'; +import { InvalidNameError, RPCAbortedError, WalletWrongNetworkError } from '@wraith-protocol/sdk'; +import { + buildStealthPayment, + fetchAnnouncementsStream, + resolveStellarName, +} from '@wraith-protocol/sdk/chains/stellar'; vi.mock('@wraith-protocol/sdk/chains/stellar', () => ({ META_ADDRESS_PREFIX: 'st:xlm:', @@ -23,6 +28,7 @@ vi.mock('@wraith-protocol/sdk/chains/stellar', () => ({ fetchAnnouncements: vi.fn(), fetchAnnouncementsStream: vi.fn(), buildStealthPayment: vi.fn(), + resolveStellarName: vi.fn(), getDeployment: vi.fn(() => ({ horizonUrl: 'https://horizon.test', sorobanUrl: 'https://soroban.test', @@ -106,11 +112,64 @@ describe('Stellar store parity', () => { expect(get(state.loading)).toBe(false); }); - it('matches the React name primitive shape', () => { + it('matches the React name primitive shape when idle', () => { const state = useStellarName(undefined); expect(get(state.address)).toBeNull(); expect(get(state.loading)).toBe(false); expect(get(state.error)).toBeNull(); + expect(resolveStellarName).not.toHaveBeenCalled(); + }); + + it('resolves found and missing names through resolveStellarName', async () => { + const meta = `st:xlm:${'b'.repeat(128)}`; + vi.mocked(resolveStellarName).mockResolvedValueOnce(meta).mockResolvedValueOnce(null); + + const state = useStellarName('alice.wraith', 'testnet'); + await waitFor(() => expect(get(state.address)).toBe(meta)); + expect(get(state.loading)).toBe(false); + expect(get(state.error)).toBeNull(); + + await state.resolve('missing.wraith', 'testnet'); + expect(get(state.address)).toBeNull(); + expect(get(state.loading)).toBe(false); + expect(get(state.error)).toBeNull(); + }); + + it('surfaces typed errors for malformed, wrong-network, and aborted lookups and supports abort()', async () => { + const malformedErr = new InvalidNameError('ab', 'Too short'); + vi.mocked(resolveStellarName).mockRejectedValueOnce(malformedErr); + + const state = useStellarName('ab', 'testnet'); + await waitFor(() => expect(get(state.error)).toBe(malformedErr)); + expect(get(state.loading)).toBe(false); + + const wrongNetErr = new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: 'testnet', + actualNetwork: 'mainnet', + }); + vi.mocked(resolveStellarName).mockRejectedValueOnce(wrongNetErr); + await state.resolve('alice.wraith', 'mainnet'); + expect(get(state.error)).toBe(wrongNetErr); + + const abortedErr = new RPCAbortedError('https://soroban-testnet.stellar.org'); + vi.mocked(resolveStellarName).mockRejectedValueOnce(abortedErr); + const controller = new AbortController(); + controller.abort(); + await state.resolve('alice.wraith', 'testnet', { signal: controller.signal }); + expect(get(state.error)).toBe(abortedErr); + + let capturedSignal: AbortSignal | undefined; + vi.mocked(resolveStellarName).mockImplementationOnce((_n, _c, opts) => { + capturedSignal = opts?.signal; + return new Promise(() => {}); + }); + void state.resolve('alice.wraith', 'testnet'); + expect(get(state.loading)).toBe(true); + expect(capturedSignal?.aborted).toBe(false); + state.abort(); + expect(capturedSignal?.aborted).toBe(true); + expect(get(state.loading)).toBe(false); }); }); diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 83abb13..5104b6a 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -160,3 +160,13 @@ export { createRpcClient } from './rpc'; export type { RpcClient, RpcClientConfig, RpcEndpoint, RpcRequestOptions } from './rpc'; export type { RequestTimeouts } from './timeouts'; export type { HorizonCallOptions } from './request'; + +export { + DEFAULT_STELLAR_NAME_TTL_MS, + normalizeStellarName, + resolveStellarName, + clearStellarNameCache, + getStellarNameDefaultTtl, + setStellarNameDefaultTtl, +} from './names'; +export type { ResolveStellarNameOptions } from './names'; diff --git a/src/chains/stellar/names.ts b/src/chains/stellar/names.ts new file mode 100644 index 0000000..98fe234 --- /dev/null +++ b/src/chains/stellar/names.ts @@ -0,0 +1,379 @@ +import { Account, Contract, TransactionBuilder, rpc, xdr } from '@stellar/stellar-sdk'; +import { + ContractRevertError, + InvalidMetaAddressError, + InvalidNameError, + NameNotFoundError, + RPCAbortedError, + RPCRequestError, + WraithError, + WalletWrongNetworkError, +} from '../../errors'; +import { META_ADDRESS_PREFIX } from './constants'; +import { DEPLOYMENTS, getDeployment, type StellarChainDeployment } from './deployments'; +import { decodeStealthMetaAddress, encodeStealthMetaAddress } from './meta-address'; + +/** Default in-memory TTL for resolved Stellar `.wraith` names (5 minutes). */ +export const DEFAULT_STELLAR_NAME_TTL_MS = 5 * 60 * 1000; + +const MIN_NAME_LEN = 3; +const MAX_NAME_LEN = 32; +const LABEL_PATTERN = /^[a-z0-9]+$/; +const DUMMY_SOURCE = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + +/** + * Options for {@link resolveStellarName}. + */ +export interface ResolveStellarNameOptions { + /** Override the Soroban RPC URL from the configured deployment. */ + rpcUrl?: string; + /** Override the Wraith Names contract ID from the configured deployment. */ + contractId?: string; + /** Override the Stellar network passphrase used for simulation. */ + networkPassphrase?: string; + /** Bypass the in-memory positive resolution cache. */ + bypassCache?: boolean; + /** Override the cache TTL in milliseconds for this lookup (`0` disables caching). */ + ttlMs?: number; + /** Optional signal to cancel the in-flight Soroban RPC simulation. */ + signal?: AbortSignal; + /** Throw {@link NameNotFoundError} instead of returning `null` when unregistered. */ + throwOnNotFound?: boolean; +} + +interface CachedNameEntry { + metaAddress: string; + expiresAt: number; +} + +const NAME_CACHE = new Map(); +let defaultTtlMs = DEFAULT_STELLAR_NAME_TTL_MS; + +/** + * Normalizes and validates a Stellar `.wraith` name according to the on-chain + * `wraith-names` contract rules (`3..=32` chars total, lowercase ASCII alphanumeric + * labels, optional single-level subdomain `sub.parent` such as `a.alice`, trailing + * `.wraith` stripped). + * + * @param name - Raw name string (e.g. `"alice.wraith"`, `"alice"`, `"a.alice.wraith"`). + * @returns Normalized contract lookup key without the `.wraith` suffix. + * @throws {InvalidNameError} If the name is empty, too short/long, or contains invalid characters/labels. + */ +export function normalizeStellarName(name: string): string { + if (typeof name !== 'string' || name.length === 0) { + throw new InvalidNameError(String(name ?? ''), 'Name must be a non-empty string'); + } + + const stripped = name.endsWith('.wraith') ? name.slice(0, -7) : name; + if (stripped.length < MIN_NAME_LEN || stripped.length > MAX_NAME_LEN) { + throw new InvalidNameError( + name, + `Name must be between ${MIN_NAME_LEN} and ${MAX_NAME_LEN} characters, got ${stripped.length}`, + ); + } + + const labels = stripped.split('.'); + if (labels.length > 2) { + throw new InvalidNameError(name, 'Subdomains deeper than one level are not supported'); + } + + for (const label of labels) { + if (label.length === 0) { + throw new InvalidNameError(name, 'Name labels must not be empty'); + } + if (!LABEL_PATTERN.test(label)) { + throw new InvalidNameError( + name, + 'Name labels may only contain lowercase alphanumeric characters (a-z, 0-9)', + ); + } + } + + return stripped; +} + +function resolveNamesDeployment( + chain: string, + options: ResolveStellarNameOptions, +): { + deployment: StellarChainDeployment; + rpcUrl: string; + contractId: string; + networkPassphrase: string; +} { + const key = chain === 'testnet' && !DEPLOYMENTS.testnet ? 'stellar' : chain; + let deployment: StellarChainDeployment; + try { + deployment = getDeployment(key); + } catch (cause) { + throw new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: DEPLOYMENTS.stellar?.network ?? 'testnet', + actualNetwork: chain, + reason: `No Stellar Wraith Names deployment is configured for network "${chain}"`, + cause, + }); + } + + if (deployment.network && chain !== 'stellar' && deployment.network !== chain) { + throw new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: deployment.network, + actualNetwork: chain, + }); + } + + const expectedPassphrase = + deployment.networkPassphrase ?? DEPLOYMENTS.stellar?.networkPassphrase ?? ''; + if ( + options.networkPassphrase && + expectedPassphrase && + options.networkPassphrase !== expectedPassphrase + ) { + throw new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: expectedPassphrase, + actualNetwork: options.networkPassphrase, + }); + } + + const contractId = options.contractId ?? deployment.contracts?.names; + const rpcUrl = + options.rpcUrl ?? + deployment.sorobanUrl ?? + (deployment as unknown as { rpcUrl?: string }).rpcUrl ?? + DEPLOYMENTS.stellar.sorobanUrl; + + if (!contractId) { + throw new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: deployment.network ?? 'testnet', + actualNetwork: chain, + reason: `No Wraith Names contract is configured for network "${chain}"`, + }); + } + + return { + deployment, + rpcUrl, + contractId, + networkPassphrase: options.networkPassphrase ?? expectedPassphrase, + }; +} + +function raceWithSignal( + promise: Promise, + signal: AbortSignal | undefined, + url: string, +): Promise { + if (!signal) return promise; + if (signal.aborted) { + return Promise.reject(new RPCAbortedError(url, { cause: signal.reason })); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(new RPCAbortedError(url, { cause: signal.reason })); + }; + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +function parseMetaAddressPayload(retval: xdr.ScVal): string | null { + const arm = retval.switch().name; + if (arm === 'scvVoid') { + return null; + } + + if (arm === 'scvError') { + const err = retval.error(); + if (err.switch().name === 'sceContract' && err.contractCode() === 6) { + return null; + } + throw new ContractRevertError(`Error(Contract, #${err.contractCode()})`); + } + + if (arm === 'scvString' || arm === 'scvSymbol') { + const text = arm === 'scvString' ? retval.str().toString() : retval.sym().toString(); + decodeStealthMetaAddress(text); + return text; + } + + if (arm === 'scvBytes') { + const raw = Uint8Array.from(retval.bytes()); + if (raw.length > META_ADDRESS_PREFIX.length) { + const text = new TextDecoder().decode(raw); + if (text.startsWith(META_ADDRESS_PREFIX)) { + decodeStealthMetaAddress(text); + return text; + } + } + if (raw.length !== 64) { + throw new InvalidMetaAddressError( + '', + `Wraith Names contract returned ${raw.length} bytes, expected 64`, + ); + } + return encodeStealthMetaAddress(raw.subarray(0, 32), raw.subarray(32, 64)); + } + + throw new InvalidMetaAddressError( + '', + `Unexpected ScVal return type "${arm}" from Wraith Names contract`, + ); +} + +/** + * Resolves a `.wraith` name on Stellar via the configured Soroban `wraith-names` contract. + * + * Positive resolutions are cached in memory for {@link DEFAULT_STELLAR_NAME_TTL_MS} + * (unless overridden or bypassed). Unregistered names (`null`) and errors are never cached. + * + * @param name - The `.wraith` name to resolve (with or without `.wraith`). + * @param chain - Configured Stellar deployment or network name (`'testnet'` or `'stellar'`). + * @param options - Optional RPC/contract overrides, cache controls, and `AbortSignal`. + * @returns The validated `st:xlm:...` stealth meta-address, or `null` when not found. + */ +export async function resolveStellarName( + name: string, + chain = 'testnet', + options: ResolveStellarNameOptions = {}, +): Promise { + const fallbackUrl = options.rpcUrl ?? DEPLOYMENTS.stellar.sorobanUrl; + if (options.signal?.aborted) { + throw new RPCAbortedError(fallbackUrl, { cause: options.signal.reason }); + } + + const normalized = normalizeStellarName(name); + const { deployment, rpcUrl, contractId, networkPassphrase } = resolveNamesDeployment( + chain, + options, + ); + + if (options.signal?.aborted) { + throw new RPCAbortedError(rpcUrl, { cause: options.signal.reason }); + } + + const ttl = options.ttlMs ?? defaultTtlMs; + const cacheKey = `${deployment.network ?? chain}:${rpcUrl}:${contractId}:${normalized}`; + + if (!options.bypassCache && ttl > 0) { + const cached = NAME_CACHE.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.metaAddress; + } + if (cached) { + NAME_CACHE.delete(cacheKey); + } + } + + const server = new rpc.Server(rpcUrl); + const contract = new Contract(contractId); + const tx = new TransactionBuilder(new Account(DUMMY_SOURCE, '0'), { + networkPassphrase, + fee: '100', + }) + .addOperation(contract.call('resolve', xdr.ScVal.scvString(normalized))) + .setTimeout(30) + .build(); + + let sim: rpc.Api.SimulateTransactionResponse; + try { + sim = await raceWithSignal(server.simulateTransaction(tx), options.signal, rpcUrl); + } catch (cause) { + if (cause instanceof WraithError) throw cause; + if (options.signal?.aborted) { + throw new RPCAbortedError(rpcUrl, { cause: options.signal.reason }); + } + const message = cause instanceof Error ? cause.message : String(cause); + if (/wrong\s*network|network\s*passphrase|passphrase\s*mismatch/i.test(message)) { + throw new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: networkPassphrase, + reason: message, + cause, + }); + } + throw new RPCRequestError(rpcUrl, 503, message); + } + + if (rpc.Api.isSimulationError(sim)) { + const detail = sim.error ?? 'Unknown simulation error'; + const codeMatch = detail.match(/Error\(Contract,\s*#(\d+)\)/i); + if (codeMatch) { + const code = Number(codeMatch[1]); + if (code === 6) { + if (options.throwOnNotFound) throw new NameNotFoundError(normalized); + return null; + } + if (code === 2 || code === 3 || code === 4) { + throw new InvalidNameError(name, detail); + } + if (code === 5) { + throw new InvalidMetaAddressError('', detail); + } + throw new ContractRevertError(detail); + } + + if (/NameNotFound/i.test(detail)) { + if (options.throwOnNotFound) throw new NameNotFoundError(normalized); + return null; + } + + if (/wrong\s*network|network\s*passphrase|passphrase\s*mismatch/i.test(detail)) { + throw new WalletWrongNetworkError({ + chain: 'stellar', + expectedNetwork: networkPassphrase, + reason: detail, + }); + } + + throw new RPCRequestError(rpcUrl, 502, detail); + } + + if (!sim.result?.retval) { + throw new InvalidMetaAddressError('', 'Wraith Names contract returned no result'); + } + + const resolved = parseMetaAddressPayload(sim.result.retval); + if (resolved === null) { + if (options.throwOnNotFound) throw new NameNotFoundError(normalized); + return null; + } + + if (ttl > 0) { + NAME_CACHE.set(cacheKey, { + metaAddress: resolved, + expiresAt: Date.now() + ttl, + }); + } + + return resolved; +} + +/** Clears the in-memory Stellar Wraith Names resolution cache. */ +export function clearStellarNameCache(): void { + NAME_CACHE.clear(); +} + +/** Returns the default in-memory cache TTL (in milliseconds) for {@link resolveStellarName}. */ +export function getStellarNameDefaultTtl(): number { + return defaultTtlMs; +} + +/** Sets the default in-memory cache TTL (in milliseconds) for {@link resolveStellarName}. */ +export function setStellarNameDefaultTtl(ttlMs: number): void { + if (!Number.isFinite(ttlMs) || ttlMs < 0) { + throw new RangeError(`Expected a non-negative TTL in milliseconds, got ${ttlMs}`); + } + defaultTtlMs = ttlMs; +} diff --git a/test/chains/stellar/names.test.ts b/test/chains/stellar/names.test.ts new file mode 100644 index 0000000..0fdea27 --- /dev/null +++ b/test/chains/stellar/names.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockSimulateTransaction } = vi.hoisted(() => ({ + mockSimulateTransaction: vi.fn(), +})); + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal(); + class Server { + async simulateTransaction(tx: { toXDR(): string }) { + tx.toXDR(); + return mockSimulateTransaction(tx); + } + } + return { ...actual, rpc: { ...actual.rpc, Server } }; +}); + +import { rpc, xdr } from '@stellar/stellar-sdk'; +import { + ContractRevertError, + InvalidMetaAddressError, + InvalidNameError, + NameNotFoundError, + RPCAbortedError, + RPCRequestError, + WalletWrongNetworkError, +} from '../../../src/errors'; +import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; +import { encodeStealthMetaAddress } from '../../../src/chains/stellar/meta-address'; +import { + DEFAULT_STELLAR_NAME_TTL_MS, + clearStellarNameCache, + getStellarNameDefaultTtl, + normalizeStellarName, + resolveStellarName, + setStellarNameDefaultTtl, +} from '../../../src/chains/stellar/names'; + +const simulated = (value: xdr.ScVal) => + rpc.parseRawSimulation({ + id: '1', + latestLedger: 100, + minResourceFee: '100', + results: [{ auth: [], xdr: value.toXDR('base64') }], + }); + +const keys = deriveStealthKeys(new Uint8Array(64).fill(7)); +const validMetaBytes = new Uint8Array(64); +validMetaBytes.set(keys.spendingPubKey, 0); +validMetaBytes.set(keys.viewingPubKey, 32); +const validMetaAddress = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey); + +describe('Stellar Wraith Names Resolver', () => { + beforeEach(() => { + clearStellarNameCache(); + setStellarNameDefaultTtl(DEFAULT_STELLAR_NAME_TTL_MS); + mockSimulateTransaction.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('normalizeStellarName', () => { + it('normalizes root names and single-level subdomains including short subdomain labels', () => { + expect(normalizeStellarName('alice')).toBe('alice'); + expect(normalizeStellarName('alice.wraith')).toBe('alice'); + expect(normalizeStellarName('pay.alice.wraith')).toBe('pay.alice'); + expect(normalizeStellarName('a.alice')).toBe('a.alice'); + expect(normalizeStellarName('a.alice.wraith')).toBe('a.alice'); + expect(normalizeStellarName('ab.alice.wraith')).toBe('ab.alice'); + expect(normalizeStellarName('bob123.wraith')).toBe('bob123'); + }); + + it('rejects malformed names with InvalidNameError', () => { + expect(() => normalizeStellarName('')).toThrow(InvalidNameError); + expect(() => normalizeStellarName('.wraith')).toThrow(InvalidNameError); + expect(() => normalizeStellarName('ab.wraith')).toThrow(InvalidNameError); + expect(() => normalizeStellarName(`${'a'.repeat(33)}.wraith`)).toThrow(InvalidNameError); + expect(() => normalizeStellarName('Alice.wraith')).toThrow(InvalidNameError); + expect(() => normalizeStellarName('alice_bob.wraith')).toThrow(InvalidNameError); + expect(() => normalizeStellarName('a.b.alice.wraith')).toThrow(InvalidNameError); + expect(() => normalizeStellarName('.alice.wraith')).toThrow(InvalidNameError); + expect(() => normalizeStellarName('alice..wraith')).toThrow(InvalidNameError); + }); + }); + + describe('found', () => { + it('resolves a 64-byte contract payload into an st:xlm: stealth meta-address and caches it', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + simulated(xdr.ScVal.scvBytes(Buffer.from(validMetaBytes))), + ); + + const first = await resolveStellarName('alice.wraith', 'testnet'); + const second = await resolveStellarName('alice', 'stellar'); + + expect(first).toBe(validMetaAddress); + expect(second).toBe(validMetaAddress); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + }); + + it('resolves a short single-level subdomain such as a.alice', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + simulated(xdr.ScVal.scvBytes(Buffer.from(validMetaBytes))), + ); + + const resolved = await resolveStellarName('a.alice.wraith', 'testnet'); + const cached = await resolveStellarName('a.alice', 'testnet'); + + expect(resolved).toBe(validMetaAddress); + expect(cached).toBe(validMetaAddress); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + }); + + it('resolves an ASCII st:xlm: string payload returned by the contract', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + simulated(xdr.ScVal.scvString(validMetaAddress)), + ); + + const resolved = await resolveStellarName('pay.alice.wraith', 'testnet'); + expect(resolved).toBe(validMetaAddress); + }); + + it('respects bypassCache and TTL expiration', async () => { + vi.useFakeTimers(); + mockSimulateTransaction.mockResolvedValue( + simulated(xdr.ScVal.scvBytes(Buffer.from(validMetaBytes))), + ); + + await resolveStellarName('alice.wraith', 'testnet', { ttlMs: 1_000 }); + await resolveStellarName('alice.wraith', 'testnet'); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(1); + + await resolveStellarName('alice.wraith', 'testnet', { bypassCache: true, ttlMs: 1_000 }); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(1_500); + await resolveStellarName('alice.wraith', 'testnet'); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(3); + }); + }); + + describe('missing', () => { + it('returns null without caching when the contract reports NameNotFound (#6)', async () => { + mockSimulateTransaction + .mockResolvedValueOnce({ error: 'HostError: Error(Contract, #6)' }) + .mockResolvedValueOnce(simulated(xdr.ScVal.scvBytes(Buffer.from(validMetaBytes)))); + + const missing = await resolveStellarName('unregistered.wraith', 'testnet'); + expect(missing).toBeNull(); + + const afterRegister = await resolveStellarName('unregistered.wraith', 'testnet'); + expect(afterRegister).toBe(validMetaAddress); + expect(mockSimulateTransaction).toHaveBeenCalledTimes(2); + }); + + it('throws NameNotFoundError when throwOnNotFound is true', async () => { + mockSimulateTransaction.mockResolvedValueOnce({ + error: 'HostError: Error(Contract, #6)', + }); + + await expect( + resolveStellarName('missing.wraith', 'testnet', { throwOnNotFound: true }), + ).rejects.toBeInstanceOf(NameNotFoundError); + }); + + it('handles scvVoid and scvError(#6) return values as missing', async () => { + mockSimulateTransaction.mockResolvedValueOnce(simulated(xdr.ScVal.scvVoid())); + await expect(resolveStellarName('voidname.wraith', 'testnet')).resolves.toBeNull(); + + mockSimulateTransaction.mockResolvedValueOnce( + simulated(xdr.ScVal.scvError(xdr.ScError.sceContract(6))), + ); + await expect(resolveStellarName('err6name.wraith', 'testnet')).resolves.toBeNull(); + }); + }); + + describe('malformed', () => { + it('rejects malformed input names before hitting Soroban RPC', async () => { + await expect(resolveStellarName('BAD_NAME!', 'testnet')).rejects.toBeInstanceOf( + InvalidNameError, + ); + expect(mockSimulateTransaction).not.toHaveBeenCalled(); + }); + + it('rejects malformed contract byte lengths and invalid curve points with InvalidMetaAddressError', async () => { + mockSimulateTransaction.mockResolvedValueOnce( + simulated(xdr.ScVal.scvBytes(Buffer.alloc(31, 1))), + ); + await expect(resolveStellarName('alice.wraith', 'testnet')).rejects.toBeInstanceOf( + InvalidMetaAddressError, + ); + + mockSimulateTransaction.mockResolvedValueOnce(simulated(xdr.ScVal.scvU32(42))); + await expect(resolveStellarName('alice.wraith', 'testnet')).rejects.toBeInstanceOf( + InvalidMetaAddressError, + ); + + mockSimulateTransaction.mockResolvedValueOnce({}); + await expect(resolveStellarName('alice.wraith', 'testnet')).rejects.toBeInstanceOf( + InvalidMetaAddressError, + ); + }); + }); + + describe('wrong-network', () => { + it('rejects unconfigured networks and passphrase mismatches with WalletWrongNetworkError', async () => { + await expect(resolveStellarName('alice.wraith', 'mainnet')).rejects.toBeInstanceOf( + WalletWrongNetworkError, + ); + await expect( + resolveStellarName('alice.wraith', 'testnet', { + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }), + ).rejects.toBeInstanceOf(WalletWrongNetworkError); + expect(mockSimulateTransaction).not.toHaveBeenCalled(); + }); + + it('maps RPC network passphrase mismatch errors onto WalletWrongNetworkError', async () => { + mockSimulateTransaction.mockResolvedValueOnce({ + error: 'Transaction network passphrase mismatch', + }); + await expect(resolveStellarName('alice.wraith', 'testnet')).rejects.toBeInstanceOf( + WalletWrongNetworkError, + ); + }); + }); + + describe('aborted', () => { + it('rejects immediately with RPCAbortedError when signal is already aborted', async () => { + const controller = new AbortController(); + const reason = new Error('user cancelled'); + controller.abort(reason); + + await expect( + resolveStellarName('alice.wraith', 'testnet', { signal: controller.signal }), + ).rejects.toMatchObject({ + name: 'RPCAbortedError', + code: 'WRAITH/NETWORK/RPC_ABORTED', + cause: reason, + }); + expect(mockSimulateTransaction).not.toHaveBeenCalled(); + }); + + it('rejects in-flight simulation with RPCAbortedError when signal aborts mid-request', async () => { + const controller = new AbortController(); + mockSimulateTransaction.mockImplementationOnce( + () => + new Promise(() => { + // Never settles; abort signal cancels it. + }), + ); + + const pending = resolveStellarName('alice.wraith', 'testnet', { + signal: controller.signal, + }); + controller.abort('timeout'); + + await expect(pending).rejects.toBeInstanceOf(RPCAbortedError); + }); + }); + + describe('contract reverts and transport errors', () => { + it('maps unexpected contract error codes and transport errors to typed WraithError classes', async () => { + mockSimulateTransaction.mockResolvedValueOnce({ + error: 'HostError: Error(Contract, #7)', + }); + await expect(resolveStellarName('alice.wraith', 'testnet')).rejects.toBeInstanceOf( + ContractRevertError, + ); + + mockSimulateTransaction.mockRejectedValueOnce(new Error('ECONNRESET')); + await expect(resolveStellarName('alice.wraith', 'testnet')).rejects.toBeInstanceOf( + RPCRequestError, + ); + }); + + it('validates setStellarNameDefaultTtl bounds', () => { + setStellarNameDefaultTtl(10_000); + expect(getStellarNameDefaultTtl()).toBe(10_000); + expect(() => setStellarNameDefaultTtl(-1)).toThrow(RangeError); + }); + }); +});