Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions backend/src/controllers/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,49 @@ export async function deleteWebhook(
}
}

export async function updateWebhook(req: Request, res: Response): Promise<void> {
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<void> {
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<void> {
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<void> {
try {
const idParam = req.params.id;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/routes/v1/webhook.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 48 additions & 1 deletion backend/src/services/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export async function listWebhookSubscriptions(
const subscriptions = await prisma.webhookSubscription.findMany({
where: {
userAddress,
isActive: true,
},
orderBy: {
createdAt: "desc",
Expand Down Expand Up @@ -130,6 +129,54 @@ export async function deleteWebhookSubscription(
});
}

export async function updateWebhookSubscription(
id: string,
userAddress: string,
input: { targetUrl?: string; eventTypes?: string[]; isActive?: boolean },
): Promise<Omit<WebhookSubscription, "secretKey">> {
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<string> {
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
*/
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/__tests__/batch-claim-drawer.test.tsx
Original file line number Diff line number Diff line change
@@ -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); });
});
11 changes: 8 additions & 3 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -82,8 +84,10 @@ export default function RootLayout({
disableTransitionOnChange
>
<QueryProvider>
<WalletProvider>
<Navbar />
<NetworkProvider>
<WalletProvider>
<Navbar />
<WalletMismatchBanner />
<Toaster
position="top-right"
toastOptions={{
Expand All @@ -97,7 +101,8 @@ export default function RootLayout({
}}
/>
{children}
</WalletProvider>
</WalletProvider>
</NetworkProvider>
</QueryProvider>
</ThemeProvider>
</body>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/app/settings/webhooks/page.tsx
Original file line number Diff line number Diff line change
@@ -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<WebhookSubscription[]>([]); const [editing, setEditing] = useState<WebhookSubscription | null>(null); const [delivery, setDelivery] = useState<WebhookSubscription | null>(null); const [creating, setCreating] = useState(false); const [secretKey, setSecretKey] = useState<string | undefined>();
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 <main className="min-h-screen bg-slate-950 px-6 py-12 text-white"><div className="mx-auto max-w-6xl"><div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end"><div><p className="text-xs uppercase tracking-[0.25em] text-emerald-300">Developer tools</p><h1 className="mt-2 text-4xl font-semibold">Webhooks</h1><p className="mt-2 max-w-2xl text-sm text-slate-400">Register HTTPS receivers, manage event subscriptions, and inspect delivery attempts.</p></div><button onClick={() => { setEditing(null); setSecretKey(undefined); setCreating(true); }} className="rounded-lg bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950">Add endpoint</button></div>{session?.publicKey ? <div className="mt-8"><WebhookTable subscriptions={subscriptions} onEdit={(subscription) => { 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} /></div> : <div className="mt-8 rounded-2xl border border-white/10 p-10 text-center text-slate-400">Connect a wallet to manage developer webhooks.</div>}</div>{creating && session?.publicKey && <WebhookModal userAddress={session.publicKey} subscription={editing ?? undefined} secretKey={secretKey} onClose={() => { 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 && <WebhookDeliveryModal subscription={delivery} userAddress={session.publicKey} onClose={() => setDelivery(null)} />}</main>;
}
3 changes: 3 additions & 0 deletions frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -50,6 +51,7 @@ export const Navbar = () => {
</Link>
))}
<ModeToggle />
<NetworkSelector />
</div>

<div className="flex items-center gap-4">
Expand Down Expand Up @@ -95,6 +97,7 @@ const MobileMenu = ({ onClose }: { onClose: () => void }) => {
{link.label}
</Link>
))}
<NetworkSelector />
<ModeToggle />
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/NetworkSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<NetworkId, string> = { testnet: "bg-orange-400", futurenet: "bg-cyan-400", mainnet: "bg-emerald-400", sandbox: "bg-purple-400" };
const labels: Record<NetworkId, string> = { testnet: "Testnet", futurenet: "Futurenet", mainnet: "Mainnet", sandbox: "Sandbox" };
export function NetworkSelector() { const { networkId, setNetworkId } = useNetwork(); return <label className="flex items-center gap-2 text-xs text-slate-400"><Globe2 size={15} /><span className={`h-2 w-2 rounded-full ${colors[networkId]}`} /><select aria-label="Active Stellar network" value={networkId} onChange={(event) => setNetworkId(event.target.value as NetworkId)} className="bg-transparent text-xs font-semibold text-slate-200 outline-none">{(Object.keys(labels) as NetworkId[]).map((id) => <option key={id} value={id}>{labels[id]}</option>)}</select></label>; }
21 changes: 21 additions & 0 deletions frontend/src/components/dashboard/BatchClaimDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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> | 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<Record<string, number>>((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 <aside className="fixed inset-y-0 right-0 z-[55] flex w-full max-w-lg flex-col border-l border-white/10 bg-slate-950 p-6 text-white shadow-2xl" aria-label="Batch claim drawer"><div className="flex items-center justify-between"><div><p className="text-xs uppercase tracking-widest text-emerald-300">Incoming streams</p><h2 className="mt-1 text-2xl font-semibold">Batch claim</h2></div><button onClick={onClose} aria-label="Close batch claim"><X /></button></div><div className="mt-6 grid gap-2 sm:grid-cols-2">{Object.entries(totals).map(([token, amount]) => <div key={token} className="rounded-xl bg-white/5 p-4"><p className="text-xs text-slate-400">Ready to claim</p><p className="mt-1 text-xl font-semibold">{amount.toFixed(4)} {token}</p></div>)}</div><label className="mt-6 flex items-center gap-2 border-b border-white/10 pb-3 text-sm"><input type="checkbox" checked={selected.size === claimableStreams.length && claimableStreams.length > 0} onChange={(event) => setSelected(event.target.checked ? new Set(claimableStreams.map((item) => item.stream.id)) : new Set())} />Select all</label><div className="min-h-0 flex-1 overflow-y-auto">{claimableStreams.map(({ stream, amount }) => <label key={stream.id} className="flex items-center gap-3 border-b border-white/10 py-4"><input type="checkbox" checked={selected.has(stream.id)} onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(stream.id)) next.delete(stream.id); else next.add(stream.id); return next; })} /><span className="min-w-0 flex-1"><span className="block text-sm">Stream #{stream.id} · {stream.recipient}</span><span className="text-xs text-slate-400">{stream.token}</span></span><strong className="text-sm text-emerald-300">{amount.toFixed(4)}</strong></label>)}</div><div className="border-t border-white/10 pt-4"><p className="mb-3 text-sm text-slate-400">{selectedItems.length} streams selected</p><button disabled={!session || selectedItems.length === 0 || pending} onClick={() => void submit()} className="w-full rounded-lg bg-emerald-400 px-4 py-3 font-semibold text-slate-950 disabled:opacity-50">{pending ? "Confirming..." : `Claim selected (${selectedItems.length})`}</button></div></aside>;
}
12 changes: 12 additions & 0 deletions frontend/src/components/dashboard/CashflowProjectionChart.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(30); const [currency, setCurrency] = useState<"token" | "fiat">("token"); const [hovered, setHovered] = useState<number | null>(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 <section className="dashboard-panel mt-8"><div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><h3>Cashflow projection</h3><p className="text-sm text-slate-400">Actual accrual and projected balance</p></div><div className="flex gap-1">{horizons.map((value) => <button key={value} onClick={() => setHorizon(value)} className={`rounded px-2 py-1 text-xs ${horizon === value ? "bg-accent/20 text-accent" : "text-slate-400"}`}>{value === 365 ? "1Y" : `${value}D`}</button>)}<button onClick={() => setCurrency(currency === "token" ? "fiat" : "token")} className="ml-2 rounded border border-white/10 px-2 py-1 text-xs">{currency === "token" ? "Token" : "USD"}</button></div></div><div className="relative mt-6 h-64 rounded-xl bg-slate-950/40 p-4"><svg viewBox="0 0 100 100" preserveAspectRatio="none" className="h-full w-full" onMouseMove={(event) => setHovered(Math.round((event.nativeEvent.offsetX / event.currentTarget.clientWidth) * (points.length - 1)))}><path d={area} fill="currentColor" className="text-emerald-400/10" /><path d={`M ${path}`} fill="none" stroke="currentColor" strokeWidth="0.8" className="text-emerald-300" />{runway && <line x1={`${Math.min(100, Math.max(0, ((runway.getTime() - points[0]!.date.getTime()) / (points[points.length - 1]!.date.getTime() - points[0]!.date.getTime())) * 100))}`} x2={`${Math.min(100, Math.max(0, ((runway.getTime() - points[0]!.date.getTime()) / (points[points.length - 1]!.date.getTime() - points[0]!.date.getTime())) * 100))}`} y1="0" y2="100" stroke="#fb7185" strokeDasharray="3 2" />}{cliffs.map((cliff) => <line key={cliff.toISOString()} x1={`${Math.min(100, Math.max(0, ((cliff.getTime() - points[0]!.date.getTime()) / (points[points.length - 1]!.date.getTime() - points[0]!.date.getTime())) * 100))}`} x2={`${Math.min(100, Math.max(0, ((cliff.getTime() - points[0]!.date.getTime()) / (points[points.length - 1]!.date.getTime() - points[0]!.date.getTime())) * 100))}`} y1="0" y2="100" stroke="#4ade80" />)}</svg>{hovered !== null && points[hovered] && <div className="absolute right-4 top-4 rounded bg-slate-900/90 px-3 py-2 text-xs text-slate-200">{points[hovered].date.toLocaleDateString()}<br />Rate: {points[hovered].dailyRate.toFixed(4)}<br />Balance: {points[hovered].balance.toFixed(4)} {currency === "token" ? "tokens" : "USD"}</div>}</div><div className="mt-3 flex gap-5 text-xs text-slate-400"><span><i className="mr-1 inline-block h-2 w-2 rounded-full bg-rose-400" />Runway {runway ? runway.toLocaleDateString() : "not estimated"}</span><span><i className="mr-1 inline-block h-2 w-2 rounded-full bg-green-400" />Cliff unlocks</span></div></section>;
}
Loading
Loading