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
9 changes: 9 additions & 0 deletions features/sep24-transaction-status-tracker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SEP-24 Transaction Status Tracker

Reads `TRANSFER_SERVER_SEP0024` from `https://{home-domain}/.well-known/stellar.toml`, then requests `GET {transfer-server}/transactions/{id}` with the pasted SEP-10 JWT as a bearer token.

An expired or malformed token, and an anchor response of 401 or 403, are `unauthorized`. A 404 after the token is accepted is `transaction_not_found`. An anchor toml with no `TRANSFER_SERVER_SEP0024` is `missing_transfer_server`.

Every documented SEP-24 status has a plain-language explanation and a next step. An unknown status is labeled undocumented instead of being guessed.

The token is sent once and is not copied into the result, logs, or storage. This tool does not start a SEP-10 challenge and does not create a deposit or withdrawal.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { renderFeature, screen } from "@/core/testing/render";
import { withMswHandlers } from "@/core/testing/msw";
import { Sep24TransactionStatusTrackerPanel } from "@/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel";
import { copy } from "@/features/sep24-transaction-status-tracker/copy";
import { handlers } from "@/features/sep24-transaction-status-tracker/msw/handlers";
import {
homeDomain,
liveToken,
transactionId
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

withMswHandlers(...handlers);

describe("Sep24TransactionStatusTrackerPanel", () => {
it("renders the empty state and the token warning", () => {
renderFeature(<Sep24TransactionStatusTrackerPanel />);
expect(screen.getByText(copy.emptyTitle)).toBeInTheDocument();
expect(screen.getByText(copy.jwtWarningTitle, { exact: false })).toBeInTheDocument();
});

it("shows a validation error when submitted empty", async () => {
const { user } = renderFeature(<Sep24TransactionStatusTrackerPanel />);
await user.click(screen.getByRole("button", { name: copy.submit }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
});

it("shows the pending deposit next step", async () => {
const { user } = renderFeature(<Sep24TransactionStatusTrackerPanel />);
await user.type(screen.getByLabelText(copy.domainLabel), homeDomain);
await user.type(screen.getByLabelText(copy.transactionLabel), transactionId);
await user.type(screen.getByLabelText(copy.tokenLabel), liveToken);
await user.click(screen.getByRole("button", { name: copy.submit }));
expect(await screen.findByText("pending_external")).toBeInTheDocument();
expect(screen.queryByText(liveToken)).not.toBeInTheDocument();
});
});
31 changes: 31 additions & 0 deletions features/sep24-transaction-status-tracker/__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 { Sep24TransactionStatusTrackerPanel } from "@/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel";
import { copy } from "@/features/sep24-transaction-status-tracker/copy";
import { handlers } from "@/features/sep24-transaction-status-tracker/msw/handlers";
import {
homeDomain,
liveToken,
transactionId
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

withMswHandlers(...handlers);

describe("Sep24TransactionStatusTrackerPanel accessibility", () => {
it("has no WCAG A/AA violations in its initial state", async () => {
const { container } = renderFeature(<Sep24TransactionStatusTrackerPanel />);
await expectNoAxeViolations(container);
});

it("has no WCAG A/AA violations after a status check", async () => {
const { container, user } = renderFeature(<Sep24TransactionStatusTrackerPanel />);
await user.type(screen.getByLabelText(copy.domainLabel), homeDomain);
await user.type(screen.getByLabelText(copy.transactionLabel), transactionId);
await user.type(screen.getByLabelText(copy.tokenLabel), liveToken);
await user.click(screen.getByRole("button", { name: copy.submit }));
await screen.findByText(copy.resultTitle);
await expectNoAxeViolations(container);
});
});
15 changes: 15 additions & 0 deletions features/sep24-transaction-status-tracker/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { formatAmount, formatKind } from "@/features/sep24-transaction-status-tracker/lib/format";

describe("format", () => {
it("labels deposit and withdrawal exactly", () => {
expect(formatKind("deposit")).toBe("Deposit");
expect(formatKind("withdrawal")).toBe("Withdrawal");
expect(formatKind("unknown")).toBe("Unknown");
});

it("renders a missing amount as Not provided", () => {
expect(formatAmount(null)).toBe("Not provided");
expect(formatAmount("18.34")).toBe("18.34");
});
});
34 changes: 34 additions & 0 deletions features/sep24-transaction-status-tracker/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { parseSep24TransactionStatusTrackerInput } from "@/features/sep24-transaction-status-tracker/schema";
import {
expiredToken,
homeDomain,
liveToken,
transactionId
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

const valid = { homeDomain, transactionId, token: liveToken };

describe("parseSep24TransactionStatusTrackerInput", () => {
it("accepts a domain, id, and unexpired JWT", () => {
expect(parseSep24TransactionStatusTrackerInput(valid).ok).toBe(true);
});

it("rejects a URL and a slashy transaction id", () => {
expect(parseSep24TransactionStatusTrackerInput({ ...valid, homeDomain: "https://anchor.example" })).toEqual({
ok: false,
code: "invalid_home_domain"
});
expect(parseSep24TransactionStatusTrackerInput({ ...valid, transactionId: "a/b" })).toEqual({
ok: false,
code: "invalid_transaction_id"
});
});

it("rejects an expired JWT before any request", () => {
expect(parseSep24TransactionStatusTrackerInput({ ...valid, token: expiredToken })).toEqual({
ok: false,
code: "unauthorized"
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { fetchSep24Transaction } from "@/features/sep24-transaction-status-tracker/lib/sep24-status-fetch";
import {
liveToken,
pendingDeposit,
transactionId,
transferServer
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

function respond(status: number, body?: unknown): typeof fetch {
return (async (_url, init) => {
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe(`Bearer ${liveToken}`);
return new Response(body === undefined ? "" : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" }
});
}) as typeof fetch;
}

describe("fetchSep24Transaction", () => {
it("reads a pending deposit and does not return the token", async () => {
const result = await fetchSep24Transaction(transferServer, transactionId, liveToken, respond(200, pendingDeposit));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe("pending_external");
expect(JSON.stringify(result.value)).not.toContain(liveToken);
});

it("keeps a refused token distinct from a missing transaction", async () => {
expect(await fetchSep24Transaction(transferServer, transactionId, liveToken, respond(401))).toEqual({
ok: false,
code: "unauthorized"
});
expect(await fetchSep24Transaction(transferServer, transactionId, liveToken, respond(404))).toEqual({
ok: false,
code: "transaction_not_found"
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { runSep24TransactionStatusTracker } from "@/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker";
import { completedWithdrawal } from "@/features/sep24-transaction-status-tracker/fixtures/completed-withdrawal.fixture";
import {
homeDomain,
liveToken,
pendingDeposit,
toml,
tomlUrl,
tomlWithoutServer,
transactionId,
transferServer
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

const input = { homeDomain, transactionId, token: liveToken };

function fetchRoutes(options: { tomlBody?: string; txStatus?: number; txBody?: unknown }): typeof fetch {
return (async (url: string | URL | Request) => {
const target = String(url);
if (target === tomlUrl) return new Response(options.tomlBody ?? toml, { status: 200 });
if (target === `${transferServer}/transactions/${transactionId}`) {
return new Response(JSON.stringify(options.txBody ?? pendingDeposit), {
status: options.txStatus ?? 200,
headers: { "Content-Type": "application/json" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
}

describe("runSep24TransactionStatusTracker", () => {
it("explains a pending deposit and omits the token", async () => {
const result = await runSep24TransactionStatusTracker(input, undefined, { fetchImpl: fetchRoutes({}) });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.status).toBe("pending_external");
expect(result.value.explanation).toMatch(/off-chain/i);
expect(JSON.stringify(result.value)).not.toContain(liveToken);
});

it("explains a completed withdrawal", async () => {
const result = await runSep24TransactionStatusTracker(
{ ...input, transactionId: "wd-100" },
undefined,
{
fetchImpl: (async (url: string | URL | Request) => {
if (String(url) === tomlUrl) return new Response(toml, { status: 200 });
return new Response(JSON.stringify(completedWithdrawal), { status: 200 });
}) as typeof fetch
}
);
expect(result.ok && result.value.kind).toBe("withdrawal");
expect(result.ok && result.value.status).toBe("completed");
});

it("reports a missing transfer server, a refused token, and a missing transaction", async () => {
expect(
await runSep24TransactionStatusTracker(input, undefined, {
fetchImpl: fetchRoutes({ tomlBody: tomlWithoutServer })
})
).toEqual({ ok: false, code: "missing_transfer_server" });

expect(
await runSep24TransactionStatusTracker(input, undefined, { fetchImpl: fetchRoutes({ txStatus: 401 }) })
).toEqual({ ok: false, code: "unauthorized" });

expect(
await runSep24TransactionStatusTracker(input, undefined, { fetchImpl: fetchRoutes({ txStatus: 404 }) })
).toEqual({ ok: false, code: "transaction_not_found" });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { documentedStatuses, explainStatus } from "@/features/sep24-transaction-status-tracker/lib/status-explanations";

describe("explainStatus", () => {
it("explains every documented SEP-24 status", () => {
for (const status of documentedStatuses()) {
const explanation = explainStatus(status);
expect(explanation.documented).toBe(true);
expect(explanation.nextStep.length).toBeGreaterThan(0);
}
expect(documentedStatuses()).toEqual(
expect.arrayContaining([
"incomplete",
"pending_user_transfer_start",
"pending_external",
"pending_anchor",
"pending_stellar",
"pending_trust",
"pending_user",
"on_hold",
"completed",
"refunded",
"expired",
"error",
"no_market",
"too_small",
"too_large"
])
);
});

it("does not invent guidance for an unknown status", () => {
expect(explainStatus("made_up").documented).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { readTransferServer } from "@/features/sep24-transaction-status-tracker/lib/transfer-server-resolve";
import {
toml,
tomlWithoutServer,
transferServer
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

describe("readTransferServer", () => {
it("reads an https TRANSFER_SERVER_SEP0024", () => {
expect(readTransferServer(toml)).toEqual({ ok: true, value: transferServer });
});

it("reports an anchor that does not publish SEP-24", () => {
expect(readTransferServer(tomlWithoutServer)).toEqual({ ok: false, code: "missing_transfer_server" });
});

it("rejects a non-https transfer server", () => {
expect(readTransferServer('TRANSFER_SERVER_SEP0024="http://anchor.example/sep24"\n')).toEqual({
ok: false,
code: "request_failed"
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 { useSep24TransactionStatusTracker } from "@/features/sep24-transaction-status-tracker/hooks/useSep24TransactionStatusTracker";
import { handlers } from "@/features/sep24-transaction-status-tracker/msw/handlers";
import {
homeDomain,
liveToken,
transactionId
} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";

withMswHandlers(...handlers);

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

const valid = { homeDomain, transactionId, token: liveToken };

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

it("loads a pending deposit", async () => {
const { result } = renderHook(() => useSep24TransactionStatusTracker(), { wrapper });
await act(async () => {
await result.current.submit(valid);
});
await waitFor(() => expect(result.current.state.status).toBe("success"));
});

it("rejects a bad domain before fetching", async () => {
const { result } = renderHook(() => useSep24TransactionStatusTracker(), { wrapper });
await act(async () => {
await result.current.submit({ ...valid, homeDomain: "not a domain" });
});
expect(result.current.state).toMatchObject({ status: "error", code: "invalid_home_domain" });
});

it("drops a result when the network changes", async () => {
const { result } = renderHook(() => useSep24TransactionStatusTracker(), { wrapper });
await act(async () => {
await result.current.submit(valid);
});
await waitFor(() => expect(result.current.state.status).toBe("success"));
act(() => result.current.network.setNetwork("mainnet"));
await waitFor(() => expect(result.current.state.status).toBe("idle"));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { copy } from "@/features/sep24-transaction-status-tracker/copy";

export function JwtInputWarning() {
return (
<p className="rounded-md border border-[#c7d6e8] bg-white/50 p-3 text-sm text-[#172033]">
<span className="font-medium">{copy.jwtWarningTitle}. </span>
{copy.jwtWarning}
</p>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function NextStepGuidance({ nextStep }: { nextStep: string }) {
return <p className="text-sm">{nextStep}</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Radio } from "lucide-react";
import { EmptyState } from "@/core/ui/EmptyState";
import { copy } from "@/features/sep24-transaction-status-tracker/copy";

export function Sep24TransactionStatusTrackerEmptyState() {
return <EmptyState icon={Radio} title={copy.emptyTitle} description={copy.emptyDescription} />;
}
Loading
Loading