Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell

- **Next.js 15** (App Router) with **React 19**
- **TailwindCSS** for styling
- Operator pages for quotes, registered pairs, and per-pair fee edits
- Starter landing page; Stellar wallet integration can be added here

## Prerequisites
Expand Down
2 changes: 1 addition & 1 deletion src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const sections = [
{ h: "GET /api/v1/pairs", p: "List every registered pair. ETag caching." },
{
h: "PATCH /api/v1/pairs/:src/:dest/fee_bps",
p: "Set the per-pair routing fee in basis points (0..1000).",
p: "Set the per-pair routing fee in basis points (0..1000) with { fee_bps }.",
},
{
h: "GET /api/v1/quote",
Expand Down
132 changes: 132 additions & 0 deletions src/app/pairs/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ToastProvider } from "@/components/ToastProvider";
import PairsPage from "./page";

function renderPairsPage() {
return render(
<ToastProvider>
<PairsPage />
</ToastProvider>
);
}

function jsonResponse(body: unknown, ok = true) {
return {
ok,
json: async () => body,
} as unknown as Response;
}

describe("PairsPage", () => {
let originalFetch: typeof globalThis.fetch;

beforeEach(() => {
originalFetch = globalThis.fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it("renders pair fees and defaults missing fees to zero", async () => {
globalThis.fetch = jest.fn().mockResolvedValueOnce(
jsonResponse({
pairs: [
{ source: "USDC", destination: "EURC", fee_bps: 5 },
{ source: "XLM", destination: "USDC" },
],
})
);

renderPairsPage();

expect(await screen.findByText("Current fee: 5 bps")).toBeInTheDocument();
expect(screen.getByText("Current fee: 0 bps")).toBeInTheDocument();
expect(screen.getByLabelText("Fee bps for USDC to EURC")).toHaveValue(5);
expect(screen.getByLabelText("Fee bps for XLM to USDC")).toHaveValue(0);
});

it("saves a valid fee with encoded path segments", async () => {
const mockFetch = jest
.fn()
.mockResolvedValueOnce(
jsonResponse({
pairs: [{ source: "USD/C", destination: "EUR C", fee_bps: 5 }],
})
)
.mockResolvedValueOnce(
jsonResponse({ source: "USD/C", destination: "EUR C", fee_bps: 25 })
);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

renderPairsPage();

fireEvent.change(await screen.findByLabelText("Fee bps for USD/C to EUR C"), {
target: { value: "25" },
});
fireEvent.click(screen.getByRole("button", { name: "Save fee for USD/C to EUR C" }));

await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3001/api/v1/pairs/USD%2FC/EUR%20C/fee_bps",
expect.objectContaining({
body: JSON.stringify({ fee_bps: 25 }),
method: "PATCH",
})
);
});
expect(await screen.findByText("Current fee: 25 bps")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Saved USD/C → EUR C fee");
});

it.each(["1001", "-1", "1.5"])(
"rejects invalid fee %s before calling the backend",
async (invalidFee) => {
const mockFetch = jest.fn().mockResolvedValueOnce(
jsonResponse({
pairs: [{ source: "USDC", destination: "EURC", fee_bps: 5 }],
})
);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

renderPairsPage();

fireEvent.change(await screen.findByLabelText("Fee bps for USDC to EURC"), {
target: { value: invalidFee },
});
fireEvent.click(screen.getByRole("button", { name: "Save fee for USDC to EURC" }));

expect(await screen.findByRole("alert")).toHaveTextContent(
"Fee must be an integer between 0 and 1000 bps."
);
expect(mockFetch).toHaveBeenCalledTimes(1);
}
);

it("rolls back the current fee and draft when the backend rejects the update", async () => {
const mockFetch = jest
.fn()
.mockResolvedValueOnce(
jsonResponse({
pairs: [{ source: "USDC", destination: "EURC", fee_bps: 5 }],
})
)
.mockResolvedValueOnce(
jsonResponse(
{ error: "invalid_request", message: "fee cannot be changed" },
false
)
);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

renderPairsPage();

const feeInput = await screen.findByLabelText("Fee bps for USDC to EURC");
fireEvent.change(feeInput, { target: { value: "25" } });
fireEvent.click(screen.getByRole("button", { name: "Save fee for USDC to EURC" }));

expect(await screen.findByRole("alert")).toHaveTextContent("fee cannot be changed");
expect(screen.getByText("Current fee: 5 bps")).toBeInTheDocument();
expect(feeInput).toHaveValue(5);
});
});
138 changes: 129 additions & 9 deletions src/app/pairs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,99 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { apiGet } from "@/lib/apiClient";
import { Button } from "@/components/Button";
import { TextField } from "@/components/TextField";
import { useToast } from "@/components/ToastProvider";
import { apiGet, apiPatch } from "@/lib/apiClient";

type Pair = { source: string; destination: string };
type Pair = { source: string; destination: string; fee_bps?: number };

const MAX_FEE_BPS = 1000;
const pairKey = (pair: Pair) => `${pair.source}::${pair.destination}`;

function parseFeeBps(value: string): number | null {
if (!/^\d+$/.test(value)) return null;
const fee = Number(value);
return fee <= MAX_FEE_BPS ? fee : null;
}

export default function PairsPage() {
const toast = useToast();
const [pairs, setPairs] = useState<Pair[] | null>(null);
const [draftFees, setDraftFees] = useState<Record<string, string>>({});
const [rowErrors, setRowErrors] = useState<Record<string, string>>({});
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const loadPairs = useCallback(
() =>
apiGet<{ pairs: Pair[] }>("/api/v1/pairs").then((b) => {
setPairs(b.pairs);
setDraftFees(
Object.fromEntries(
b.pairs.map((pair) => [pairKey(pair), String(pair.fee_bps ?? 0)])
)
);
}),
[]
);

useEffect(() => {
apiGet<{ pairs: Pair[] }>("/api/v1/pairs")
.then((b) => setPairs(b.pairs))
.catch((e) => setError(e.message));
}, []);
loadPairs().catch((e) => setError(e.message));
}, [loadPairs]);

const saveFee = async (pair: Pair) => {
const key = pairKey(pair);
const fee = parseFeeBps(draftFees[key] ?? "");
if (fee === null) {
setRowErrors((current) => ({
...current,
[key]: `Fee must be an integer between 0 and ${MAX_FEE_BPS} bps.`,
}));
return;
}

const previousFee = pair.fee_bps ?? 0;
setRowErrors((current) => ({ ...current, [key]: "" }));
setSavingKey(key);
setPairs(
(current) =>
current?.map((item) =>
pairKey(item) === key ? { ...item, fee_bps: fee } : item
) ?? null
);

try {
const updated = await apiPatch<Pair>(
`/api/v1/pairs/${encodeURIComponent(pair.source)}/${encodeURIComponent(
pair.destination
)}/fee_bps`,
{ fee_bps: fee }
);
const savedFee = updated.fee_bps ?? fee;
setPairs((current) =>
current?.map((item) =>
pairKey(item) === key ? { ...item, fee_bps: savedFee } : item
) ?? null
);
setDraftFees((current) => ({ ...current, [key]: String(savedFee) }));
toast.push(`Saved ${pair.source} → ${pair.destination} fee`);
} catch (err) {
setPairs((current) =>
current?.map((item) =>
pairKey(item) === key ? { ...item, fee_bps: previousFee } : item
) ?? null
);
setDraftFees((current) => ({ ...current, [key]: String(previousFee) }));
setRowErrors((current) => ({
...current,
[key]: (err as Error).message,
}));
} finally {
setSavingKey(null);
}
};

return (
<main
Expand Down Expand Up @@ -45,8 +124,49 @@ export default function PairsPage() {
{pairs && pairs.length > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{pairs.map((p) => (
<li key={`${p.source}::${p.destination}`} className="py-3 font-mono text-sm">
{p.source} → {p.destination}
<li key={pairKey(p)} className="grid gap-3 py-4 sm:grid-cols-[1fr_auto]">
<div>
<p className="font-mono text-sm">
{p.source} → {p.destination}
</p>
<p className="mt-1 text-xs text-neutral-500">
Current fee: {p.fee_bps ?? 0} bps
</p>
</div>
<div className="flex flex-col gap-2 sm:min-w-64">
<div className="flex items-start gap-2">
<TextField
label="Fee bps"
aria-label={`Fee bps for ${p.source} to ${p.destination}`}
type="number"
inputMode="numeric"
min={0}
max={MAX_FEE_BPS}
step={1}
value={draftFees[pairKey(p)] ?? String(p.fee_bps ?? 0)}
onChange={(e) => {
const key = pairKey(p);
setDraftFees((current) => ({
...current,
[key]: e.target.value,
}));
setRowErrors((current) => ({ ...current, [key]: "" }));
}}
error={rowErrors[pairKey(p)]}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
aria-label={`Save fee for ${p.source} to ${p.destination}`}
disabled={savingKey === pairKey(p)}
onClick={() => saveFee(p)}
className="mt-6 px-4"
>
{savingKey === pairKey(p) ? "Saving…" : "Save"}
</Button>
</div>
</div>
</li>
))}
</ul>
Expand Down