Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions core/__tests__/issuePublishing.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
23 changes: 16 additions & 7 deletions docs/ISSUE_PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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

Expand Down
24 changes: 24 additions & 0 deletions features/path-payment-finder/README.md
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 111 additions & 0 deletions features/path-payment-finder/__tests__/PathPaymentFinderPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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", <PathPaymentFinderPanel />);
}

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();
});
});
31 changes: 31 additions & 0 deletions features/path-payment-finder/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PathPaymentFinderPanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations with route results loaded", async () => {
resetHorizonClients();
const { container, user } = renderFeature(<PathPaymentFinderPanel />);

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);
});
});
38 changes: 38 additions & 0 deletions features/path-payment-finder/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading