From 45f7ccaa2295638040866043ac5a13e2c3d12ca3 Mon Sep 17 00:00:00 2001 From: karagozemin Date: Thu, 22 Oct 2026 16:21:20 +0300 Subject: [PATCH 1/2] feat: add path payment slippage calculator --- .../README.md | 7 +++ ...athPaymentSlippageCalculatorPanel.test.tsx | 17 ++++++ .../__tests__/a11y.test.tsx | 18 ++++++ .../__tests__/declared-bound.test.ts | 7 +++ .../__tests__/format.test.ts | 6 ++ .../__tests__/path-payment-extract.test.ts | 7 +++ .../pathPaymentSlippageCalculator.test.ts | 6 ++ .../__tests__/schema.test.ts | 6 ++ .../__tests__/slippage-math.test.ts | 7 +++ .../usePathPaymentSlippageCalculator.test.tsx | 24 ++++++++ .../components/DeclaredBoundNotice.tsx | 4 ++ .../components/ExecutedPathRows.tsx | 4 ++ ...athPaymentSlippageCalculatorEmptyState.tsx | 9 +++ .../PathPaymentSlippageCalculatorForm.tsx | 43 +++++++++++++ .../PathPaymentSlippageCalculatorPanel.tsx | 33 ++++++++++ .../PathPaymentSlippageCalculatorResult.tsx | 7 +++ .../components/SlippageResult.tsx | 3 + .../path-payment-slippage-calculator/copy.ts | 20 ++++++ .../path-payment-slippage-calculator.spec.ts | 15 +++++ .../e2e/strict-receive-slippage.spec.ts | 2 + .../pathPaymentSlippageCalculator.fixture.ts | 5 ++ .../strict-receive-payment.fixture.ts | 4 ++ .../fixtures/strict-send-payment.fixture.ts | 6 ++ .../hooks/usePathPaymentSlippageCalculator.ts | 61 +++++++++++++++++++ .../lib/declared-bound.ts | 6 ++ .../lib/format.ts | 8 +++ .../lib/path-payment-extract.ts | 19 ++++++ .../pathPaymentSlippageCalculator.errors.ts | 7 +++ .../lib/pathPaymentSlippageCalculator.ts | 17 ++++++ .../lib/slippage-math.ts | 13 ++++ .../manifest.ts | 8 +++ .../msw/handlers.ts | 6 ++ .../panel.tsx | 1 + .../schema.ts | 7 +++ .../path-payment-slippage-calculator/types.ts | 18 ++++++ 35 files changed, 431 insertions(+) create mode 100644 features/path-payment-slippage-calculator/README.md create mode 100644 features/path-payment-slippage-calculator/__tests__/PathPaymentSlippageCalculatorPanel.test.tsx create mode 100644 features/path-payment-slippage-calculator/__tests__/a11y.test.tsx create mode 100644 features/path-payment-slippage-calculator/__tests__/declared-bound.test.ts create mode 100644 features/path-payment-slippage-calculator/__tests__/format.test.ts create mode 100644 features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts create mode 100644 features/path-payment-slippage-calculator/__tests__/pathPaymentSlippageCalculator.test.ts create mode 100644 features/path-payment-slippage-calculator/__tests__/schema.test.ts create mode 100644 features/path-payment-slippage-calculator/__tests__/slippage-math.test.ts create mode 100644 features/path-payment-slippage-calculator/__tests__/usePathPaymentSlippageCalculator.test.tsx create mode 100644 features/path-payment-slippage-calculator/components/DeclaredBoundNotice.tsx create mode 100644 features/path-payment-slippage-calculator/components/ExecutedPathRows.tsx create mode 100644 features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorEmptyState.tsx create mode 100644 features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorForm.tsx create mode 100644 features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel.tsx create mode 100644 features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorResult.tsx create mode 100644 features/path-payment-slippage-calculator/components/SlippageResult.tsx create mode 100644 features/path-payment-slippage-calculator/copy.ts create mode 100644 features/path-payment-slippage-calculator/e2e/path-payment-slippage-calculator.spec.ts create mode 100644 features/path-payment-slippage-calculator/e2e/strict-receive-slippage.spec.ts create mode 100644 features/path-payment-slippage-calculator/fixtures/pathPaymentSlippageCalculator.fixture.ts create mode 100644 features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts create mode 100644 features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts create mode 100644 features/path-payment-slippage-calculator/hooks/usePathPaymentSlippageCalculator.ts create mode 100644 features/path-payment-slippage-calculator/lib/declared-bound.ts create mode 100644 features/path-payment-slippage-calculator/lib/format.ts create mode 100644 features/path-payment-slippage-calculator/lib/path-payment-extract.ts create mode 100644 features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors.ts create mode 100644 features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.ts create mode 100644 features/path-payment-slippage-calculator/lib/slippage-math.ts create mode 100644 features/path-payment-slippage-calculator/manifest.ts create mode 100644 features/path-payment-slippage-calculator/msw/handlers.ts create mode 100644 features/path-payment-slippage-calculator/panel.tsx create mode 100644 features/path-payment-slippage-calculator/schema.ts create mode 100644 features/path-payment-slippage-calculator/types.ts diff --git a/features/path-payment-slippage-calculator/README.md b/features/path-payment-slippage-calculator/README.md new file mode 100644 index 00000000..89876b20 --- /dev/null +++ b/features/path-payment-slippage-calculator/README.md @@ -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. diff --git a/features/path-payment-slippage-calculator/__tests__/PathPaymentSlippageCalculatorPanel.test.tsx b/features/path-payment-slippage-calculator/__tests__/PathPaymentSlippageCalculatorPanel.test.tsx new file mode 100644 index 00000000..339c5c1b --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/PathPaymentSlippageCalculatorPanel.test.tsx @@ -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(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows a validation error when submitted empty", async () => { + const { user } = renderFeature(); + await user.click(screen.getByRole("button", { name: copy.submit })); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + }); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/a11y.test.tsx b/features/path-payment-slippage-calculator/__tests__/a11y.test.tsx new file mode 100644 index 00000000..c720eaf5 --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/a11y.test.tsx @@ -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(); + await expectNoAxeViolations(container); + }); + it("has no WCAG A/AA violations after validation", async () => { + const { container, user } = renderFeature(); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByRole("alert"); + await expectNoAxeViolations(container); + }); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/declared-bound.test.ts b/features/path-payment-slippage-calculator/__tests__/declared-bound.test.ts new file mode 100644 index 00000000..b4236c09 --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/declared-bound.test.ts @@ -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" } }); }); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/format.test.ts b/features/path-payment-slippage-calculator/__tests__/format.test.ts new file mode 100644 index 00000000..02c394bc --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/format.test.ts @@ -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")); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts b/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts new file mode 100644 index 00000000..28ae8ea0 --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; +import { extractPathPayment } from "@/features/path-payment-slippage-calculator/lib/path-payment-extract"; +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" })); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/pathPaymentSlippageCalculator.test.ts b/features/path-payment-slippage-calculator/__tests__/pathPaymentSlippageCalculator.test.ts new file mode 100644 index 00000000..37b214ae --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/pathPaymentSlippageCalculator.test.ts @@ -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")); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/schema.test.ts b/features/path-payment-slippage-calculator/__tests__/schema.test.ts new file mode 100644 index 00000000..a54950f7 --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/schema.test.ts @@ -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" })); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/slippage-math.test.ts b/features/path-payment-slippage-calculator/__tests__/slippage-math.test.ts new file mode 100644 index 00000000..3565f9ee --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/slippage-math.test.ts @@ -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" })); +}); diff --git a/features/path-payment-slippage-calculator/__tests__/usePathPaymentSlippageCalculator.test.tsx b/features/path-payment-slippage-calculator/__tests__/usePathPaymentSlippageCalculator.test.tsx new file mode 100644 index 00000000..cec327a1 --- /dev/null +++ b/features/path-payment-slippage-calculator/__tests__/usePathPaymentSlippageCalculator.test.tsx @@ -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 {children}; +} + +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" }); + }); +}); diff --git a/features/path-payment-slippage-calculator/components/DeclaredBoundNotice.tsx b/features/path-payment-slippage-calculator/components/DeclaredBoundNotice.tsx new file mode 100644 index 00000000..902e9643 --- /dev/null +++ b/features/path-payment-slippage-calculator/components/DeclaredBoundNotice.tsx @@ -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

{copy.declaredBound}: {amount} {formatAsset(asset)} — {variantCopy[variant]}

; } diff --git a/features/path-payment-slippage-calculator/components/ExecutedPathRows.tsx b/features/path-payment-slippage-calculator/components/ExecutedPathRows.tsx new file mode 100644 index 00000000..f0bc36a3 --- /dev/null +++ b/features/path-payment-slippage-calculator/components/ExecutedPathRows.tsx @@ -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

{copy.pathTitle}

{path.length ?
    {path.map((asset, index) =>
  1. {index + 1}. {formatAsset(asset)}
  2. )}
:

{copy.directPath}

}
; } diff --git a/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorEmptyState.tsx b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorEmptyState.tsx new file mode 100644 index 00000000..43682ebf --- /dev/null +++ b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorEmptyState.tsx @@ -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 ( + + ); +} diff --git a/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorForm.tsx b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorForm.tsx new file mode 100644 index 00000000..b7d8ed37 --- /dev/null +++ b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorForm.tsx @@ -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) { + event.preventDefault(); + onSubmit(value); + } + + return ( +
+ + {({ inputId, describedBy, invalid }) => ( + setValue(event.target.value)} + autoComplete="off" + spellCheck={false} + /> + )} + + +
+ ); +} diff --git a/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel.tsx b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel.tsx new file mode 100644 index 00000000..137bb1bf --- /dev/null +++ b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel.tsx @@ -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 ( +
+ + + + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorResult.tsx b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorResult.tsx new file mode 100644 index 00000000..181e4a58 --- /dev/null +++ b/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorResult.tsx @@ -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 {copy.resultTitle}

{copy.actualAmount}: {result.actualAmount}

; } diff --git a/features/path-payment-slippage-calculator/components/SlippageResult.tsx b/features/path-payment-slippage-calculator/components/SlippageResult.tsx new file mode 100644 index 00000000..4a238cb7 --- /dev/null +++ b/features/path-payment-slippage-calculator/components/SlippageResult.tsx @@ -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

{copy.resultTitle}: {formatBasisPoints(basisPoints)}

; } diff --git a/features/path-payment-slippage-calculator/copy.ts b/features/path-payment-slippage-calculator/copy.ts new file mode 100644 index 00000000..e7900ba7 --- /dev/null +++ b/features/path-payment-slippage-calculator/copy.ts @@ -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 = { + "strict-send": "Minimum destination bound (strict send)", + "strict-receive": "Maximum send bound (strict receive)" +}; +export const errorCopy: Record = { + 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." } +}; diff --git a/features/path-payment-slippage-calculator/e2e/path-payment-slippage-calculator.spec.ts b/features/path-payment-slippage-calculator/e2e/path-payment-slippage-calculator.spec.ts new file mode 100644 index 00000000..3c72789c --- /dev/null +++ b/features/path-payment-slippage-calculator/e2e/path-payment-slippage-calculator.spec.ts @@ -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; diff --git a/features/path-payment-slippage-calculator/e2e/strict-receive-slippage.spec.ts b/features/path-payment-slippage-calculator/e2e/strict-receive-slippage.spec.ts new file mode 100644 index 00000000..1343b85b --- /dev/null +++ b/features/path-payment-slippage-calculator/e2e/strict-receive-slippage.spec.ts @@ -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; diff --git a/features/path-payment-slippage-calculator/fixtures/pathPaymentSlippageCalculator.fixture.ts b/features/path-payment-slippage-calculator/fixtures/pathPaymentSlippageCalculator.fixture.ts new file mode 100644 index 00000000..8d300bd6 --- /dev/null +++ b/features/path-payment-slippage-calculator/fixtures/pathPaymentSlippageCalculator.fixture.ts @@ -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" +}; diff --git a/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts b/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts new file mode 100644 index 00000000..5aceea94 --- /dev/null +++ b/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts @@ -0,0 +1,4 @@ +export const strictReceiveRecord = { + type: "path_payment_strict_receive", send_max: "12.0000000", source_amount: "11.4000000", + source_asset_type: "native", path: [{ asset_type: "credit_alphanum4", asset_code: "EURC", asset_issuer: "GBRPYHIL2CI3CWB5GTXL7OMXNUJ6BGL5IABL5DRQO2IIGYF5BJSL5TLT" }] +}; diff --git a/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts b/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts new file mode 100644 index 00000000..3ed08043 --- /dev/null +++ b/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts @@ -0,0 +1,6 @@ +export const strictSendRecord = { + type: "path_payment_strict_send", dest_min: "9.5000000", amount: "10.0000000", + destination_asset_type: "credit_alphanum4", destination_asset_code: "USDC", + destination_asset_issuer: "GBRPYHIL2CI3CWB5GTXL7OMXNUJ6BGL5IABL5DRQO2IIGYF5BJSL5TLT", + path: [{ asset_type: "native" }] +}; diff --git a/features/path-payment-slippage-calculator/hooks/usePathPaymentSlippageCalculator.ts b/features/path-payment-slippage-calculator/hooks/usePathPaymentSlippageCalculator.ts new file mode 100644 index 00000000..df9a9265 --- /dev/null +++ b/features/path-payment-slippage-calculator/hooks/usePathPaymentSlippageCalculator.ts @@ -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({ status: "idle" }); + const controller = useRef(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 = 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 }; +} diff --git a/features/path-payment-slippage-calculator/lib/declared-bound.ts b/features/path-payment-slippage-calculator/lib/declared-bound.ts new file mode 100644 index 00000000..f9057a23 --- /dev/null +++ b/features/path-payment-slippage-calculator/lib/declared-bound.ts @@ -0,0 +1,6 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { PathPaymentSlippageCalculatorErrorCode, PathPaymentRecord } from "@/features/path-payment-slippage-calculator/types"; +export function declaredBound(record: PathPaymentRecord): Result<{ direction: "minimum-destination" | "maximum-send"; amount: string }, PathPaymentSlippageCalculatorErrorCode> { + if (!record.declaredBound) return err("malformed_effect"); + return ok({ direction: record.variant === "strict-send" ? "minimum-destination" : "maximum-send", amount: record.declaredBound }); +} diff --git a/features/path-payment-slippage-calculator/lib/format.ts b/features/path-payment-slippage-calculator/lib/format.ts new file mode 100644 index 00000000..3e7cb031 --- /dev/null +++ b/features/path-payment-slippage-calculator/lib/format.ts @@ -0,0 +1,8 @@ +import type { AssetIdentity } from "@/features/path-payment-slippage-calculator/types"; +export function formatBasisPoints(value: string): string { + const bps = BigInt(value); const sign = bps < 0n ? "-" : ""; const absolute = bps < 0n ? -bps : bps; + return `${sign}${absolute / 100n}.${(absolute % 100n).toString().padStart(2, "0")}%`; +} +export function formatAsset(asset: AssetIdentity): string { + return asset.type === "native" ? "XLM" : `${asset.code}:${asset.issuer}`; +} diff --git a/features/path-payment-slippage-calculator/lib/path-payment-extract.ts b/features/path-payment-slippage-calculator/lib/path-payment-extract.ts new file mode 100644 index 00000000..1716f5d1 --- /dev/null +++ b/features/path-payment-slippage-calculator/lib/path-payment-extract.ts @@ -0,0 +1,19 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { AssetIdentity, PathPaymentRecord, PathPaymentSlippageCalculatorErrorCode } from "@/features/path-payment-slippage-calculator/types"; +interface HorizonOperation { type?: string; dest_min?: string; send_max?: string; amount?: string; source_amount?: string; destination_asset_type?: string; destination_asset_code?: string; destination_asset_issuer?: string; source_asset_type?: string; source_asset_code?: string; source_asset_issuer?: string; path?: Array> } +function asset(type = "", code = "", issuer?: string): AssetIdentity { return type === "native" ? { type: "native", code: "XLM", issuer: null } : { type, code, issuer: issuer ?? null }; } +export function extractPathPayment(records: HorizonOperation[]): Result { + const operation = records.find((item) => item.type === "path_payment_strict_send" || item.type === "path_payment_strict_receive"); + if (!operation) return err("no_path_payment_operation"); + const strictSend = operation.type === "path_payment_strict_send"; + const declared = strictSend ? operation.dest_min : operation.send_max; + const actual = strictSend ? operation.amount : operation.source_amount; + if (!declared || !actual) return err("malformed_effect"); + return ok({ + variant: strictSend ? "strict-send" : "strict-receive", declaredBound: declared, actualAmount: actual, + boundAsset: strictSend + ? asset(operation.destination_asset_type, operation.destination_asset_code, operation.destination_asset_issuer) + : asset(operation.source_asset_type, operation.source_asset_code, operation.source_asset_issuer), + path: (operation.path ?? []).map((item) => asset(item.asset_type, item.asset_code, item.asset_issuer)) + }); +} diff --git a/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors.ts b/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors.ts new file mode 100644 index 00000000..12fa2c4c --- /dev/null +++ b/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors.ts @@ -0,0 +1,7 @@ +import type { PathPaymentSlippageCalculatorErrorCode } from "@/features/path-payment-slippage-calculator/types"; +export function statusToPathPaymentError(status: number): PathPaymentSlippageCalculatorErrorCode { + if (status === 404) return "transaction_not_found"; + if (status === 429) return "rate_limited"; + return "request_failed"; +} +export function toPathPaymentSlippageCalculatorErrorCode(_error: unknown): PathPaymentSlippageCalculatorErrorCode { return "request_failed"; } diff --git a/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.ts b/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.ts new file mode 100644 index 00000000..f6bb62e5 --- /dev/null +++ b/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.ts @@ -0,0 +1,17 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { horizonUrl } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { extractPathPayment } from "@/features/path-payment-slippage-calculator/lib/path-payment-extract"; +import { calculateSlippageBasisPoints } from "@/features/path-payment-slippage-calculator/lib/slippage-math"; +import { statusToPathPaymentError } from "@/features/path-payment-slippage-calculator/lib/pathPaymentSlippageCalculator.errors"; +import type { PathPaymentSlippageCalculatorErrorCode, PathPaymentSlippageCalculatorInput, PathPaymentSlippageCalculatorResult } from "@/features/path-payment-slippage-calculator/types"; +export async function runPathPaymentSlippageCalculator(input: PathPaymentSlippageCalculatorInput, network: StellarNetwork, signal?: AbortSignal): Promise> { + const response = await fetch(horizonUrl(network, `/transactions/${input.hash}/operations`, { limit: 200 }), { signal }); + if (!response.ok) return err(statusToPathPaymentError(response.status)); + const page = await response.json() as { _embedded?: { records?: unknown[] } }; + const extracted = extractPathPayment((page._embedded?.records ?? []) as never[]); + if (!extracted.ok) return extracted; + const slippage = calculateSlippageBasisPoints(extracted.value.variant, extracted.value.declaredBound, extracted.value.actualAmount); + if (!slippage.ok) return slippage; + return ok({ ...extracted.value, network, slippageBasisPoints: slippage.value }); +} diff --git a/features/path-payment-slippage-calculator/lib/slippage-math.ts b/features/path-payment-slippage-calculator/lib/slippage-math.ts new file mode 100644 index 00000000..b7e0637f --- /dev/null +++ b/features/path-payment-slippage-calculator/lib/slippage-math.ts @@ -0,0 +1,13 @@ +import { err, ok, type Result } from "@/core/result/result"; +import type { PathPaymentSlippageCalculatorErrorCode, PathVariant } from "@/features/path-payment-slippage-calculator/types"; +function units(value: string): bigint | null { + if (!/^\d+(?:\.\d{1,7})?$/.test(value)) return null; + const [whole, fraction = ""] = value.split("."); + return BigInt(whole) * 10_000_000n + BigInt(fraction.padEnd(7, "0")); +} +export function calculateSlippageBasisPoints(variant: PathVariant, bound: string, actual: string): Result { + const boundUnits = units(bound); const actualUnits = units(actual); + if (boundUnits === null || actualUnits === null || boundUnits === 0n) return err("malformed_effect"); + const delta = variant === "strict-send" ? actualUnits - boundUnits : boundUnits - actualUnits; + return ok(((delta * 10_000n) / boundUnits).toString()); +} diff --git a/features/path-payment-slippage-calculator/manifest.ts b/features/path-payment-slippage-calculator/manifest.ts new file mode 100644 index 00000000..cbbddcd2 --- /dev/null +++ b/features/path-payment-slippage-calculator/manifest.ts @@ -0,0 +1,8 @@ +import { Route } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; +export const manifest: FeatureManifest = { + slug: "path-payment-slippage-calculator", title: "Path Payment Slippage Calculator", + description: "Audit realized path-payment execution against its declared bound.", character: "Follow every hop; measure every stroop.", + category: "payments", status: "beta", icon: Route, networks: ["testnet", "mainnet"], + keywords: ["path payment", "slippage", "strict send", "strict receive"] +}; diff --git a/features/path-payment-slippage-calculator/msw/handlers.ts b/features/path-payment-slippage-calculator/msw/handlers.ts new file mode 100644 index 00000000..3a7ba77d --- /dev/null +++ b/features/path-payment-slippage-calculator/msw/handlers.ts @@ -0,0 +1,6 @@ +import { http, HttpResponse } from "msw"; + +/** Request mocks used by this slice's tests. Keep responses realistic. */ +export const handlers = [ + http.get("https://horizon-testnet.stellar.org/*", () => HttpResponse.json({})) +]; diff --git a/features/path-payment-slippage-calculator/panel.tsx b/features/path-payment-slippage-calculator/panel.tsx new file mode 100644 index 00000000..d3b82e54 --- /dev/null +++ b/features/path-payment-slippage-calculator/panel.tsx @@ -0,0 +1 @@ +export { PathPaymentSlippageCalculatorPanel as default } from "@/features/path-payment-slippage-calculator/components/PathPaymentSlippageCalculatorPanel"; diff --git a/features/path-payment-slippage-calculator/schema.ts b/features/path-payment-slippage-calculator/schema.ts new file mode 100644 index 00000000..07431538 --- /dev/null +++ b/features/path-payment-slippage-calculator/schema.ts @@ -0,0 +1,7 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { normalizeInput } from "@/core/lib/strings"; +import type { PathPaymentSlippageCalculatorErrorCode, PathPaymentSlippageCalculatorInput } from "@/features/path-payment-slippage-calculator/types"; +export function parsePathPaymentSlippageCalculatorInput(raw: string): Result { + const hash = normalizeInput(raw).toLowerCase(); + return /^[0-9a-f]{64}$/.test(hash) ? ok({ hash }) : err("invalid_hash"); +} diff --git a/features/path-payment-slippage-calculator/types.ts b/features/path-payment-slippage-calculator/types.ts new file mode 100644 index 00000000..0356219f --- /dev/null +++ b/features/path-payment-slippage-calculator/types.ts @@ -0,0 +1,18 @@ +import type { StellarNetwork } from "@/core/network/types"; +export type PathVariant = "strict-send" | "strict-receive"; +export interface PathPaymentSlippageCalculatorInput { hash: string } +export interface AssetIdentity { type: string; code: string; issuer: string | null } +export interface PathPaymentRecord { + variant: PathVariant; + declaredBound: string; + actualAmount: string; + boundAsset: AssetIdentity; + path: AssetIdentity[]; +} +export interface PathPaymentSlippageCalculatorResult extends PathPaymentRecord { + network: StellarNetwork; + slippageBasisPoints: string; +} +export type PathPaymentSlippageCalculatorErrorCode = + | "invalid_hash" | "transaction_not_found" | "no_path_payment_operation" + | "malformed_effect" | "rate_limited" | "request_failed"; From 8af7a1fc7eb18c7947cf61ac08a0b6b00ad69446 Mon Sep 17 00:00:00 2001 From: Ege Hidayet Koca Date: Tue, 27 Oct 2026 17:27:36 +0300 Subject: [PATCH 2/2] fix(slippage): read the documented Horizon operation fields --- .../__tests__/path-payment-extract.test.ts | 17 +++++++++++++++++ .../fixtures/strict-receive-payment.fixture.ts | 2 +- .../fixtures/strict-send-payment.fixture.ts | 6 +++--- .../lib/path-payment-extract.ts | 6 +++--- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts b/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts index 28ae8ea0..f38086c2 100644 --- a/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts +++ b/features/path-payment-slippage-calculator/__tests__/path-payment-extract.test.ts @@ -1,7 +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 }); + }); + }); diff --git a/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts b/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts index 5aceea94..b70262c2 100644 --- a/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts +++ b/features/path-payment-slippage-calculator/fixtures/strict-receive-payment.fixture.ts @@ -1,4 +1,4 @@ export const strictReceiveRecord = { - type: "path_payment_strict_receive", send_max: "12.0000000", source_amount: "11.4000000", + 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" }] }; diff --git a/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts b/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts index 3ed08043..8fb63b53 100644 --- a/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts +++ b/features/path-payment-slippage-calculator/fixtures/strict-send-payment.fixture.ts @@ -1,6 +1,6 @@ export const strictSendRecord = { - type: "path_payment_strict_send", dest_min: "9.5000000", amount: "10.0000000", - destination_asset_type: "credit_alphanum4", destination_asset_code: "USDC", - destination_asset_issuer: "GBRPYHIL2CI3CWB5GTXL7OMXNUJ6BGL5IABL5DRQO2IIGYF5BJSL5TLT", + 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" }] }; diff --git a/features/path-payment-slippage-calculator/lib/path-payment-extract.ts b/features/path-payment-slippage-calculator/lib/path-payment-extract.ts index 1716f5d1..ca4be711 100644 --- a/features/path-payment-slippage-calculator/lib/path-payment-extract.ts +++ b/features/path-payment-slippage-calculator/lib/path-payment-extract.ts @@ -1,18 +1,18 @@ import { err, ok, type Result } from "@/core/result/result"; import type { AssetIdentity, PathPaymentRecord, PathPaymentSlippageCalculatorErrorCode } from "@/features/path-payment-slippage-calculator/types"; -interface HorizonOperation { type?: string; dest_min?: string; send_max?: string; amount?: string; source_amount?: string; destination_asset_type?: string; destination_asset_code?: string; destination_asset_issuer?: string; source_asset_type?: string; source_asset_code?: string; source_asset_issuer?: string; path?: Array> } +interface HorizonOperation { type?: string; destination_min?: string; source_max?: string; amount?: string; source_amount?: string; asset_type?: string; asset_code?: string; asset_issuer?: string; source_asset_type?: string; source_asset_code?: string; source_asset_issuer?: string; path?: Array> } function asset(type = "", code = "", issuer?: string): AssetIdentity { return type === "native" ? { type: "native", code: "XLM", issuer: null } : { type, code, issuer: issuer ?? null }; } export function extractPathPayment(records: HorizonOperation[]): Result { const operation = records.find((item) => item.type === "path_payment_strict_send" || item.type === "path_payment_strict_receive"); if (!operation) return err("no_path_payment_operation"); const strictSend = operation.type === "path_payment_strict_send"; - const declared = strictSend ? operation.dest_min : operation.send_max; + const declared = strictSend ? operation.destination_min : operation.source_max; const actual = strictSend ? operation.amount : operation.source_amount; if (!declared || !actual) return err("malformed_effect"); return ok({ variant: strictSend ? "strict-send" : "strict-receive", declaredBound: declared, actualAmount: actual, boundAsset: strictSend - ? asset(operation.destination_asset_type, operation.destination_asset_code, operation.destination_asset_issuer) + ? asset(operation.asset_type, operation.asset_code, operation.asset_issuer) : asset(operation.source_asset_type, operation.source_asset_code, operation.source_asset_issuer), path: (operation.path ?? []).map((item) => asset(item.asset_type, item.asset_code, item.asset_issuer)) });