From 96dc82d38f067ce3291d0b6dd7bdd21a254bf590 Mon Sep 17 00:00:00 2001 From: Marvell69 Date: Fri, 25 Sep 2026 15:13:07 +0100 Subject: [PATCH] frontend features --- backend/src/controllers/webhook.controller.ts | 43 +++++++ backend/src/routes/v1/webhook.routes.ts | 4 + backend/src/services/webhook.service.ts | 49 +++++++- .../src/__tests__/batch-claim-drawer.test.tsx | 10 ++ frontend/src/app/layout.tsx | 11 +- frontend/src/app/settings/webhooks/page.tsx | 17 +++ frontend/src/components/Navbar.tsx | 3 + frontend/src/components/NetworkSelector.tsx | 9 ++ .../components/dashboard/BatchClaimDrawer.tsx | 21 ++++ .../dashboard/CashflowProjectionChart.tsx | 12 ++ .../dashboard/DashboardIncoming.tsx | 8 ++ .../components/dashboard/dashboard-view.tsx | 3 + .../wallet/WalletMismatchBanner.tsx | 12 ++ .../webhooks/WebhookDeliveryModal.tsx | 13 ++ .../src/components/webhooks/WebhookModal.tsx | 42 +++++++ .../src/components/webhooks/WebhookTable.tsx | 10 ++ frontend/src/context/NetworkContext.tsx | 18 +++ frontend/src/lib/api/webhooks.ts | 118 ++++++++++++++++++ frontend/src/lib/soroban.ts | 46 ++++--- frontend/src/lib/stellar-config.ts | 22 ++++ .../src/utils/cashflowCalculations.test.ts | 9 ++ frontend/src/utils/cashflowCalculations.ts | 45 +++++++ 22 files changed, 505 insertions(+), 20 deletions(-) create mode 100644 frontend/src/__tests__/batch-claim-drawer.test.tsx create mode 100644 frontend/src/app/settings/webhooks/page.tsx create mode 100644 frontend/src/components/NetworkSelector.tsx create mode 100644 frontend/src/components/dashboard/BatchClaimDrawer.tsx create mode 100644 frontend/src/components/dashboard/CashflowProjectionChart.tsx create mode 100644 frontend/src/components/wallet/WalletMismatchBanner.tsx create mode 100644 frontend/src/components/webhooks/WebhookDeliveryModal.tsx create mode 100644 frontend/src/components/webhooks/WebhookModal.tsx create mode 100644 frontend/src/components/webhooks/WebhookTable.tsx create mode 100644 frontend/src/context/NetworkContext.tsx create mode 100644 frontend/src/lib/api/webhooks.ts create mode 100644 frontend/src/lib/stellar-config.ts create mode 100644 frontend/src/utils/cashflowCalculations.test.ts create mode 100644 frontend/src/utils/cashflowCalculations.ts diff --git a/backend/src/controllers/webhook.controller.ts b/backend/src/controllers/webhook.controller.ts index 7054262e..7af0d37e 100644 --- a/backend/src/controllers/webhook.controller.ts +++ b/backend/src/controllers/webhook.controller.ts @@ -88,6 +88,49 @@ export async function deleteWebhook( } } +export async function updateWebhook(req: Request, res: Response): Promise { + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { userAddress } = req.query; + if (!id || typeof userAddress !== "string") { + res.status(400).json({ error: "id and userAddress are required" }); + return; + } + const subscription = await webhookService.updateWebhookSubscription(id, userAddress, req.body); + res.json(subscription); + } catch (error: any) { + res.status(400).json({ error: error.message || "Failed to update webhook" }); + } +} + +export async function listDeliveries(req: Request, res: Response): Promise { + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { userAddress, page = "1", limit = "20" } = req.query; + if (!id || typeof userAddress !== "string") { + res.status(400).json({ error: "id and userAddress are required" }); + return; + } + res.json(await webhookService.listWebhookDeliveries(id, userAddress, Number(page), Number(limit))); + } catch (error: any) { + res.status(400).json({ error: error.message || "Failed to list webhook deliveries" }); + } +} + +export async function regenerateSecret(req: Request, res: Response): Promise { + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const { userAddress } = req.body; + if (!id || !userAddress) { + res.status(400).json({ error: "id and userAddress are required" }); + return; + } + res.json({ secretKey: await webhookService.regenerateWebhookSecret(id, userAddress) }); + } catch (error: any) { + res.status(400).json({ error: error.message || "Failed to regenerate webhook secret" }); + } +} + export async function testWebhook(req: Request, res: Response): Promise { try { const idParam = req.params.id; diff --git a/backend/src/routes/v1/webhook.routes.ts b/backend/src/routes/v1/webhook.routes.ts index 1a4b6cb3..d9d45dde 100644 --- a/backend/src/routes/v1/webhook.routes.ts +++ b/backend/src/routes/v1/webhook.routes.ts @@ -139,6 +139,10 @@ router.get("/", webhookController.listWebhooks); */ router.delete("/:id", webhookController.deleteWebhook); +router.patch("/:id", webhookController.updateWebhook); +router.post("/:id/secret", webhookController.regenerateSecret); +router.get("/:id/deliveries", webhookController.listDeliveries); + /** * @openapi * /v1/webhooks/{id}/test: diff --git a/backend/src/services/webhook.service.ts b/backend/src/services/webhook.service.ts index fe1b7443..25e467a8 100644 --- a/backend/src/services/webhook.service.ts +++ b/backend/src/services/webhook.service.ts @@ -102,7 +102,6 @@ export async function listWebhookSubscriptions( const subscriptions = await prisma.webhookSubscription.findMany({ where: { userAddress, - isActive: true, }, orderBy: { createdAt: "desc", @@ -130,6 +129,54 @@ export async function deleteWebhookSubscription( }); } +export async function updateWebhookSubscription( + id: string, + userAddress: string, + input: { targetUrl?: string; eventTypes?: string[]; isActive?: boolean }, +): Promise> { + if (input.targetUrl !== undefined && !input.targetUrl.startsWith("https://")) { + throw new Error("Webhook URL must use HTTPS"); + } + const subscription = await prisma.webhookSubscription.updateMany({ + where: { id, userAddress }, + data: input, + }); + if (subscription.count === 0) throw new Error("Webhook subscription not found"); + const updated = await prisma.webhookSubscription.findUniqueOrThrow({ where: { id } }); + const { secretKey: _secretKey, ...safe } = updated; + return safe; +} + +export async function listWebhookDeliveries( + id: string, + userAddress: string, + page: number, + limit: number, +) { + const safePage = Math.max(1, Math.floor(page) || 1); + const safeLimit = Math.min(100, Math.max(1, Math.floor(limit) || 20)); + const subscription = await prisma.webhookSubscription.findFirst({ where: { id, userAddress } }); + if (!subscription) throw new Error("Webhook subscription not found"); + const [deliveries, total] = await Promise.all([ + prisma.webhookDelivery.findMany({ + where: { subscriptionId: id }, + orderBy: { createdAt: "desc" }, + skip: (safePage - 1) * safeLimit, + take: safeLimit, + }), + prisma.webhookDelivery.count({ where: { subscriptionId: id } }), + ]); + return { deliveries, total, page: safePage, limit: safeLimit }; +} + +export async function regenerateWebhookSecret(id: string, userAddress: string): Promise { + const owned = await prisma.webhookSubscription.findFirst({ where: { id, userAddress } }); + if (!owned) throw new Error("Webhook subscription not found"); + const secretKey = crypto.randomBytes(32).toString("hex"); + await prisma.webhookSubscription.update({ where: { id }, data: { secretKey } }); + return secretKey; +} + /** * Send test webhook ping */ diff --git a/frontend/src/__tests__/batch-claim-drawer.test.tsx b/frontend/src/__tests__/batch-claim-drawer.test.tsx new file mode 100644 index 00000000..acfd660c --- /dev/null +++ b/frontend/src/__tests__/batch-claim-drawer.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +function claimable(deposited: number, withdrawn: number, ratePerSecond: number, elapsed: number, active = true) { + return active ? Math.min(Math.max(0, deposited - withdrawn), elapsed * ratePerSecond) : 0; +} + +describe("batch claim selection math", () => { + it("caps accrued balance at the deposited remainder", () => { expect(claimable(10, 4, 1, 20)).toBe(6); }); + it("excludes inactive streams", () => { expect(claimable(10, 0, 1, 20, false)).toBe(0); }); +}); \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f22db926..7b2863ca 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,6 +8,8 @@ import { Toaster } from "react-hot-toast"; import { ThemeProvider } from "@/context/theme-provider"; import { Navbar } from "@/components/Navbar"; import { QueryProvider } from "@/components/providers/query-provider"; +import { NetworkProvider } from "@/context/NetworkContext"; +import { WalletMismatchBanner } from "@/components/wallet/WalletMismatchBanner"; const sora = Sora({ variable: "--font-display", @@ -82,8 +84,10 @@ export default function RootLayout({ disableTransitionOnChange > - - + + + + {children} - + + diff --git a/frontend/src/app/settings/webhooks/page.tsx b/frontend/src/app/settings/webhooks/page.tsx new file mode 100644 index 00000000..c10d70c6 --- /dev/null +++ b/frontend/src/app/settings/webhooks/page.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useWallet } from "@/context/wallet-context"; +import { createWebhook, deleteWebhook, listWebhooks, regenerateWebhookSecret, updateWebhook, type WebhookSubscription } from "@/lib/api/webhooks"; +import { WebhookDeliveryModal } from "@/components/webhooks/WebhookDeliveryModal"; +import { WebhookModal } from "@/components/webhooks/WebhookModal"; +import { WebhookTable } from "@/components/webhooks/WebhookTable"; + +export default function WebhooksPage() { + const { session } = useWallet(); const [subscriptions, setSubscriptions] = useState([]); const [editing, setEditing] = useState(null); const [delivery, setDelivery] = useState(null); const [creating, setCreating] = useState(false); const [secretKey, setSecretKey] = useState(); + const refresh = async () => { if (!session?.publicKey) return; try { setSubscriptions(await listWebhooks(session.publicKey)); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to load webhooks"); } }; + useEffect(() => { void refresh(); }, [session?.publicKey]); + const save = async (data: { targetUrl: string; eventTypes: string[] }) => { if (!session?.publicKey) return; if (editing) await updateWebhook(editing.id, session.publicKey, data); else { const result = await createWebhook({ userAddress: session.publicKey, ...data }); setSecretKey(result.secretKey); setEditing(result.subscription); } await refresh(); }; + return

Developer tools

Webhooks

Register HTTPS receivers, manage event subscriptions, and inspect delivery attempts.

{session?.publicKey ?
{ setEditing(subscription); setSecretKey(undefined); setCreating(true); }} onToggle={async (subscription) => { await updateWebhook(subscription.id, session.publicKey, { isActive: !subscription.isActive }); await refresh(); }} onDelete={async (subscription) => { if (window.confirm("Delete this webhook endpoint?")) { await deleteWebhook(subscription.id, session.publicKey); await refresh(); } }} onDeliveries={setDelivery} />
:
Connect a wallet to manage developer webhooks.
}
{creating && session?.publicKey && { setCreating(false); setEditing(null); setSecretKey(undefined); }} onSave={async (data) => { await save(data); }} onRegenerate={editing ? async () => { const next = await regenerateWebhookSecret(editing.id, session.publicKey); setSecretKey(next); return next; } : undefined} />}{delivery && session?.publicKey && setDelivery(null)} />}
; +} \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 50efaa97..a7a93648 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -7,6 +7,7 @@ import { useWallet } from "@/context/wallet-context"; import { ModeToggle } from "./ModeToggle"; import { WalletButton } from "./wallet/WalletButton"; import { useModalDialog } from "@/hooks/useModalDialog"; +import { NetworkSelector } from "@/components/NetworkSelector"; const NAV_LINKS = [ { href: "/", label: "Home" }, @@ -50,6 +51,7 @@ export const Navbar = () => { ))} +
@@ -95,6 +97,7 @@ const MobileMenu = ({ onClose }: { onClose: () => void }) => { {link.label} ))} +
); diff --git a/frontend/src/components/NetworkSelector.tsx b/frontend/src/components/NetworkSelector.tsx new file mode 100644 index 00000000..22836698 --- /dev/null +++ b/frontend/src/components/NetworkSelector.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { Globe2 } from "lucide-react"; +import { useNetwork } from "@/context/NetworkContext"; +import type { NetworkId } from "@/lib/stellar-config"; + +const colors: Record = { testnet: "bg-orange-400", futurenet: "bg-cyan-400", mainnet: "bg-emerald-400", sandbox: "bg-purple-400" }; +const labels: Record = { testnet: "Testnet", futurenet: "Futurenet", mainnet: "Mainnet", sandbox: "Sandbox" }; +export function NetworkSelector() { const { networkId, setNetworkId } = useNetwork(); return ; } \ No newline at end of file diff --git a/frontend/src/components/dashboard/BatchClaimDrawer.tsx b/frontend/src/components/dashboard/BatchClaimDrawer.tsx new file mode 100644 index 00000000..4559260f --- /dev/null +++ b/frontend/src/components/dashboard/BatchClaimDrawer.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { X } from "lucide-react"; +import toast from "react-hot-toast"; +import type { Stream } from "@/lib/dashboard"; +import { batchWithdrawFromStreams } from "@/lib/soroban"; +import { useWallet } from "@/context/wallet-context"; + +function claimable(stream: Stream): number { + if (!stream.isActive || stream.status !== "Active") return 0; + const elapsed = Math.max(0, Date.now() / 1000 - stream.lastUpdateTime); + return Math.min(Math.max(0, stream.deposited - stream.withdrawn), elapsed * stream.ratePerSecond); +} + +export function BatchClaimDrawer({ streams, onClose, onSuccess }: { streams: Stream[]; onClose: () => void; onSuccess: () => Promise | void }) { + const { session } = useWallet(); const claimableStreams = useMemo(() => streams.map((stream) => ({ stream, amount: claimable(stream) })).filter((item) => item.amount > 0), [streams]); const [selected, setSelected] = useState(() => new Set(claimableStreams.map((item) => item.stream.id))); const [pending, setPending] = useState(false); + const selectedItems = claimableStreams.filter((item) => selected.has(item.stream.id)); const totals = selectedItems.reduce>((result, item) => { result[item.stream.token] = (result[item.stream.token] ?? 0) + item.amount; return result; }, {}); + const submit = async () => { if (!session || selectedItems.length === 0) return; setPending(true); try { await batchWithdrawFromStreams(session, { streamIds: selectedItems.map((item) => BigInt(item.stream.id.replace(/\D/g, "") || "0")) }); toast.success("Batch claim submitted"); await onSuccess(); onClose(); } catch (error) { toast.error(error instanceof Error ? error.message : "Batch claim failed"); } finally { setPending(false); } }; + return ; +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/CashflowProjectionChart.tsx b/frontend/src/components/dashboard/CashflowProjectionChart.tsx new file mode 100644 index 00000000..c859da0d --- /dev/null +++ b/frontend/src/components/dashboard/CashflowProjectionChart.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { estimateRunwayDate, getCliffDates, projectCashflow, type ProjectionStream } from "@/utils/cashflowCalculations"; + +const horizons = [7, 30, 90, 180, 365] as const; +export function CashflowProjectionChart({ streams }: { streams: ProjectionStream[] }) { + const [horizon, setHorizon] = useState(30); const [currency, setCurrency] = useState<"token" | "fiat">("token"); const [hovered, setHovered] = useState(null); + const points = useMemo(() => projectCashflow(streams, horizon), [streams, horizon]); const runway = useMemo(() => estimateRunwayDate(streams), [streams]); const cliffs = useMemo(() => getCliffDates(streams), [streams]); const max = Math.max(1, ...points.map((point) => Math.max(point.projected, point.actual))); + const path = points.map((point, index) => `${(index / Math.max(1, points.length - 1)) * 100},${100 - (point.projected / max) * 90}`).join(" L "); const area = `M 0,100 L ${path} L 100,100 Z`; + return

Cashflow projection

Actual accrual and projected balance

{horizons.map((value) => )}
setHovered(Math.round((event.nativeEvent.offsetX / event.currentTarget.clientWidth) * (points.length - 1)))}>{runway && }{cliffs.map((cliff) => )}{hovered !== null && points[hovered] &&
{points[hovered].date.toLocaleDateString()}
Rate: {points[hovered].dailyRate.toFixed(4)}
Balance: {points[hovered].balance.toFixed(4)} {currency === "token" ? "tokens" : "USD"}
}
Runway {runway ? runway.toLocaleDateString() : "not estimated"}Cliff unlocks
; +} \ No newline at end of file diff --git a/frontend/src/components/dashboard/DashboardIncoming.tsx b/frontend/src/components/dashboard/DashboardIncoming.tsx index 76e91e45..f72bd5b6 100644 --- a/frontend/src/components/dashboard/DashboardIncoming.tsx +++ b/frontend/src/components/dashboard/DashboardIncoming.tsx @@ -2,18 +2,24 @@ import IncomingStreams from "../IncomingStreams"; import type { Stream } from "@/lib/dashboard"; import { EmptyState } from "./dashboard-view"; import { InboxIcon } from "./dashboard-view"; +import { useState } from "react"; +import { BatchClaimDrawer } from "./BatchClaimDrawer"; interface DashboardIncomingProps { incomingStreams: Stream[]; onWithdraw: (stream: Stream) => Promise; withdrawingStreamId: string | null; + onBatchClaimSuccess?: () => Promise | void; } export function DashboardIncoming({ incomingStreams, onWithdraw, withdrawingStreamId, + onBatchClaimSuccess, }: DashboardIncomingProps) { + const [showBatchClaim, setShowBatchClaim] = useState(false); + const claimableCount = incomingStreams.filter((stream) => stream.isActive && stream.status === "Active" && stream.deposited > stream.withdrawn).length; if (incomingStreams.length === 0) { return ( + {claimableCount >= 2 && } + {showBatchClaim && setShowBatchClaim(false)} onSuccess={onBatchClaimSuccess ?? (() => undefined)} />} ); } \ No newline at end of file diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 5ce62f80..4005b193 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -51,6 +51,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; import { StreamDetailsModal } from "./StreamDetailsModal"; import { Button } from "../ui/Button"; +import { CashflowProjectionChart } from "./CashflowProjectionChart"; // @ts-expect-error unused var const DashboardOverviewDynamic = dynamic( @@ -752,6 +753,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
{renderStats(snapshot)} {renderAnalytics(snapshot)} + ({ ...stream, direction: "incoming" as const, token: stream.token })), ...snapshot.outgoingStreams.map((stream) => ({ ...stream, direction: "outgoing" as const, token: stream.token }))]} /> ); } diff --git a/frontend/src/components/wallet/WalletMismatchBanner.tsx b/frontend/src/components/wallet/WalletMismatchBanner.tsx new file mode 100644 index 00000000..005a2573 --- /dev/null +++ b/frontend/src/components/wallet/WalletMismatchBanner.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { useWallet } from "@/context/wallet-context"; +import { useNetwork } from "@/context/NetworkContext"; +import { formatNetwork } from "@/lib/wallet"; + +export function WalletMismatchBanner() { + const { session, connect, selectedWalletId } = useWallet(); const { network } = useNetwork(); + if (!session || formatNetwork(session.network).toLowerCase() === network.name.toLowerCase()) return null; + return
Wallet is on {formatNetwork(session.network)}; app is using {network.name}. Transactions may fail.
; +} \ No newline at end of file diff --git a/frontend/src/components/webhooks/WebhookDeliveryModal.tsx b/frontend/src/components/webhooks/WebhookDeliveryModal.tsx new file mode 100644 index 00000000..99a1efc6 --- /dev/null +++ b/frontend/src/components/webhooks/WebhookDeliveryModal.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ChevronDown, ChevronRight, X } from "lucide-react"; +import { listWebhookDeliveries, sendWebhookTest, type WebhookDelivery, type WebhookSubscription } from "@/lib/api/webhooks"; +import toast from "react-hot-toast"; + +export function WebhookDeliveryModal({ subscription, userAddress, onClose }: { subscription: WebhookSubscription; userAddress: string; onClose: () => void }) { + const [deliveries, setDeliveries] = useState([]); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [expanded, setExpanded] = useState(null); const [testing, setTesting] = useState(false); + useEffect(() => { void listWebhookDeliveries(subscription.id, userAddress, page).then((result) => { setDeliveries(result.deliveries); setTotal(result.total); }).catch((error: unknown) => toast.error(error instanceof Error ? error.message : "Failed to load delivery logs")); }, [subscription.id, userAddress, page]); + const test = async () => { setTesting(true); try { const result = await sendWebhookTest(subscription.id, userAddress); result.success ? toast.success(`Test delivered (${result.status ?? "2xx"})`) : toast.error(result.error ?? `Receiver returned ${result.status ?? 0}`); } catch (error) { toast.error(error instanceof Error ? error.message : "Test failed"); } finally { setTesting(false); } }; + return

Delivery logs

{subscription.targetUrl}

{deliveries.map((delivery) => )}
TimeEventHTTPAttemptsPayload
{new Date(delivery.createdAt).toLocaleString()}{delivery.eventType}{delivery.responseStatus ?? "ERR"}{delivery.attempts}{expanded === delivery.id &&
{delivery.payload}
}
{total} deliveries
; +} \ No newline at end of file diff --git a/frontend/src/components/webhooks/WebhookModal.tsx b/frontend/src/components/webhooks/WebhookModal.tsx new file mode 100644 index 00000000..711c9aec --- /dev/null +++ b/frontend/src/components/webhooks/WebhookModal.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy, RefreshCw, X } from "lucide-react"; +import toast from "react-hot-toast"; +import { copyToClipboard } from "@/lib/clipboard"; +import { WEBHOOK_EVENT_TYPES, type WebhookSubscription } from "@/lib/api/webhooks"; + +interface Props { + userAddress: string; + subscription?: WebhookSubscription; + secretKey?: string; + onClose: () => void; + onSave: (data: { targetUrl: string; eventTypes: string[] }) => Promise; + onRegenerate?: () => Promise; +} + +export function WebhookModal({ userAddress: _userAddress, subscription, secretKey, onClose, onSave, onRegenerate }: Props) { + const [targetUrl, setTargetUrl] = useState(subscription?.targetUrl ?? ""); + const [events, setEvents] = useState(subscription?.eventTypes ?? [...WEBHOOK_EVENT_TYPES]); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(false); + const validUrl = /^https:\/\/[^\s]+$/i.test(targetUrl); + + const submit = async () => { + if (!validUrl || events.length === 0) return; + setSaving(true); + try { await onSave({ targetUrl, eventTypes: events }); } finally { setSaving(false); } + }; + + return
+
+

{subscription ? "Edit webhook" : "Register webhook"}

+ + setTargetUrl(event.target.value)} placeholder="https://api.example.com/flowfi" className="mt-2 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 outline-none focus:border-emerald-400" /> + {targetUrl && !validUrl &&

Enter a valid HTTPS URL.

} +
Events
{WEBHOOK_EVENT_TYPES.map((event) => )}
+ {secretKey &&

Copy this signing secret now. It will not be shown again.

{secretKey}{onRegenerate && }
} +
+
+
; +} \ No newline at end of file diff --git a/frontend/src/components/webhooks/WebhookTable.tsx b/frontend/src/components/webhooks/WebhookTable.tsx new file mode 100644 index 00000000..56f60e53 --- /dev/null +++ b/frontend/src/components/webhooks/WebhookTable.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { Activity, Pencil, Power, Trash2 } from "lucide-react"; +import type { WebhookSubscription } from "@/lib/api/webhooks"; + +interface Props { subscriptions: WebhookSubscription[]; onEdit: (subscription: WebhookSubscription) => void; onToggle: (subscription: WebhookSubscription) => void; onDelete: (subscription: WebhookSubscription) => void; onDeliveries: (subscription: WebhookSubscription) => void; } + +export function WebhookTable({ subscriptions, onEdit, onToggle, onDelete, onDeliveries }: Props) { + return
{subscriptions.map((subscription) => )}
EndpointEventsCreatedStatusActions
{subscription.targetUrl}
{subscription.id}
{subscription.eventTypes.map((event) => {event})}
{new Date(subscription.createdAt).toLocaleDateString()}{subscription.isActive ? "Active" : "Inactive"}
{subscriptions.length === 0 &&

No webhook endpoints registered yet.

}
; +} \ No newline at end of file diff --git a/frontend/src/context/NetworkContext.tsx b/frontend/src/context/NetworkContext.tsx new file mode 100644 index 00000000..599db7b4 --- /dev/null +++ b/frontend/src/context/NetworkContext.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { getNetworkConfig, NETWORK_CONFIGS, type NetworkConfig, type NetworkId } from "@/lib/stellar-config"; + +const STORAGE_KEY = "flowfi.network"; +interface NetworkContextValue { network: NetworkConfig; networkId: NetworkId; setNetworkId: (id: NetworkId) => void; isHydrated: boolean; } +const NetworkContext = createContext(undefined); + +export function NetworkProvider({ children }: { children: React.ReactNode }) { + const [networkId, setNetworkId] = useState("testnet"); const [isHydrated, setHydrated] = useState(false); + useEffect(() => { const stored = window.localStorage.getItem(STORAGE_KEY) as NetworkId | null; if (stored && stored in NETWORK_CONFIGS) setNetworkId(stored); setHydrated(true); }, []); + const setPersistedNetwork = (id: NetworkId) => { setNetworkId(id); window.localStorage.setItem(STORAGE_KEY, id); }; + const value = useMemo(() => ({ network: getNetworkConfig(networkId), networkId, setNetworkId: setPersistedNetwork, isHydrated }), [networkId, isHydrated]); + return {children}; +} + +export function useNetwork(): NetworkContextValue { const context = useContext(NetworkContext); if (!context) throw new Error("useNetwork must be used within NetworkProvider"); return context; } \ No newline at end of file diff --git a/frontend/src/lib/api/webhooks.ts b/frontend/src/lib/api/webhooks.ts new file mode 100644 index 00000000..52988ca1 --- /dev/null +++ b/frontend/src/lib/api/webhooks.ts @@ -0,0 +1,118 @@ +import { fetchWithTimeout, getApiBaseUrl } from "@/lib/api/_shared"; + +export const WEBHOOK_EVENT_TYPES = [ + "STREAM_CREATED", + "TOKENS_WITHDRAWN", + "STREAM_CANCELLED", + "STREAM_PAUSED", + "STREAM_TOPPED_UP", +] as const; + +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +export interface WebhookSubscription { + id: string; + userAddress: string; + targetUrl: string; + eventTypes: string[]; + isActive: boolean; + createdAt: string; + updatedAt?: string; +} + +export interface WebhookDelivery { + id: string; + subscriptionId: string; + eventType: string; + payload: string; + responseStatus: number | null; + responseBody?: string | null; + attempts: number; + deliveredAt: string | null; + error: string | null; + createdAt: string; + latencyMs?: number | null; +} + +export interface WebhookTestResult { + success: boolean; + status?: number; + error?: string; +} + +function endpoint(path = ""): string { + const base = getApiBaseUrl(); + return `${base}${base.endsWith("/v1") ? "" : "/v1"}/webhooks${path}`; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetchWithTimeout(endpoint(path), { + headers: { "Content-Type": "application/json", ...init?.headers }, + ...init, + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(body?.error ?? `Webhook request failed (${response.status})`); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +export async function listWebhooks(userAddress: string): Promise { + const result = await request<{ subscriptions: WebhookSubscription[] }>( + `?userAddress=${encodeURIComponent(userAddress)}`, + ); + return result.subscriptions; +} + +export async function createWebhook(input: { + userAddress: string; + targetUrl: string; + eventTypes: string[]; +}): Promise<{ subscription: WebhookSubscription; secretKey: string }> { + return request("", { method: "POST", body: JSON.stringify(input) }); +} + +export async function updateWebhook( + id: string, + userAddress: string, + input: { targetUrl?: string; eventTypes?: string[]; isActive?: boolean }, +): Promise { + return request(`/${id}?userAddress=${encodeURIComponent(userAddress)}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export async function deleteWebhook(id: string, userAddress: string): Promise { + await request(`/${id}?userAddress=${encodeURIComponent(userAddress)}`, { + method: "DELETE", + }); +} + +export async function listWebhookDeliveries( + id: string, + userAddress: string, + page = 1, + limit = 20, +): Promise<{ deliveries: WebhookDelivery[]; total: number; page: number; limit: number }> { + return request( + `/${id}/deliveries?userAddress=${encodeURIComponent(userAddress)}&page=${page}&limit=${limit}`, + ); +} + +export async function sendWebhookTest(id: string, userAddress: string): Promise { + const result = await request<{ result: WebhookTestResult }>(`/${id}/test`, { + method: "POST", + body: JSON.stringify({ userAddress }), + }); + return result.result; +} + +export async function regenerateWebhookSecret(id: string, userAddress: string): Promise { + const result = await request<{ secretKey: string }>(`/${id}/secret`, { + method: "POST", + body: JSON.stringify({ userAddress }), + }); + return result.secretKey; +} \ No newline at end of file diff --git a/frontend/src/lib/soroban.ts b/frontend/src/lib/soroban.ts index 3f5f5275..06c6ed84 100644 --- a/frontend/src/lib/soroban.ts +++ b/frontend/src/lib/soroban.ts @@ -1,13 +1,11 @@ import type { WalletSession } from "@/lib/wallet"; +import { getNetworkConfig, type NetworkId } from "@/lib/stellar-config"; -const CONTRACT_ID = - process.env.NEXT_PUBLIC_STREAM_CONTRACT_ID ?? "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"; - -const SOROBAN_RPC_URL = - process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; - -const NETWORK_PASSPHRASE = - process.env.NEXT_PUBLIC_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; +function activeNetworkConfig() { + const stored = typeof window === "undefined" ? null : window.localStorage.getItem("flowfi.network"); + const id: NetworkId = stored === "mainnet" || stored === "futurenet" || stored === "sandbox" ? stored : "testnet"; + return getNetworkConfig(id); +} export interface CreateStreamParams { recipient: string; @@ -29,6 +27,10 @@ export interface WithdrawParams { streamId: bigint; } +export interface BatchWithdrawParams { + streamIds: bigint[]; +} + export interface PauseParams { streamId: bigint; } @@ -149,13 +151,14 @@ export async function fetchTokenBalance( const rpc: any = sdk.rpc ?? sdk.SorobanRpc; const tokenAddress = getTokenAddress(tokenSymbol); - const server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false }); + const config = activeNetworkConfig(); + const server = new rpc.Server(config.rpcUrl, { allowHttp: config.id === "sandbox" }); const account = await server.getAccount(publicKey); const tokenContract = new Contract(tokenAddress); const tx = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase: config.passphrase, }) .addOperation(tokenContract.call("balance", new Address(publicKey).toScVal())) .setTimeout(30) @@ -217,13 +220,14 @@ async function freighterCall( const { signTransaction } = await import("@stellar/freighter-api"); - const server = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false }); + const config = activeNetworkConfig(); + const server = new rpc.Server(config.rpcUrl, { allowHttp: config.id === "sandbox" }); const account = await server.getAccount(publicKey); - const contract = new Contract(CONTRACT_ID); + const contract = new Contract(config.contractId); const tx = new TransactionBuilder(account, { fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, + networkPassphrase: config.passphrase, }) .addOperation(contract.call(method, ...args)) .setTimeout(30) @@ -232,7 +236,7 @@ async function freighterCall( const simResult = await server.simulateTransaction(tx); if (rpc.Api?.isSimulationError?.(simResult) ?? simResult?.error) { if (isContractNotFoundError(simResult)) { - throw contractNotFoundError(CONTRACT_ID); + throw contractNotFoundError(config.contractId); } throw new SorobanCallError(`Simulation failed: ${simResult.error}`, "NetworkError"); } @@ -241,7 +245,7 @@ async function freighterCall( const { signedTxXdr, error: signError } = await signTransaction( preparedTx.toXDR(), - { networkPassphrase: NETWORK_PASSPHRASE }, + { networkPassphrase: config.passphrase }, ); if (signError) { @@ -252,7 +256,7 @@ async function freighterCall( throw new SorobanCallError(msg, "Unknown"); } - const signedTx = TransactionBuilder.fromXDR(signedTxXdr, NETWORK_PASSPHRASE); + const signedTx = TransactionBuilder.fromXDR(signedTxXdr, config.passphrase); const sendResult = await server.sendTransaction(signedTx); if (sendResult.status === "ERROR") { @@ -339,6 +343,16 @@ export async function withdrawFromStream( ]); } +export async function batchWithdrawFromStreams( + session: WalletSession, + params: BatchWithdrawParams, +): Promise { + const { nativeToScVal } = await import("@stellar/stellar-sdk"); + return freighterCall(session.publicKey, "batch_withdraw", [ + nativeToScVal(params.streamIds, { type: "vec" }), + ]); +} + export async function pauseStream( session: WalletSession, params: PauseParams, diff --git a/frontend/src/lib/stellar-config.ts b/frontend/src/lib/stellar-config.ts new file mode 100644 index 00000000..d299a41e --- /dev/null +++ b/frontend/src/lib/stellar-config.ts @@ -0,0 +1,22 @@ +export type NetworkId = "testnet" | "futurenet" | "mainnet" | "sandbox"; + +export interface NetworkConfig { + id: NetworkId; + name: string; + passphrase: string; + horizonUrl: string; + rpcUrl: string; + contractId: string; + explorerUrl: string; +} + +const fallbackContract = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4"; + +export const NETWORK_CONFIGS: Record = { + testnet: { id: "testnet", name: "Testnet", passphrase: "Test SDF Network ; September 2015", horizonUrl: "https://horizon-testnet.stellar.org", rpcUrl: "https://soroban-testnet.stellar.org", contractId: process.env.NEXT_PUBLIC_STREAM_CONTRACT_ID ?? fallbackContract, explorerUrl: "https://stellar.expert/explorer/testnet" }, + futurenet: { id: "futurenet", name: "Futurenet", passphrase: "Test SDF Future Network ; October 2022", horizonUrl: "https://horizon-futurenet.stellar.org", rpcUrl: "https://rpc-futurenet.stellar.org", contractId: process.env.NEXT_PUBLIC_FUTURENET_STREAM_CONTRACT_ID ?? fallbackContract, explorerUrl: "https://stellar.expert/explorer/futurenet" }, + mainnet: { id: "mainnet", name: "Mainnet", passphrase: "Public Global Stellar Network ; September 2015", horizonUrl: "https://horizon.stellar.org", rpcUrl: "https://soroban-rpc.mainnet.stellar.gateway.fm", contractId: process.env.NEXT_PUBLIC_MAINNET_STREAM_CONTRACT_ID ?? fallbackContract, explorerUrl: "https://stellar.expert/explorer/public" }, + sandbox: { id: "sandbox", name: "Local Sandbox", passphrase: "Standalone Network ; February 2017", horizonUrl: process.env.NEXT_PUBLIC_SANDBOX_HORIZON_URL ?? "http://localhost:8000", rpcUrl: process.env.NEXT_PUBLIC_SANDBOX_RPC_URL ?? "http://localhost:8000/soroban/rpc", contractId: process.env.NEXT_PUBLIC_SANDBOX_STREAM_CONTRACT_ID ?? fallbackContract, explorerUrl: "http://localhost:8000" }, +}; + +export function getNetworkConfig(id: NetworkId): NetworkConfig { return NETWORK_CONFIGS[id]; } \ No newline at end of file diff --git a/frontend/src/utils/cashflowCalculations.test.ts b/frontend/src/utils/cashflowCalculations.test.ts new file mode 100644 index 00000000..bd55e506 --- /dev/null +++ b/frontend/src/utils/cashflowCalculations.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { estimateRunwayDate, projectCashflow } from "./cashflowCalculations"; + +describe("cashflow calculations", () => { + const now = new Date("2026-01-01T00:00:00Z"); + it("does not accrue paused streams", () => { const result = projectCashflow([{ id: "1", direction: "incoming", token: "USDC", deposited: 100, withdrawn: 0, ratePerSecond: 1, isActive: true, isPaused: true }], 1, now); expect(result[1]?.projected).toBe(0); }); + it("projects active flow in daily intervals", () => { const result = projectCashflow([{ id: "1", direction: "incoming", token: "USDC", deposited: 1000, withdrawn: 0, ratePerSecond: 0.001, isActive: true }], 1, now); expect(result[1]?.projected).toBeCloseTo(86.4); }); + it("estimates outgoing runway from remaining balance", () => { expect(estimateRunwayDate([{ id: "1", direction: "outgoing", token: "USDC", deposited: 100, withdrawn: 0, ratePerSecond: 1 / 86400, isActive: true }], now)?.toISOString()).toBe("2026-04-11T00:00:00.000Z"); }); +}); \ No newline at end of file diff --git a/frontend/src/utils/cashflowCalculations.ts b/frontend/src/utils/cashflowCalculations.ts new file mode 100644 index 00000000..9980b6cf --- /dev/null +++ b/frontend/src/utils/cashflowCalculations.ts @@ -0,0 +1,45 @@ +export type StreamDirection = "incoming" | "outgoing"; + +export interface ProjectionStream { + id: string; + direction: StreamDirection; + token: string; + deposited: number; + withdrawn: number; + ratePerSecond: number; + isActive: boolean; + isPaused?: boolean; + startTime?: number; + cliffDates?: number[]; +} + +export interface CashflowPoint { date: Date; actual: number; projected: number; balance: number; dailyRate: number; } + +export function projectCashflow(streams: ProjectionStream[], horizonDays: number, now = new Date()): CashflowPoint[] { + const start = new Date(now); start.setHours(0, 0, 0, 0); + let balance = streams.filter((stream) => stream.direction === "outgoing").reduce((sum, stream) => sum + Math.max(0, stream.deposited - stream.withdrawn), 0); + let cumulative = streams.filter((stream) => stream.direction === "incoming").reduce((sum, stream) => sum + stream.withdrawn, 0); + return Array.from({ length: horizonDays + 1 }, (_, index) => { + const date = new Date(start); date.setDate(start.getDate() + index); + const day = streams.reduce((total, stream) => { + if (!stream.isActive || stream.isPaused || (stream.startTime && stream.startTime * 1000 > date.getTime())) return total; + const amount = stream.ratePerSecond * 86400; + return total + (stream.direction === "incoming" ? amount : -amount); + }, 0); + cumulative += Math.max(0, day); + balance = Math.max(0, balance + Math.min(0, day)); + return { date, actual: index === 0 ? cumulative : 0, projected: cumulative, balance, dailyRate: day }; + }); +} + +export function estimateRunwayDate(streams: ProjectionStream[], now = new Date()): Date | null { + const outgoing = streams.filter((stream) => stream.direction === "outgoing" && stream.isActive && !stream.isPaused); + const balance = outgoing.reduce((sum, stream) => sum + Math.max(0, stream.deposited - stream.withdrawn), 0); + const rate = outgoing.reduce((sum, stream) => sum + stream.ratePerSecond * 86400, 0); + if (balance <= 0 || rate <= 0) return null; + return new Date(now.getTime() + (balance / rate) * 86400000); +} + +export function getCliffDates(streams: ProjectionStream[]): Date[] { + return streams.flatMap((stream) => (stream.cliffDates ?? []).map((timestamp) => new Date(timestamp * 1000))).sort((a, b) => a.getTime() - b.getTime()); +} \ No newline at end of file