From 21cc64d2735a8e448bda6e36bd971b678fc6d178 Mon Sep 17 00:00:00 2001 From: Similoluwa Abidoye Date: Fri, 24 Jul 2026 11:05:52 +0100 Subject: [PATCH] feat(frontend): add Fiat Onramp Integration Interface with interactive loading states Adds a SEP-0024 hosted-deposit modal (fiat -> Stellar token onramp) that mirrors the existing SEP-0024 withdrawal flow's anchor discovery, SEP-10 auth, and interactive-iframe pattern, wired into the dashboard's quick actions. This module didn't exist in the codebase before, so the focus for this first pass is a fully realized interaction/loading model: distinct states for connecting to the anchor, waiting on a wallet signature, and generating the deposit form, an inline error state that returns the user to the form instead of dead-ending, a skeleton placeholder while the anchor's interactive iframe loads, and disabled/aria-live states throughout so the loading sequence is announced to assistive tech. Note: main currently fails to compile locally (duplicate `themeRef` declaration in src/lib/theme-context.tsx, pre-existing and unrelated to this change), so this was verified via tsc/eslint/vitest rather than a live browser session. Closes #1211 Co-Authored-By: Claude Sonnet 5 --- .../app/(authenticated)/dashboard/page.tsx | 27 ++ .../src/components/FiatOnrampModal.test.tsx | 153 ++++++++++ frontend/src/components/FiatOnrampModal.tsx | 267 ++++++++++++++++++ frontend/src/lib/stellar.ts | 33 +++ 4 files changed, 480 insertions(+) create mode 100644 frontend/src/components/FiatOnrampModal.test.tsx create mode 100644 frontend/src/components/FiatOnrampModal.tsx diff --git a/frontend/src/app/(authenticated)/dashboard/page.tsx b/frontend/src/app/(authenticated)/dashboard/page.tsx index 8db4ab22..7f8a4ae0 100644 --- a/frontend/src/app/(authenticated)/dashboard/page.tsx +++ b/frontend/src/app/(authenticated)/dashboard/page.tsx @@ -12,9 +12,11 @@ import FirstApiKeyModal from "@/components/FirstApiKeyModal"; import FirstPaymentCelebration from "@/components/FirstPaymentCelebration"; import PaymentMetrics from "@/components/PaymentMetrics"; import PaymentsTabs from "@/components/PaymentsTabs"; +import FiatOnrampModal from "@/components/FiatOnrampModal"; export default function DashboardPage() { const [isFirstKeyModalOpen, setIsFirstKeyModalOpen] = useState(false); + const [isOnrampModalOpen, setIsOnrampModalOpen] = useState(false); const hydrated = useMerchantHydrated(); const apiKey = useMerchantApiKey(); const [loading, setLoading] = useState(true); @@ -71,6 +73,27 @@ export default function DashboardPage() {
+ + setIsFirstKeyModalOpen(false)} /> + setIsOnrampModalOpen(false)} + />
); diff --git a/frontend/src/components/FiatOnrampModal.test.tsx b/frontend/src/components/FiatOnrampModal.test.tsx new file mode 100644 index 00000000..52e8daff --- /dev/null +++ b/frontend/src/components/FiatOnrampModal.test.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; + +import FiatOnrampModal from "./FiatOnrampModal"; + +// ─── framer-motion mock ─────────────────────────────────────────────────────── +// jsdom has no layout engine so framer-motion's measurement APIs fail; strip +// animation-only props and render plain HTML so the DOM stays inspectable. +vi.mock("framer-motion", () => { + const strip = (props: Record) => { + const { initial: _i, animate: _a, exit: _e, transition: _t, ...rest } = props; + return rest; + }; + return { + motion: { + div: ({ children, ...p }: any) =>
{children}
, + }, + AnimatePresence: ({ children }: any) => <>{children}, + }; +}); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const mockGetFreighterPublicKey = vi.fn(); +const mockSignWithFreighter = vi.fn(); +vi.mock("@/lib/freighter", () => ({ + getFreighterPublicKey: (...args: unknown[]) => mockGetFreighterPublicKey(...args), + signWithFreighter: (...args: unknown[]) => mockSignWithFreighter(...args), +})); + +const mockGetAnchorServices = vi.fn(); +const mockAuthenticateWithAnchor = vi.fn(); +const mockInitiateDeposit = vi.fn(); +vi.mock("@/lib/stellar", () => ({ + getAnchorServices: (...args: unknown[]) => mockGetAnchorServices(...args), + authenticateWithAnchor: (...args: unknown[]) => mockAuthenticateWithAnchor(...args), + initiateDeposit: (...args: unknown[]) => mockInitiateDeposit(...args), +})); + +describe("FiatOnrampModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetFreighterPublicKey.mockResolvedValue("GABC123PUBLICKEY"); + mockGetAnchorServices.mockResolvedValue({ + transferServer: "https://anchor.example/sep24", + webAuthEndpoint: "https://anchor.example/auth", + signingKey: "GSIGNINGKEY", + }); + mockSignWithFreighter.mockResolvedValue({ signedXDR: "signed-xdr" }); + mockAuthenticateWithAnchor.mockResolvedValue("jwt-token"); + mockInitiateDeposit.mockResolvedValue("https://anchor.example/interactive/abc"); + }); + + it("does not render when closed", () => { + render(); + expect(screen.queryByText("Buy / Deposit Funds")).not.toBeInTheDocument(); + }); + + it("renders the asset selection form when open", () => { + render(); + expect(screen.getByText("Buy / Deposit Funds")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /USDC/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /SRT/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue to Anchor" })).toBeInTheDocument(); + }); + + it("lets the user switch the selected asset", () => { + render(); + const usdcButton = screen.getByRole("button", { name: /USDC/ }); + const srtButton = screen.getByRole("button", { name: /SRT/ }); + + expect(usdcButton).toHaveAttribute("aria-pressed", "true"); + expect(srtButton).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(srtButton); + + expect(srtButton).toHaveAttribute("aria-pressed", "true"); + expect(usdcButton).toHaveAttribute("aria-pressed", "false"); + }); + + it("walks through connecting, auth, and generating loading states before showing the interactive iframe", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Continue to Anchor" })); + + await waitFor(() => { + expect(mockGetFreighterPublicKey).toHaveBeenCalled(); + expect(mockGetAnchorServices).toHaveBeenCalledWith("testanchor.stellar.org"); + }); + + await waitFor(() => { + expect(mockAuthenticateWithAnchor).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockInitiateDeposit).toHaveBeenCalledWith( + "https://anchor.example/sep24", + "jwt-token", + "USDC", + "GABC123PUBLICKEY", + undefined, + ); + }); + + await waitFor(() => { + expect(screen.getByTitle("Anchor deposit form")).toBeInTheDocument(); + }); + }); + + it("passes the entered amount through to initiateDeposit", async () => { + render(); + + fireEvent.change(screen.getByLabelText(/Amount/), { target: { value: "250" } }); + fireEvent.click(screen.getByRole("button", { name: "Continue to Anchor" })); + + await waitFor(() => { + expect(mockInitiateDeposit).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + "USDC", + "GABC123PUBLICKEY", + "250", + ); + }); + }); + + it("shows an inline error and returns to the select step when the wallet is unavailable", async () => { + mockGetFreighterPublicKey.mockRejectedValue(new Error("Freighter is not installed")); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Continue to Anchor" })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Freighter is not installed"); + }); + + expect(screen.getByRole("button", { name: "Continue to Anchor" })).toBeInTheDocument(); + expect(mockGetAnchorServices).not.toHaveBeenCalled(); + }); + + it("resets state and calls onClose when closed", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId("modal-close")); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/FiatOnrampModal.tsx b/frontend/src/components/FiatOnrampModal.tsx new file mode 100644 index 00000000..6fded897 --- /dev/null +++ b/frontend/src/components/FiatOnrampModal.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import Skeleton from "react-loading-skeleton"; +import "react-loading-skeleton/dist/skeleton.css"; +import { + getAnchorServices, + authenticateWithAnchor, + initiateDeposit, +} from "@/lib/stellar"; +import { signWithFreighter, getFreighterPublicKey } from "@/lib/freighter"; +import { toast } from "sonner"; +import { Modal } from "@/components/ui/Modal"; +import { Spinner } from "@/components/ui/Spinner"; + +interface FiatOnrampModalProps { + isOpen: boolean; + onClose: () => void; +} + +const DEFAULT_ANCHOR = "testanchor.stellar.org"; +const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; +const SUPPORTED_ASSETS = [ + { + code: "USDC", + issuer: "GBBD67V63DU7D3SXXF4SOT5O7GNCGYL65B66S3YUKG6VCH3TFRZ7I7YQ", + }, // Testnet USDC + { + code: "SRT", + issuer: "GCDGUC3OCYLAU7XIK7EUBTWSOT3N4XALR6IRLKEW3V3AEL3Z5W5SOT4F", + }, // Testnet SRT (Stellar Resource Token) +]; + +type Step = "SELECT" | "CONNECTING" | "AUTH" | "GENERATING" | "INTERACTIVE"; + +const STEP_MESSAGE: Record, string> = { + CONNECTING: "Connecting to anchor…", + AUTH: "Waiting for wallet signature…", + GENERATING: "Preparing your deposit form…", +}; + +export default function FiatOnrampModal({ isOpen, onClose }: FiatOnrampModalProps) { + const [step, setStep] = useState("SELECT"); + const [amount, setAmount] = useState(""); + const [anchorDomain, setAnchorDomain] = useState(DEFAULT_ANCHOR); + const [selectedAsset, setSelectedAsset] = useState(SUPPORTED_ASSETS[0]); + const [interactiveUrl, setInteractiveUrl] = useState(null); + const [iframeLoading, setIframeLoading] = useState(true); + const [error, setError] = useState(null); + + const isBusy = step === "CONNECTING" || step === "AUTH" || step === "GENERATING"; + + const reset = useCallback(() => { + setStep("SELECT"); + setInteractiveUrl(null); + setIframeLoading(true); + setError(null); + }, []); + + const handleClose = useCallback(() => { + reset(); + onClose(); + }, [reset, onClose]); + + const handleStartDeposit = useCallback(async () => { + setError(null); + try { + setStep("CONNECTING"); + const publicKey = await getFreighterPublicKey(); + const services = await getAnchorServices(anchorDomain); + + if (!services.webAuthEndpoint || !services.transferServer) { + throw new Error("Anchor does not support SEP-0024 or SEP-0010"); + } + + setStep("AUTH"); + const jwt = await authenticateWithAnchor( + publicKey, + services.webAuthEndpoint, + async (xdr) => { + const res = await signWithFreighter(xdr, NETWORK_PASSPHRASE); + return res.signedXDR; + }, + ); + + setStep("GENERATING"); + const url = await initiateDeposit( + services.transferServer, + jwt, + selectedAsset.code, + publicKey, + amount || undefined, + ); + + setInteractiveUrl(url); + setStep("INTERACTIVE"); + } catch (err) { + const message = err instanceof Error ? err.message : "Deposit failed"; + toast.error(message); + setError(message); + setStep("SELECT"); + } + }, [anchorDomain, amount, selectedAsset]); + + return ( + + + {step === "SELECT" && ( + +

+ Deposit fiat via a Stellar anchor (SEP-0024). Funds arrive as tokens directly + in your connected wallet. +

+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ {SUPPORTED_ASSETS.map((asset) => ( + + ))} +
+
+ +
+ + setAmount(e.target.value)} + disabled={isBusy} + className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-mint disabled:cursor-not-allowed disabled:opacity-50" + placeholder={`e.g. 100 ${selectedAsset.code}`} + /> +
+ +
+ + setAnchorDomain(e.target.value)} + disabled={isBusy} + className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-mint disabled:cursor-not-allowed disabled:opacity-50" + placeholder="e.g. testanchor.stellar.org" + /> +
+ + +
+ )} + + {(step === "CONNECTING" || step === "AUTH" || step === "GENERATING") && ( + +
+
+
+ +
+
+

{STEP_MESSAGE[step]}

+

+ {step === "AUTH" + ? `Please sign the challenge transaction in your wallet to securely connect to ${anchorDomain}.` + : "This should only take a moment."} +

+ + )} + + {step === "INTERACTIVE" && interactiveUrl && ( + + {iframeLoading && ( +
+ + + +
+ )} +