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() {
+ setIsOnrampModalOpen(true)}
+ className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/5 px-5 py-3 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
+ >
+
+
+
+ Buy / Deposit
+
+
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}
+
+ )}
+
+
+
+ Select Asset
+
+
+ {SUPPORTED_ASSETS.map((asset) => (
+ setSelectedAsset(asset)}
+ disabled={isBusy}
+ aria-pressed={selectedAsset.code === asset.code}
+ className={`flex flex-col items-center gap-2 rounded-2xl border p-4 transition-all disabled:cursor-not-allowed disabled:opacity-50 ${
+ selectedAsset.code === asset.code
+ ? "border-mint bg-mint/5 ring-1 ring-mint"
+ : "border-white/10 bg-white/5 hover:border-white/20"
+ }`}
+ >
+ {asset.code}
+
+ {asset.issuer.slice(0, 8)}...
+
+
+ ))}
+
+
+
+
+
+ Amount (optional)
+
+ 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}`}
+ />
+
+
+
+
+ Anchor Domain
+
+ 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"
+ />
+
+
+
+ {isBusy ? (
+ <>
+
+ {STEP_MESSAGE[step as Exclude]}
+ >
+ ) : (
+ "Continue to Anchor"
+ )}
+
+
+ )}
+
+ {(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 && (
+
+
+
+
+
+ )}
+
+ )}
+
+
+ {step !== "INTERACTIVE" && (
+
+ Secured by Stellar Network • SEP-0024 Standard
+
+ )}
+
+ );
+}
diff --git a/frontend/src/lib/stellar.ts b/frontend/src/lib/stellar.ts
index b95d4dc7..232c31cc 100644
--- a/frontend/src/lib/stellar.ts
+++ b/frontend/src/lib/stellar.ts
@@ -234,6 +234,39 @@ export async function initiateWithdrawal(
return data.url;
}
+/**
+ * SEP-0024: Initiate a hosted deposit (fiat → Stellar token onramp) to get the interactive URL
+ */
+export async function initiateDeposit(
+ transferServer: string,
+ jwt: string,
+ assetCode: string,
+ account: string,
+ amount?: string
+): Promise {
+ const formData = new FormData();
+ formData.append("asset_code", assetCode);
+ formData.append("account", account);
+ if (amount) {
+ formData.append("amount", amount);
+ }
+
+ const res = await fetch(`${transferServer}/transactions/deposit/interactive`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: formData,
+ });
+
+ const data = await res.json();
+ if (!data.url) {
+ throw new Error("Failed to initiate deposit: No URL returned");
+ }
+
+ return data.url;
+}
+
export interface AssetBalance {
code: string;
issuer: string | null;