diff --git a/frontend/components/SendPaymentForm.tsx b/frontend/components/SendPaymentForm.tsx index 106d7ce..4cb2bcd 100644 --- a/frontend/components/SendPaymentForm.tsx +++ b/frontend/components/SendPaymentForm.tsx @@ -147,10 +147,11 @@ function SendPaymentForm({ const [isResolvingDestination, setIsResolvingDestination] = useState(false); const [destinationResolutionError, setDestinationResolutionError] = useState(null); const [resolvedPaymentDestination, setResolvedPaymentDestination] = useState(null); - // SNS inline resolution state: tracks the resolved address shown below the - // destination field when a .xlm name or federation address is entered. - const [snsResolvedAddress, setSnsResolvedAddress] = 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); @@ -501,9 +502,10 @@ function SendPaymentForm({ const isMemoValid = memoBytes <= 28; const canSubmit = - (isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolvedAddress)) && + (isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolved)) && !isResolvingDestination && !snsResolving && + !snsError && !destinationResolutionError && isValidAmt && status === "idle" && @@ -546,6 +548,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); } @@ -626,7 +637,9 @@ function SendPaymentForm({ setAmount(""); setMemo(""); setResolvedPaymentDestination(null); - setSnsResolvedAddress(null); + setSnsResolved(null); + setSnsError(null); + setSnsResolving(false); } setStatus("idle"); }; @@ -928,15 +941,45 @@ function SendPaymentForm({ type="text" value={destination} onChange={(e) => { - setDestination(e.target.value); + const val = e.target.value; + setDestination(val); setDestinationResolutionError(null); setResolvedPaymentDestination(null); setSnsResolvedAddress(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={t("dest_placeholder")} + placeholder="G... address or alice.xlm" className={clsx( "input-field font-mono text-sm", destination && @@ -949,6 +992,22 @@ function SendPaymentForm({ onBlur={runImmediateDestinationValidation} /> + {/* 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 c219974..4b65ab1 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -1793,137 +1793,82 @@ export async function fetchNetworkStats(): Promise { // ── Stellar Name Service ────────────────────────────────────────────────── -/** - * Cached resolution entry for a Stellar name. - * - * @property name - The original name string as entered by the user. - * @property address - The resolved Stellar public key (G...). - * @property resolvedAt - Unix epoch milliseconds when the resolution occurred (used for TTL). - */ -export interface ResolvedName { - name: string; - address: string; - resolvedAt: number; -} - -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; /** - * Clear the in-memory SNS resolution cache. - * - * Primarily useful in tests to avoid cross-test pollution when mocking timers - * or resolution results. + * Module-level cache for resolved Stellar names. + * Entries expire after {@link SNS_CACHE_TTL_MS}. + * Survives across renders; resets on page reload. */ -export function clearNameCache(): void { - snsCache.clear(); -} +export const resolvedNameCache = new Map(); /** - * Resolve a human-readable Stellar name to a public key. - * - * Supports two input formats: - * - **Federation addresses** — the native `name*domain.com` SEP-0002 format - * supported by any wallet that publishes a `stellar.toml`. Passed directly - * to `Federation.Server.resolve`. - * - **`.xlm` shorthand** — a convenience alias (e.g. `alice.xlm`) that is - * translated to `alice*stellarnames.org` before resolution. StellarNames - * (stellarnames.org) is a community-run federation server for the `.xlm` - * namespace. This mapping is documented here so it is easy to swap for - * another provider if needed. - * - * Raw `G...` public keys bypass resolution entirely and are returned as-is, - * so callers can pass any user input without pre-checking the format. + * Resolves a human-readable Stellar name to a public key (G... address). * - * Results are cached for {@link SNS_CACHE_TTL_MS} (10 minutes) to avoid - * redundant network lookups on every keystroke. Use {@link clearNameCache} - * to invalidate the cache in tests. + * - 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 - A `.xlm` name, `name*domain.com` federation address, or raw - * Stellar public key. - * @returns A promise resolving to the Stellar public key (G...). - * @throws {Error} If the name cannot be resolved to a valid public key. - * - * @example - * ```ts - * const address = await resolveStellarName("alice.xlm"); - * // → "GABC...XYZ" - * - * const same = await resolveStellarName("alice*stellarnames.org"); - * // → "GABC...XYZ" - * - * // Raw addresses bypass resolution - * const raw = await resolveStellarName("GABC...XYZ"); - * // → "GABC...XYZ" - * ``` + * @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(); - // Raw Stellar public keys bypass resolution entirely - if (isValidStellarAddress(trimmed)) return trimmed; - - const key = trimmed.toLowerCase(); - - // Cache hit within TTL - const cached = snsCache.get(key); - if (cached && Date.now() - cached.resolvedAt < SNS_CACHE_TTL_MS) { - return cached.address; + if (!trimmed) { + throw new Error("Name cannot be empty."); } - // Determine the federation address to look up - let federationAddress = trimmed; - if (trimmed.toLowerCase().endsWith(".xlm")) { - // alice.xlm → alice*stellarnames.org - // StellarNames (https://stellarnames.org) is a community federation server - // for the .xlm namespace. Update this mapping to switch providers. - const localPart = trimmed.slice(0, trimmed.lastIndexOf(".")); - federationAddress = `${localPart}*stellarnames.org`; - } else if (!trimmed.includes("*")) { - throw new Error(`Could not resolve "${trimmed}" to a Stellar address`); + // 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"); + throw new Error("Name resolved but no Stellar address was returned."); } - snsCache.set(key, { - name: key, - address: record.account_id, - resolvedAt: Date.now(), - }); + // 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}" to a Stellar address`); + throw new Error(`Could not resolve "${trimmed}": ${message}`); } } /** - * Returns `true` when the input looks like a Stellar name rather than a raw - * public key. - * - * Detects: - * - `.xlm` suffix shorthand (e.g. `alice.xlm`) - * - Native federation format (e.g. `alice*domain.com`) - * - * Raw `G...` public keys and plain usernames (no `*` or `.xlm`) return `false`. - * - * @param value - User-supplied destination string. - * @returns `true` if the value should be resolved via {@link resolveStellarName}. - * - * @example - * ```ts - * isStellarName("alice.xlm") // true - * isStellarName("alice*domain.com") // true - * isStellarName("GABC...XYZ") // false - * isStellarName("@username") // false - * ``` + * 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.toLowerCase().endsWith(".xlm") || v.includes("*"); + return v.endsWith(".xlm") || v.includes("*"); } // ─── Escrow (issue #213) ────────────────────────────────────────────────────── diff --git a/frontend/pages/settings.tsx b/frontend/pages/settings.tsx index 2c788ad..3dab49b 100644 --- a/frontend/pages/settings.tsx +++ b/frontend/pages/settings.tsx @@ -799,75 +799,37 @@ export default function SettingsPage({
)} - {/* Stellar Name Service section */} + + {/* ── Your Stellar Name (SNS) ── */}

- - + - Stellar Name Service + Your Stellar Name

-

- Register a human-readable name like alice.xlm so others can send you - payments without needing your full G… address. +

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

- {/* How it works */} -
-

How it works

-
    -
  • - Names like alice.xlm are resolved via the{" "} - Stellar Federation protocol — the same standard built into - every Stellar wallet. -
  • -
  • - Registration is managed by{" "} - - StellarNames.org - {" "} - — a community-run federation server for the{" "} - .xlm namespace. -
  • -
  • - Once registered, anyone can type your name in the Send Payment form and it - will automatically resolve to your address. -
  • -
  • - Resolution is cached locally for 10 minutes to keep things snappy. -
  • -
-
- - {publicKey && ( -

- Your wallet address:{" "} - {publicKey} -

- )} - - Register your name on StellarNames - - + Register your name on xlm.money + + -

- Note: resolution depends on the recipient's domain publishing a valid{" "} - stellar.toml. Names not registered with - a federation server will fail to resolve. +

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