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 ?
Close
: 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 && (
+
+ )}
+ {!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.
-
- Replay onboarding tour
-
- {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 →
-
-
-
+
)}
>
);