From 27c2ed897d9beaf98d4ed38739df54e1106f5b88 Mon Sep 17 00:00:00 2001 From: observerr411 Date: Mon, 22 Jun 2026 15:57:07 +0100 Subject: [PATCH 1/2] feat(dashboard): add Gas Sponsorship overview page at /dashboard/sponsorship Shows per-wallet sponsorship status and budget at a glance. Removes the 'soon' flag from the sidebar navigation item now that the page is live. Co-Authored-By: Claude Opus 4.8 --- .../src/app/dashboard/sponsorship/page.tsx | 159 ++++++++++++++++++ frontend/src/components/dashboard/Sidebar.tsx | 2 +- frontend/src/lib/sponsorship.ts | 27 +++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/dashboard/sponsorship/page.tsx create mode 100644 frontend/src/lib/sponsorship.ts diff --git a/frontend/src/app/dashboard/sponsorship/page.tsx b/frontend/src/app/dashboard/sponsorship/page.tsx new file mode 100644 index 0000000..4c7f95a --- /dev/null +++ b/frontend/src/app/dashboard/sponsorship/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useAuth } from "@/lib/useAuth"; +import { listWallets, type WalletView } from "@/lib/wallets"; +import { + getSponsorshipConfig, + stroopsToXlm, + type SponsorshipConfig, +} from "@/lib/sponsorship"; +import { DashboardShell } from "@/components/dashboard/DashboardShell"; + +type WalletSponsorship = { + wallet: WalletView; + config: SponsorshipConfig | null; +}; + +export default function SponsorshipPage() { + const { user, token, loading, logout } = useAuth(); + const [rows, setRows] = useState(null); + + useEffect(() => { + if (!token) return; + listWallets(token) + .then(async (wallets) => { + // Only the sponsorship config is fetched per wallet — not full wallet details. + const configs = await Promise.all( + wallets.map((w) => + getSponsorshipConfig(w.id, token).catch(() => null), + ), + ); + return wallets.map((wallet, i) => ({ wallet, config: configs[i] })); + }) + .then(setRows) + .catch(() => setRows([])); + }, [token]); + + if (loading || !user) { + return ( +
+ Loading… +
+ ); + } + + return ( + +
+ {/* How it works */} +
+

How it works

+

+ Gas sponsorship lets your wallet pay the Stellar network fees on + behalf of your users, so they can transact without holding XLM for + fees. Octo fee-bumps each eligible transaction up to the per-transaction + cap and daily budget you configure per wallet. Enable it on a wallet + and set spend controls to keep costs predictable. +

+ + Read the gas sponsorship docs → + +
+ + {/* Per-wallet cards */} + {rows === null ? ( +

Loading wallets…

+ ) : rows.length === 0 ? ( + + ) : ( +
+ {rows.map(({ wallet, config }) => ( + + ))} +
+ )} +
+
+ ); +} + +function WalletCard({ + wallet, + config, +}: { + wallet: WalletView; + config: SponsorshipConfig | null; +}) { + const enabled = config?.enabled ?? false; + + return ( +
+
+
+

+ {wallet.label ?? "Master wallet"} +

+

{wallet.network}

+
+ + {enabled ? "Enabled" : "Disabled"} + +
+ +
+
+
Max fee / tx
+
+ {config?.per_tx_fee_cap_stroops != null + ? stroopsToXlm(config.per_tx_fee_cap_stroops) + : "—"} +
+
+
+
Daily budget
+
+ {config?.daily_budget_stroops != null + ? stroopsToXlm(config.daily_budget_stroops) + : "—"} +
+
+
+ + + Sponsorship settings → + +
+ ); +} + +function EmptyState() { + return ( +
+

No wallets yet

+

+ Create a master wallet first, then enable gas sponsorship on it to + start covering network fees for your users. +

+ + Create master wallet + +
+ ); +} diff --git a/frontend/src/components/dashboard/Sidebar.tsx b/frontend/src/components/dashboard/Sidebar.tsx index ac560c3..649d029 100644 --- a/frontend/src/components/dashboard/Sidebar.tsx +++ b/frontend/src/components/dashboard/Sidebar.tsx @@ -6,7 +6,7 @@ import { Logo } from "@/components/Logo"; const NAV: { label: string; href: string; icon: string; soon?: boolean }[] = [ { label: "Home", href: "/dashboard", icon: "⌂" }, - { label: "Gas Sponsorship", href: "/dashboard/sponsorship", icon: "⛽", soon: true }, + { label: "Gas Sponsorship", href: "/dashboard/sponsorship", icon: "⛽" }, { label: "Asset Recovery", href: "/dashboard/recovery", icon: "↺" }, { label: "Developers", href: "/dashboard/developers", icon: "›_" }, { label: "Audit Logs", href: "/dashboard/audit", icon: "▤" }, diff --git a/frontend/src/lib/sponsorship.ts b/frontend/src/lib/sponsorship.ts new file mode 100644 index 0000000..d5ef161 --- /dev/null +++ b/frontend/src/lib/sponsorship.ts @@ -0,0 +1,27 @@ +/** Gas sponsorship config API calls + types, mirroring the octo backend. */ + +"use client"; + +import { apiFetch } from "./api"; + +export type SponsorshipConfig = { + enabled: boolean; + per_tx_fee_cap_stroops: number | null; + daily_budget_stroops: number | null; + spent_today_stroops: number; +}; + +/** Fetch the gas sponsorship config for a single wallet. */ +export function getSponsorshipConfig(walletId: string, token: string) { + return apiFetch(`/v1/wallets/${walletId}/sponsorship`, { + token, + }); +} + +/** + * Format integer stroops as a human-readable XLM string (2 dp). + * Raw stroop values are for the API only — never expose them to end users. + */ +export function stroopsToXlm(stroops: number): string { + return `${(stroops / 10_000_000).toFixed(2)} XLM`; +} From c7640b99c56638eda215fecf4b757f984179e2a2 Mon Sep 17 00:00:00 2001 From: observerr411 Date: Mon, 22 Jun 2026 16:05:21 +0100 Subject: [PATCH 2/2] feat(dashboard): add per-wallet sponsorship settings panel Allows enabling/disabling gas sponsorship and configuring fee cap and daily budget per wallet. Shows today's spend against the daily limit. Adds updateSponsorshipConfig() PUT helper to lib/sponsorship.ts, surfaces the config's spent_today_stroops for the remaining-budget indicator, and links the panel from the wallet-level sidebar navigation. Client-side validation enforces both values > 0 and max fee <= daily budget; save is disabled while the request is in-flight. The toggle uses role="switch" with aria-checked. Co-Authored-By: Claude Opus 4.8 --- .../wallets/[id]/sponsorship/page.tsx | 261 ++++++++++++++++++ .../components/dashboard/WalletSidebar.tsx | 1 + frontend/src/lib/sponsorship.ts | 20 ++ 3 files changed, 282 insertions(+) create mode 100644 frontend/src/app/dashboard/wallets/[id]/sponsorship/page.tsx diff --git a/frontend/src/app/dashboard/wallets/[id]/sponsorship/page.tsx b/frontend/src/app/dashboard/wallets/[id]/sponsorship/page.tsx new file mode 100644 index 0000000..61a1854 --- /dev/null +++ b/frontend/src/app/dashboard/wallets/[id]/sponsorship/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import Link from "next/link"; +import { useAuth } from "@/lib/useAuth"; +import { getWallet, stroopsToAmount, amountToStroops, type WalletView } from "@/lib/wallets"; +import { + getSponsorshipConfig, + updateSponsorshipConfig, + type SponsorshipConfig, +} from "@/lib/sponsorship"; +import { WalletSidebar } from "@/components/dashboard/WalletSidebar"; +import { ApiError } from "@/lib/api"; + +export default function SponsorshipSettingsPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const { user, token, loading, logout } = useAuth(); + + const [wallet, setWallet] = useState(null); + const [config, setConfig] = useState(null); + + // form state (XLM strings, converted to stroops only at the API boundary) + const [enabled, setEnabled] = useState(false); + const [maxFee, setMaxFee] = useState(""); + const [dailyBudget, setDailyBudget] = useState(""); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [toast, setToast] = useState(null); + + useEffect(() => { + if (!token) return; + getWallet(token, id).then(setWallet).catch(() => {}); + getSponsorshipConfig(id, token) + .then((c) => { + setConfig(c); + setEnabled(c.enabled); + setMaxFee( + c.per_tx_fee_cap_stroops != null + ? stroopsToAmount(c.per_tx_fee_cap_stroops) + : "", + ); + setDailyBudget( + c.daily_budget_stroops != null + ? stroopsToAmount(c.daily_budget_stroops) + : "", + ); + }) + .catch(() => {}); + }, [token, id]); + + async function onSave() { + if (!token) return; + setError(null); + + const feeStroops = amountToStroops(maxFee); + const budgetStroops = amountToStroops(dailyBudget); + + if (feeStroops === null || budgetStroops === null) { + setError("Enter a max fee and daily budget greater than 0 XLM."); + return; + } + if (feeStroops > budgetStroops) { + setError("Max fee per transaction cannot exceed the daily budget."); + return; + } + + setSaving(true); + try { + const updated = await updateSponsorshipConfig(id, token, { + enabled, + per_tx_fee_cap_stroops: feeStroops, + daily_budget_stroops: budgetStroops, + }); + setConfig(updated); + setToast("Sponsorship settings saved."); + setTimeout(() => setToast(null), 3000); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "Failed to save settings.", + ); + } finally { + setSaving(false); + } + } + + if (loading || !user) { + return ( +
+ Loading… +
+ ); + } + + const spentToday = config?.spent_today_stroops ?? 0; + const budgetStroops = config?.daily_budget_stroops ?? 0; + const remaining = Math.max(0, budgetStroops - spentToday); + const pct = + budgetStroops > 0 + ? Math.min(100, Math.round((spentToday / budgetStroops) * 100)) + : 0; + + return ( +
+
+ You are currently on test mode (Stellar testnet). +
+
+ + +
+
+
+ + My Wallets + + + Sponsorship +
+ +
+ +
+
+
+

+ Gas Sponsorship +

+

+ Pay Stellar network fees on behalf of this wallet's users. + Set spend controls to keep costs predictable. +

+
+ + {/* Today's spend */} +
+
+ Today's spend + + {stroopsToAmount(spentToday)} XLM spent of{" "} + {stroopsToAmount(budgetStroops)} XLM daily budget + +
+
+
+
+

+ {stroopsToAmount(remaining)} XLM remaining today +

+
+ + {/* Settings form */} +
+ {/* toggle */} +
+
+

+ Enable gas sponsorship +

+

+ Octo fee-bumps eligible transactions for this wallet. +

+
+ +
+ + {/* max fee */} +
+ + setMaxFee(e.target.value)} + inputMode="decimal" + placeholder="0.0000000" + className="mt-1.5 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-foreground placeholder:text-muted/50 focus:border-burgundy-bright focus:outline-none" + /> +

+ Maximum fee the master wallet will pay per sponsored + transaction. +

+
+ + {/* daily budget */} +
+ + setDailyBudget(e.target.value)} + inputMode="decimal" + placeholder="0.0000000" + className="mt-1.5 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-foreground placeholder:text-muted/50 focus:border-burgundy-bright focus:outline-none" + /> +

+ {stroopsToAmount(remaining)} XLM remaining of today's + budget. +

+
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+
+
+ + {toast && ( +
+ ✓ {toast} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/WalletSidebar.tsx b/frontend/src/components/dashboard/WalletSidebar.tsx index aa5909f..26f5d7d 100644 --- a/frontend/src/components/dashboard/WalletSidebar.tsx +++ b/frontend/src/components/dashboard/WalletSidebar.tsx @@ -20,6 +20,7 @@ export function WalletSidebar({ { label: "Transactions", href: `${base}/transactions`, icon: "◷" }, { label: "Addresses", href: `${base}/addresses`, icon: "▢" }, { label: "Beneficiaries", href: `${base}/beneficiaries`, icon: "⚇" }, + { label: "Sponsorship", href: `${base}/sponsorship`, icon: "⛽" }, { label: "Developers", href: `${base}/api`, icon: "›_" }, ]; diff --git a/frontend/src/lib/sponsorship.ts b/frontend/src/lib/sponsorship.ts index d5ef161..754037b 100644 --- a/frontend/src/lib/sponsorship.ts +++ b/frontend/src/lib/sponsorship.ts @@ -8,9 +8,16 @@ export type SponsorshipConfig = { enabled: boolean; per_tx_fee_cap_stroops: number | null; daily_budget_stroops: number | null; + /** Fees already reserved/spent today, used to show remaining budget. */ spent_today_stroops: number; }; +export type SponsorshipConfigPayload = { + enabled: boolean; + per_tx_fee_cap_stroops: number | null; + daily_budget_stroops: number | null; +}; + /** Fetch the gas sponsorship config for a single wallet. */ export function getSponsorshipConfig(walletId: string, token: string) { return apiFetch(`/v1/wallets/${walletId}/sponsorship`, { @@ -18,6 +25,19 @@ export function getSponsorshipConfig(walletId: string, token: string) { }); } +/** Update (upsert) the gas sponsorship config for a wallet. */ +export function updateSponsorshipConfig( + walletId: string, + token: string, + payload: SponsorshipConfigPayload, +) { + return apiFetch(`/v1/wallets/${walletId}/sponsorship`, { + method: "PUT", + token, + body: JSON.stringify(payload), + }); +} + /** * Format integer stroops as a human-readable XLM string (2 dp). * Raw stroop values are for the API only — never expose them to end users.