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
7 changes: 7 additions & 0 deletions features/path-payment-slippage-calculator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Path Payment Slippage Calculator

Loads the operations for one settled transaction and audits its path payment against the bound committed in the operation.

Strict-send compares actual destination amount with the minimum destination bound. Strict-receive compares actual source amount with the maximum send bound. Decimal amounts are converted to seven-decimal integer units and the percentage is computed as basis points with `BigInt`; floating point is never used.

The selected network is part of the result. The tool only reads public Horizon data, never signs or submits transactions and never accepts a secret key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { PathPaymentSlippageCalculatorPanel } from "@/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel";
import { copy } from "@/features/path-payment-slippage-calculator/copy";

describe("PathPaymentSlippageCalculatorPanel", () => {
it("renders the empty state before any input", () => {
renderFeature(<PathPaymentSlippageCalculatorPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
});

it("shows a validation error when submitted empty", async () => {
const { user } = renderFeature(<PathPaymentSlippageCalculatorPanel />);
await user.click(screen.getByRole("button", { name: copy.submit }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
});
});
18 changes: 18 additions & 0 deletions features/path-payment-slippage-calculator/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { expectNoAxeViolations } from "@/core/testing/axe";
import { PathPaymentSlippageCalculatorPanel } from "@/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel";
import { copy } from "@/features/path-payment-slippage-calculator/copy";

describe("PathPaymentSlippageCalculatorPanel accessibility", () => {
it("has no WCAG A/AA violations in its initial state", async () => {
const { container } = renderFeature(<PathPaymentSlippageCalculatorPanel />);
await expectNoAxeViolations(container);
});
it("has no WCAG A/AA violations after validation", async () => {
const { container, user } = renderFeature(<PathPaymentSlippageCalculatorPanel />);
await user.click(screen.getByRole("button", { name: copy.submit }));
await screen.findByRole("alert");
await expectNoAxeViolations(container);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from "vitest";
import { declaredBound } from "@/features/path-payment-slippage-calculator/lib/declared-bound";
import { extractPathPayment } from "@/features/path-payment-slippage-calculator/lib/path-payment-extract";
import { strictReceiveRecord } from "@/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture";
describe("declaredBound", () => {
it("labels strict-receive as a maximum-send bound", () => { const record = extractPathPayment([strictReceiveRecord]); expect(record.ok && declaredBound(record.value)).toEqual({ ok: true, value: { direction: "maximum-send", amount: "12.0000000" } }); });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
import { formatAsset, formatBasisPoints } from "@/features/path-payment-slippage-calculator/lib/format";
describe("slippage formatting", () => {
it("formats signed basis points", () => expect(formatBasisPoints("-526")).toBe("-5.26%"));
it("formats native assets", () => expect(formatAsset({ type: "native", code: "XLM", issuer: null })).toBe("XLM"));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { extractPathPayment } from "@/features/path-payment-slippage-calculator/lib/path-payment-extract";
import { strictReceiveRecord } from "@/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture";
import { strictSendRecord } from "@/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture";
describe("extractPathPayment", () => {
it("extracts strict-send bounds and path", () => { const result = extractPathPayment([strictSendRecord]); expect(result.ok && result.value.variant).toBe("strict-send"); expect(result.ok && result.value.path).toHaveLength(1); });
it("distinguishes a transaction with no path payment", () => expect(extractPathPayment([{ type: "payment" }])).toEqual({ ok: false, code: "no_path_payment_operation" }));
it("reads Horizon destination_min and destination asset fields", () => {
const result = extractPathPayment([strictSendRecord]);
expect(result.ok && result.value.declaredBound).toBe("9.5000000");
expect(result.ok && result.value.actualAmount).toBe("10.0000000");
expect(result.ok && result.value.boundAsset).toEqual({
type: "credit_alphanum4", code: "USDC", issuer: strictSendRecord.asset_issuer,
});
});
it("reads Horizon source_max and the executed source amount", () => {
const result = extractPathPayment([strictReceiveRecord]);
expect(result.ok && result.value.variant).toBe("strict-receive");
expect(result.ok && result.value.declaredBound).toBe("12.0000000");
expect(result.ok && result.value.actualAmount).toBe("11.4000000");
expect(result.ok && result.value.boundAsset).toEqual({ type: "native", code: "XLM", issuer: null });
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
import { statusToPathPaymentError } from "@/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors";
describe("path payment request outcomes", () => {
it("maps not found distinctly", () => expect(statusToPathPaymentError(404)).toBe("transaction_not_found"));
it("maps rate limits distinctly", () => expect(statusToPathPaymentError(429)).toBe("rate_limited"));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { describe, expect, it } from "vitest";
import { parsePathPaymentSlippageCalculatorInput } from "@/features/path-payment-slippage-calculator/schema";
describe("parsePathPaymentSlippageCalculatorInput", () => {
it("accepts and normalizes a transaction hash", () => expect(parsePathPaymentSlippageCalculatorInput(" " + "AB".repeat(32) + " ")).toEqual({ ok: true, value: { hash: "ab".repeat(32) } }));
it("rejects malformed hashes", () => expect(parsePathPaymentSlippageCalculatorInput("123")).toEqual({ ok: false, code: "invalid_hash" }));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from "vitest";
import { calculateSlippageBasisPoints } from "@/features/path-payment-slippage-calculator/lib/slippage-math";
describe("calculateSlippageBasisPoints", () => {
it("measures strict-send above the minimum", () => expect(calculateSlippageBasisPoints("strict-send", "9.5000000", "10.0000000")).toEqual({ ok: true, value: "526" }));
it("measures strict-receive below the maximum", () => expect(calculateSlippageBasisPoints("strict-receive", "12.0000000", "11.4000000")).toEqual({ ok: true, value: "500" }));
it("rejects malformed amounts", () => expect(calculateSlippageBasisPoints("strict-send", "0", "1")).toEqual({ ok: false, code: "malformed_effect" }));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { NetworkProvider } from "@/core/network/NetworkProvider";
import { usePathPaymentSlippageCalculator } from "@/features/path-payment-slippage-calculator/hooks/usePathPaymentSlippageCalculator";

function wrapper({ children }: { children: React.ReactNode }) {
return <NetworkProvider initialNetwork="testnet">{children}</NetworkProvider>;
}

describe("usePathPaymentSlippageCalculator", () => {
it("starts idle", () => {
const { result } = renderHook(() => usePathPaymentSlippageCalculator(), { wrapper });
expect(result.current.state.status).toBe("idle");
});

it("reports invalid_hash for empty input", async () => {
const { result } = renderHook(() => usePathPaymentSlippageCalculator(), { wrapper });
await act(async () => {
await result.current.submit("");
});
await waitFor(() => expect(result.current.state.status).toBe("error"));
expect(result.current.state).toEqual({ status: "error", code: "invalid_hash" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { copy, variantCopy } from "@/features/path-payment-slippage-calculator/copy";
import { formatAsset } from "@/features/path-payment-slippage-calculator/lib/format";
import type { AssetIdentity, PathVariant } from "@/features/path-payment-slippage-calculator/types";
export function DeclaredBoundNotice({ variant, amount, asset }: { variant: PathVariant; amount: string; asset: AssetIdentity }) { return <p><strong>{copy.declaredBound}:</strong> {amount} {formatAsset(asset)} — {variantCopy[variant]}</p>; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { copy } from "@/features/path-payment-slippage-calculator/copy";
import { formatAsset } from "@/features/path-payment-slippage-calculator/lib/format";
import type { AssetIdentity } from "@/features/path-payment-slippage-calculator/types";
export function ExecutedPathRows({ path }: { path: AssetIdentity[] }) { return <section><h3 className="font-bold">{copy.pathTitle}</h3>{path.length ? <ol>{path.map((asset, index) => <li key={`${formatAsset(asset)}-${index}`}>{index + 1}. {formatAsset(asset)}</li>)}</ol> : <p>{copy.directPath}</p>}</section>; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Sparkles } from "lucide-react";
import { EmptyState } from "@/core/ui/EmptyState";
import { copy } from "@/features/path-payment-slippage-calculator/copy";

export function PathPaymentSlippageCalculatorEmptyState() {
return (
<EmptyState icon={Sparkles} title={copy.emptyTitle} description={copy.emptyDescription} />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use client";

import { useState, type FormEvent } from "react";
import { Button } from "@/core/ui/Button";
import { Field } from "@/core/ui/Field";
import { Input } from "@/core/ui/Input";
import { copy } from "@/features/path-payment-slippage-calculator/copy";

export function PathPaymentSlippageCalculatorForm({
onSubmit,
pending
}: {
onSubmit: (value: string) => void;
pending: boolean;
}) {
const [value, setValue] = useState("");

function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
onSubmit(value);
}

return (
<form onSubmit={handleSubmit} className="space-y-4">
<Field label={copy.formLabel} hint={copy.formHint}>
{({ inputId, describedBy, invalid }) => (
<Input
id={inputId}
aria-describedby={describedBy}
aria-invalid={invalid}
value={value}
onChange={(event) => setValue(event.target.value)}
autoComplete="off"
spellCheck={false}
/>
)}
</Field>
<Button type="submit" disabled={pending}>
{pending ? copy.working : copy.submit}
</Button>
</form>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Card } from "@/core/ui/Card";
import { StatusMessage } from "@/core/ui/StatusMessage";
import { usePathPaymentSlippageCalculator } from "@/features/path-payment-slippage-calculator/hooks/usePathPaymentSlippageCalculator";
import { errorCopy } from "@/features/path-payment-slippage-calculator/copy";
import { PathPaymentSlippageCalculatorForm } from "@/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorForm";
import { PathPaymentSlippageCalculatorResult } from "@/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorResult";
import { PathPaymentSlippageCalculatorEmptyState } from "@/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorEmptyState";

export function PathPaymentSlippageCalculatorPanel() {
const { state, submit } = usePathPaymentSlippageCalculator();

return (
<div className="space-y-5">
<Card>
<PathPaymentSlippageCalculatorForm onSubmit={submit} pending={state.status === "loading"} />
</Card>

{state.status === "error" ? (
<StatusMessage
type="error"
title={errorCopy[state.code].title}
description={errorCopy[state.code].description}
/>
) : null}

{state.status === "success" ? <PathPaymentSlippageCalculatorResult result={state.result} /> : null}

{state.status === "idle" ? <PathPaymentSlippageCalculatorEmptyState /> : null}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
import { copy } from "@/features/path-payment-slippage-calculator/copy";
import { DeclaredBoundNotice } from "@/features/path-payment-slippage-calculator/components/DeclaredBoundNotice";
import { ExecutedPathRows } from "@/features/path-payment-slippage-calculator/components/ExecutedPathRows";
import { SlippageResult } from "@/features/path-payment-slippage-calculator/components/SlippageResult";
import type { PathPaymentSlippageCalculatorResult as Result } from "@/features/path-payment-slippage-calculator/types";
export function PathPaymentSlippageCalculatorResult({ result }: { result: Result }) { return <Card><CardHeader><CardTitle>{copy.resultTitle}</CardTitle></CardHeader><div className="space-y-4"><DeclaredBoundNotice variant={result.variant} amount={result.declaredBound} asset={result.boundAsset} /><p>{copy.actualAmount}: {result.actualAmount}</p><SlippageResult basisPoints={result.slippageBasisPoints} /><ExecutedPathRows path={result.path} /></div></Card>; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { copy } from "@/features/path-payment-slippage-calculator/copy";
import { formatBasisPoints } from "@/features/path-payment-slippage-calculator/lib/format";
export function SlippageResult({ basisPoints }: { basisPoints: string }) { return <p role="status"><strong>{copy.resultTitle}:</strong> {formatBasisPoints(basisPoints)}</p>; }
20 changes: 20 additions & 0 deletions features/path-payment-slippage-calculator/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { PathPaymentSlippageCalculatorErrorCode, PathVariant } from "@/features/path-payment-slippage-calculator/types";
export const copy = {
formLabel: "Settled transaction hash", formHint: "Enter the 64-character hexadecimal hash.",
submit: "Calculate slippage", working: "Loading execution…", emptyTitle: "No path payment inspected",
emptyDescription: "Load one settled transaction to compare its executed amount with the declared bound.",
resultTitle: "Realized slippage", declaredBound: "Declared bound", actualAmount: "Actual executed amount",
pathTitle: "Executed path", directPath: "Direct conversion; no intermediate assets were reported."
} as const;
export const variantCopy: Record<PathVariant, string> = {
"strict-send": "Minimum destination bound (strict send)",
"strict-receive": "Maximum send bound (strict receive)"
};
export const errorCopy: Record<PathPaymentSlippageCalculatorErrorCode, { title: string; description: string }> = {
invalid_hash: { title: "Transaction hash is invalid", description: "Enter exactly 64 hexadecimal characters." },
transaction_not_found: { title: "Transaction was not found", description: "Check the hash and selected network, then try again." },
no_path_payment_operation: { title: "No path payment found", description: "Use a settled transaction containing a strict-send or strict-receive path payment." },
malformed_effect: { title: "Execution data is incomplete", description: "Horizon returned a path payment without the amounts needed for an exact audit." },
rate_limited: { title: "Horizon rate limit reached", description: "Wait briefly, then retry the request." },
request_failed: { title: "Horizon request failed", description: "Check your connection and retry." }
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* End-to-end specification for the Path Payment Slippage Calculator tool.
*
* Documented as executable steps so the behaviour is reviewable even before a
* browser runner is wired into CI.
*/
export const spec = {
route: "/tools/path-payment-slippage-calculator",
steps: [
{ action: "visit", target: "/tools/path-payment-slippage-calculator" },
{ action: "expect", target: "heading", value: "Path Payment Slippage Calculator" },
{ action: "click", target: "submit" },
{ action: "expect", target: "alert" }
]
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Browser journey: strict-receive labels its maximum-send bound and exact result. */
export const strictReceiveSpec = { route: "/tools/path-payment-slippage-calculator", expectedBound: "Maximum send bound (strict receive)", expectedSlippage: "5.00%" } as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { PathPaymentSlippageCalculatorResult } from "@/features/path-payment-slippage-calculator/types";
export const pathPaymentSlippageCalculatorFixture: PathPaymentSlippageCalculatorResult = {
variant: "strict-send", declaredBound: "9.5", actualAmount: "10", slippageBasisPoints: "526",
boundAsset: { type: "native", code: "XLM", issuer: null }, path: [], network: "testnet"
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const strictReceiveRecord = {
type: "path_payment_strict_receive", source_max: "12.0000000", source_amount: "11.4000000",
source_asset_type: "native", path: [{ asset_type: "credit_alphanum4", asset_code: "EURC", asset_issuer: "GBRPYHIL2CI3CWB5GTXL7OMXNUJ6BGL5IABL5DRQO2IIGYF5BJSL5TLT" }]
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const strictSendRecord = {
type: "path_payment_strict_send", destination_min: "9.5000000", amount: "10.0000000",
asset_type: "credit_alphanum4", asset_code: "USDC",
asset_issuer: "GBRPYHIL2CI3CWB5GTXL7OMXNUJ6BGL5IABL5DRQO2IIGYF5BJSL5TLT",
path: [{ asset_type: "native" }]
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import { useCallback, useRef, useState } from "react";
import { useNetwork } from "@/core/network/NetworkProvider";
import { isErr, type Result } from "@/core/result/result";
import { parsePathPaymentSlippageCalculatorInput } from "@/features/path-payment-slippage-calculator/schema";
import { runPathPaymentSlippageCalculator } from "@/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator";
import { toPathPaymentSlippageCalculatorErrorCode } from "@/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors";
import type { PathPaymentSlippageCalculatorErrorCode, PathPaymentSlippageCalculatorResult } from "@/features/path-payment-slippage-calculator/types";

export type PathPaymentSlippageCalculatorState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; result: PathPaymentSlippageCalculatorResult }
| { status: "error"; code: PathPaymentSlippageCalculatorErrorCode };

export function usePathPaymentSlippageCalculator() {
const { network } = useNetwork();
const [state, setState] = useState<PathPaymentSlippageCalculatorState>({ status: "idle" });
const controller = useRef<AbortController | null>(null);

const submit = useCallback(
async (raw: string) => {
controller.current?.abort();
const parsed = parsePathPaymentSlippageCalculatorInput(raw);
if (isErr(parsed)) {
setState({ status: "error", code: parsed.code });
return;
}

const next = new AbortController();
controller.current = next;
setState({ status: "loading" });

try {
const result: Result<PathPaymentSlippageCalculatorResult, PathPaymentSlippageCalculatorErrorCode> = await runPathPaymentSlippageCalculator(
parsed.value,
network,
next.signal
);
if (next.signal.aborted) return;
setState(
result.ok
? { status: "success", result: result.value }
: { status: "error", code: result.code }
);
} catch (error) {
if (next.signal.aborted) return;
setState({ status: "error", code: toPathPaymentSlippageCalculatorErrorCode(error) });
}
},
[network]
);

const reset = useCallback(() => {
controller.current?.abort();
setState({ status: "idle" });
}, []);

return { state, submit, reset };
}
Loading
Loading