Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ The frontend communicates with the StableRoute API backend.
- **`/api/v1/events`**: Retrieves system event audit logs (`GET`).
- **`/api/v1/webhooks`**: Creates (`POST`), lists (`GET`), and revokes (`DELETE` at `/api/v1/webhooks/:id`) webhook subscriptions.

### Quote Amount Display

The quote API accepts and returns amounts in base units. The `/quote` page keeps
the raw API values intact for requests and operator inspection, while rendering
safe integer quote amounts through the shared `formatStroops` helper and numeric
rates through `formatNumber`. If a backend value cannot be parsed safely, the UI
falls back to the raw string instead of rounding or coercing it.

### Asset Codes

Stellar asset codes entered through the new-pair form are trimmed, validated as
Expand Down
44 changes: 40 additions & 4 deletions src/app/quote/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useState } from "react";
import type { ApiError } from "@/lib/apiClient";
import { formatNumber, formatStroops } from "@/lib/format";
import { assetsDiffer, isValidAmount } from "@/lib/quote";

type Quote = {
source_asset: string;
Expand All @@ -23,6 +25,24 @@ function normalizeAssetCode(value: string): string | null {
return ASSET_CODE_PATTERN.test(trimmed) ? trimmed : null;
}

function parseSafeInteger(value: string): number | null {
if (!/^[0-9]+$/.test(value)) {
return null;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}

function formatQuoteAmount(amount: string): string {
const parsed = parseSafeInteger(amount);
return parsed === null ? amount : formatStroops(parsed);
}

function formatQuoteRate(rate: string): string {
const parsed = Number(rate);
return Number.isFinite(parsed) ? formatNumber(parsed) : rate;
}

export default function QuoteClient() {
const [sourceAsset, setSourceAsset] = useState("");
const [destAsset, setDestAsset] = useState("");
Expand All @@ -38,11 +58,19 @@ export default function QuoteClient() {
setRequestId(null);
setQuote(null);

if (!assetsDiffer(sourceAsset, destAsset)) {
const normalizedSourceAsset = normalizeAssetCode(sourceAsset);
const normalizedDestAsset = normalizeAssetCode(destAsset);
const normalizedAmount = amount.trim();

if (!normalizedSourceAsset || !normalizedDestAsset) {
setError("Asset codes must be 1-12 letters or numbers.");
return;
}
if (!assetsDiffer(normalizedSourceAsset, normalizedDestAsset)) {
setError("Source and destination assets must differ.");
return;
}
if (!isValidAmount(amount)) {
if (!isValidAmount(normalizedAmount)) {
setError("Amount must be a positive integer (base units).");
return;
}
Expand Down Expand Up @@ -133,8 +161,16 @@ export default function QuoteClient() {
className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm dark:border-emerald-900 dark:bg-emerald-950"
>
<p className="font-medium">Route: {quote.route.join(" → ")}</p>
<p>Amount: {quote.amount}</p>
<p>Estimated rate: {quote.estimated_rate}</p>
<p>
Amount:{" "}
<span title={quote.amount}>{formatQuoteAmount(quote.amount)}</span>
</p>
<p>
Estimated rate:{" "}
<span title={quote.estimated_rate}>
{formatQuoteRate(quote.estimated_rate)}
</span>
</p>
</section>
)}
{error && (
Expand Down
75 changes: 75 additions & 0 deletions src/app/quote/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,81 @@ describe("QuotePage", () => {
);
});

it("formats quote amount and estimated rate while preserving raw values", async () => {
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
source_asset: "USDC",
dest_asset: "EURC",
amount: "100000000",
estimated_rate: "1234.5",
route: ["USDC", "EURC"],
}),
} as unknown as Response);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

render(<QuotePage />);
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
target: { value: "EURC" },
});
fireEvent.change(screen.getByLabelText(/Amount/i), {
target: { value: "100000000" },
});
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent("Amount: 10.00 XLM");
});
expect(screen.getByRole("status")).toHaveTextContent(
"Estimated rate: 1,234.5",
);
expect(screen.getByTitle("100000000")).toHaveTextContent("10.00 XLM");
expect(screen.getByTitle("1234.5")).toHaveTextContent("1,234.5");
});

it("falls back to raw quote values when formatting is unsafe", async () => {
const unsafeAmount = "9007199254740993";
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
source_asset: "USDC",
dest_asset: "EURC",
amount: unsafeAmount,
estimated_rate: "rate unavailable",
route: ["USDC", "EURC"],
}),
} as unknown as Response);
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;

render(<QuotePage />);
fireEvent.change(screen.getByLabelText(/Source asset/i), {
target: { value: "USDC" },
});
fireEvent.change(screen.getByLabelText(/Destination asset/i), {
target: { value: "EURC" },
});
fireEvent.change(screen.getByLabelText(/Amount/i), {
target: { value: "100000000" },
});
fireEvent.click(screen.getByRole("button", { name: /Get quote/i }));

await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent(
`Amount: ${unsafeAmount}`,
);
});
expect(screen.getByRole("status")).toHaveTextContent(
"Estimated rate: rate unavailable",
);
expect(screen.getByTitle(unsafeAmount)).toHaveTextContent(unsafeAmount);
expect(screen.getByTitle("rate unavailable")).toHaveTextContent(
"rate unavailable",
);
});

it("blocks submission when source == destination", async () => {
const mockFetch = jest.fn();
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
Expand Down