diff --git a/features/sep24-transaction-status-tracker/README.md b/features/sep24-transaction-status-tracker/README.md
new file mode 100644
index 00000000..9777e491
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/README.md
@@ -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.
diff --git a/features/sep24-transaction-status-tracker/__tests__/Sep24TransactionStatusTrackerPanel.test.tsx b/features/sep24-transaction-status-tracker/__tests__/Sep24TransactionStatusTrackerPanel.test.tsx
new file mode 100644
index 00000000..bd836a49
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/Sep24TransactionStatusTrackerPanel.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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();
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/a11y.test.tsx b/features/sep24-transaction-status-tracker/__tests__/a11y.test.tsx
new file mode 100644
index 00000000..7cbedc58
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__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 { 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();
+ await expectNoAxeViolations(container);
+ });
+
+ it("has no WCAG A/AA violations after a status check", async () => {
+ const { container, user } = renderFeature();
+ 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);
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/format.test.ts b/features/sep24-transaction-status-tracker/__tests__/format.test.ts
new file mode 100644
index 00000000..0120b491
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/format.test.ts
@@ -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");
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/schema.test.ts b/features/sep24-transaction-status-tracker/__tests__/schema.test.ts
new file mode 100644
index 00000000..65fccd2c
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/schema.test.ts
@@ -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"
+ });
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/sep24-status-fetch.test.ts b/features/sep24-transaction-status-tracker/__tests__/sep24-status-fetch.test.ts
new file mode 100644
index 00000000..485175fc
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/sep24-status-fetch.test.ts
@@ -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"
+ });
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/sep24TransactionStatusTracker.test.ts b/features/sep24-transaction-status-tracker/__tests__/sep24TransactionStatusTracker.test.ts
new file mode 100644
index 00000000..7fe20df4
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/sep24TransactionStatusTracker.test.ts
@@ -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" });
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/status-explanations.test.ts b/features/sep24-transaction-status-tracker/__tests__/status-explanations.test.ts
new file mode 100644
index 00000000..6753951c
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/status-explanations.test.ts
@@ -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);
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/transfer-server-resolve.test.ts b/features/sep24-transaction-status-tracker/__tests__/transfer-server-resolve.test.ts
new file mode 100644
index 00000000..b88a03d9
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/transfer-server-resolve.test.ts
@@ -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"
+ });
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/__tests__/useSep24TransactionStatusTracker.test.tsx b/features/sep24-transaction-status-tracker/__tests__/useSep24TransactionStatusTracker.test.tsx
new file mode 100644
index 00000000..5752e56a
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/__tests__/useSep24TransactionStatusTracker.test.tsx
@@ -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 {children};
+}
+
+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"));
+ });
+});
diff --git a/features/sep24-transaction-status-tracker/components/JwtInputWarning.tsx b/features/sep24-transaction-status-tracker/components/JwtInputWarning.tsx
new file mode 100644
index 00000000..6e8b7645
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/JwtInputWarning.tsx
@@ -0,0 +1,10 @@
+import { copy } from "@/features/sep24-transaction-status-tracker/copy";
+
+export function JwtInputWarning() {
+ return (
+
+ {copy.jwtWarningTitle}.
+ {copy.jwtWarning}
+
+ );
+}
diff --git a/features/sep24-transaction-status-tracker/components/NextStepGuidance.tsx b/features/sep24-transaction-status-tracker/components/NextStepGuidance.tsx
new file mode 100644
index 00000000..2283728c
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/NextStepGuidance.tsx
@@ -0,0 +1,3 @@
+export function NextStepGuidance({ nextStep }: { nextStep: string }) {
+ return {nextStep}
;
+}
diff --git a/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerEmptyState.tsx b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerEmptyState.tsx
new file mode 100644
index 00000000..05ba6d4b
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerEmptyState.tsx
@@ -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 ;
+}
diff --git a/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerForm.tsx b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerForm.tsx
new file mode 100644
index 00000000..9a8aa93c
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerForm.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useState, type FormEvent } from "react";
+import { Button } from "@/core/ui/Button";
+import { Field } from "@/core/ui/Field";
+import { Input } from "@/core/ui/Input";
+import { copy, errorCopy } from "@/features/sep24-transaction-status-tracker/copy";
+import type {
+ Sep24Field,
+ Sep24TransactionStatusTrackerErrorCode
+} from "@/features/sep24-transaction-status-tracker/types";
+import { JwtInputWarning } from "@/features/sep24-transaction-status-tracker/components/JwtInputWarning";
+
+export function Sep24TransactionStatusTrackerForm({
+ onSubmit,
+ pending,
+ errorField,
+ errorCode
+}: {
+ onSubmit: (values: { homeDomain: string; transactionId: string; token: string }) => void;
+ pending: boolean;
+ errorField: Sep24Field | null;
+ errorCode: Sep24TransactionStatusTrackerErrorCode | null;
+}) {
+ const [homeDomain, setHomeDomain] = useState("");
+ const [transactionId, setTransactionId] = useState("");
+ const [token, setToken] = useState("");
+ const message = errorCode ? errorCopy[errorCode].title : null;
+
+ function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ onSubmit({ homeDomain, transactionId, token });
+ }
+
+ return (
+
+ );
+}
diff --git a/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel.tsx b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel.tsx
new file mode 100644
index 00000000..ffc6ca00
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { Card } from "@/core/ui/Card";
+import { SkeletonRows } from "@/core/ui/Skeleton";
+import { StatusMessage } from "@/core/ui/StatusMessage";
+import { useSep24TransactionStatusTracker } from "@/features/sep24-transaction-status-tracker/hooks/useSep24TransactionStatusTracker";
+import { copy, errorCopy } from "@/features/sep24-transaction-status-tracker/copy";
+import { Sep24TransactionStatusTrackerForm } from "@/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerForm";
+import { Sep24TransactionStatusTrackerResult } from "@/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerResult";
+import { Sep24TransactionStatusTrackerEmptyState } from "@/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerEmptyState";
+
+export function Sep24TransactionStatusTrackerPanel() {
+ const { state, submit } = useSep24TransactionStatusTracker();
+ const errorCode = state.status === "error" ? state.code : null;
+ const errorField = state.status === "error" ? state.field : null;
+
+ return (
+
+
+
+
+ {state.status === "loading" ? (
+
+
+ {copy.loading}
+
+
+
+ ) : null}
+ {state.status === "error" && !errorField ? (
+
+ ) : null}
+ {state.status === "success" ?
: null}
+ {state.status === "idle" ?
: null}
+
+ );
+}
diff --git a/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerResult.tsx b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerResult.tsx
new file mode 100644
index 00000000..41d9d4ea
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerResult.tsx
@@ -0,0 +1,30 @@
+import { Card, CardHeader, CardTitle } from "@/core/ui/Card";
+import { DataList } from "@/core/ui/DataList";
+import { copy } from "@/features/sep24-transaction-status-tracker/copy";
+import { formatAmount, formatKind } from "@/features/sep24-transaction-status-tracker/lib/format";
+import type { Sep24TransactionStatusTrackerResult as ResultValue } from "@/features/sep24-transaction-status-tracker/types";
+import { NextStepGuidance } from "@/features/sep24-transaction-status-tracker/components/NextStepGuidance";
+import { StatusSummary } from "@/features/sep24-transaction-status-tracker/components/StatusSummary";
+
+export function Sep24TransactionStatusTrackerResult({ result }: { result: ResultValue }) {
+ return (
+
+
+ {copy.resultTitle}
+
+
+
+
+
+
+
+ );
+}
diff --git a/features/sep24-transaction-status-tracker/components/StatusSummary.tsx b/features/sep24-transaction-status-tracker/components/StatusSummary.tsx
new file mode 100644
index 00000000..9bd096c9
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/components/StatusSummary.tsx
@@ -0,0 +1,8 @@
+export function StatusSummary({ status, explanation }: { status: string; explanation: string }) {
+ return (
+
+ {status}
+ {explanation}
+
+ );
+}
diff --git a/features/sep24-transaction-status-tracker/copy.ts b/features/sep24-transaction-status-tracker/copy.ts
new file mode 100644
index 00000000..629c4edb
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/copy.ts
@@ -0,0 +1,123 @@
+import type { Sep24TransactionStatusTrackerErrorCode } from "@/features/sep24-transaction-status-tracker/types";
+
+export const copy = {
+ domainLabel: "Anchor home domain",
+ domainHint: "The domain that publishes stellar.toml, without https://.",
+ transactionLabel: "Transaction id",
+ transactionHint: "The id returned when the deposit or withdrawal started.",
+ tokenLabel: "SEP-10 token",
+ tokenHint: "A bearer JWT for this anchor. It stays in memory for this check and is not shown again.",
+ submit: "Check status",
+ loading: "Asking the anchor...",
+ emptyTitle: "No transaction checked yet",
+ emptyDescription: "Enter an anchor domain, a SEP-24 transaction id, and a SEP-10 token.",
+ resultTitle: "SEP-24 status",
+ jwtWarningTitle: "This token is a bearer credential",
+ jwtWarning:
+ "The SEP-10 JWT authorizes this anchor session. It is sent once as an Authorization header, kept only in memory, and never written into the result.",
+ transferServerLabel: "Transfer server",
+ kindLabel: "Kind",
+ amountInLabel: "Amount in",
+ amountOutLabel: "Amount out",
+ amountFeeLabel: "Fee",
+ notProvided: "Not provided",
+ unknownStatus: "Undocumented status",
+ unknownNext: "The anchor returned a status this tool does not document. Open the anchor's own instructions before doing anything else."
+} as const;
+
+export const statusCopy: Record = {
+ incomplete: {
+ explanation: "The anchor does not have everything it needs yet.",
+ nextStep: "Finish the anchor's form, then check this transaction again."
+ },
+ pending_user_transfer_start: {
+ explanation: "The anchor is waiting for you to send the Stellar payment.",
+ nextStep: "Send the payment described by the anchor, using the memo they gave you."
+ },
+ pending_user_transfer_complete: {
+ explanation: "The anchor is waiting for your payment to arrive.",
+ nextStep: "Wait for the payment to settle, then check again."
+ },
+ pending_external: {
+ explanation: "The anchor is waiting on an off-chain transfer.",
+ nextStep: "Complete the bank or external transfer if you have not, then check again."
+ },
+ pending_anchor: {
+ explanation: "The anchor is processing the transaction.",
+ nextStep: "Wait, then check again. No payment is required from you right now."
+ },
+ pending_stellar: {
+ explanation: "The anchor is submitting the Stellar payment.",
+ nextStep: "Wait for that payment to appear, then check again."
+ },
+ pending_trust: {
+ explanation: "Your account is missing a trustline for this asset.",
+ nextStep: "Add the trustline, then check again."
+ },
+ pending_user: {
+ explanation: "The anchor needs another action from you.",
+ nextStep: "Open the anchor's instructions and complete the requested step."
+ },
+ on_hold: {
+ explanation: "The anchor paused this transaction.",
+ nextStep: "Check the anchor for what they need before sending anything else."
+ },
+ completed: {
+ explanation: "The transaction is finished.",
+ nextStep: "No further action is required."
+ },
+ refunded: {
+ explanation: "The anchor refunded this transaction.",
+ nextStep: "Confirm the refund with the anchor if the funds are not where you expect."
+ },
+ expired: {
+ explanation: "The transaction expired.",
+ nextStep: "Start a new deposit or withdrawal if you still want to move the funds."
+ },
+ error: {
+ explanation: "The anchor reported an error.",
+ nextStep: "Read the anchor's message before trying again."
+ },
+ no_market: {
+ explanation: "The anchor could not convert this amount.",
+ nextStep: "Try a different amount or asset."
+ },
+ too_small: {
+ explanation: "The amount is below the anchor's minimum.",
+ nextStep: "Increase the amount and start a new transaction."
+ },
+ too_large: {
+ explanation: "The amount is above the anchor's maximum.",
+ nextStep: "Decrease the amount and start a new transaction."
+ }
+};
+
+export const errorCopy: Record<
+ Sep24TransactionStatusTrackerErrorCode,
+ { title: string; description: string }
+> = {
+ invalid_home_domain: {
+ title: "Enter a valid anchor domain",
+ description: "Use a hostname such as anchor.example. Do not include a scheme or path."
+ },
+ missing_transfer_server: {
+ title: "This anchor does not publish SEP-24",
+ description: "stellar.toml has no TRANSFER_SERVER_SEP0024 field, so there is no transfer server to ask."
+ },
+ invalid_transaction_id: {
+ title: "Enter a valid transaction id",
+ description: "Use the id the anchor returned. It can contain letters, digits, _ and -."
+ },
+ unauthorized: {
+ title: "The SEP-10 token was rejected",
+ description: "The token is missing, expired, or the anchor refused it. This is not a missing transaction. Paste a fresh token."
+ },
+ transaction_not_found: {
+ title: "That transaction was not found",
+ description: "The anchor accepted the token and reported no transaction with this id."
+ },
+ request_failed: {
+ title: "The anchor could not be reached",
+ description: "The stellar.toml or transaction request failed. Check the domain and try again."
+ }
+};
diff --git a/features/sep24-transaction-status-tracker/e2e/sep24-transaction-status-tracker.spec.ts b/features/sep24-transaction-status-tracker/e2e/sep24-transaction-status-tracker.spec.ts
new file mode 100644
index 00000000..60f6633e
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/e2e/sep24-transaction-status-tracker.spec.ts
@@ -0,0 +1,9 @@
+export const spec = {
+ route: "/tools/sep24-transaction-status-tracker",
+ steps: [
+ { action: "visit", target: "/tools/sep24-transaction-status-tracker" },
+ { action: "expect", target: "heading", value: "SEP-24 Transaction Status Tracker" },
+ { action: "click", target: "Check status" },
+ { action: "expect", target: "alert", value: "Enter a valid anchor domain" }
+ ]
+} as const;
diff --git a/features/sep24-transaction-status-tracker/e2e/unauthorized-token.spec.ts b/features/sep24-transaction-status-tracker/e2e/unauthorized-token.spec.ts
new file mode 100644
index 00000000..c155eca1
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/e2e/unauthorized-token.spec.ts
@@ -0,0 +1,8 @@
+export const spec = {
+ route: "/tools/sep24-transaction-status-tracker",
+ steps: [
+ { action: "visit", target: "/tools/sep24-transaction-status-tracker" },
+ { action: "expect", target: "heading", value: "SEP-24 Transaction Status Tracker" },
+ { action: "expect", target: "alert", value: "The SEP-10 token was rejected" }
+ ]
+} as const;
diff --git a/features/sep24-transaction-status-tracker/fixtures/completed-withdrawal.fixture.ts b/features/sep24-transaction-status-tracker/fixtures/completed-withdrawal.fixture.ts
new file mode 100644
index 00000000..bfc07170
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/fixtures/completed-withdrawal.fixture.ts
@@ -0,0 +1,10 @@
+export const completedWithdrawal = {
+ transaction: {
+ id: "wd-100",
+ kind: "withdrawal",
+ status: "completed",
+ amount_in: "10",
+ amount_out: "9.5",
+ amount_fee: "0.5"
+ }
+};
diff --git a/features/sep24-transaction-status-tracker/fixtures/pending-deposit.fixture.ts b/features/sep24-transaction-status-tracker/fixtures/pending-deposit.fixture.ts
new file mode 100644
index 00000000..fdfba363
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/fixtures/pending-deposit.fixture.ts
@@ -0,0 +1,11 @@
+export const pendingDeposit = {
+ transaction: {
+ id: "82fhs729f63dh0v4",
+ kind: "deposit",
+ status: "pending_external",
+ amount_in: "18.34",
+ amount_out: "18.24",
+ amount_fee: "0.1",
+ more_info_url: "https://anchor.example/tx/242523523"
+ }
+};
diff --git a/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture.ts b/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture.ts
new file mode 100644
index 00000000..a8cccfbb
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture.ts
@@ -0,0 +1,22 @@
+import { completedWithdrawal } from "@/features/sep24-transaction-status-tracker/fixtures/completed-withdrawal.fixture";
+import { pendingDeposit } from "@/features/sep24-transaction-status-tracker/fixtures/pending-deposit.fixture";
+
+function part(value: object): string {
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
+}
+
+export function jwt(exp: number): string {
+ return `${part({ alg: "none", typ: "JWT" })}.${part({ exp, sub: "GEXAMPLE" })}.sig`;
+}
+
+export const homeDomain = "anchor.example";
+export const tomlUrl = `https://${homeDomain}/.well-known/stellar.toml`;
+export const transferServer = "https://anchor.example/sep24";
+export const transactionId = "82fhs729f63dh0v4";
+export const liveToken = jwt(4_000_000_000);
+export const expiredToken = jwt(1);
+
+export const toml = `TRANSFER_SERVER_SEP0024="${transferServer}"\n`;
+export const tomlWithoutServer = `TRANSFER_SERVER="https://anchor.example"\n`;
+
+export { pendingDeposit, completedWithdrawal };
diff --git a/features/sep24-transaction-status-tracker/hooks/useSep24TransactionStatusTracker.ts b/features/sep24-transaction-status-tracker/hooks/useSep24TransactionStatusTracker.ts
new file mode 100644
index 00000000..0c8cf3ac
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/hooks/useSep24TransactionStatusTracker.ts
@@ -0,0 +1,67 @@
+"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 { FIELD_OF_CODE, parseSep24TransactionStatusTrackerInput } from "@/features/sep24-transaction-status-tracker/schema";
+import { runSep24TransactionStatusTracker } from "@/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker";
+import type {
+ Sep24Field,
+ Sep24TransactionStatusTrackerErrorCode,
+ Sep24TransactionStatusTrackerResult
+} from "@/features/sep24-transaction-status-tracker/types";
+
+export type Sep24TransactionStatusTrackerState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "success"; result: Sep24TransactionStatusTrackerResult }
+ | { status: "error"; code: Sep24TransactionStatusTrackerErrorCode; field: Sep24Field | null };
+
+const IDLE: Sep24TransactionStatusTrackerState = { status: "idle" };
+
+interface Held {
+ state: Sep24TransactionStatusTrackerState;
+ network: StellarNetwork;
+}
+
+export function useSep24TransactionStatusTracker() {
+ const networkState = useNetwork();
+ const { network } = networkState;
+ const [held, setHeld] = useState({ state: IDLE, network });
+ const requestId = useRef(0);
+ const state = held.network === network ? held.state : IDLE;
+
+ const submit = useCallback(
+ async (raw: { homeDomain: string; transactionId: string; token: string }) => {
+ const parsed = parseSep24TransactionStatusTrackerInput(raw);
+ if (isErr(parsed)) {
+ setHeld({
+ state: { status: "error", code: parsed.code, field: FIELD_OF_CODE[parsed.code] },
+ network
+ });
+ return;
+ }
+
+ requestId.current += 1;
+ const id = requestId.current;
+ setHeld({ state: { status: "loading" }, network });
+ const result = await runSep24TransactionStatusTracker(parsed.value);
+ if (id !== requestId.current) return;
+ setHeld({
+ state: result.ok
+ ? { status: "success", result: result.value }
+ : { status: "error", code: result.code, field: FIELD_OF_CODE[result.code] },
+ network
+ });
+ },
+ [network]
+ );
+
+ const reset = useCallback(() => {
+ requestId.current += 1;
+ setHeld({ state: IDLE, network });
+ }, [network]);
+
+ return { state, submit, reset, network: networkState };
+}
diff --git a/features/sep24-transaction-status-tracker/lib/format.ts b/features/sep24-transaction-status-tracker/lib/format.ts
new file mode 100644
index 00000000..dd790033
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/lib/format.ts
@@ -0,0 +1,12 @@
+import { copy } from "@/features/sep24-transaction-status-tracker/copy";
+import type { Sep24Kind } from "@/features/sep24-transaction-status-tracker/types";
+
+export function formatKind(kind: Sep24Kind): string {
+ if (kind === "deposit") return "Deposit";
+ if (kind === "withdrawal") return "Withdrawal";
+ return "Unknown";
+}
+
+export function formatAmount(value: string | null): string {
+ return value ?? copy.notProvided;
+}
diff --git a/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts b/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts
new file mode 100644
index 00000000..d511c504
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts
@@ -0,0 +1,70 @@
+import { err, ok, type Result } from "@/core/result/result";
+import {
+ toSep24TransactionStatusTrackerErrorCode,
+ type Sep24FetchErrorCode
+} from "@/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors";
+import type { Sep24Kind } from "@/features/sep24-transaction-status-tracker/types";
+
+export interface Sep24TransactionRecord {
+ id: string;
+ kind: Sep24Kind;
+ status: string;
+ amountIn: string | null;
+ amountOut: string | null;
+ amountFee: string | null;
+ moreInfoUrl: string | null;
+}
+
+export async function fetchSep24Transaction(
+ transferServer: string,
+ transactionId: string,
+ token: string,
+ fetchImpl: typeof fetch = globalThis.fetch,
+ signal?: AbortSignal
+): Promise> {
+ let response: Response;
+ try {
+ response = await fetchImpl(`${transferServer}/transactions/${encodeURIComponent(transactionId)}`, {
+ signal,
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`
+ }
+ });
+ } catch {
+ return err("request_failed");
+ }
+
+ if (!response.ok) return err(toSep24TransactionStatusTrackerErrorCode(response.status));
+
+ let body: unknown;
+ try {
+ body = await response.json();
+ } catch {
+ return err("request_failed");
+ }
+
+ const transaction = isRecord(body) && isRecord(body.transaction) ? body.transaction : isRecord(body) ? body : null;
+ if (!transaction || typeof transaction.status !== "string" || transaction.status.length === 0) {
+ return err("request_failed");
+ }
+
+ const kind = transaction.kind === "deposit" || transaction.kind === "withdrawal" ? transaction.kind : "unknown";
+ return ok({
+ id: typeof transaction.id === "string" ? transaction.id : transactionId,
+ kind,
+ status: transaction.status,
+ amountIn: stringOrNull(transaction.amount_in),
+ amountOut: stringOrNull(transaction.amount_out),
+ amountFee: stringOrNull(transaction.amount_fee),
+ moreInfoUrl: stringOrNull(transaction.more_info_url)
+ });
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
diff --git a/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts
new file mode 100644
index 00000000..18fd701e
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts
@@ -0,0 +1,8 @@
+/** HTTP failures from the SEP-24 transaction endpoint. Schema codes stay outside this set. */
+export type Sep24FetchErrorCode = "unauthorized" | "transaction_not_found" | "request_failed";
+
+export function toSep24TransactionStatusTrackerErrorCode(status: number): Sep24FetchErrorCode {
+ if (status === 401 || status === 403) return "unauthorized";
+ if (status === 404) return "transaction_not_found";
+ return "request_failed";
+}
diff --git a/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.ts b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.ts
new file mode 100644
index 00000000..35bba19d
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.ts
@@ -0,0 +1,60 @@
+import { err, ok, type Result } from "@/core/result/result";
+import { fetchSep24Transaction } from "@/features/sep24-transaction-status-tracker/lib/sep24-status-fetch";
+import { explainStatus } from "@/features/sep24-transaction-status-tracker/lib/status-explanations";
+import {
+ readTransferServer,
+ stellarTomlUrl
+} from "@/features/sep24-transaction-status-tracker/lib/transfer-server-resolve";
+import type {
+ Sep24TransactionStatusTrackerErrorCode,
+ Sep24TransactionStatusTrackerInput,
+ Sep24TransactionStatusTrackerResult
+} from "@/features/sep24-transaction-status-tracker/types";
+
+export interface TrackerDeps {
+ fetchImpl?: typeof fetch;
+}
+
+export async function runSep24TransactionStatusTracker(
+ input: Sep24TransactionStatusTrackerInput,
+ signal?: AbortSignal,
+ deps: TrackerDeps = {}
+): Promise> {
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
+ const tomlUrl = stellarTomlUrl(input.homeDomain);
+ let toml: string;
+ try {
+ const response = await fetchImpl(tomlUrl, { signal, redirect: "manual" });
+ if (!response.ok || (response.status >= 300 && response.status < 400)) return err("request_failed");
+ toml = await response.text();
+ } catch {
+ return err("request_failed");
+ }
+
+ const server = readTransferServer(toml);
+ if (!server.ok) return err(server.code);
+
+ const transaction = await fetchSep24Transaction(
+ server.value,
+ input.transactionId,
+ input.token,
+ fetchImpl,
+ signal
+ );
+ if (!transaction.ok) return err(transaction.code);
+
+ const explanation = explainStatus(transaction.value.status);
+ return ok({
+ homeDomain: input.homeDomain,
+ transferServer: server.value,
+ transactionId: transaction.value.id,
+ kind: transaction.value.kind,
+ status: transaction.value.status,
+ explanation: explanation.explanation,
+ nextStep: explanation.nextStep,
+ amountIn: transaction.value.amountIn,
+ amountOut: transaction.value.amountOut,
+ amountFee: transaction.value.amountFee,
+ moreInfoUrl: transaction.value.moreInfoUrl
+ });
+}
diff --git a/features/sep24-transaction-status-tracker/lib/status-explanations.ts b/features/sep24-transaction-status-tracker/lib/status-explanations.ts
new file mode 100644
index 00000000..5d94dc37
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/lib/status-explanations.ts
@@ -0,0 +1,19 @@
+import { copy, statusCopy } from "@/features/sep24-transaction-status-tracker/copy";
+
+export interface StatusExplanation {
+ explanation: string;
+ nextStep: string;
+ documented: boolean;
+}
+
+export function explainStatus(status: string): StatusExplanation {
+ const known = statusCopy[status];
+ if (!known) {
+ return { explanation: copy.unknownStatus, nextStep: copy.unknownNext, documented: false };
+ }
+ return { ...known, documented: true };
+}
+
+export function documentedStatuses(): string[] {
+ return Object.keys(statusCopy);
+}
diff --git a/features/sep24-transaction-status-tracker/lib/transfer-server-resolve.ts b/features/sep24-transaction-status-tracker/lib/transfer-server-resolve.ts
new file mode 100644
index 00000000..50873dda
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/lib/transfer-server-resolve.ts
@@ -0,0 +1,20 @@
+import { err, ok, type Result } from "@/core/result/result";
+
+const SERVER_LINE = /^TRANSFER_SERVER_SEP0024\s*=\s*"([^"]+)"/m;
+
+export function stellarTomlUrl(homeDomain: string): string {
+ return `https://${homeDomain}/.well-known/stellar.toml`;
+}
+
+export function readTransferServer(toml: string): Result {
+ const match = toml.match(SERVER_LINE);
+ if (!match) return err("missing_transfer_server");
+ let url: URL;
+ try {
+ url = new URL(match[1]);
+ } catch {
+ return err("request_failed");
+ }
+ if (url.protocol !== "https:") return err("request_failed");
+ return ok(url.toString().replace(/\/$/, ""));
+}
diff --git a/features/sep24-transaction-status-tracker/manifest.ts b/features/sep24-transaction-status-tracker/manifest.ts
new file mode 100644
index 00000000..4e7f7394
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/manifest.ts
@@ -0,0 +1,15 @@
+import { Radio } from "lucide-react";
+import type { FeatureManifest } from "@/core/registry/types";
+
+export const manifest: FeatureManifest = {
+ slug: "sep24-transaction-status-tracker",
+ title: "SEP-24 Transaction Status Tracker",
+ description:
+ "Look up one SEP-24 deposit or withdrawal on an anchor's transfer server and explain the status in plain language.",
+ character: "A clerk who reads the anchor's status and never repeats the token that opened the door.",
+ category: "standards",
+ status: "working",
+ icon: Radio,
+ networks: ["testnet", "mainnet"],
+ keywords: ["sep-24", "deposit", "withdrawal", "anchor", "transaction status"]
+};
diff --git a/features/sep24-transaction-status-tracker/msw/handlers.ts b/features/sep24-transaction-status-tracker/msw/handlers.ts
new file mode 100644
index 00000000..76d2344a
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/msw/handlers.ts
@@ -0,0 +1,13 @@
+import { http, HttpResponse } from "msw";
+import {
+ pendingDeposit,
+ toml,
+ tomlUrl,
+ transactionId,
+ transferServer
+} from "@/features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture";
+
+export const handlers = [
+ http.get(tomlUrl, () => HttpResponse.text(toml)),
+ http.get(`${transferServer}/transactions/${transactionId}`, () => HttpResponse.json(pendingDeposit))
+];
diff --git a/features/sep24-transaction-status-tracker/panel.tsx b/features/sep24-transaction-status-tracker/panel.tsx
new file mode 100644
index 00000000..7c0168b3
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/panel.tsx
@@ -0,0 +1 @@
+export { Sep24TransactionStatusTrackerPanel as default } from "@/features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel";
diff --git a/features/sep24-transaction-status-tracker/schema.ts b/features/sep24-transaction-status-tracker/schema.ts
new file mode 100644
index 00000000..9b905bd7
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/schema.ts
@@ -0,0 +1,56 @@
+import { err, ok, type Result } from "@/core/result/result";
+import type {
+ Sep24Field,
+ Sep24TransactionStatusTrackerErrorCode,
+ Sep24TransactionStatusTrackerInput
+} from "@/features/sep24-transaction-status-tracker/types";
+
+const DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+const TRANSACTION_ID = /^[A-Za-z0-9_-]{1,128}$/;
+
+export const FIELD_OF_CODE: Record = {
+ invalid_home_domain: "homeDomain",
+ invalid_transaction_id: "transactionId",
+ unauthorized: "token",
+ missing_transfer_server: null,
+ transaction_not_found: null,
+ request_failed: null
+};
+
+export function parseSep24TransactionStatusTrackerInput(raw: {
+ homeDomain: string;
+ transactionId: string;
+ token: string;
+}): Result {
+ const homeDomain = raw.homeDomain.trim().toLowerCase().replace(/\.$/, "");
+ const transactionId = raw.transactionId.trim();
+ const token = raw.token.trim();
+
+ if (!DOMAIN.test(homeDomain)) return err("invalid_home_domain");
+ if (!TRANSACTION_ID.test(transactionId)) return err("invalid_transaction_id");
+ if (!isJwtShape(token) || isExpiredJwt(token)) return err("unauthorized");
+
+ return ok({ homeDomain, transactionId, token });
+}
+
+function isJwtShape(token: string): boolean {
+ const parts = token.split(".");
+ return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part) && part.length > 0);
+}
+
+/** Reads exp without verifying the signature. A past exp is unauthorized before any request. */
+export function isExpiredJwt(token: string): boolean {
+ try {
+ const payload = JSON.parse(decodeBase64Url(token.split(".")[1])) as { exp?: unknown };
+ return typeof payload.exp === "number" && payload.exp * 1000 <= Date.now();
+ } catch {
+ return true;
+ }
+}
+
+function decodeBase64Url(value: string): string {
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4);
+ const binary = atob(padded);
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+}
diff --git a/features/sep24-transaction-status-tracker/types.ts b/features/sep24-transaction-status-tracker/types.ts
new file mode 100644
index 00000000..9d9d4141
--- /dev/null
+++ b/features/sep24-transaction-status-tracker/types.ts
@@ -0,0 +1,31 @@
+export interface Sep24TransactionStatusTrackerInput {
+ homeDomain: string;
+ transactionId: string;
+ token: string;
+}
+
+export type Sep24Kind = "deposit" | "withdrawal" | "unknown";
+
+export interface Sep24TransactionStatusTrackerResult {
+ homeDomain: string;
+ transferServer: string;
+ transactionId: string;
+ kind: Sep24Kind;
+ status: string;
+ explanation: string;
+ nextStep: string;
+ amountIn: string | null;
+ amountOut: string | null;
+ amountFee: string | null;
+ moreInfoUrl: string | null;
+}
+
+export type Sep24TransactionStatusTrackerErrorCode =
+ | "invalid_home_domain"
+ | "missing_transfer_server"
+ | "invalid_transaction_id"
+ | "unauthorized"
+ | "transaction_not_found"
+ | "request_failed";
+
+export type Sep24Field = "homeDomain" | "transactionId" | "token";