From b91147b52b16570e055850e06789b91457ef44be Mon Sep 17 00:00:00 2001 From: cyber-builder Date: Sun, 27 Sep 2026 17:01:06 +0100 Subject: [PATCH] feat: implement issue batch (#108, #109, #110, #111) - #108: migrate contributor-issue publishing metadata to Parcel-Protocol/utix with strict repo selection and validation tests - #109: build SEP-0007 Payment URI Parser for web+stellar:pay and web+stellar:tx - #110: build Path Payment Route Finder for strict-send and strict-receive routes - #111: build Account Payment History with cursor-based pagination and typed error handling --- core/__tests__/issuePublishing.test.ts | 28 ++ docs/ISSUE_PUBLISHING.md | 23 +- features/path-payment-finder/README.md | 24 ++ .../__tests__/PathPaymentFinderPanel.test.tsx | 111 ++++++++ .../__tests__/a11y.test.tsx | 31 +++ .../__tests__/format.test.ts | 38 +++ .../__tests__/pathPaymentFinder.test.ts | 132 ++++++++++ .../__tests__/schema.test.ts | 109 ++++++++ .../__tests__/usePathPaymentFinder.test.tsx | 86 +++++++ .../PathPaymentFinderEmptyState.tsx | 13 + .../components/PathPaymentFinderForm.tsx | 166 ++++++++++++ .../components/PathPaymentFinderPanel.tsx | 45 ++++ .../components/PathPaymentFinderResult.tsx | 86 +++++++ features/path-payment-finder/copy.ts | 35 +++ .../e2e/path-payment-finder.spec.ts | 15 ++ .../fixtures/pathPaymentFinder.fixture.ts | 60 +++++ .../hooks/usePathPaymentFinder.ts | 51 ++++ features/path-payment-finder/lib/format.ts | 28 ++ .../lib/pathPaymentFinder.errors.ts | 14 + .../lib/pathPaymentFinder.ts | 113 ++++++++ features/path-payment-finder/manifest.ts | 23 ++ features/path-payment-finder/msw/handlers.ts | 48 ++++ features/path-payment-finder/panel.tsx | 1 + features/path-payment-finder/schema.ts | 96 +++++++ features/path-payment-finder/types.ts | 40 +++ features/payment-history/README.md | 25 ++ .../__tests__/PaymentHistoryPanel.test.tsx | 81 ++++++ .../payment-history/__tests__/a11y.test.tsx | 29 +++ .../payment-history/__tests__/format.test.ts | 34 +++ .../__tests__/paymentHistory.test.ts | 114 +++++++++ .../payment-history/__tests__/schema.test.ts | 37 +++ .../__tests__/usePaymentHistory.test.tsx | 74 ++++++ .../components/PaymentHistoryEmptyState.tsx | 13 + .../components/PaymentHistoryForm.tsx | 49 ++++ .../components/PaymentHistoryPanel.tsx | 52 ++++ .../components/PaymentHistoryResult.tsx | 119 +++++++++ features/payment-history/copy.ts | 44 ++++ .../e2e/payment-history.spec.ts | 15 ++ .../fixtures/paymentHistory.fixture.ts | 88 +++++++ .../hooks/usePaymentHistory.ts | 96 +++++++ features/payment-history/lib/format.ts | 29 +++ .../lib/paymentHistory.errors.ts | 10 + .../payment-history/lib/paymentHistory.ts | 151 +++++++++++ features/payment-history/manifest.ts | 15 ++ features/payment-history/msw/handlers.ts | 40 +++ features/payment-history/panel.tsx | 1 + features/payment-history/schema.ts | 17 ++ features/payment-history/types.ts | 38 +++ features/payment-uri-parser/README.md | 24 ++ .../__tests__/PaymentUriParserPanel.test.tsx | 98 +++++++ .../__tests__/a11y.test.tsx | 25 ++ .../__tests__/format.test.ts | 29 +++ .../__tests__/paymentUriParser.test.ts | 64 +++++ .../__tests__/schema.test.ts | 54 ++++ .../__tests__/usePaymentUriParser.test.tsx | 68 +++++ .../components/PaymentUriParserEmptyState.tsx | 13 + .../components/PaymentUriParserForm.tsx | 50 ++++ .../components/PaymentUriParserPanel.tsx | 45 ++++ .../components/PaymentUriParserResult.tsx | 129 ++++++++++ features/payment-uri-parser/copy.ts | 34 +++ .../e2e/payment-uri-parser.spec.ts | 15 ++ .../fixtures/paymentUriParser.fixture.ts | 17 ++ .../hooks/usePaymentUriParser.ts | 48 ++++ features/payment-uri-parser/lib/format.ts | 39 +++ .../lib/paymentUriParser.errors.ts | 14 + .../lib/paymentUriParser.ts | 242 ++++++++++++++++++ features/payment-uri-parser/manifest.ts | 25 ++ features/payment-uri-parser/msw/handlers.ts | 7 + features/payment-uri-parser/panel.tsx | 1 + features/payment-uri-parser/schema.ts | 43 ++++ features/payment-uri-parser/types.ts | 34 +++ scripts/create-issues.mjs | 10 +- scripts/grantfox-config.mjs | 7 +- scripts/repo-config.mjs | 20 ++ scripts/validate-issue-catalog.mjs | 6 + 75 files changed, 3736 insertions(+), 12 deletions(-) create mode 100644 core/__tests__/issuePublishing.test.ts create mode 100644 features/path-payment-finder/README.md create mode 100644 features/path-payment-finder/__tests__/PathPaymentFinderPanel.test.tsx create mode 100644 features/path-payment-finder/__tests__/a11y.test.tsx create mode 100644 features/path-payment-finder/__tests__/format.test.ts create mode 100644 features/path-payment-finder/__tests__/pathPaymentFinder.test.ts create mode 100644 features/path-payment-finder/__tests__/schema.test.ts create mode 100644 features/path-payment-finder/__tests__/usePathPaymentFinder.test.tsx create mode 100644 features/path-payment-finder/components/PathPaymentFinderEmptyState.tsx create mode 100644 features/path-payment-finder/components/PathPaymentFinderForm.tsx create mode 100644 features/path-payment-finder/components/PathPaymentFinderPanel.tsx create mode 100644 features/path-payment-finder/components/PathPaymentFinderResult.tsx create mode 100644 features/path-payment-finder/copy.ts create mode 100644 features/path-payment-finder/e2e/path-payment-finder.spec.ts create mode 100644 features/path-payment-finder/fixtures/pathPaymentFinder.fixture.ts create mode 100644 features/path-payment-finder/hooks/usePathPaymentFinder.ts create mode 100644 features/path-payment-finder/lib/format.ts create mode 100644 features/path-payment-finder/lib/pathPaymentFinder.errors.ts create mode 100644 features/path-payment-finder/lib/pathPaymentFinder.ts create mode 100644 features/path-payment-finder/manifest.ts create mode 100644 features/path-payment-finder/msw/handlers.ts create mode 100644 features/path-payment-finder/panel.tsx create mode 100644 features/path-payment-finder/schema.ts create mode 100644 features/path-payment-finder/types.ts create mode 100644 features/payment-history/README.md create mode 100644 features/payment-history/__tests__/PaymentHistoryPanel.test.tsx create mode 100644 features/payment-history/__tests__/a11y.test.tsx create mode 100644 features/payment-history/__tests__/format.test.ts create mode 100644 features/payment-history/__tests__/paymentHistory.test.ts create mode 100644 features/payment-history/__tests__/schema.test.ts create mode 100644 features/payment-history/__tests__/usePaymentHistory.test.tsx create mode 100644 features/payment-history/components/PaymentHistoryEmptyState.tsx create mode 100644 features/payment-history/components/PaymentHistoryForm.tsx create mode 100644 features/payment-history/components/PaymentHistoryPanel.tsx create mode 100644 features/payment-history/components/PaymentHistoryResult.tsx create mode 100644 features/payment-history/copy.ts create mode 100644 features/payment-history/e2e/payment-history.spec.ts create mode 100644 features/payment-history/fixtures/paymentHistory.fixture.ts create mode 100644 features/payment-history/hooks/usePaymentHistory.ts create mode 100644 features/payment-history/lib/format.ts create mode 100644 features/payment-history/lib/paymentHistory.errors.ts create mode 100644 features/payment-history/lib/paymentHistory.ts create mode 100644 features/payment-history/manifest.ts create mode 100644 features/payment-history/msw/handlers.ts create mode 100644 features/payment-history/panel.tsx create mode 100644 features/payment-history/schema.ts create mode 100644 features/payment-history/types.ts create mode 100644 features/payment-uri-parser/README.md create mode 100644 features/payment-uri-parser/__tests__/PaymentUriParserPanel.test.tsx create mode 100644 features/payment-uri-parser/__tests__/a11y.test.tsx create mode 100644 features/payment-uri-parser/__tests__/format.test.ts create mode 100644 features/payment-uri-parser/__tests__/paymentUriParser.test.ts create mode 100644 features/payment-uri-parser/__tests__/schema.test.ts create mode 100644 features/payment-uri-parser/__tests__/usePaymentUriParser.test.tsx create mode 100644 features/payment-uri-parser/components/PaymentUriParserEmptyState.tsx create mode 100644 features/payment-uri-parser/components/PaymentUriParserForm.tsx create mode 100644 features/payment-uri-parser/components/PaymentUriParserPanel.tsx create mode 100644 features/payment-uri-parser/components/PaymentUriParserResult.tsx create mode 100644 features/payment-uri-parser/copy.ts create mode 100644 features/payment-uri-parser/e2e/payment-uri-parser.spec.ts create mode 100644 features/payment-uri-parser/fixtures/paymentUriParser.fixture.ts create mode 100644 features/payment-uri-parser/hooks/usePaymentUriParser.ts create mode 100644 features/payment-uri-parser/lib/format.ts create mode 100644 features/payment-uri-parser/lib/paymentUriParser.errors.ts create mode 100644 features/payment-uri-parser/lib/paymentUriParser.ts create mode 100644 features/payment-uri-parser/manifest.ts create mode 100644 features/payment-uri-parser/msw/handlers.ts create mode 100644 features/payment-uri-parser/panel.tsx create mode 100644 features/payment-uri-parser/schema.ts create mode 100644 features/payment-uri-parser/types.ts create mode 100644 scripts/repo-config.mjs diff --git a/core/__tests__/issuePublishing.test.ts b/core/__tests__/issuePublishing.test.ts new file mode 100644 index 0000000..a2ca337 --- /dev/null +++ b/core/__tests__/issuePublishing.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { DEFAULT_REPO, LEGACY_REPOS, resolveRepository } from "../../scripts/repo-config.mjs"; + +describe("Contributor Issue Publishing Metadata (#108)", () => { + it("defaults to Parcel-Protocol/utix when no GH_REPO is set", () => { + const repo = resolveRepository(undefined); + expect(repo).toBe("Parcel-Protocol/utix"); + expect(DEFAULT_REPO).toBe("Parcel-Protocol/utix"); + }); + + it("accepts a valid custom GH_REPO", () => { + const repo = resolveRepository("MyOrg/my-utix-fork"); + expect(repo).toBe("MyOrg/my-utix-fork"); + }); + + it("rejects legacy RevyHub repositories", () => { + for (const legacy of LEGACY_REPOS) { + expect(() => resolveRepository(legacy)).toThrow(/legacy repository/i); + } + expect(() => resolveRepository("AnyOrg/RevyHub")).toThrow(/legacy repository/i); + expect(() => resolveRepository("RevenantLabs/RevyHub")).toThrow(/legacy repository/i); + }); + + it("rejects invalid repository formats", () => { + expect(() => resolveRepository("invalid-repo-no-owner")).toThrow(/invalid repository format/i); + expect(() => resolveRepository("owner/repo/extra")).toThrow(/invalid repository format/i); + }); +}); diff --git a/docs/ISSUE_PUBLISHING.md b/docs/ISSUE_PUBLISHING.md index 1fb0572..35776e4 100644 --- a/docs/ISSUE_PUBLISHING.md +++ b/docs/ISSUE_PUBLISHING.md @@ -1,9 +1,15 @@ # Publishing contributor issues -RevyHubX issues are product specifications for external contributors. The +Utix issues are product specifications for external contributors. The maintainer prepares and publishes them; contributors implement them. An issue must never be pre-solved in the repository before it is offered. +## Contributor workflow policy + +- **Branch and PR targets**: All contribution pull requests must target the `dev` branch (NOT `main`). +- **No pre-assignment**: Direct maintainer or contributor pre-assignment of issues is not allowed before an open pull request is submitted with passing local checks. +- **Local checks**: Contributors must run the verification suite before opening a PR (`npm run check`, `npm run verify:features`). + ## Release policy - Issues are released in batches of exactly five. @@ -18,16 +24,19 @@ must never be pre-solved in the repository before it is offered. The stable wave order lives in `scripts/issue-status.mjs`. Detailed product requirements live in `scripts/issue-catalog.mjs`. +The active project repository is `Parcel-Protocol/utix`. +All contributor-issue commands target `Parcel-Protocol/utix` by default or use a validated `GH_REPO` environment variable. + The active GrantFox project, repository, campaign and required labels live in `scripts/grantfox-config.mjs`. For the current campaign they are: -- Project: `RevenantLabs` (`16987fb9-18ec-4555-acd9-dbf2c8ec9074`) -- Repository: `RevyHub` (`1302133299`) -- Campaign: `Third Campaign` (`624dee9c-2bc5-48fc-ae07-3c2c2a8262e8`) -- Required labels: `GrantFox OSS`, `Maybe Rewarded`, `Third Campaign` +- Project: `Parcel-Protocol` +- Repository: `Parcel-Protocol/utix` +- Active Campaign: `Stellar Wave` +- Required labels: `GrantFox OSS`, `Maybe Rewarded`, `Stellar Wave` Do not reuse labels from a completed campaign such as -`Official Campaign | FWC26`; GrantFox campaign visibility follows the active +`Third Campaign` or `Official Campaign | FWC26`; GrantFox campaign visibility follows the active campaign attachment and its corresponding label. ## Preview the next batch @@ -39,7 +48,7 @@ node scripts/create-issues.mjs --json > next-five.json ``` The JSON contains the exact title, Markdown body, labels, wave position and -slug for each issue. Review all five payloads before publishing. +slug for each issue. Review all five payloads before publishing. The command displays the resolved repository (`Parcel-Protocol/utix`) before any actions. ## Publish through GrantFox diff --git a/features/path-payment-finder/README.md b/features/path-payment-finder/README.md new file mode 100644 index 0000000..18968d1 --- /dev/null +++ b/features/path-payment-finder/README.md @@ -0,0 +1,24 @@ +# Path Payment Route Finder + +Find the routes Stellar's decentralised exchange offers between two assets, for both strict-send and strict-receive, and inspect the hops and effective rates for each route. + +## How it works + +Queries Stellar Horizon's `/paths/strict-send` or `/paths/strict-receive` endpoints to discover viable trade routes across order books and liquidity pools between a source asset and destination asset. Each route details the source amount required, destination amount received, intermediate hops (assets traversed), and the resulting effective exchange rate. + +## Files + +| Path | Responsibility | +| --- | --- | +| `manifest.ts` | Registry metadata | +| `schema.ts` | Input parsing, asset specification and validation | +| `lib/` | Horizon path payment routing queries, normalization, and formatting | +| `hooks/` | React state machine for asynchronous path finding | +| `components/` | Form, result, empty and error UI | +| `__tests__/` | Unit, hook, component and accessibility tests | +| `fixtures/` | Deterministic sample data for routes and endpoints | +| `msw/` | Request mocks for Horizon `/paths/*` calls | + +## Safety + +This tool queries public decentralized exchange order books and path-payment routing endpoints on the Stellar network. It operates exclusively with public asset codes and issuer account addresses. It never requests, accepts, displays, stores, or transmits secret keys or private credentials. diff --git a/features/path-payment-finder/__tests__/PathPaymentFinderPanel.test.tsx b/features/path-payment-finder/__tests__/PathPaymentFinderPanel.test.tsx new file mode 100644 index 0000000..15d1c20 --- /dev/null +++ b/features/path-payment-finder/__tests__/PathPaymentFinderPanel.test.tsx @@ -0,0 +1,111 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { renderFeatureSlice } from "@/core/testing/contract"; +import { copy } from "@/features/path-payment-finder/copy"; +import { + rateLimitedIssuer, + testIssuerA +} from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; +import { pendingHandler, handlers } from "@/features/path-payment-finder/msw/handlers"; +import { withMswHandlers } from "@/core/testing/msw"; +import { PathPaymentFinderPanel } from "@/features/path-payment-finder/components/PathPaymentFinderPanel"; + +const server = withMswHandlers(...handlers); + +function render() { + return renderFeatureSlice("path-payment-finder", ); +} + +describe("PathPaymentFinderPanel", () => { + it("shows the empty state first", () => { + const slice = render(); + slice.expectEmptyState(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows loading state while request is in flight", async () => { + server.use(pendingHandler); + const user = userEvent.setup(); + const slice = render(); + + const issuerInput = screen.getAllByPlaceholderText("G...")[1]; + await user.type(issuerInput, testIssuerA); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + await slice.waitForState("loading"); + slice.expectLoadingState(); + }); + + it("renders routes on success", async () => { + resetHorizonClients(); + const user = userEvent.setup(); + const slice = render(); + + const issuerInput = screen.getAllByPlaceholderText("G...")[1]; + await user.type(issuerInput, testIssuerA); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + expect(screen.getByText("Direct (0 hops)")).toBeInTheDocument(); + expect(screen.getByText("1 hop")).toBeInTheDocument(); + expect(screen.getByText("EURT")).toBeInTheDocument(); + }); + + it("distinguishes no routes from error state", async () => { + resetHorizonClients(); + const user = userEvent.setup(); + const slice = render(); + + const issuerInput = screen.getAllByPlaceholderText("G...")[1]; + await user.type(issuerInput, testIssuerA); + + const amountInput = screen.getByLabelText(new RegExp(copy.amountLabel, "i")); + await user.clear(amountInput); + await user.type(amountInput, "999"); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + expect(await screen.findByText(copy.noRoutesTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.noRoutesDescription)).toBeInTheDocument(); + }); + + it("shows error state when rate limited", async () => { + resetHorizonClients(); + const user = userEvent.setup(); + const slice = render(); + + const issuerInput = screen.getAllByPlaceholderText("G...")[1]; + await user.clear(issuerInput); + await user.type(issuerInput, rateLimitedIssuer); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + await slice.waitForState("error"); + slice.expectErrorState(); + expect(screen.getByText(copy.errors.rate_limited)).toBeInTheDocument(); + }); + + it("shows error state on invalid input", async () => { + resetHorizonClients(); + const user = userEvent.setup(); + const slice = render(); + + const amountInput = screen.getByLabelText(new RegExp(copy.amountLabel, "i")); + await user.clear(amountInput); + await user.type(amountInput, "invalid"); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + await slice.waitForState("error"); + slice.expectErrorState(); + }); +}); diff --git a/features/path-payment-finder/__tests__/a11y.test.tsx b/features/path-payment-finder/__tests__/a11y.test.tsx new file mode 100644 index 0000000..399a95e --- /dev/null +++ b/features/path-payment-finder/__tests__/a11y.test.tsx @@ -0,0 +1,31 @@ +import { describe, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { PathPaymentFinderPanel } from "@/features/path-payment-finder/components/PathPaymentFinderPanel"; +import { copy } from "@/features/path-payment-finder/copy"; +import { handlers } from "@/features/path-payment-finder/msw/handlers"; +import { testIssuerA } from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; + +withMswHandlers(...handlers); + +describe("PathPaymentFinderPanel 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 with route results loaded", async () => { + resetHorizonClients(); + const { container, user } = renderFeature(); + + const issuerInput = screen.getAllByPlaceholderText("G...")[1]; + await user.type(issuerInput, testIssuerA); + + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.resultTitle); + + await expectNoAxeViolations(container); + }); +}); diff --git a/features/path-payment-finder/__tests__/format.test.ts b/features/path-payment-finder/__tests__/format.test.ts new file mode 100644 index 0000000..95739aa --- /dev/null +++ b/features/path-payment-finder/__tests__/format.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + formatAssetDisplay, + formatEffectiveRate, + formatHopBreadcrumbs +} from "@/features/path-payment-finder/lib/format"; +import { testIssuerA, testIssuerB } from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; + +describe("PathPaymentFinder format helpers", () => { + it("formats asset display correctly", () => { + expect(formatAssetDisplay("XLM")).toBe("XLM"); + expect(formatAssetDisplay("native")).toBe("XLM"); + expect(formatAssetDisplay("USDC", testIssuerA)).toContain("USDC"); + expect(formatAssetDisplay("USDC", testIssuerA)).toContain("…"); + }); + + it("calculates effective rates", () => { + expect(formatEffectiveRate("100", "12.5")).toBe("0.1250"); + expect(formatEffectiveRate("10", "15")).toBe("1.5000"); + expect(formatEffectiveRate("0", "15")).toBe("—"); + expect(formatEffectiveRate("invalid", "15")).toBe("—"); + }); + + it("formats hop breadcrumbs", () => { + expect(formatHopBreadcrumbs([])).toBe("Direct"); + expect( + formatHopBreadcrumbs([ + { code: "EURT", issuer: testIssuerB, type: "credit_alphanum4" } + ]) + ).toBe("EURT"); + expect( + formatHopBreadcrumbs([ + { code: "EURT", issuer: testIssuerB, type: "credit_alphanum4" }, + { code: "XLM", type: "native" } + ]) + ).toBe("EURT → XLM"); + }); +}); diff --git a/features/path-payment-finder/__tests__/pathPaymentFinder.test.ts b/features/path-payment-finder/__tests__/pathPaymentFinder.test.ts new file mode 100644 index 0000000..4a443c6 --- /dev/null +++ b/features/path-payment-finder/__tests__/pathPaymentFinder.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { findPaymentPaths, normalizePaymentRoute, toStellarAsset } from "@/features/path-payment-finder/lib/pathPaymentFinder"; +import { + rateLimitedIssuer, + testIssuerA, + testIssuerB +} from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; +import { withMswHandlers } from "@/core/testing/msw"; +import { handlers } from "@/features/path-payment-finder/msw/handlers"; + +withMswHandlers(...handlers); + +describe("pathPaymentFinder library", () => { + it("converts code and issuer to Stellar Asset", () => { + const native = toStellarAsset("XLM"); + expect(native.isNative()).toBe(true); + + const issued = toStellarAsset("USDC", testIssuerA); + expect(issued.isNative()).toBe(false); + expect(issued.getCode()).toBe("USDC"); + expect(issued.getIssuer()).toBe(testIssuerA); + }); + + it("normalizes payment route records correctly", () => { + const route = normalizePaymentRoute({ + source_asset_type: "native", + source_amount: "100.0000000", + destination_asset_type: "credit_alphanum4", + destination_asset_code: "USDC", + destination_asset_issuer: testIssuerA, + destination_amount: "12.5000000", + path: [ + { + asset_type: "credit_alphanum4", + asset_code: "EURT", + asset_issuer: testIssuerB + } + ] + }); + + expect(route.sourceAsset).toBe("XLM"); + expect(route.sourceAmount).toBe("100.0000000"); + expect(route.destinationAsset).toContain("USDC"); + expect(route.destinationAmount).toBe("12.5000000"); + expect(route.hopsCount).toBe(1); + expect(route.path[0].code).toBe("EURT"); + expect(route.effectiveRate).toBe("0.1250"); + }); + + it("queries strict-send routes successfully", async () => { + resetHorizonClients(); + const res = await findPaymentPaths( + { + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "100" + }, + "testnet" + ); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.mode).toBe("strict-send"); + expect(res.value.routes).toHaveLength(2); + expect(res.value.routes[0].hopsCount).toBe(0); + expect(res.value.routes[1].hopsCount).toBe(1); + } + }); + + it("queries strict-receive routes successfully", async () => { + resetHorizonClients(); + const res = await findPaymentPaths( + { + mode: "strict-receive", + sourceCode: "USDC", + sourceIssuer: testIssuerA, + destCode: "XLM", + amount: "50" + }, + "testnet" + ); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.mode).toBe("strict-receive"); + expect(res.value.routes).toHaveLength(1); + expect(res.value.routes[0].sourceAmount).toBe("8.0000000"); + expect(res.value.routes[0].destinationAmount).toBe("50.0000000"); + } + }); + + it("returns rate_limited error when Horizon rate limit is hit", async () => { + resetHorizonClients(); + const res = await findPaymentPaths( + { + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: rateLimitedIssuer, + amount: "100" + }, + "testnet" + ); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("rate_limited"); + } + }); + + it("returns empty routes array when no routes exist", async () => { + resetHorizonClients(); + const res = await findPaymentPaths( + { + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "999" + }, + "testnet" + ); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.routes).toHaveLength(0); + } + }); +}); diff --git a/features/path-payment-finder/__tests__/schema.test.ts b/features/path-payment-finder/__tests__/schema.test.ts new file mode 100644 index 0000000..188621c --- /dev/null +++ b/features/path-payment-finder/__tests__/schema.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { testIssuerA, testIssuerB } from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; +import { validatePathPaymentFinderInput } from "@/features/path-payment-finder/schema"; + +describe("validatePathPaymentFinderInput", () => { + it("accepts valid strict-send input with XLM and issued asset", () => { + const res = validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "100" + }); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.mode).toBe("strict-send"); + expect(res.value.sourceCode).toBe("XLM"); + expect(res.value.sourceIssuer).toBeUndefined(); + expect(res.value.destCode).toBe("USDC"); + expect(res.value.destIssuer).toBe(testIssuerA); + expect(res.value.amount).toBe("100"); + } + }); + + it("accepts valid strict-receive input between two issued assets", () => { + const res = validatePathPaymentFinderInput({ + mode: "strict-receive", + sourceCode: "EURT", + sourceIssuer: testIssuerB, + destCode: "USDC", + destIssuer: testIssuerA, + amount: "25.5" + }); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.mode).toBe("strict-receive"); + expect(res.value.sourceCode).toBe("EURT"); + expect(res.value.sourceIssuer).toBe(testIssuerB); + expect(res.value.amount).toBe("25.5"); + } + }); + + it("rejects non-positive amounts or invalid formats", () => { + expect( + validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "0" + }).ok + ).toBe(false); + + expect( + validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "-50" + }).ok + ).toBe(false); + + expect( + validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "abc" + }).ok + ).toBe(false); + }); + + it("rejects identical source and destination assets", () => { + const res = validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "XLM", + amount: "10" + }); + expect(res.ok).toBe(false); + }); + + it("rejects invalid or missing issuer for issued asset", () => { + const res = validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: "invalid-issuer", + amount: "10" + }); + expect(res.ok).toBe(false); + }); + + it("never accepts secret keys as issuer or codes", () => { + const secret = "SB6V53J232IWB32U64WTYZ57ZGB25RFZ3Y45C5AYU5A7DNHFGL2L76K6"; + const res = validatePathPaymentFinderInput({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: secret, + amount: "10" + }); + expect(res.ok).toBe(false); + }); +}); diff --git a/features/path-payment-finder/__tests__/usePathPaymentFinder.test.tsx b/features/path-payment-finder/__tests__/usePathPaymentFinder.test.tsx new file mode 100644 index 0000000..028afda --- /dev/null +++ b/features/path-payment-finder/__tests__/usePathPaymentFinder.test.tsx @@ -0,0 +1,86 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { NetworkProvider } from "@/core/network/NetworkProvider"; +import { withMswHandlers } from "@/core/testing/msw"; +import { testIssuerA, rateLimitedIssuer } from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; +import { usePathPaymentFinder } from "@/features/path-payment-finder/hooks/usePathPaymentFinder"; +import { handlers } from "@/features/path-payment-finder/msw/handlers"; + +withMswHandlers(...handlers); + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("usePathPaymentFinder hook", () => { + it("starts in idle state", () => { + const { result } = renderHook(() => usePathPaymentFinder(), { wrapper }); + + expect(result.current.status).toBe("idle"); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("handles successful strict-send lookup", async () => { + resetHorizonClients(); + const { result } = renderHook(() => usePathPaymentFinder(), { wrapper }); + + await act(async () => { + await result.current.run({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "100" + }); + }); + + expect(result.current.status).toBe("success"); + expect(result.current.result?.routes).toHaveLength(2); + expect(result.current.error).toBeNull(); + }); + + it("handles rate limited error", async () => { + resetHorizonClients(); + const { result } = renderHook(() => usePathPaymentFinder(), { wrapper }); + + await act(async () => { + await result.current.run({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: rateLimitedIssuer, + amount: "100" + }); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("rate_limited"); + expect(result.current.result).toBeNull(); + }); + + it("resets back to idle state", async () => { + resetHorizonClients(); + const { result } = renderHook(() => usePathPaymentFinder(), { wrapper }); + + await act(async () => { + await result.current.run({ + mode: "strict-send", + sourceCode: "XLM", + destCode: "USDC", + destIssuer: testIssuerA, + amount: "100" + }); + }); + + expect(result.current.status).toBe("success"); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.result).toBeNull(); + }); +}); diff --git a/features/path-payment-finder/components/PathPaymentFinderEmptyState.tsx b/features/path-payment-finder/components/PathPaymentFinderEmptyState.tsx new file mode 100644 index 0000000..b67c5bb --- /dev/null +++ b/features/path-payment-finder/components/PathPaymentFinderEmptyState.tsx @@ -0,0 +1,13 @@ +import { Route } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/path-payment-finder/copy"; + +export function PathPaymentFinderEmptyState() { + return ( + + ); +} diff --git a/features/path-payment-finder/components/PathPaymentFinderForm.tsx b/features/path-payment-finder/components/PathPaymentFinderForm.tsx new file mode 100644 index 0000000..2735031 --- /dev/null +++ b/features/path-payment-finder/components/PathPaymentFinderForm.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } 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-finder/copy"; +import type { PathMode, PathPaymentFinderInput } from "@/features/path-payment-finder/types"; + +interface Props { + onSubmit: (input: PathPaymentFinderInput) => void; + pending: boolean; +} + +export function PathPaymentFinderForm({ onSubmit, pending }: Props) { + const [mode, setMode] = useState("strict-send"); + const [amount, setAmount] = useState("100"); + const [sourceCode, setSourceCode] = useState("XLM"); + const [sourceIssuer, setSourceIssuer] = useState(""); + const [destCode, setDestCode] = useState("USDC"); + const [destIssuer, setDestIssuer] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + mode, + amount, + sourceCode, + sourceIssuer: sourceCode.toUpperCase() === "XLM" ? undefined : sourceIssuer, + destCode, + destIssuer: destCode.toUpperCase() === "XLM" ? undefined : destIssuer + }); + }; + + return ( +
+
+ + {({ inputId, describedBy }) => ( + + )} + + + + {({ inputId, describedBy, invalid, required }) => ( + setAmount(e.target.value)} + placeholder="100.00" + disabled={pending} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + +
+ +
+

+ {copy.sourceHeading} +

+
+ + {({ inputId, describedBy, invalid, required }) => ( + setSourceCode(e.target.value)} + placeholder="XLM" + disabled={pending} + autoComplete="off" + spellCheck={false} + /> + )} + + +
+ + {({ inputId, describedBy, invalid }) => ( + setSourceIssuer(e.target.value)} + placeholder="G..." + disabled={pending || sourceCode.toUpperCase() === "XLM"} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + +
+
+
+ +
+

+ {copy.destHeading} +

+
+ + {({ inputId, describedBy, invalid, required }) => ( + setDestCode(e.target.value)} + placeholder="USDC" + disabled={pending} + autoComplete="off" + spellCheck={false} + /> + )} + + +
+ + {({ inputId, describedBy, invalid }) => ( + setDestIssuer(e.target.value)} + placeholder="G..." + disabled={pending || destCode.toUpperCase() === "XLM"} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + +
+
+
+ +
+ +
+
+ ); +} diff --git a/features/path-payment-finder/components/PathPaymentFinderPanel.tsx b/features/path-payment-finder/components/PathPaymentFinderPanel.tsx new file mode 100644 index 0000000..7dbf9ec --- /dev/null +++ b/features/path-payment-finder/components/PathPaymentFinderPanel.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { SkeletonRows } from "@/core/ui/Skeleton"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { copy } from "@/features/path-payment-finder/copy"; +import { usePathPaymentFinder } from "@/features/path-payment-finder/hooks/usePathPaymentFinder"; +import { PathPaymentFinderEmptyState } from "@/features/path-payment-finder/components/PathPaymentFinderEmptyState"; +import { PathPaymentFinderForm } from "@/features/path-payment-finder/components/PathPaymentFinderForm"; +import { PathPaymentFinderResult } from "@/features/path-payment-finder/components/PathPaymentFinderResult"; + +export function PathPaymentFinderPanel() { + const { status, result, error, run, pending } = usePathPaymentFinder(); + + return ( +
+ + + + + {status === "loading" ? ( + +

+ {copy.loading} +

+ +
+ ) : null} + + {status === "error" && error ? ( + + ) : null} + + {status === "success" && result ? ( + + ) : null} + + {status === "idle" ? : null} +
+ ); +} diff --git a/features/path-payment-finder/components/PathPaymentFinderResult.tsx b/features/path-payment-finder/components/PathPaymentFinderResult.tsx new file mode 100644 index 0000000..86214fe --- /dev/null +++ b/features/path-payment-finder/components/PathPaymentFinderResult.tsx @@ -0,0 +1,86 @@ +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { copy } from "@/features/path-payment-finder/copy"; +import { formatHopBreadcrumbs } from "@/features/path-payment-finder/lib/format"; +import type { PathPaymentFinderResult as ResultType } from "@/features/path-payment-finder/types"; + +interface Props { + result: ResultType; +} + +export function PathPaymentFinderResult({ result }: Props) { + if (result.routes.length === 0) { + return ( + + ); + } + + return ( +
+ + +
+ {copy.resultTitle} + + {result.mode === "strict-send" ? "Strict Send" : "Strict Receive"}:{" "} + {result.amount}{" "} + {result.mode === "strict-send" ? result.sourceAsset : result.destAsset} + +
+
+ +
+ + + + + + + + + + + + {result.routes.map((route, index) => ( + + + + + + + + ))} + +
#{copy.sourceAmountHeader}{copy.destAmountHeader}{copy.hopsHeader}{copy.effectiveRateHeader}
{index + 1} + {route.sourceAmount}{" "} + {route.sourceAsset} + + {route.destinationAmount}{" "} + {route.destinationAsset} + + {route.hopsCount === 0 ? ( + + {copy.directRoute} + + ) : ( +
+ + {copy.hopsCount(route.hopsCount)} + +
+ {formatHopBreadcrumbs(route.path)} +
+
+ )} +
+ {route.effectiveRate} +
+
+
+
+ ); +} diff --git a/features/path-payment-finder/copy.ts b/features/path-payment-finder/copy.ts new file mode 100644 index 0000000..57a0f1a --- /dev/null +++ b/features/path-payment-finder/copy.ts @@ -0,0 +1,35 @@ +export const copy = { + title: "Path Payment Route Finder", + description: + "Find the routes Stellar's decentralised exchange offers between two assets, for both strict-send and strict-receive, and show the hops and effective rate for each.", + emptyTitle: "Find Path Payment Routes", + emptyDescription: + "Enter a source asset, destination asset, and amount to discover available DEX conversion paths, intermediate hops, and effective rates.", + modeLabel: "Path Payment Mode", + strictSend: "Strict Send (Send exact amount)", + strictReceive: "Strict Receive (Receive exact amount)", + amountLabel: "Amount", + amountHint: "Amount to send (strict-send) or receive (strict-receive)", + sourceHeading: "Source Asset", + destHeading: "Destination Asset", + assetCodeLabel: "Asset Code", + assetIssuerLabel: "Issuer Account ID (leave blank for XLM)", + submit: "Find Routes", + loading: "Searching Routes...", + resultTitle: "Discovered Routes", + noRoutesTitle: "No Routes Available", + noRoutesDescription: + "The Stellar DEX does not currently offer an exchange path between these assets for the requested amount.", + sourceAmountHeader: "Source Amount", + destAmountHeader: "Destination Amount", + hopsHeader: "Hops", + effectiveRateHeader: "Effective Rate", + directRoute: "Direct (0 hops)", + hopsCount: (count: number) => (count === 1 ? "1 hop" : `${count} hops`), + errors: { + invalid_input: "Please check your asset codes, issuer addresses, and amount.", + no_routes_found: "No conversion routes found for this pair and amount.", + rate_limited: "Horizon rate limit exceeded. Please wait a moment.", + request_failed: "Unable to query route information from Horizon." + } +}; diff --git a/features/path-payment-finder/e2e/path-payment-finder.spec.ts b/features/path-payment-finder/e2e/path-payment-finder.spec.ts new file mode 100644 index 0000000..7abe8b5 --- /dev/null +++ b/features/path-payment-finder/e2e/path-payment-finder.spec.ts @@ -0,0 +1,15 @@ +/** + * End-to-end specification for the Path Payment Route Finder 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-finder", + steps: [ + { action: "visit", target: "/tools/path-payment-finder" }, + { action: "expect", target: "heading", value: "Path Payment Route Finder" }, + { action: "click", target: "submit" }, + { action: "expect", target: "alert" } + ] +} as const; diff --git a/features/path-payment-finder/fixtures/pathPaymentFinder.fixture.ts b/features/path-payment-finder/fixtures/pathPaymentFinder.fixture.ts new file mode 100644 index 0000000..1db4b98 --- /dev/null +++ b/features/path-payment-finder/fixtures/pathPaymentFinder.fixture.ts @@ -0,0 +1,60 @@ +import { Keypair } from "@stellar/stellar-sdk"; + +const seed = (byte: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte)); + +export const testIssuerA = seed(10).publicKey(); +export const testIssuerB = seed(11).publicKey(); +export const rateLimitedIssuer = seed(12).publicKey(); + +export const mockStrictSendPathsResponse = { + _embedded: { + records: [ + { + source_asset_type: "native", + source_amount: "100.0000000", + destination_asset_type: "credit_alphanum4", + destination_asset_code: "USDC", + destination_asset_issuer: testIssuerA, + destination_amount: "12.5000000", + path: [] + }, + { + source_asset_type: "native", + source_amount: "100.0000000", + destination_asset_type: "credit_alphanum4", + destination_asset_code: "USDC", + destination_asset_issuer: testIssuerA, + destination_amount: "12.3000000", + path: [ + { + asset_type: "credit_alphanum4", + asset_code: "EURT", + asset_issuer: testIssuerB + } + ] + } + ] + } +}; + +export const mockStrictReceivePathsResponse = { + _embedded: { + records: [ + { + source_asset_type: "credit_alphanum4", + source_asset_code: "USDC", + source_asset_issuer: testIssuerA, + source_amount: "8.0000000", + destination_asset_type: "native", + destination_amount: "50.0000000", + path: [] + } + ] + } +}; + +export const emptyPathsResponse = { + _embedded: { + records: [] + } +}; diff --git a/features/path-payment-finder/hooks/usePathPaymentFinder.ts b/features/path-payment-finder/hooks/usePathPaymentFinder.ts new file mode 100644 index 0000000..d33814a --- /dev/null +++ b/features/path-payment-finder/hooks/usePathPaymentFinder.ts @@ -0,0 +1,51 @@ +import { useState, useCallback } from "react"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { findPaymentPaths } from "@/features/path-payment-finder/lib/pathPaymentFinder"; +import type { + PathPaymentFinderErrorCode, + PathPaymentFinderInput, + PathPaymentFinderResult +} from "@/features/path-payment-finder/types"; + +export type PathPaymentFinderStatus = "idle" | "loading" | "success" | "error"; + +export function usePathPaymentFinder() { + const { network } = useNetwork(); + const [status, setStatus] = useState("idle"); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const run = useCallback( + async (input: PathPaymentFinderInput) => { + setStatus("loading"); + setError(null); + + const res = await findPaymentPaths(input, network); + + if (res.ok) { + setResult(res.value); + setStatus("success"); + } else { + setResult(null); + setError(res.code); + setStatus("error"); + } + }, + [network] + ); + + const reset = useCallback(() => { + setStatus("idle"); + setResult(null); + setError(null); + }, []); + + return { + status, + result, + error, + run, + reset, + pending: status === "loading" + }; +} diff --git a/features/path-payment-finder/lib/format.ts b/features/path-payment-finder/lib/format.ts new file mode 100644 index 0000000..6342d62 --- /dev/null +++ b/features/path-payment-finder/lib/format.ts @@ -0,0 +1,28 @@ +import type { PathHop } from "@/features/path-payment-finder/types"; + +export function formatAssetDisplay(code: string, issuer?: string): string { + if (!code || code === "XLM" || code.toLowerCase() === "native") return "XLM"; + if (!issuer) return code; + const shortIssuer = issuer.length > 8 ? `${issuer.slice(0, 4)}…${issuer.slice(-4)}` : issuer; + return `${code} (${shortIssuer})`; +} + +export function formatEffectiveRate(sourceAmount: string, destAmount: string): string { + const src = Number(sourceAmount); + const dst = Number(destAmount); + if (!src || !dst || Number.isNaN(src) || Number.isNaN(dst)) return "—"; + + const rate = dst / src; + if (rate >= 1000) { + return rate.toFixed(2); + } + if (rate >= 0.0001) { + return rate.toFixed(4); + } + return rate.toFixed(7); +} + +export function formatHopBreadcrumbs(path: PathHop[]): string { + if (!path || path.length === 0) return "Direct"; + return path.map((hop) => hop.code).join(" → "); +} diff --git a/features/path-payment-finder/lib/pathPaymentFinder.errors.ts b/features/path-payment-finder/lib/pathPaymentFinder.errors.ts new file mode 100644 index 0000000..bab2a38 --- /dev/null +++ b/features/path-payment-finder/lib/pathPaymentFinder.errors.ts @@ -0,0 +1,14 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { PathPaymentFinderErrorCode } from "@/features/path-payment-finder/types"; + +export function toPathPaymentFinderErrorCode(error: unknown): PathPaymentFinderErrorCode { + if (error === "invalid_input" || error === "no_routes_found") { + return error; + } + + const { code } = classifyHorizonError(error); + + if (code === "rate_limited") return "rate_limited"; + if (code === "bad_request") return "invalid_input"; + return "request_failed"; +} diff --git a/features/path-payment-finder/lib/pathPaymentFinder.ts b/features/path-payment-finder/lib/pathPaymentFinder.ts new file mode 100644 index 0000000..d247164 --- /dev/null +++ b/features/path-payment-finder/lib/pathPaymentFinder.ts @@ -0,0 +1,113 @@ +import { Asset } from "@stellar/stellar-sdk"; +import { err, ok, type Result } from "@/core/result/result"; +import { horizonServer } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { formatAssetDisplay, formatEffectiveRate } from "@/features/path-payment-finder/lib/format"; +import { toPathPaymentFinderErrorCode } from "@/features/path-payment-finder/lib/pathPaymentFinder.errors"; +import { validatePathPaymentFinderInput } from "@/features/path-payment-finder/schema"; +import type { + PathHop, + PathPaymentFinderErrorCode, + PathPaymentFinderInput, + PathPaymentFinderResult, + PaymentRoute +} from "@/features/path-payment-finder/types"; + +interface RawPathHop { + asset_type?: string; + asset_code?: string; + asset_issuer?: string; +} + +interface RawPaymentPathRecord { + source_amount: string; + source_asset_type: string; + source_asset_code?: string; + source_asset_issuer?: string; + destination_amount: string; + destination_asset_type: string; + destination_asset_code?: string; + destination_asset_issuer?: string; + path: RawPathHop[]; +} + +export function toStellarAsset(code: string, issuer?: string): Asset { + if (!code || code === "XLM" || code.toLowerCase() === "native" || !issuer) { + return Asset.native(); + } + return new Asset(code, issuer); +} + +export function normalizePaymentRoute(raw: RawPaymentPathRecord): PaymentRoute { + const pathHops: PathHop[] = Array.isArray(raw.path) + ? raw.path.map((hop) => ({ + code: hop.asset_type === "native" ? "XLM" : (hop.asset_code || "UNKNOWN"), + issuer: hop.asset_issuer, + type: hop.asset_type || "credit_alphanum4" + })) + : []; + + const sourceAsset = + raw.source_asset_type === "native" + ? "XLM" + : formatAssetDisplay(raw.source_asset_code || "UNKNOWN", raw.source_asset_issuer); + + const destinationAsset = + raw.destination_asset_type === "native" + ? "XLM" + : formatAssetDisplay(raw.destination_asset_code || "UNKNOWN", raw.destination_asset_issuer); + + return { + sourceAmount: raw.source_amount, + sourceAsset, + destinationAmount: raw.destination_amount, + destinationAsset, + path: pathHops, + hopsCount: pathHops.length, + effectiveRate: formatEffectiveRate(raw.source_amount, raw.destination_amount) + }; +} + +export async function findPaymentPaths( + input: PathPaymentFinderInput, + network: StellarNetwork +): Promise> { + const validation = validatePathPaymentFinderInput(input); + if (!validation.ok) { + return err(validation.code); + } + + const valid = validation.value; + const sourceAsset = toStellarAsset(valid.sourceCode, valid.sourceIssuer); + const destAsset = toStellarAsset(valid.destCode, valid.destIssuer); + + try { + const server = horizonServer(network); + + let records: RawPaymentPathRecord[] = []; + + if (valid.mode === "strict-send") { + const page = await server + .strictSendPaths(sourceAsset, valid.amount, [destAsset]) + .call(); + records = (page.records || []) as unknown as RawPaymentPathRecord[]; + } else { + const page = await server + .strictReceivePaths([sourceAsset], destAsset, valid.amount) + .call(); + records = (page.records || []) as unknown as RawPaymentPathRecord[]; + } + + const routes = records.map(normalizePaymentRoute); + + return ok({ + mode: valid.mode, + sourceAsset: formatAssetDisplay(valid.sourceCode, valid.sourceIssuer), + destAsset: formatAssetDisplay(valid.destCode, valid.destIssuer), + amount: valid.amount, + routes + }); + } catch (error) { + return err(toPathPaymentFinderErrorCode(error)); + } +} diff --git a/features/path-payment-finder/manifest.ts b/features/path-payment-finder/manifest.ts new file mode 100644 index 0000000..b8e999f --- /dev/null +++ b/features/path-payment-finder/manifest.ts @@ -0,0 +1,23 @@ +import { Route } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "path-payment-finder", + title: "Path Payment Route Finder", + description: + "Find the routes Stellar's decentralised exchange offers between two assets, for both strict-send and strict-receive, and show the hops and effective rate for each.", + character: "A route explorer charts paths through Stellar DEX order books and liquidity pools.", + category: "payments", + status: "working", + icon: Route, + networks: ["testnet", "mainnet"], + keywords: [ + "path-payment", + "strict-send", + "strict-receive", + "dex", + "liquidity-pool", + "swap", + "routing" + ] +}; diff --git a/features/path-payment-finder/msw/handlers.ts b/features/path-payment-finder/msw/handlers.ts new file mode 100644 index 0000000..b1a5772 --- /dev/null +++ b/features/path-payment-finder/msw/handlers.ts @@ -0,0 +1,48 @@ +import { http, HttpResponse, delay } from "msw"; +import { + emptyPathsResponse, + mockStrictReceivePathsResponse, + mockStrictSendPathsResponse, + rateLimitedIssuer +} from "@/features/path-payment-finder/fixtures/pathPaymentFinder.fixture"; + +const TESTNET = "https://horizon-testnet.stellar.org"; + +export const handlers = [ + http.get(`${TESTNET}/paths/strict-send`, ({ request }) => { + const url = new URL(request.url); + const destAssets = url.searchParams.get("destination_assets") || ""; + + if (destAssets.includes(rateLimitedIssuer)) { + return HttpResponse.json( + { title: "Rate Limit Exceeded", status: 429, detail: "Too Many Requests" }, + { status: 429 } + ); + } + + if (url.searchParams.get("source_amount")?.startsWith("999")) { + return HttpResponse.json(emptyPathsResponse); + } + + return HttpResponse.json(mockStrictSendPathsResponse); + }), + + http.get(`${TESTNET}/paths/strict-receive`, ({ request }) => { + const url = new URL(request.url); + const sourceAssets = url.searchParams.get("source_assets") || ""; + + if (sourceAssets.includes(rateLimitedIssuer)) { + return HttpResponse.json( + { title: "Rate Limit Exceeded", status: 429, detail: "Too Many Requests" }, + { status: 429 } + ); + } + + return HttpResponse.json(mockStrictReceivePathsResponse); + }) +]; + +export const pendingHandler = http.get(`${TESTNET}/paths/strict-send`, async () => { + await delay("infinite"); + return HttpResponse.json(mockStrictSendPathsResponse); +}); diff --git a/features/path-payment-finder/panel.tsx b/features/path-payment-finder/panel.tsx new file mode 100644 index 0000000..fc6ca6a --- /dev/null +++ b/features/path-payment-finder/panel.tsx @@ -0,0 +1 @@ +export { PathPaymentFinderPanel as default } from "@/features/path-payment-finder/components/PathPaymentFinderPanel"; diff --git a/features/path-payment-finder/schema.ts b/features/path-payment-finder/schema.ts new file mode 100644 index 0000000..11d2bc6 --- /dev/null +++ b/features/path-payment-finder/schema.ts @@ -0,0 +1,96 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { StrKey } from "@stellar/stellar-sdk"; +import type { + PathMode, + PathPaymentFinderErrorCode, + PathPaymentFinderInput +} from "@/features/path-payment-finder/types"; + +function isSecretKey(val: string): boolean { + const trimmed = val.trim(); + if (trimmed.startsWith("S") && trimmed.length === 56) return true; + try { + return StrKey.isValidEd25519SecretSeed(trimmed); + } catch { + return false; + } +} + +function isValidAssetCode(code: string): boolean { + return /^[A-Za-z0-9]{1,12}$/.test(code); +} + +function isValidIssuer(issuer: string): boolean { + return ( + !issuer.startsWith("S") && + issuer.length === 56 && + StrKey.isValidEd25519PublicKey(issuer) + ); +} + +export function validatePathPaymentFinderInput( + raw: unknown +): Result { + if (!raw || typeof raw !== "object") { + return err("invalid_input"); + } + + const data = raw as Record; + + const mode = (data.mode as PathMode) || "strict-send"; + if (mode !== "strict-send" && mode !== "strict-receive") { + return err("invalid_input"); + } + + const amount = typeof data.amount === "string" ? data.amount.trim() : ""; + if ( + !amount || + !/^(?:0|[1-9]\d*)(?:\.\d{1,7})?$/.test(amount) || + Number(amount) <= 0 + ) { + return err("invalid_input"); + } + + const sourceCode = typeof data.sourceCode === "string" ? data.sourceCode.trim().toUpperCase() : ""; + const sourceIssuer = typeof data.sourceIssuer === "string" ? data.sourceIssuer.trim() : ""; + + const destCode = typeof data.destCode === "string" ? data.destCode.trim().toUpperCase() : ""; + const destIssuer = typeof data.destIssuer === "string" ? data.destIssuer.trim() : ""; + + if ( + isSecretKey(sourceCode) || + isSecretKey(sourceIssuer) || + isSecretKey(destCode) || + isSecretKey(destIssuer) + ) { + return err("invalid_input"); + } + + if (!isValidAssetCode(sourceCode) || !isValidAssetCode(destCode)) { + return err("invalid_input"); + } + + if (sourceCode !== "XLM" && !isValidIssuer(sourceIssuer)) { + return err("invalid_input"); + } + + if (destCode !== "XLM" && !isValidIssuer(destIssuer)) { + return err("invalid_input"); + } + + // Assets cannot be identical + const sourceKey = sourceCode === "XLM" ? "native" : `${sourceCode}:${sourceIssuer}`; + const destKey = destCode === "XLM" ? "native" : `${destCode}:${destIssuer}`; + if (sourceKey === destKey) { + return err("invalid_input"); + } + + return ok({ + mode, + sourceCode, + sourceIssuer: sourceCode === "XLM" ? undefined : sourceIssuer, + destCode, + destIssuer: destCode === "XLM" ? undefined : destIssuer, + amount + }); +} diff --git a/features/path-payment-finder/types.ts b/features/path-payment-finder/types.ts new file mode 100644 index 0000000..603b731 --- /dev/null +++ b/features/path-payment-finder/types.ts @@ -0,0 +1,40 @@ +export type PathMode = "strict-send" | "strict-receive"; + +export interface PathHop { + code: string; + issuer?: string; + type: string; +} + +export interface PaymentRoute { + sourceAmount: string; + sourceAsset: string; + destinationAmount: string; + destinationAsset: string; + path: PathHop[]; + hopsCount: number; + effectiveRate: string; +} + +export interface PathPaymentFinderInput { + mode: PathMode; + sourceCode: string; + sourceIssuer?: string; + destCode: string; + destIssuer?: string; + amount: string; +} + +export interface PathPaymentFinderResult { + mode: PathMode; + sourceAsset: string; + destAsset: string; + amount: string; + routes: PaymentRoute[]; +} + +export type PathPaymentFinderErrorCode = + | "invalid_input" + | "no_routes_found" + | "rate_limited" + | "request_failed"; diff --git a/features/payment-history/README.md b/features/payment-history/README.md new file mode 100644 index 0000000..c00c3bd --- /dev/null +++ b/features/payment-history/README.md @@ -0,0 +1,25 @@ +# Account Payment History + +TODO: what this tool does, in two or three sentences. + +## How it works + +TODO: the data source, the request it makes (or why it makes none), and the +shape of the result. + +## Files + +| Path | Responsibility | +| --- | --- | +| `manifest.ts` | Registry metadata | +| `schema.ts` | Input parsing and validation | +| `lib/` | Tool logic and error mapping | +| `hooks/` | React state machine | +| `components/` | Form, result, empty and error UI | +| `__tests__/` | Unit, hook, component and accessibility tests | +| `fixtures/` | Deterministic sample data | +| `msw/` | Request mocks | + +## Safety + +TODO: state explicitly that this tool never asks for a secret key. diff --git a/features/payment-history/__tests__/PaymentHistoryPanel.test.tsx b/features/payment-history/__tests__/PaymentHistoryPanel.test.tsx new file mode 100644 index 0000000..9d7201a --- /dev/null +++ b/features/payment-history/__tests__/PaymentHistoryPanel.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@/core/testing/render"; +import { renderFeatureSlice } from "@/core/testing/contract"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { PaymentHistoryPanel } from "@/features/payment-history/components/PaymentHistoryPanel"; +import { copy, errorCopy } from "@/features/payment-history/copy"; +import { handlers, pendingHandler } from "@/features/payment-history/msw/handlers"; +import { + emptyAccount, + missingAccount, + queriedAccount +} from "@/features/payment-history/fixtures/paymentHistory.fixture"; + +const server = withMswHandlers(...handlers); + +function render() { + return renderFeatureSlice("payment-history", ); +} + +async function fetchFor(slice: ReturnType, address: string) { + await slice.user.type(screen.getByLabelText(copy.formLabel), address); + await slice.user.click(screen.getByRole("button", { name: copy.submit })); +} + +describe("PaymentHistoryPanel", () => { + it("shows the empty state first", () => { + const slice = render(); + slice.expectEmptyState(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows loading state while request is in flight", async () => { + server.use(pendingHandler); + const slice = render(); + await fetchFor(slice, queriedAccount); + await slice.waitForState("loading"); + slice.expectLoadingState(); + }); + + it("renders payment records on success", async () => { + resetHorizonClients(); + const slice = render(); + await fetchFor(slice, queriedAccount); + + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + + expect(screen.getByText("Create Account")).toBeInTheDocument(); + expect(screen.getByText("Payment")).toBeInTheDocument(); + expect(screen.getByText("Path Payment (Strict Send)")).toBeInTheDocument(); + expect(screen.getByText("Path Payment (Strict Receive)")).toBeInTheDocument(); + expect(screen.getByText("25.0000000")).toBeInTheDocument(); + expect(screen.getByText("100.5000000")).toBeInTheDocument(); + }); + + it("distinguishes account with no payments from error state", async () => { + resetHorizonClients(); + const slice = render(); + await fetchFor(slice, emptyAccount); + + expect(await screen.findByText(copy.noPaymentsTitle)).toBeInTheDocument(); + expect(screen.getByText(copy.noPaymentsDescription)).toBeInTheDocument(); + }); + + it("shows error state when account does not exist (404)", async () => { + resetHorizonClients(); + const slice = render(); + await fetchFor(slice, missingAccount); + + expect(await screen.findByText(errorCopy.account_not_found.title)).toBeInTheDocument(); + slice.expectErrorState(); + }); + + it("shows error state on invalid address", async () => { + const slice = render(); + await fetchFor(slice, "GBADADDRESS"); + + expect(await screen.findByText(errorCopy.invalid_address.title)).toBeInTheDocument(); + slice.expectErrorState(); + }); +}); diff --git a/features/payment-history/__tests__/a11y.test.tsx b/features/payment-history/__tests__/a11y.test.tsx new file mode 100644 index 0000000..1a185c8 --- /dev/null +++ b/features/payment-history/__tests__/a11y.test.tsx @@ -0,0 +1,29 @@ +import { describe, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { PaymentHistoryPanel } from "@/features/payment-history/components/PaymentHistoryPanel"; +import { copy } from "@/features/payment-history/copy"; +import { handlers } from "@/features/payment-history/msw/handlers"; +import { queriedAccount } from "@/features/payment-history/fixtures/paymentHistory.fixture"; + +withMswHandlers(...handlers); + +describe("PaymentHistoryPanel 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 with payment history loaded", async () => { + resetHorizonClients(); + const { container, user } = renderFeature(); + + await user.type(screen.getByLabelText(copy.formLabel), queriedAccount); + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.resultTitle); + + await expectNoAxeViolations(container); + }); +}); diff --git a/features/payment-history/__tests__/format.test.ts b/features/payment-history/__tests__/format.test.ts new file mode 100644 index 0000000..877337b --- /dev/null +++ b/features/payment-history/__tests__/format.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + formatShortAddress, + formatTimestamp, + formatTypeLabel, + getDirectionBadgeClass +} from "@/features/payment-history/lib/format"; + +describe("PaymentHistory format helpers", () => { + it("formats short address", () => { + expect(formatShortAddress("")).toBe("—"); + expect(formatShortAddress("—")).toBe("—"); + expect(formatShortAddress("GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H")).toBe( + "GAAQCA…AQDZ7H" + ); + }); + + it("formats timestamp", () => { + expect(formatTimestamp("")).toBe("—"); + expect(formatTimestamp("2026-05-01T12:00:00Z")).toContain("2026"); + }); + + it("returns direction badge class", () => { + expect(getDirectionBadgeClass("incoming")).toContain("emerald"); + expect(getDirectionBadgeClass("outgoing")).toContain("sky"); + }); + + it("formats type label", () => { + expect(formatTypeLabel("create_account")).toBe("Create Account"); + expect(formatTypeLabel("payment")).toBe("Payment"); + expect(formatTypeLabel("path_payment_strict_send")).toBe("Path Send"); + expect(formatTypeLabel("path_payment_strict_receive")).toBe("Path Receive"); + }); +}); diff --git a/features/payment-history/__tests__/paymentHistory.test.ts b/features/payment-history/__tests__/paymentHistory.test.ts new file mode 100644 index 0000000..f2aebcc --- /dev/null +++ b/features/payment-history/__tests__/paymentHistory.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + fetchPaymentHistory, + normalizePaymentRecord +} from "@/features/payment-history/lib/paymentHistory"; +import { + counterpartyAccount, + emptyAccount, + missingAccount, + queriedAccount, + rateLimitedAccount +} from "@/features/payment-history/fixtures/paymentHistory.fixture"; +import { withMswHandlers } from "@/core/testing/msw"; +import { handlers } from "@/features/payment-history/msw/handlers"; + +withMswHandlers(...handlers); + +describe("paymentHistory library", () => { + it("normalizes create_account correctly for funder and recipient", () => { + const raw = { + type: "create_account", + id: "1", + account: queriedAccount, + funder: counterpartyAccount, + starting_balance: "50.0000000", + transaction_hash: "tx1", + created_at: "2026-05-01T12:00:00Z" + }; + + // Queried account was the created account -> incoming + const inRecord = normalizePaymentRecord(raw, queriedAccount); + expect(inRecord.direction).toBe("incoming"); + expect(inRecord.counterparty).toBe(counterpartyAccount); + expect(inRecord.amount).toBe("50.0000000"); + expect(inRecord.asset).toBe("XLM"); + + // Counterparty was funder -> outgoing + const outRecord = normalizePaymentRecord(raw, counterpartyAccount); + expect(outRecord.direction).toBe("outgoing"); + expect(outRecord.counterparty).toBe(queriedAccount); + }); + + it("normalizes standard payment and path payments correctly", () => { + const paymentRaw = { + type: "payment", + id: "2", + from: queriedAccount, + to: counterpartyAccount, + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: counterpartyAccount, + amount: "10.0000000", + transaction_hash: "tx2" + }; + + const outPayment = normalizePaymentRecord(paymentRaw, queriedAccount); + expect(outPayment.direction).toBe("outgoing"); + expect(outPayment.counterparty).toBe(counterpartyAccount); + expect(outPayment.asset).toBe("USDC"); + expect(outPayment.amount).toBe("10.0000000"); + + const pathRaw = { + type: "path_payment_strict_send", + id: "3", + from: counterpartyAccount, + to: queriedAccount, + asset_type: "native", + amount: "100.0000000", + source_asset_type: "credit_alphanum4", + source_asset_code: "EURT", + source_amount: "20.0000000" + }; + + const inPath = normalizePaymentRecord(pathRaw, queriedAccount); + expect(inPath.direction).toBe("incoming"); + expect(inPath.counterparty).toBe(counterpartyAccount); + expect(inPath.asset).toBe("XLM"); + expect(inPath.amount).toBe("100.0000000"); + }); + + it("fetches payment history for an account on testnet", async () => { + const res = await fetchPaymentHistory({ accountId: queriedAccount }, "testnet"); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.payments).toHaveLength(4); + expect(res.value.nextCursor).toBe("104"); + expect(res.value.prevCursor).toBe("101"); + } + }); + + it("returns empty payments array for account with no payments", async () => { + const res = await fetchPaymentHistory({ accountId: emptyAccount }, "testnet"); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.payments).toHaveLength(0); + } + }); + + it("returns account_not_found error for missing account (404)", async () => { + const res = await fetchPaymentHistory({ accountId: missingAccount }, "testnet"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("account_not_found"); + } + }); + + it("returns rate_limited error when Horizon returns 429", async () => { + const res = await fetchPaymentHistory({ accountId: rateLimitedAccount }, "testnet"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("rate_limited"); + } + }); +}); diff --git a/features/payment-history/__tests__/schema.test.ts b/features/payment-history/__tests__/schema.test.ts new file mode 100644 index 0000000..ce349a7 --- /dev/null +++ b/features/payment-history/__tests__/schema.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { parsePaymentHistoryInput } from "@/features/payment-history/schema"; + +describe("PaymentHistory schema", () => { + it("rejects empty input", () => { + const res = parsePaymentHistoryInput(""); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("empty_input"); + }); + + it("rejects whitespace-only input", () => { + const res = parsePaymentHistoryInput(" \n\t "); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("empty_input"); + }); + + it("rejects invalid StrKey address", () => { + const res = parsePaymentHistoryInput("GBADADDRESSWITHINVALIDCHECKSUM"); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("invalid_address"); + }); + + it("rejects non-G address (e.g. secret key or contract)", () => { + const secret = Keypair.random().secret(); + const res = parsePaymentHistoryInput(secret); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.code).toBe("invalid_address"); + }); + + it("accepts a valid Stellar G-address and strips whitespace", () => { + const pubKey = Keypair.random().publicKey(); + const res = parsePaymentHistoryInput(` ${pubKey} `); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value.accountId).toBe(pubKey); + }); +}); diff --git a/features/payment-history/__tests__/usePaymentHistory.test.tsx b/features/payment-history/__tests__/usePaymentHistory.test.tsx new file mode 100644 index 0000000..4ef7119 --- /dev/null +++ b/features/payment-history/__tests__/usePaymentHistory.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { NetworkProvider } from "@/core/network/NetworkProvider"; +import { withMswHandlers } from "@/core/testing/msw"; +import { resetHorizonClients } from "@/core/horizon/client"; +import { usePaymentHistory } from "@/features/payment-history/hooks/usePaymentHistory"; +import { handlers } from "@/features/payment-history/msw/handlers"; +import { + missingAccount, + queriedAccount +} from "@/features/payment-history/fixtures/paymentHistory.fixture"; + +withMswHandlers(...handlers); + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("usePaymentHistory", () => { + it("starts idle", () => { + const { result } = renderHook(() => usePaymentHistory(), { wrapper }); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("loads payment history for a valid account", async () => { + resetHorizonClients(); + const { result } = renderHook(() => usePaymentHistory(), { wrapper }); + + await act(async () => { + await result.current.submit(queriedAccount); + }); + + await waitFor(() => expect(result.current.state.status).toBe("success")); + if (result.current.state.status === "success") { + expect(result.current.state.page.payments).toHaveLength(4); + } + }); + + it("rejects an invalid address without a request", async () => { + const { result } = renderHook(() => usePaymentHistory(), { wrapper }); + + await act(async () => { + await result.current.submit("not-an-address"); + }); + + expect(result.current.state).toEqual({ status: "error", code: "invalid_address" }); + }); + + it("reports account_not_found when account does not exist", async () => { + resetHorizonClients(); + const { result } = renderHook(() => usePaymentHistory(), { wrapper }); + + await act(async () => { + await result.current.submit(missingAccount); + }); + + await waitFor(() => + expect(result.current.state).toEqual({ status: "error", code: "account_not_found" }) + ); + }); + + it("clears state on reset", async () => { + resetHorizonClients(); + const { result } = renderHook(() => usePaymentHistory(), { wrapper }); + + await act(async () => { + await result.current.submit(queriedAccount); + }); + await waitFor(() => expect(result.current.state.status).toBe("success")); + + act(() => result.current.reset()); + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/features/payment-history/components/PaymentHistoryEmptyState.tsx b/features/payment-history/components/PaymentHistoryEmptyState.tsx new file mode 100644 index 0000000..7302d30 --- /dev/null +++ b/features/payment-history/components/PaymentHistoryEmptyState.tsx @@ -0,0 +1,13 @@ +import { History } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/payment-history/copy"; + +export function PaymentHistoryEmptyState() { + return ( + + ); +} diff --git a/features/payment-history/components/PaymentHistoryForm.tsx b/features/payment-history/components/PaymentHistoryForm.tsx new file mode 100644 index 0000000..b5a0116 --- /dev/null +++ b/features/payment-history/components/PaymentHistoryForm.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { Input } from "@/core/ui/Input"; +import { copy } from "@/features/payment-history/copy"; + +interface Props { + onSubmit: (accountId: string) => void; + pending: boolean; +} + +export function PaymentHistoryForm({ onSubmit, pending }: Props) { + const [value, setValue] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(value); + }; + + return ( +
+ + {({ inputId, describedBy, invalid, required }) => ( + setValue(e.target.value)} + placeholder="G..." + disabled={pending} + autoComplete="off" + spellCheck={false} + className="font-mono text-xs" + /> + )} + + +
+ +
+
+ ); +} diff --git a/features/payment-history/components/PaymentHistoryPanel.tsx b/features/payment-history/components/PaymentHistoryPanel.tsx new file mode 100644 index 0000000..924d245 --- /dev/null +++ b/features/payment-history/components/PaymentHistoryPanel.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Card } from "@/core/ui/Card"; +import { SkeletonRows } from "@/core/ui/Skeleton"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { usePaymentHistory } from "@/features/payment-history/hooks/usePaymentHistory"; +import { copy, errorCopy } from "@/features/payment-history/copy"; +import { PaymentHistoryForm } from "@/features/payment-history/components/PaymentHistoryForm"; +import { PaymentHistoryResult } from "@/features/payment-history/components/PaymentHistoryResult"; +import { PaymentHistoryEmptyState } from "@/features/payment-history/components/PaymentHistoryEmptyState"; + +export function PaymentHistoryPanel() { + const { state, submit, paginate } = usePaymentHistory(); + + return ( +
+ + + + + {state.status === "loading" ? ( + +

+ {copy.loading} +

+ +
+ ) : null} + + {state.status === "error" ? ( + + ) : null} + + {state.status === "success" ? ( + + ) : null} + + {state.status === "idle" ? : null} +
+ ); +} diff --git a/features/payment-history/components/PaymentHistoryResult.tsx b/features/payment-history/components/PaymentHistoryResult.tsx new file mode 100644 index 0000000..249031e --- /dev/null +++ b/features/payment-history/components/PaymentHistoryResult.tsx @@ -0,0 +1,119 @@ +import { Card, CardHeader, CardTitle } from "@/core/ui/Card"; +import { Button } from "@/core/ui/Button"; +import { CopyableValue } from "@/core/ui/CopyableValue"; +import { StatusMessage } from "@/core/ui/StatusMessage"; +import { copy } from "@/features/payment-history/copy"; +import { + formatShortAddress, + formatTimestamp, + getDirectionBadgeClass +} from "@/features/payment-history/lib/format"; +import type { PaymentHistoryPage } from "@/features/payment-history/types"; + +interface Props { + page: PaymentHistoryPage; + onPaginate?: (cursor: string) => void; + pending?: boolean; +} + +export function PaymentHistoryResult({ page, onPaginate, pending }: Props) { + if (page.payments.length === 0) { + return ( + + ); + } + + return ( +
+ + + {copy.resultTitle} + + +
+ + + + + + + + + + + + + {page.payments.map((payment) => ( + + + + + + + + + ))} + +
DirectionType{copy.counterpartyLabel}{copy.amountLabel}{copy.txLabel}{copy.dateLabel}
+ + {payment.direction === "incoming" ? copy.directionIncoming : copy.directionOutgoing} + + + {payment.typeLabel} + + {payment.counterparty !== "—" ? ( + + ) : ( + "—" + )} + + {payment.amount}{" "} + {payment.asset} + + {payment.transactionHash ? ( + + ) : ( + "—" + )} + + {formatTimestamp(payment.createdAt)} +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/features/payment-history/copy.ts b/features/payment-history/copy.ts new file mode 100644 index 0000000..d0b1302 --- /dev/null +++ b/features/payment-history/copy.ts @@ -0,0 +1,44 @@ +import type { PaymentHistoryErrorCode } from "@/features/payment-history/types"; + +export const copy = { + formLabel: "Account address", + formHint: "Public G-address whose payments you want to browse.", + submit: "Fetch payment history", + loading: "Loading payments...", + emptyTitle: "No account selected", + emptyDescription: "Enter a Stellar public key to inspect its incoming and outgoing payment history.", + noPaymentsTitle: "No payments found", + noPaymentsDescription: "This account exists on the network but has no payment operations recorded.", + resultTitle: "Payment history", + directionIncoming: "IN", + directionOutgoing: "OUT", + counterpartyLabel: "Counterparty", + amountLabel: "Amount", + txLabel: "Transaction", + dateLabel: "Date", + prevPage: "Previous page", + nextPage: "Next page" +} as const; + +export const errorCopy: Record = { + empty_input: { + title: "Enter an account address", + description: "Provide a valid Stellar G-address to retrieve its payment history." + }, + invalid_address: { + title: "Invalid Stellar address", + description: "The address must be a valid 56-character Ed25519 public key starting with G." + }, + account_not_found: { + title: "Account not found", + description: "This account does not exist on the selected network." + }, + rate_limited: { + title: "Horizon rate limited", + description: "Horizon is temporarily rate limiting requests. Please wait a moment and try again." + }, + request_failed: { + title: "Could not fetch payment history", + description: "The request did not complete. Check your connection or the Horizon endpoint status." + } +}; diff --git a/features/payment-history/e2e/payment-history.spec.ts b/features/payment-history/e2e/payment-history.spec.ts new file mode 100644 index 0000000..dcc586c --- /dev/null +++ b/features/payment-history/e2e/payment-history.spec.ts @@ -0,0 +1,15 @@ +/** + * End-to-end specification for the Account Payment History tool. + * + * Documented as executable steps so the behaviour is reviewable even before a + * browser runner is wired into CI. + */ +export const spec = { + route: "/tools/payment-history", + steps: [ + { action: "visit", target: "/tools/payment-history" }, + { action: "expect", target: "heading", value: "Account Payment History" }, + { action: "click", target: "submit" }, + { action: "expect", target: "alert" } + ] +} as const; diff --git a/features/payment-history/fixtures/paymentHistory.fixture.ts b/features/payment-history/fixtures/paymentHistory.fixture.ts new file mode 100644 index 0000000..bf491cb --- /dev/null +++ b/features/payment-history/fixtures/paymentHistory.fixture.ts @@ -0,0 +1,88 @@ +import { Keypair } from "@stellar/stellar-sdk"; + +const seed = (byte: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, byte)); + +export const queriedAccount = seed(1).publicKey(); +export const counterpartyAccount = seed(2).publicKey(); +export const emptyAccount = seed(3).publicKey(); +export const missingAccount = seed(4).publicKey(); +export const rateLimitedAccount = seed(5).publicKey(); + +export const txHash1 = "1".repeat(64); +export const txHash2 = "2".repeat(64); +export const txHash3 = "3".repeat(64); +export const txHash4 = "4".repeat(64); + +export const mockPaymentRecords = [ + { + id: "op-101", + paging_token: "101", + type: "create_account", + account: queriedAccount, + funder: counterpartyAccount, + starting_balance: "25.0000000", + transaction_hash: txHash1, + created_at: "2026-05-01T12:00:00Z" + }, + { + id: "op-102", + paging_token: "102", + type: "payment", + from: queriedAccount, + to: counterpartyAccount, + asset_type: "native", + amount: "100.5000000", + transaction_hash: txHash2, + created_at: "2026-05-02T14:30:00Z" + }, + { + id: "op-103", + paging_token: "103", + type: "path_payment_strict_send", + from: counterpartyAccount, + to: queriedAccount, + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: counterpartyAccount, + amount: "50.0000000", + source_asset_type: "native", + source_amount: "250.0000000", + transaction_hash: txHash3, + created_at: "2026-05-03T09:15:00Z" + }, + { + id: "op-104", + paging_token: "104", + type: "path_payment_strict_receive", + from: queriedAccount, + to: counterpartyAccount, + asset_type: "native", + amount: "75.0000000", + source_asset_type: "credit_alphanum4", + source_asset_code: "EURT", + source_asset_issuer: counterpartyAccount, + source_amount: "15.0000000", + transaction_hash: txHash4, + created_at: "2026-05-04T16:45:00Z" + } +]; + +export const paymentsResponse = { + _embedded: { + records: mockPaymentRecords + }, + _links: { + self: { href: `https://horizon-testnet.stellar.org/accounts/${queriedAccount}/payments?order=desc&limit=20` }, + next: { href: `https://horizon-testnet.stellar.org/accounts/${queriedAccount}/payments?order=desc&limit=20&cursor=104` }, + prev: { href: `https://horizon-testnet.stellar.org/accounts/${queriedAccount}/payments?order=desc&limit=20&cursor=101` } + } +}; + +export const emptyPaymentsResponse = { + _embedded: { + records: [] + }, + _links: { + self: { href: `https://horizon-testnet.stellar.org/accounts/${emptyAccount}/payments?order=desc&limit=20` } + } +}; diff --git a/features/payment-history/hooks/usePaymentHistory.ts b/features/payment-history/hooks/usePaymentHistory.ts new file mode 100644 index 0000000..b0cb68a --- /dev/null +++ b/features/payment-history/hooks/usePaymentHistory.ts @@ -0,0 +1,96 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useNetwork } from "@/core/network/NetworkProvider"; +import { isErr } from "@/core/result/result"; +import type { StellarNetwork } from "@/core/network/types"; +import { parsePaymentHistoryInput } from "@/features/payment-history/schema"; +import { fetchPaymentHistory } from "@/features/payment-history/lib/paymentHistory"; +import type { + PaymentHistoryErrorCode, + PaymentHistoryPage +} from "@/features/payment-history/types"; + +export type PaymentHistoryState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; page: PaymentHistoryPage } + | { status: "error"; code: PaymentHistoryErrorCode }; + +const IDLE: PaymentHistoryState = { status: "idle" }; + +interface Held { + state: PaymentHistoryState; + network: StellarNetwork; +} + +export function usePaymentHistory() { + const { network } = useNetwork(); + const [held, setHeld] = useState({ state: IDLE, network }); + const currentAccount = useRef(""); + const requestId = useRef(0); + + const state = held.network === network ? held.state : IDLE; + + const submit = useCallback( + async (raw: string) => { + const parsed = parsePaymentHistoryInput(raw); + if (isErr(parsed)) { + setHeld({ state: { status: "error", code: parsed.code }, network }); + return; + } + + currentAccount.current = parsed.value.accountId; + requestId.current += 1; + const id = requestId.current; + setHeld({ state: { status: "loading" }, network }); + + const result = await fetchPaymentHistory( + { accountId: parsed.value.accountId }, + network + ); + + if (id !== requestId.current) return; + + setHeld({ + state: result.ok + ? { status: "success", page: result.value } + : { status: "error", code: result.code }, + network + }); + }, + [network] + ); + + const paginate = useCallback( + async (cursor: string) => { + if (!currentAccount.current) return; + requestId.current += 1; + const id = requestId.current; + setHeld({ state: { status: "loading" }, network }); + + const result = await fetchPaymentHistory( + { accountId: currentAccount.current, cursor }, + network + ); + + if (id !== requestId.current) return; + + setHeld({ + state: result.ok + ? { status: "success", page: result.value } + : { status: "error", code: result.code }, + network + }); + }, + [network] + ); + + const reset = useCallback(() => { + currentAccount.current = ""; + requestId.current += 1; + setHeld({ state: IDLE, network }); + }, [network]); + + return { state, submit, paginate, reset }; +} diff --git a/features/payment-history/lib/format.ts b/features/payment-history/lib/format.ts new file mode 100644 index 0000000..2a27bc4 --- /dev/null +++ b/features/payment-history/lib/format.ts @@ -0,0 +1,29 @@ +import { formatDateTime } from "@/core/format/date"; +import type { PaymentDirection, PaymentType } from "@/features/payment-history/types"; + +export function formatTimestamp(isoString: string): string { + if (!isoString) return "—"; + return formatDateTime(isoString); +} + +export function formatShortAddress(address: string): string { + if (!address || address === "—") return "—"; + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-6)}`; +} + +export function getDirectionBadgeClass(direction: PaymentDirection): string { + return direction === "incoming" + ? "bg-emerald-50 text-emerald-700 border-emerald-200" + : "bg-sky-50 text-sky-700 border-sky-200"; +} + +export function formatTypeLabel(type: PaymentType): string { + const labels: Record = { + create_account: "Create Account", + payment: "Payment", + path_payment_strict_send: "Path Send", + path_payment_strict_receive: "Path Receive" + }; + return labels[type] || type; +} diff --git a/features/payment-history/lib/paymentHistory.errors.ts b/features/payment-history/lib/paymentHistory.errors.ts new file mode 100644 index 0000000..651897d --- /dev/null +++ b/features/payment-history/lib/paymentHistory.errors.ts @@ -0,0 +1,10 @@ +import { classifyHorizonError } from "@/core/horizon/errors"; +import type { PaymentHistoryErrorCode } from "@/features/payment-history/types"; + +export function toPaymentHistoryErrorCode(error: unknown): PaymentHistoryErrorCode { + const { code } = classifyHorizonError(error); + + if (code === "not_found") return "account_not_found"; + if (code === "rate_limited") return "rate_limited"; + return "request_failed"; +} diff --git a/features/payment-history/lib/paymentHistory.ts b/features/payment-history/lib/paymentHistory.ts new file mode 100644 index 0000000..863ce1a --- /dev/null +++ b/features/payment-history/lib/paymentHistory.ts @@ -0,0 +1,151 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { horizonServer } from "@/core/horizon/client"; +import type { StellarNetwork } from "@/core/network/types"; +import { toPaymentHistoryErrorCode } from "@/features/payment-history/lib/paymentHistory.errors"; +import type { + NormalizedPayment, + PaymentDirection, + PaymentHistoryErrorCode, + PaymentHistoryInput, + PaymentHistoryPage, + PaymentType +} from "@/features/payment-history/types"; + +interface HorizonRawPaymentRecord { + id?: string; + type?: string; + type_i?: number; + paging_token?: string; + transaction_hash?: string; + created_at?: string; + account?: string; + funder?: string; + starting_balance?: string; + from?: string; + to?: string; + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + amount?: string; + source_asset_type?: string; + source_asset_code?: string; + source_asset_issuer?: string; + source_amount?: string; + [key: string]: unknown; +} + +interface HorizonPaymentsResponse { + records: HorizonRawPaymentRecord[]; + _links?: { + next?: { href?: string }; + prev?: { href?: string }; + }; +} + +export function normalizePaymentRecord( + raw: HorizonRawPaymentRecord, + queriedAccountId: string +): NormalizedPayment { + const type = (raw.type as PaymentType) || "payment"; + let direction: PaymentDirection = "incoming"; + let counterparty = "—"; + let asset = "XLM"; + let amount = "0"; + + const typeLabels: Record = { + create_account: "Create Account", + payment: "Payment", + path_payment_strict_send: "Path Payment (Strict Send)", + path_payment_strict_receive: "Path Payment (Strict Receive)" + }; + + if (type === "create_account") { + if (raw.funder === queriedAccountId) { + direction = "outgoing"; + counterparty = raw.account || "—"; + } else { + direction = "incoming"; + counterparty = raw.funder || "—"; + } + asset = "XLM"; + amount = raw.starting_balance ?? "0"; + } else if (type === "payment") { + if (raw.from === queriedAccountId) { + direction = "outgoing"; + counterparty = raw.to || "—"; + } else { + direction = "incoming"; + counterparty = raw.from || "—"; + } + asset = raw.asset_type === "native" ? "XLM" : (raw.asset_code || "UNKNOWN"); + amount = raw.amount ?? "0"; + } else if (type === "path_payment_strict_send" || type === "path_payment_strict_receive") { + if (raw.from === queriedAccountId) { + direction = "outgoing"; + counterparty = raw.to || "—"; + asset = raw.source_asset_type === "native" ? "XLM" : (raw.source_asset_code || "UNKNOWN"); + amount = raw.source_amount ?? "0"; + } else { + direction = "incoming"; + counterparty = raw.from || "—"; + asset = raw.asset_type === "native" ? "XLM" : (raw.asset_code || "UNKNOWN"); + amount = raw.amount ?? "0"; + } + } + + return { + id: String(raw.id ?? raw.paging_token ?? ""), + type, + typeLabel: typeLabels[type] || String(type), + direction, + counterparty, + asset, + amount, + transactionHash: String(raw.transaction_hash ?? ""), + createdAt: String(raw.created_at ?? "") + }; +} + +function extractCursor(href: string | undefined): string | null { + if (!href) return null; + try { + const url = new URL(href, "https://horizon.stellar.org"); + return url.searchParams.get("cursor"); + } catch { + return null; + } +} + +export async function fetchPaymentHistory( + { accountId, cursor }: PaymentHistoryInput, + network: StellarNetwork +): Promise> { + try { + const server = horizonServer(network); + + let builder = server.payments().forAccount(accountId).order("desc").limit(20); + if (cursor) { + builder = builder.cursor(cursor); + } + + const res = (await builder.call()) as unknown as HorizonPaymentsResponse; + const records = Array.isArray(res.records) ? res.records : []; + + const payments = records.map((record) => normalizePaymentRecord(record, accountId)); + const nextCursor = + extractCursor(res._links?.next?.href) ?? + (records.length > 0 ? (records[records.length - 1].paging_token ?? null) : null); + const prevCursor = + extractCursor(res._links?.prev?.href) ?? + (records.length > 0 ? (records[0].paging_token ?? null) : null); + + return ok({ + payments, + prevCursor, + nextCursor, + accountId + }); + } catch (error) { + return err(toPaymentHistoryErrorCode(error)); + } +} diff --git a/features/payment-history/manifest.ts b/features/payment-history/manifest.ts new file mode 100644 index 0000000..d119716 --- /dev/null +++ b/features/payment-history/manifest.ts @@ -0,0 +1,15 @@ +import { History } from "lucide-react"; +import type { FeatureManifest } from "@/core/registry/types"; + +export const manifest: FeatureManifest = { + slug: "payment-history", + title: "Account Payment History", + description: + "Browse the payments in and out of an account, with direction, counterparty, asset and amount, and cursor-based paging.", + character: "Every payment tells a story, and this is where it starts.", + category: "payments", + status: "beta", + icon: History, + networks: ["testnet", "mainnet"], + keywords: ["payments", "history", "account", "transfers", "horizon"] +}; diff --git a/features/payment-history/msw/handlers.ts b/features/payment-history/msw/handlers.ts new file mode 100644 index 0000000..58135b2 --- /dev/null +++ b/features/payment-history/msw/handlers.ts @@ -0,0 +1,40 @@ +import { http, HttpResponse, delay } from "msw"; +import { + emptyAccount, + emptyPaymentsResponse, + missingAccount, + paymentsResponse, + queriedAccount, + rateLimitedAccount +} from "@/features/payment-history/fixtures/paymentHistory.fixture"; + +const TESTNET = "https://horizon-testnet.stellar.org"; + +export const handlers = [ + http.get(`${TESTNET}/accounts/${queriedAccount}/payments`, () => { + return HttpResponse.json(paymentsResponse); + }), + + http.get(`${TESTNET}/accounts/${emptyAccount}/payments`, () => { + return HttpResponse.json(emptyPaymentsResponse); + }), + + http.get(`${TESTNET}/accounts/${missingAccount}/payments`, () => { + return HttpResponse.json( + { title: "Resource Missing", status: 404, detail: "Account not found" }, + { status: 404 } + ); + }), + + http.get(`${TESTNET}/accounts/${rateLimitedAccount}/payments`, () => { + return HttpResponse.json( + { title: "Rate Limit Exceeded", status: 429, detail: "Too Many Requests" }, + { status: 429 } + ); + }) +]; + +export const pendingHandler = http.get(`${TESTNET}/accounts/${queriedAccount}/payments`, async () => { + await delay("infinite"); + return HttpResponse.json(paymentsResponse); +}); diff --git a/features/payment-history/panel.tsx b/features/payment-history/panel.tsx new file mode 100644 index 0000000..52c2970 --- /dev/null +++ b/features/payment-history/panel.tsx @@ -0,0 +1 @@ +export { PaymentHistoryPanel as default } from "@/features/payment-history/components/PaymentHistoryPanel"; diff --git a/features/payment-history/schema.ts b/features/payment-history/schema.ts new file mode 100644 index 0000000..07b4913 --- /dev/null +++ b/features/payment-history/schema.ts @@ -0,0 +1,17 @@ +import { StrKey } from "@stellar/stellar-sdk"; +import { err, ok, type Result } from "@/core/result/result"; +import type { + PaymentHistoryErrorCode, + PaymentHistoryInput +} from "@/features/payment-history/types"; + +export function parsePaymentHistoryInput( + raw: string +): Result { + const accountId = raw.replace(/\s+/g, ""); + + if (!accountId) return err("empty_input"); + if (!StrKey.isValidEd25519PublicKey(accountId)) return err("invalid_address"); + + return ok({ accountId }); +} diff --git a/features/payment-history/types.ts b/features/payment-history/types.ts new file mode 100644 index 0000000..71242da --- /dev/null +++ b/features/payment-history/types.ts @@ -0,0 +1,38 @@ +export type PaymentType = + | "create_account" + | "payment" + | "path_payment_strict_send" + | "path_payment_strict_receive"; + +export type PaymentDirection = "incoming" | "outgoing"; + +export interface NormalizedPayment { + id: string; + type: PaymentType; + typeLabel: string; + direction: PaymentDirection; + counterparty: string; + asset: string; + amount: string; + transactionHash: string; + createdAt: string; +} + +export interface PaymentHistoryPage { + payments: NormalizedPayment[]; + prevCursor: string | null; + nextCursor: string | null; + accountId: string; +} + +export interface PaymentHistoryInput { + accountId: string; + cursor?: string; +} + +export type PaymentHistoryErrorCode = + | "empty_input" + | "invalid_address" + | "account_not_found" + | "rate_limited" + | "request_failed"; diff --git a/features/payment-uri-parser/README.md b/features/payment-uri-parser/README.md new file mode 100644 index 0000000..9384c51 --- /dev/null +++ b/features/payment-uri-parser/README.md @@ -0,0 +1,24 @@ +# SEP-0007 Payment URI Parser + +Parse, decode, and validate SEP-0007 `web+stellar:` payment request and transaction envelope links locally in your browser. + +## How it works + +The parser processes `web+stellar:pay` and `web+stellar:tx` URIs purely on the client side without making any network requests. It unpacks all query parameters, validates destination addresses, amounts, asset specifications, memo constraints, origin domains, and signatures against the SEP-0007 specification, and flags any syntax errors or unrecognized parameters. + +## Files + +| Path | Responsibility | +| --- | --- | +| `manifest.ts` | Registry metadata | +| `schema.ts` | URI input validation and secret key detection | +| `lib/` | SEP-0007 parser engine, parameter explanations, and formatters | +| `hooks/` | React state machine for local URI parsing | +| `components/` | Form, parameter inspection table, empty and error UI | +| `__tests__/` | Unit, hook, component and accessibility tests | +| `fixtures/` | Deterministic sample URIs for testing | +| `msw/` | Request mocks (empty for offline tools) | + +## Safety + +This tool is entirely offline and executes client-side in the browser. It never asks for, accepts, displays, stores, or transmits secret keys. Any input containing a Stellar secret seed is immediately rejected with a security alert. diff --git a/features/payment-uri-parser/__tests__/PaymentUriParserPanel.test.tsx b/features/payment-uri-parser/__tests__/PaymentUriParserPanel.test.tsx new file mode 100644 index 0000000..036de0c --- /dev/null +++ b/features/payment-uri-parser/__tests__/PaymentUriParserPanel.test.tsx @@ -0,0 +1,98 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { renderFeatureSlice } from "@/core/testing/contract"; +import { copy } from "@/features/payment-uri-parser/copy"; +import { + secretKeyUri, + validPayUri, + validTxUri +} from "@/features/payment-uri-parser/fixtures/paymentUriParser.fixture"; +import { PaymentUriParserPanel } from "@/features/payment-uri-parser/components/PaymentUriParserPanel"; + +function render() { + return renderFeatureSlice("payment-uri-parser", ); +} + +describe("PaymentUriParserPanel", () => { + it("shows the empty state first", () => { + const slice = render(); + slice.expectEmptyState(); + expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument(); + }); + + it("shows loading state while parsing", async () => { + const user = userEvent.setup(); + const slice = render(); + + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await user.type(input, validPayUri); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + await slice.waitForState("loading"); + slice.expectLoadingState(); + }); + + it("renders parsed pay parameters on success", async () => { + const user = userEvent.setup(); + const slice = render(); + + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await user.type(input, validPayUri); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + expect(screen.getByText("Payment Request (pay)")).toBeInTheDocument(); + expect(screen.getByText("Destination Account")).toBeInTheDocument(); + expect(screen.getByText("Payment Amount")).toBeInTheDocument(); + }); + + it("renders parsed tx parameters on success", async () => { + const user = userEvent.setup(); + const slice = render(); + + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await user.type(input, validTxUri); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + expect(await screen.findByText(copy.resultTitle)).toBeInTheDocument(); + expect(screen.getByText("Transaction Envelope (tx)")).toBeInTheDocument(); + expect(screen.getByText("Transaction Envelope XDR")).toBeInTheDocument(); + }); + + it("shows error state on invalid scheme", async () => { + const user = userEvent.setup(); + const slice = render(); + + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await user.type(input, "https://stellar.org"); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + await slice.waitForState("error"); + slice.expectErrorState(); + expect(screen.getByText(copy.errors.invalid_scheme)).toBeInTheDocument(); + }); + + it("shows error state on secret key detection", async () => { + const user = userEvent.setup(); + const slice = render(); + + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await user.type(input, secretKeyUri); + + const submitBtn = screen.getByRole("button", { name: copy.submit }); + await user.click(submitBtn); + + await slice.waitForState("error"); + slice.expectErrorState(); + expect(screen.getByText(copy.errors.secret_key_detected)).toBeInTheDocument(); + }); +}); diff --git a/features/payment-uri-parser/__tests__/a11y.test.tsx b/features/payment-uri-parser/__tests__/a11y.test.tsx new file mode 100644 index 0000000..4f99927 --- /dev/null +++ b/features/payment-uri-parser/__tests__/a11y.test.tsx @@ -0,0 +1,25 @@ +import { describe, it } from "vitest"; +import { renderFeature, screen } from "@/core/testing/render"; +import { expectNoAxeViolations } from "@/core/testing/axe"; +import { PaymentUriParserPanel } from "@/features/payment-uri-parser/components/PaymentUriParserPanel"; +import { copy } from "@/features/payment-uri-parser/copy"; +import { validPayUri } from "@/features/payment-uri-parser/fixtures/paymentUriParser.fixture"; + +describe("PaymentUriParserPanel 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 with parsed result loaded", async () => { + const { container, user } = renderFeature(); + + const input = screen.getByLabelText(new RegExp(copy.formLabel, "i")); + await user.type(input, validPayUri); + + await user.click(screen.getByRole("button", { name: copy.submit })); + await screen.findByText(copy.resultTitle); + + await expectNoAxeViolations(container); + }); +}); diff --git a/features/payment-uri-parser/__tests__/format.test.ts b/features/payment-uri-parser/__tests__/format.test.ts new file mode 100644 index 0000000..b2e0e7f --- /dev/null +++ b/features/payment-uri-parser/__tests__/format.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + formatParamLabel, + getParamStatusBadgeClass, + truncateDisplayValue +} from "@/features/payment-uri-parser/lib/format"; + +describe("PaymentUriParser format helpers", () => { + it("returns human-friendly parameter labels", () => { + expect(formatParamLabel("destination")).toBe("Destination Account"); + expect(formatParamLabel("amount")).toBe("Payment Amount"); + expect(formatParamLabel("xdr")).toBe("Transaction Envelope XDR"); + expect(formatParamLabel("custom_field")).toBe("custom_field"); + }); + + it("returns badge styles for each status", () => { + expect(getParamStatusBadgeClass("valid")).toContain("emerald"); + expect(getParamStatusBadgeClass("warning")).toContain("amber"); + expect(getParamStatusBadgeClass("error")).toContain("rose"); + expect(getParamStatusBadgeClass("info")).toContain("slate"); + }); + + it("truncates long values with ellipsis", () => { + expect(truncateDisplayValue("")).toBe("—"); + expect(truncateDisplayValue("short")).toBe("short"); + expect(truncateDisplayValue("a".repeat(100), 20)).toContain("…"); + expect(truncateDisplayValue("a".repeat(100), 20).length).toBeLessThan(30); + }); +}); diff --git a/features/payment-uri-parser/__tests__/paymentUriParser.test.ts b/features/payment-uri-parser/__tests__/paymentUriParser.test.ts new file mode 100644 index 0000000..7ea5c13 --- /dev/null +++ b/features/payment-uri-parser/__tests__/paymentUriParser.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + invalidPayUri, + unknownParamUri, + validPayUri, + validTxUri +} from "@/features/payment-uri-parser/fixtures/paymentUriParser.fixture"; +import { parseSep7Uri } from "@/features/payment-uri-parser/lib/paymentUriParser"; + +describe("paymentUriParser library", () => { + it("parses and validates a complete web+stellar:pay URI", () => { + const res = parseSep7Uri({ uri: validPayUri }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.operation).toBe("pay"); + expect(res.value.isValid).toBe(true); + expect(res.value.errors).toHaveLength(0); + expect(res.value.parameters.length).toBeGreaterThanOrEqual(8); + + const destParam = res.value.parameters.find((p) => p.key === "destination"); + expect(destParam?.status).toBe("valid"); + + const amountParam = res.value.parameters.find((p) => p.key === "amount"); + expect(amountParam?.value).toBe("100.5"); + expect(amountParam?.status).toBe("valid"); + } + }); + + it("parses and validates a web+stellar:tx URI", () => { + const res = parseSep7Uri({ uri: validTxUri }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.operation).toBe("tx"); + expect(res.value.isValid).toBe(true); + const xdrParam = res.value.parameters.find((p) => p.key === "xdr"); + expect(xdrParam?.status).toBe("valid"); + } + }); + + it("detects validation errors in invalid pay URI", () => { + const res = parseSep7Uri({ uri: invalidPayUri }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.isValid).toBe(false); + expect(res.value.errors.length).toBeGreaterThan(0); + + const destParam = res.value.parameters.find((p) => p.key === "destination"); + expect(destParam?.status).toBe("error"); + + const amountParam = res.value.parameters.find((p) => p.key === "amount"); + expect(amountParam?.status).toBe("error"); + } + }); + + it("flags unknown query parameters as warnings", () => { + const res = parseSep7Uri({ uri: unknownParamUri }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.warnings.length).toBeGreaterThan(0); + const customParam = res.value.parameters.find((p) => p.key === "custom_tracking_id"); + expect(customParam?.status).toBe("warning"); + } + }); +}); diff --git a/features/payment-uri-parser/__tests__/schema.test.ts b/features/payment-uri-parser/__tests__/schema.test.ts new file mode 100644 index 0000000..a65f8b5 --- /dev/null +++ b/features/payment-uri-parser/__tests__/schema.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + secretKeyUri, + validPayUri, + validTxUri +} from "@/features/payment-uri-parser/fixtures/paymentUriParser.fixture"; +import { validatePaymentUriInput } from "@/features/payment-uri-parser/schema"; + +describe("validatePaymentUriInput", () => { + it("accepts valid web+stellar:pay URI", () => { + const res = validatePaymentUriInput(validPayUri); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.value.uri).toBe(validPayUri); + } + }); + + it("accepts valid web+stellar:tx URI", () => { + const res = validatePaymentUriInput(validTxUri); + expect(res.ok).toBe(true); + }); + + it("rejects empty input", () => { + const res = validatePaymentUriInput(""); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("empty_input"); + } + }); + + it("rejects wrong scheme", () => { + const res = validatePaymentUriInput("https://stellar.org"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("invalid_scheme"); + } + }); + + it("rejects unknown operations", () => { + const res = validatePaymentUriInput("web+stellar:invalid?foo=bar"); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("unknown_operation"); + } + }); + + it("rejects inputs containing secret keys", () => { + const res = validatePaymentUriInput(secretKeyUri); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe("secret_key_detected"); + } + }); +}); diff --git a/features/payment-uri-parser/__tests__/usePaymentUriParser.test.tsx b/features/payment-uri-parser/__tests__/usePaymentUriParser.test.tsx new file mode 100644 index 0000000..186b485 --- /dev/null +++ b/features/payment-uri-parser/__tests__/usePaymentUriParser.test.tsx @@ -0,0 +1,68 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + secretKeyUri, + validPayUri +} from "@/features/payment-uri-parser/fixtures/paymentUriParser.fixture"; +import { usePaymentUriParser } from "@/features/payment-uri-parser/hooks/usePaymentUriParser"; + +describe("usePaymentUriParser hook", () => { + it("starts in idle state", () => { + const { result } = renderHook(() => usePaymentUriParser()); + expect(result.current.status).toBe("idle"); + expect(result.current.result).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("handles valid URI parsing", async () => { + const { result } = renderHook(() => usePaymentUriParser()); + + await act(async () => { + await result.current.parse({ uri: validPayUri }); + }); + + expect(result.current.status).toBe("success"); + expect(result.current.result?.isValid).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it("handles invalid scheme error", async () => { + const { result } = renderHook(() => usePaymentUriParser()); + + await act(async () => { + await result.current.parse({ uri: "invalid-scheme:foo" }); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("invalid_scheme"); + expect(result.current.result).toBeNull(); + }); + + it("handles secret key detection", async () => { + const { result } = renderHook(() => usePaymentUriParser()); + + await act(async () => { + await result.current.parse({ uri: secretKeyUri }); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("secret_key_detected"); + }); + + it("resets back to idle state", async () => { + const { result } = renderHook(() => usePaymentUriParser()); + + await act(async () => { + await result.current.parse({ uri: validPayUri }); + }); + + expect(result.current.status).toBe("success"); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.result).toBeNull(); + }); +}); diff --git a/features/payment-uri-parser/components/PaymentUriParserEmptyState.tsx b/features/payment-uri-parser/components/PaymentUriParserEmptyState.tsx new file mode 100644 index 0000000..61ab6e8 --- /dev/null +++ b/features/payment-uri-parser/components/PaymentUriParserEmptyState.tsx @@ -0,0 +1,13 @@ +import { Link2 } from "lucide-react"; +import { EmptyState } from "@/core/ui/EmptyState"; +import { copy } from "@/features/payment-uri-parser/copy"; + +export function PaymentUriParserEmptyState() { + return ( + + ); +} diff --git a/features/payment-uri-parser/components/PaymentUriParserForm.tsx b/features/payment-uri-parser/components/PaymentUriParserForm.tsx new file mode 100644 index 0000000..fb621bc --- /dev/null +++ b/features/payment-uri-parser/components/PaymentUriParserForm.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/core/ui/Button"; +import { Field } from "@/core/ui/Field"; +import { copy } from "@/features/payment-uri-parser/copy"; +import type { PaymentUriParserInput } from "@/features/payment-uri-parser/types"; + +interface Props { + onSubmit: (input: PaymentUriParserInput) => void; + pending: boolean; +} + +export function PaymentUriParserForm({ onSubmit, pending }: Props) { + const [uri, setUri] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ uri }); + }; + + return ( +
+ + {({ inputId, describedBy, invalid, required }) => ( +