From 778d4d3646e2abd9931cc91110d03c427d12bb76 Mon Sep 17 00:00:00 2001 From: udeachudivine-spec Date: Wed, 24 Jun 2026 02:24:33 -0700 Subject: [PATCH] feat: add Stellar Name Service (SNS) integration (#112/#79) --- frontend/components/SendPaymentForm.tsx | 73 ++++++++++++++++++- frontend/lib/stellar.ts | 94 +++++++++++++++++-------- frontend/pages/settings.tsx | 56 +++++++++------ 3 files changed, 167 insertions(+), 56 deletions(-) diff --git a/frontend/components/SendPaymentForm.tsx b/frontend/components/SendPaymentForm.tsx index e7ab6a3..88c0c98 100644 --- a/frontend/components/SendPaymentForm.tsx +++ b/frontend/components/SendPaymentForm.tsx @@ -20,7 +20,9 @@ import { fetchNetworkFeeStats, isValidFederationAddress, isValidStellarAddress, + isStellarName, resolveFederationAddress, + resolveStellarName, server, STELLAR_BASE_FEE_XLM, STELLAR_MEMO_TEXT_MAX_BYTES, @@ -138,6 +140,11 @@ function SendPaymentForm({ const [isResolvingDestination, setIsResolvingDestination] = useState(false); const [destinationResolutionError, setDestinationResolutionError] = useState(null); const [resolvedPaymentDestination, setResolvedPaymentDestination] = useState(null); + // SNS-specific state: live resolution preview as the user types + const [snsResolving, setSnsResolving] = useState(false); + const [snsResolved, setSnsResolved] = useState(null); + const [snsError, setSnsError] = useState(null); + const snsDebounceRef = useRef | null>(null); const [customAsset, setCustomAsset] = useState({ code: "", issuer: "" }); const [showCustomAssetForm, setShowCustomAssetForm] = useState(false); const [selectedMemoTemplate, setSelectedMemoTemplate] = useState(null); @@ -422,8 +429,10 @@ function SendPaymentForm({ const isMemoValid = memoBytes <= 28; const canSubmit = - (isValidDest || isFederationDestination || isUsernameDestination) && + (isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolved)) && !isResolvingDestination && + !snsResolving && + !snsError && !destinationResolutionError && isValidAmt && status === "idle" && @@ -460,6 +469,15 @@ function SendPaymentForm({ setIsResolvingDestination(true); try { + // If we already resolved the SNS name in the preview, reuse it + if (isStellarName(trimmedDestination) && snsResolved) { + return snsResolved; + } + + if (isStellarName(trimmedDestination)) { + return await resolveStellarName(trimmedDestination); + } + if (isFederationDestination) { return await resolveFederationAddress(trimmedDestination); } @@ -535,6 +553,9 @@ function SendPaymentForm({ setAmount(""); setMemo(""); setResolvedPaymentDestination(null); + setSnsResolved(null); + setSnsError(null); + setSnsResolving(false); } setStatus("idle"); }; @@ -827,14 +848,44 @@ function SendPaymentForm({ type="text" value={destination} onChange={(e) => { - setDestination(e.target.value); + const val = e.target.value; + setDestination(val); setDestinationResolutionError(null); setResolvedPaymentDestination(null); setDestAccountWarning(null); setIsContactsDropdownOpen(true); + + // SNS live resolution: trigger for federation/SNS patterns + const trimmed = val.trim(); + const looksLikeRawAddress = trimmed.startsWith("G") && trimmed.length === 56; + if (isStellarName(trimmed) && !looksLikeRawAddress) { + // Clear previous SNS state + setSnsResolved(null); + setSnsError(null); + if (snsDebounceRef.current) clearTimeout(snsDebounceRef.current); + setSnsResolving(true); + snsDebounceRef.current = setTimeout(() => { + resolveStellarName(trimmed) + .then((address) => { + setSnsResolved(address); + setSnsError(null); + }) + .catch((err: unknown) => { + setSnsResolved(null); + setSnsError(err instanceof Error ? err.message : "Name not found or invalid"); + }) + .finally(() => setSnsResolving(false)); + }, 600); + } else { + // Not an SNS name — clear SNS state + if (snsDebounceRef.current) clearTimeout(snsDebounceRef.current); + setSnsResolving(false); + setSnsResolved(null); + setSnsError(null); + } }} onFocus={() => setIsContactsDropdownOpen(true)} - placeholder="G..., alice*domain.com, or @username" + placeholder="G... address or alice.xlm" className={clsx( "input-field font-mono text-sm", destination && @@ -846,6 +897,22 @@ function SendPaymentForm({ disabled={status !== "idle" || destinationReadOnly} /> + {/* SNS resolution feedback */} + {snsResolving && ( +
+
+ Resolving… +
+ )} + {!snsResolving && snsResolved && ( +

+ Resolves to: {snsResolved} ✓ +

+ )} + {!snsResolving && snsError && ( +

{snsError}

+ )} + {destinationResolutionError && (

{destinationResolutionError}

)} diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 0baaeb9..d9990bc 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -1797,50 +1797,82 @@ export async function fetchNetworkStats(): Promise { // ── Stellar Name Service ────────────────────────────────────────────────── -const snsCache = new Map() -const SNS_CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes +/** TTL for SNS resolution cache: 10 minutes */ +const SNS_CACHE_TTL_MS = 600_000; /** - * Resolves a Stellar name (e.g. alice.xlm) to a Stellar address. - * Uses Stellar Federation protocol under the hood. - * Caches results for 10 minutes. + * Module-level cache for resolved Stellar names. + * Entries expire after {@link SNS_CACHE_TTL_MS}. + * Survives across renders; resets on page reload. + */ +export const resolvedNameCache = new Map(); + +/** + * Resolves a human-readable Stellar name to a public key (G... address). + * + * - Accepts federation addresses: `alice*domain.com` + * - Accepts `.xlm` shorthand: `alice.xlm` → resolved via `alice*xlm.money` + * - Results are cached for 10 minutes in {@link resolvedNameCache} + * + * @param name - Federation address or `.xlm` shorthand + * @returns The resolved Stellar public key (account_id) + * @throws {Error} If the name is invalid or cannot be resolved */ export async function resolveStellarName(name: string): Promise { - const trimmed = name.trim() - - // Return as-is if already a valid Stellar address - if (isValidStellarAddress(trimmed)) return trimmed - - // Check cache - const cached = snsCache.get(trimmed) - if (cached && cached.expiresAt > Date.now()) return cached.address - - // Must contain a * for federation (e.g. alice*stellar.org) or end in .xlm - let federationAddress = trimmed - if (trimmed.endsWith('.xlm')) { - // Convert alice.xlm -> alice*stellarnames.org - const parts = trimmed.split('.') - federationAddress = `${parts[0]}*stellarnames.org` - } else if (!trimmed.includes('*')) { - throw new Error(`Invalid Stellar name: ${trimmed}`) + const trimmed = name.trim(); + + if (!trimmed) { + throw new Error("Name cannot be empty."); + } + + // Return as-is if already a valid raw Stellar address + if (isValidStellarAddress(trimmed)) return trimmed; + + // Check cache first + const cached = resolvedNameCache.get(trimmed); + if (cached && cached.expiry > Date.now()) return cached.address; + + // Determine canonical federation address + let federationAddress: string; + if (trimmed.endsWith(".xlm")) { + // alice.xlm → alice*xlm.money (xlm.money is the public SNS resolver for .xlm handles) + const localPart = trimmed.slice(0, trimmed.length - 4); // strip ".xlm" + if (!localPart) throw new Error(`Invalid .xlm name: "${trimmed}"`); + federationAddress = `${localPart}*xlm.money`; + } else if (trimmed.includes("*")) { + // Standard federation address: alice*domain.com + const parts = trimmed.split("*"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid federation address format: "${trimmed}". Expected "user*domain.com".`); + } + federationAddress = trimmed; + } else { + throw new Error( + `Invalid Stellar name: "${trimmed}". Use a federation address (alice*domain.com) or .xlm name (alice.xlm).` + ); } try { - const record = await Federation.Server.resolve(federationAddress) - if (!record.account_id) throw new Error('Name resolved but no address found') - snsCache.set(trimmed, { address: record.account_id, expiresAt: Date.now() + SNS_CACHE_TTL_MS }) - return record.account_id - } catch (err: any) { - throw new Error(`Could not resolve "${trimmed}": ${err.message ?? 'Unknown error'}`) + const record = await Federation.Server.resolve(federationAddress); + if (!record.account_id) { + throw new Error("Name resolved but no Stellar address was returned."); + } + // Store in cache + resolvedNameCache.set(trimmed, { address: record.account_id, expiry: Date.now() + SNS_CACHE_TTL_MS }); + return record.account_id; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + throw new Error(`Could not resolve "${trimmed}": ${message}`); } } /** - * Returns true if the input looks like a Stellar name (not a raw address) + * Returns true if the input looks like a Stellar name (not a raw G... address). + * Matches federation addresses (contains `*`) and .xlm shorthand (ends with `.xlm`). */ export function isStellarName(value: string): boolean { - const v = value.trim() - return v.endsWith('.xlm') || v.includes('*') + const v = value.trim(); + return v.endsWith(".xlm") || v.includes("*"); } // ─── Escrow (issue #213) ────────────────────────────────────────────────────── diff --git a/frontend/pages/settings.tsx b/frontend/pages/settings.tsx index f5e4b3f..43537a9 100644 --- a/frontend/pages/settings.tsx +++ b/frontend/pages/settings.tsx @@ -668,6 +668,39 @@ export default function SettingsPage({
)} + + {/* ── Your Stellar Name (SNS) ── */} +
+

+ + + + Your Stellar Name +

+

+ Register a human-readable name (e.g.{" "} + alice.xlm) that others can use + to send you payments instead of your full address. +

+ + + Register your name on xlm.money + + + + + +

+ Already registered? Share your name (e.g.{" "} + yourname.xlm) and it will resolve to your Stellar + address automatically. +

+
@@ -707,28 +740,7 @@ export default function SettingsPage({ - - {/* ── Stellar Name Service ── */} -
-

Your Stellar Name

-

- Register a human-readable name (e.g. alice.xlm) that others can use to send you payments instead of your full address. -

- {publicKey && ( -

- Your address: {publicKey} -

- )} - - Register your name on StellarNames → - -
- + )} );