diff --git a/frontend/__tests__/SendPaymentFormSNS.test.tsx b/frontend/__tests__/SendPaymentFormSNS.test.tsx new file mode 100644 index 0000000..b1029ac --- /dev/null +++ b/frontend/__tests__/SendPaymentFormSNS.test.tsx @@ -0,0 +1,276 @@ +/** + * __tests__/SendPaymentFormSNS.test.tsx + * + * Tests for Stellar Name Service integration in SendPaymentForm. + * + * Covers: + * - Typing a .xlm name shows inline spinner then resolved address + * - Typing a raw G... address skips SNS resolution entirely + * - Invalid / unresolvable name shows inline error and blocks submit + * - Submit uses the resolved address, not the typed name string + */ + +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const mockResolveStellarName = jest.fn(); +const mockIsStellarName = jest.fn(); + +jest.mock("@/lib/stellar", () => ({ + buildPaymentTransaction: jest.fn().mockResolvedValue({ toXDR: () => "mock-xdr" }), + buildSorobanTipTransaction: jest.fn(), + buildReceiptMintTransaction: jest.fn(), + CONTRACT_ID: null, + explorerUrl: jest.fn((hash: string) => `https://stellar.expert/tx/${hash}`), + isValidStellarAddress: jest.fn((addr: string) => /^G[A-Z0-9]{55}$/.test(addr)), + isValidFederationAddress: jest.fn((addr: string) => addr.includes("*") && addr.includes(".")), + isStellarName: (...args: unknown[]) => mockIsStellarName(...args), + resolveStellarName: (...args: unknown[]) => mockResolveStellarName(...args), + resolveFederationAddress: jest.fn(), + submitTransaction: jest.fn().mockResolvedValue({ hash: "abc123" }), + fetchNetworkFeeStats: jest.fn().mockResolvedValue({ baseFeeXlm: 0.00001, feeLevel: "normal" }), + truncateMemoText: jest.fn((t: string) => t), + STELLAR_BASE_FEE_XLM: 0.00001, + STELLAR_MEMO_TEXT_MAX_BYTES: 28, + STELLAR_MINIMUM_ACCOUNT_BALANCE_XLM: 1, + server: { + loadAccount: jest.fn().mockRejectedValue(new Error("not found")), + payments: jest.fn(), + transactions: jest.fn(), + }, +})); + +jest.mock("@/lib/wallet", () => ({ + signTransactionWithWallet: jest.fn().mockResolvedValue({ signedXDR: "signed-xdr" }), +})); + +jest.mock("@/utils/format", () => ({ + formatXLM: jest.fn((n: number) => `${n} XLM`), + shortenAddress: jest.fn((a: string) => a?.slice(0, 8) + "..."), +})); + +jest.mock("@/components/PaymentStatusModal", () => ({ + __esModule: true, + default: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => + isOpen ?
: null, +})); + +jest.mock("@/components/MultiSigFlow", () => ({ + MULTISIG_THRESHOLD_XLM: 1000, +})); + +import SendPaymentForm from "../components/SendPaymentForm"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const VALID_ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNN"; +const RESOLVED_ADDRESS = "GBRPYHIL2CI3WHZDTOOQFC6EB4RRJC3D5NZ2KMSUGSRNVO7ZFGIGSZZZ"; + +const defaultProps = { + publicKey: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGXLQN8FMGEZEBQP3BHTPQP", + xlmBalance: "100.0000000", + usdcBalance: "0", + onSuccess: jest.fn(), +}; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("SendPaymentForm — SNS integration", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + // Default: isStellarName is false for everything; tests override as needed + mockIsStellarName.mockReturnValue(false); + mockResolveStellarName.mockResolvedValue(RESOLVED_ADDRESS); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("typing a .xlm name", () => { + beforeEach(() => { + // Make isStellarName return true for .xlm inputs + mockIsStellarName.mockImplementation((v: string) => v.trim().toLowerCase().endsWith(".xlm") || v.includes("*")); + }); + + it("shows a spinner while resolving the name", async () => { + // Resolution doesn't settle immediately + let resolvePromise!: (v: string) => void; + mockResolveStellarName.mockReturnValue( + new Promise((res) => { resolvePromise = res; }) + ); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + await user.type(input, "alice.xlm"); + + // Advance past the 400ms debounce + act(() => { jest.advanceTimersByTime(500); }); + + expect(await screen.findByLabelText("Resolving name")).toBeInTheDocument(); + + // Settle the promise to avoid open handle warnings + act(() => { resolvePromise(RESOLVED_ADDRESS); }); + }); + + it("shows the resolved G... address below the field after successful resolution", async () => { + mockResolveStellarName.mockResolvedValue(RESOLVED_ADDRESS); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + await user.type(input, "alice.xlm"); + + act(() => { jest.advanceTimersByTime(500); }); + + await waitFor(() => { + expect(screen.getByText(/Resolves to:/)).toBeInTheDocument(); + expect(screen.getByText(new RegExp(RESOLVED_ADDRESS))).toBeInTheDocument(); + }); + }); + + it("shows an inline error when the name cannot be resolved", async () => { + mockResolveStellarName.mockRejectedValue(new Error('Could not resolve "bad.xlm" to a Stellar address')); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + await user.type(input, "bad.xlm"); + + act(() => { jest.advanceTimersByTime(500); }); + + await waitFor(() => { + expect(screen.getByText(/Could not resolve/i)).toBeInTheDocument(); + }); + }); + + it("disables the submit button when the name is resolving", async () => { + let resolvePromise!: (v: string) => void; + mockResolveStellarName.mockReturnValue( + new Promise((res) => { resolvePromise = res; }) + ); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + const amountInput = screen.getByPlaceholderText("0.0000000"); + await user.type(input, "alice.xlm"); + await user.type(amountInput, "5"); + + act(() => { jest.advanceTimersByTime(500); }); + + const sendButton = screen.getByRole("button", { name: /Send/i }); + expect(sendButton).toBeDisabled(); + + // Settle promise + act(() => { resolvePromise(RESOLVED_ADDRESS); }); + }); + + it("disables the submit button when name resolution failed", async () => { + mockResolveStellarName.mockRejectedValue(new Error("Could not resolve")); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + const amountInput = screen.getByPlaceholderText("0.0000000"); + await user.type(input, "bad.xlm"); + await user.type(amountInput, "5"); + + act(() => { jest.advanceTimersByTime(500); }); + + await waitFor(() => { + expect(screen.getByText(/Could not resolve/i)).toBeInTheDocument(); + }); + + const sendButton = screen.getByRole("button", { name: /Send/i }); + expect(sendButton).toBeDisabled(); + }); + }); + + describe("typing a raw G... address", () => { + it("does not call resolveStellarName for a raw public key", async () => { + // isStellarName returns false for raw addresses + mockIsStellarName.mockReturnValue(false); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + await user.type(input, VALID_ADDRESS); + + act(() => { jest.advanceTimersByTime(500); }); + + // resolveStellarName should never be called for a raw G address + expect(mockResolveStellarName).not.toHaveBeenCalled(); + // No "Resolves to:" text should appear + expect(screen.queryByText(/Resolves to:/)).not.toBeInTheDocument(); + }); + + it("does not show a spinner for a raw public key", async () => { + mockIsStellarName.mockReturnValue(false); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + await user.type(input, VALID_ADDRESS); + + act(() => { jest.advanceTimersByTime(500); }); + + expect(screen.queryByLabelText("Resolving name")).not.toBeInTheDocument(); + }); + }); + + describe("submission uses resolved address", () => { + it("passes the resolved address (not the typed name) to buildPaymentTransaction", async () => { + mockIsStellarName.mockImplementation((v: string) => + v.trim().toLowerCase().endsWith(".xlm") || v.includes("*") + ); + mockResolveStellarName.mockResolvedValue(RESOLVED_ADDRESS); + + const { buildPaymentTransaction } = await import("@/lib/stellar"); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const input = screen.getByPlaceholderText(/G\.\.\./); + const amountInput = screen.getByPlaceholderText("0.0000000"); + + await user.type(input, "alice.xlm"); + act(() => { jest.advanceTimersByTime(500); }); + + // Wait for resolution to complete and resolved address to appear + await waitFor(() => { + expect(screen.getByText(/Resolves to:/)).toBeInTheDocument(); + }); + + await user.type(amountInput, "5"); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Send/i })).toBeEnabled(); + }); + + await user.click(screen.getByRole("button", { name: /Send/i })); + + const confirmButton = await screen.findByRole("button", { name: /Confirm & Sign/i }); + await user.click(confirmButton); + + await waitFor(() => { + expect(buildPaymentTransaction).toHaveBeenCalledWith( + expect.objectContaining({ toPublicKey: RESOLVED_ADDRESS }) + ); + }); + }); + }); +}); diff --git a/frontend/__tests__/resolveStellarName.test.ts b/frontend/__tests__/resolveStellarName.test.ts new file mode 100644 index 0000000..4c2eb4f --- /dev/null +++ b/frontend/__tests__/resolveStellarName.test.ts @@ -0,0 +1,199 @@ +/** + * __tests__/resolveStellarName.test.ts + * + * Unit tests for `resolveStellarName`, `isStellarName`, and `clearNameCache` + * in lib/stellar.ts — covering successful resolution, cache behaviour (TTL + * hit and miss), error propagation, and the raw-address bypass path. + */ + +import { resolveStellarName, isStellarName, clearNameCache, isValidStellarAddress } from "@/lib/stellar"; + +// ─── Mock stellar-sdk Federation ───────────────────────────────────────────── +// We mock only the Federation.Server.resolve call so the rest of the module +// (isValidStellarAddress, etc.) runs as real code. + +const mockResolve = jest.fn(); + +jest.mock("@stellar/stellar-sdk", () => { + const actual = jest.requireActual("@stellar/stellar-sdk"); + return { + ...actual, + Federation: { + Server: { + resolve: (...args: unknown[]) => mockResolve(...args), + }, + }, + }; +}); + +const VALID_ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNN"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function advanceSystemTimerBy(ms: number) { + jest.setSystemTime(Date.now() + ms); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("isStellarName", () => { + it("returns true for .xlm suffix names", () => { + expect(isStellarName("alice.xlm")).toBe(true); + expect(isStellarName("ALICE.XLM")).toBe(true); + expect(isStellarName(" alice.xlm ")).toBe(true); + }); + + it("returns true for federation name*domain format", () => { + expect(isStellarName("alice*stellar.org")).toBe(true); + expect(isStellarName("user*example.com")).toBe(true); + }); + + it("returns false for raw G... public keys", () => { + expect(isStellarName(VALID_ADDRESS)).toBe(false); + }); + + it("returns false for usernames without * or .xlm", () => { + expect(isStellarName("@alice")).toBe(false); + expect(isStellarName("alice")).toBe(false); + expect(isStellarName("")).toBe(false); + }); +}); + +describe("resolveStellarName", () => { + beforeEach(() => { + jest.useFakeTimers(); + clearNameCache(); + mockResolve.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("raw address bypass", () => { + it("returns a valid G... address as-is without calling Federation.Server.resolve", async () => { + const result = await resolveStellarName(VALID_ADDRESS); + expect(result).toBe(VALID_ADDRESS); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it("trims whitespace from raw addresses before returning", async () => { + const result = await resolveStellarName(` ${VALID_ADDRESS} `); + expect(result).toBe(VALID_ADDRESS); + }); + }); + + describe("successful resolution", () => { + it("resolves a .xlm name by translating to name*stellarnames.org", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + const result = await resolveStellarName("alice.xlm"); + + expect(result).toBe(VALID_ADDRESS); + expect(mockResolve).toHaveBeenCalledWith("alice*stellarnames.org"); + }); + + it("resolves a federation address directly", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + const result = await resolveStellarName("alice*domain.com"); + + expect(result).toBe(VALID_ADDRESS); + expect(mockResolve).toHaveBeenCalledWith("alice*domain.com"); + }); + + it("is case-insensitive for .xlm names", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + await resolveStellarName("ALICE.XLM"); + expect(mockResolve).toHaveBeenCalledWith("alice*stellarnames.org"); + }); + }); + + describe("cache behaviour", () => { + it("returns cached address on second call within TTL (does not call resolve again)", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + const first = await resolveStellarName("alice.xlm"); + const second = await resolveStellarName("alice.xlm"); + + expect(first).toBe(VALID_ADDRESS); + expect(second).toBe(VALID_ADDRESS); + // Only one network call despite two resolution calls + expect(mockResolve).toHaveBeenCalledTimes(1); + }); + + it("calls resolve again after the 10-minute TTL has expired", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + await resolveStellarName("alice.xlm"); + expect(mockResolve).toHaveBeenCalledTimes(1); + + // Advance past the 10-minute TTL + advanceSystemTimerBy(10 * 60 * 1000 + 1); + + await resolveStellarName("alice.xlm"); + expect(mockResolve).toHaveBeenCalledTimes(2); + }); + + it("still returns cached address if called 1ms before TTL expires", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + await resolveStellarName("alice.xlm"); + + // Advance to just before expiry + advanceSystemTimerBy(10 * 60 * 1000 - 1); + + await resolveStellarName("alice.xlm"); + expect(mockResolve).toHaveBeenCalledTimes(1); + }); + + it("clearNameCache() forces a fresh lookup on next call", async () => { + mockResolve.mockResolvedValue({ account_id: VALID_ADDRESS }); + + await resolveStellarName("alice.xlm"); + clearNameCache(); + await resolveStellarName("alice.xlm"); + + expect(mockResolve).toHaveBeenCalledTimes(2); + }); + }); + + describe("error handling", () => { + it("throws a clear error when the name cannot be resolved", async () => { + mockResolve.mockRejectedValue(new Error("Not found")); + + await expect(resolveStellarName("unknown.xlm")).rejects.toThrow( + /Could not resolve "unknown\.xlm"/ + ); + }); + + it("throws for input that is not a name or a valid address", async () => { + await expect(resolveStellarName("notaname")).rejects.toThrow( + /Could not resolve "notaname"/ + ); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it("throws when federation server returns no account_id", async () => { + mockResolve.mockResolvedValue({ account_id: null }); + + await expect(resolveStellarName("alice.xlm")).rejects.toThrow( + /Could not resolve "alice\.xlm"/ + ); + }); + + it("does not cache failed resolutions", async () => { + mockResolve + .mockRejectedValueOnce(new Error("Server error")) + .mockResolvedValueOnce({ account_id: VALID_ADDRESS }); + + await expect(resolveStellarName("alice.xlm")).rejects.toThrow(); + + // Second call should try again (not use a cached failure) + const result = await resolveStellarName("alice.xlm"); + expect(result).toBe(VALID_ADDRESS); + expect(mockResolve).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/__tests__/settingsSNS.test.tsx b/frontend/__tests__/settingsSNS.test.tsx new file mode 100644 index 0000000..1aa53a1 --- /dev/null +++ b/frontend/__tests__/settingsSNS.test.tsx @@ -0,0 +1,125 @@ +/** + * __tests__/settingsSNS.test.tsx + * + * Tests for the "Stellar Name Service" section in pages/settings.tsx. + * + * Covers: + * - Section renders with the expected heading + * - Informational copy mentions .xlm names + * - Registration link points to stellarnames.org and opens in a new tab + * - Known-limitation disclaimer is shown + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +// settings.tsx imports from several internal modules; mock the ones that make +// network calls or rely on browser-only state so tests stay deterministic. + +jest.mock("@/lib/stellar", () => ({ + getNetworkConfig: jest.fn(() => ({ network: "testnet", horizonUrl: "https://horizon-testnet.stellar.org" })), + setNetworkConfig: jest.fn(), + shortenAddress: jest.fn((addr: string) => addr?.slice(0, 6) + "..."), + server: { loadAccount: jest.fn() }, +})); + +jest.mock("@/lib/wallet", () => ({ + disconnectWallet: jest.fn(), + signTransactionWithWallet: jest.fn(), +})); + +jest.mock("@/lib/turrets", () => ({ + createTurretsChallenge: jest.fn(), + deployTurretsFunction: jest.fn(), + listTurretsFunctions: jest.fn().mockResolvedValue([]), + pauseTurretsFunction: jest.fn(), + resumeTurretsFunction: jest.fn(), +})); + +jest.mock("next/head", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +// Silence fetch calls during username-fetch effect +global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: jest.fn().mockResolvedValue({}), +} as any); + +import SettingsPage from "../pages/settings"; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +const defaultProps = { + publicKey: "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNN", + onConnect: jest.fn(), + onDisconnect: jest.fn(), +}; + +describe("SettingsPage — Stellar Name Service section", () => { + it("renders the 'Stellar Name Service' heading", () => { + render(); + expect( + screen.getByRole("heading", { name: /Stellar Name Service/i }) + ).toBeInTheDocument(); + }); + + it("mentions .xlm names in the description", () => { + render(); + expect(screen.getByText(/alice\.xlm/i)).toBeInTheDocument(); + }); + + it("provides a link to stellarnames.org", () => { + render(); + const links = screen + .getAllByRole("link") + .filter((el) => el.getAttribute("href")?.includes("stellarnames.org")); + + expect(links.length).toBeGreaterThan(0); + links.forEach((link) => { + expect(link).toHaveAttribute("href", expect.stringContaining("stellarnames.org")); + }); + }); + + it("opens the registration link in a new tab", () => { + render(); + const link = screen + .getAllByRole("link") + .find((el) => el.getAttribute("href")?.includes("stellarnames.org") && el.textContent?.includes("Register")); + + expect(link).toBeDefined(); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", expect.stringContaining("noopener")); + }); + + it("shows the known-limitation disclaimer about stellar.toml", () => { + render(); + // The disclaimer mentions stellar.toml dependency + expect(screen.getByText(/stellar\.toml/i)).toBeInTheDocument(); + }); + + it("shows the user's wallet address in the section when connected", () => { + render(); + // The public key should appear somewhere in the SNS section + expect( + screen.getAllByText(/GAAZI4/i).length + ).toBeGreaterThan(0); + }); + + it("does not render the SNS card inside the mainnet warning modal", () => { + render(); + // The SNS section heading should appear exactly once — as a standalone card + const headings = screen.getAllByRole("heading", { name: /Stellar Name Service/i }); + expect(headings).toHaveLength(1); + }); +}); diff --git a/frontend/components/SendPaymentForm.tsx b/frontend/components/SendPaymentForm.tsx index 5cdb555..106d7ce 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, @@ -145,6 +147,10 @@ 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); + const [snsResolving, setSnsResolving] = useState(false); const [customAsset, setCustomAsset] = useState({ code: "", issuer: "" }); const [showCustomAssetForm, setShowCustomAssetForm] = useState(false); const [selectedMemoTemplate, setSelectedMemoTemplate] = useState(null); @@ -173,8 +179,7 @@ function SendPaymentForm({ const frameRequestRef = useRef(null); const isDetectingRef = useRef(false); const destinationInputRef = useRef(null); - const destinationValidationTimeoutRef = useRef | null>(null); - const destinationValidationRequestRef = useRef(0); + const snsDebounceRef = useRef | null>(null); // Power-user shortcut: press "S" (when not already typing in a field and no // modal is open) to jump focus to the destination input (#264). @@ -366,12 +371,48 @@ function SendPaymentForm({ setResolvedPaymentDestination(null); }, [prefill]); - const validateDestinationAccount = useCallback((address: string) => { - const trimmedAddress = address.trim(); - const requestId = destinationValidationRequestRef.current + 1; - destinationValidationRequestRef.current = requestId; + // Debounced SNS resolution — fires 400ms after the user stops typing a + // .xlm name or federation address. Shows an inline spinner during lookup + // and the resolved G... address (or an error) below the destination field. + useEffect(() => { + if (snsDebounceRef.current) clearTimeout(snsDebounceRef.current); + + const trimmed = destination.trim(); + + // Only trigger for SNS/federation names — raw addresses and usernames are + // handled elsewhere. + if (!isStellarName(trimmed)) { + setSnsResolvedAddress(null); + setSnsResolving(false); + return; + } + + setSnsResolving(true); + setSnsResolvedAddress(null); + setDestinationResolutionError(null); + + snsDebounceRef.current = setTimeout(async () => { + try { + const resolved = await resolveStellarName(trimmed); + setSnsResolvedAddress(resolved); + setDestinationResolutionError(null); + } catch (err) { + const message = err instanceof Error ? err.message : "Could not resolve name"; + setDestinationResolutionError(message); + setSnsResolvedAddress(null); + } finally { + setSnsResolving(false); + } + }, 400); - if (!isValidStellarAddress(trimmedAddress)) { + return () => { + if (snsDebounceRef.current) clearTimeout(snsDebounceRef.current); + }; + }, [destination]); + + // Pre-validate destination account existence on the Stellar network (#294) + useEffect(() => { + if (!isValidStellarAddress(destination)) { setDestAccountWarning(null); setIsCheckingDest(false); return; @@ -460,8 +501,9 @@ function SendPaymentForm({ const isMemoValid = memoBytes <= 28; const canSubmit = - (isValidDest || isFederationDestination || isUsernameDestination) && + (isValidDest || isFederationDestination || isUsernameDestination || (isStellarName(trimmedDestination) && !!snsResolvedAddress)) && !isResolvingDestination && + !snsResolving && !destinationResolutionError && isValidAmt && status === "idle" && @@ -496,12 +538,22 @@ function SendPaymentForm({ return trimmedDestination; } + // If we already resolved a SNS name in the debounced effect, use that + // result directly — never submit the raw name string. + if (isStellarName(trimmedDestination) && snsResolvedAddress) { + return snsResolvedAddress; + } + setIsResolvingDestination(true); try { if (isFederationDestination) { return await resolveFederationAddress(trimmedDestination); } + if (isStellarName(trimmedDestination)) { + return await resolveStellarName(trimmedDestination); + } + if (isUsernameDestination) { return await resolveUsername(trimmedDestination); } @@ -529,6 +581,7 @@ function SendPaymentForm({ setDestination(address); setDestinationResolutionError(null); setResolvedPaymentDestination(null); + setSnsResolvedAddress(null); setIsContactsDropdownOpen(false); }; @@ -573,6 +626,7 @@ function SendPaymentForm({ setAmount(""); setMemo(""); setResolvedPaymentDestination(null); + setSnsResolvedAddress(null); } setStatus("idle"); }; @@ -877,6 +931,7 @@ function SendPaymentForm({ setDestination(e.target.value); setDestinationResolutionError(null); setResolvedPaymentDestination(null); + setSnsResolvedAddress(null); setDestAccountWarning(null); setIsContactsDropdownOpen(true); }} @@ -898,6 +953,19 @@ function SendPaymentForm({

{destinationResolutionError}

)} + {/* SNS resolution feedback */} + {snsResolving && ( +
+
+ Resolving name… +
+ )} + {!snsResolving && snsResolvedAddress && ( +

+ Resolves to: {snsResolvedAddress} +

+ )} + {/* Destination account existence warning (#294) */} {isCheckingDest && isValidDest && (

{t("checking_account")}

diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 61396f2..c219974 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -1793,51 +1793,137 @@ export async function fetchNetworkStats(): Promise { // ── Stellar Name Service ────────────────────────────────────────────────── -const snsCache = new Map() -const SNS_CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes +/** + * 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 + +/** + * Clear the in-memory SNS resolution cache. + * + * Primarily useful in tests to avoid cross-test pollution when mocking timers + * or resolution results. + */ +export function clearNameCache(): void { + snsCache.clear(); +} /** - * Resolves a Stellar name (e.g. alice.xlm) to a Stellar address. - * Uses Stellar Federation protocol under the hood. - * Caches results for 10 minutes. + * 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. + * + * 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. + * + * @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" + * ``` */ 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(); + + // 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; + } + + // 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`); } 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 + const record = await Federation.Server.resolve(federationAddress); + if (!record.account_id) { + throw new Error("Name resolved but no address found"); + } + snsCache.set(key, { + name: key, + address: record.account_id, + resolvedAt: Date.now(), + }); + return record.account_id; } catch (err: unknown) { const message = err instanceof Error ? err.message : "Unknown error"; - throw new Error(`Could not resolve "${trimmed}": ${message}`); + throw new Error(`Could not resolve "${trimmed}" to a Stellar address`); } } /** - * Returns true if the input looks like a Stellar name (not a raw address) + * 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 + * ``` */ export function isStellarName(value: string): boolean { - const v = value.trim() - return v.endsWith('.xlm') || v.includes('*') + const v = value.trim(); + return v.toLowerCase().endsWith(".xlm") || v.includes("*"); } // ─── Escrow (issue #213) ────────────────────────────────────────────────────── diff --git a/frontend/pages/settings.tsx b/frontend/pages/settings.tsx index 1e55362..2c788ad 100644 --- a/frontend/pages/settings.tsx +++ b/frontend/pages/settings.tsx @@ -799,29 +799,76 @@ export default function SettingsPage({
)} - - {/* Help & Onboarding — manually re-trigger the dashboard tour (#621) */} + {/* Stellar Name Service section */}
-

- Help & Onboarding +

+ + + + + Stellar Name Service

-

- Replay the guided tour that highlights your balance, the send form, and transaction history. +

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

- - {tourResetMessage && ( -

- {tourResetMessage}{" "} - - Go to dashboard → - + + {/* 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 + + + + + +

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

@@ -862,28 +909,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 → - -
- + )} );