From 4591f8dffc6af828cf3bd630f6fc47966ccf0950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20Yakupo=C4=9Flu?= Date: Wed, 21 Oct 2026 12:51:40 +0300 Subject: [PATCH 1/2] feat(sep24-status): explain anchor deposit and withdrawal status --- .../README.md | 9 ++ ...ep24TransactionStatusTrackerPanel.test.tsx | 37 ++++++ .../__tests__/a11y.test.tsx | 31 +++++ .../__tests__/format.test.ts | 15 +++ .../__tests__/schema.test.ts | 34 +++++ .../__tests__/sep24-status-fetch.test.ts | 40 ++++++ .../sep24TransactionStatusTracker.test.ts | 71 ++++++++++ .../__tests__/status-explanations.test.ts | 35 +++++ .../__tests__/transfer-server-resolve.test.ts | 24 ++++ .../useSep24TransactionStatusTracker.test.tsx | 52 ++++++++ .../components/JwtInputWarning.tsx | 10 ++ .../components/NextStepGuidance.tsx | 3 + ...ep24TransactionStatusTrackerEmptyState.tsx | 7 + .../Sep24TransactionStatusTrackerForm.tsx | 89 +++++++++++++ .../Sep24TransactionStatusTrackerPanel.tsx | 46 +++++++ .../Sep24TransactionStatusTrackerResult.tsx | 30 +++++ .../components/StatusSummary.tsx | 8 ++ .../sep24-transaction-status-tracker/copy.ts | 123 ++++++++++++++++++ .../sep24-transaction-status-tracker.spec.ts | 9 ++ .../e2e/unauthorized-token.spec.ts | 8 ++ .../fixtures/completed-withdrawal.fixture.ts | 10 ++ .../fixtures/pending-deposit.fixture.ts | 11 ++ .../sep24TransactionStatusTracker.fixture.ts | 22 ++++ .../hooks/useSep24TransactionStatusTracker.ts | 67 ++++++++++ .../lib/format.ts | 12 ++ .../lib/sep24-status-fetch.ts | 67 ++++++++++ .../sep24TransactionStatusTracker.errors.ts | 9 ++ .../lib/sep24TransactionStatusTracker.ts | 60 +++++++++ .../lib/status-explanations.ts | 19 +++ .../lib/transfer-server-resolve.ts | 20 +++ .../manifest.ts | 15 +++ .../msw/handlers.ts | 13 ++ .../panel.tsx | 1 + .../schema.ts | 56 ++++++++ .../sep24-transaction-status-tracker/types.ts | 31 +++++ 35 files changed, 1094 insertions(+) create mode 100644 features/sep24-transaction-status-tracker/README.md create mode 100644 features/sep24-transaction-status-tracker/__tests__/Sep24TransactionStatusTrackerPanel.test.tsx create mode 100644 features/sep24-transaction-status-tracker/__tests__/a11y.test.tsx create mode 100644 features/sep24-transaction-status-tracker/__tests__/format.test.ts create mode 100644 features/sep24-transaction-status-tracker/__tests__/schema.test.ts create mode 100644 features/sep24-transaction-status-tracker/__tests__/sep24-status-fetch.test.ts create mode 100644 features/sep24-transaction-status-tracker/__tests__/sep24TransactionStatusTracker.test.ts create mode 100644 features/sep24-transaction-status-tracker/__tests__/status-explanations.test.ts create mode 100644 features/sep24-transaction-status-tracker/__tests__/transfer-server-resolve.test.ts create mode 100644 features/sep24-transaction-status-tracker/__tests__/useSep24TransactionStatusTracker.test.tsx create mode 100644 features/sep24-transaction-status-tracker/components/JwtInputWarning.tsx create mode 100644 features/sep24-transaction-status-tracker/components/NextStepGuidance.tsx create mode 100644 features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerEmptyState.tsx create mode 100644 features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerForm.tsx create mode 100644 features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerPanel.tsx create mode 100644 features/sep24-transaction-status-tracker/components/Sep24TransactionStatusTrackerResult.tsx create mode 100644 features/sep24-transaction-status-tracker/components/StatusSummary.tsx create mode 100644 features/sep24-transaction-status-tracker/copy.ts create mode 100644 features/sep24-transaction-status-tracker/e2e/sep24-transaction-status-tracker.spec.ts create mode 100644 features/sep24-transaction-status-tracker/e2e/unauthorized-token.spec.ts create mode 100644 features/sep24-transaction-status-tracker/fixtures/completed-withdrawal.fixture.ts create mode 100644 features/sep24-transaction-status-tracker/fixtures/pending-deposit.fixture.ts create mode 100644 features/sep24-transaction-status-tracker/fixtures/sep24TransactionStatusTracker.fixture.ts create mode 100644 features/sep24-transaction-status-tracker/hooks/useSep24TransactionStatusTracker.ts create mode 100644 features/sep24-transaction-status-tracker/lib/format.ts create mode 100644 features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts create mode 100644 features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts create mode 100644 features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.ts create mode 100644 features/sep24-transaction-status-tracker/lib/status-explanations.ts create mode 100644 features/sep24-transaction-status-tracker/lib/transfer-server-resolve.ts create mode 100644 features/sep24-transaction-status-tracker/manifest.ts create mode 100644 features/sep24-transaction-status-tracker/msw/handlers.ts create mode 100644 features/sep24-transaction-status-tracker/panel.tsx create mode 100644 features/sep24-transaction-status-tracker/schema.ts create mode 100644 features/sep24-transaction-status-tracker/types.ts 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 ( +
+ + + {({ inputId, describedBy, invalid }) => ( + setHomeDomain(event.target.value)} + placeholder="anchor.example" + autoComplete="off" + spellCheck={false} + /> + )} + + + {({ inputId, describedBy, invalid }) => ( + setTransactionId(event.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> + )} + + + {({ inputId, describedBy, invalid }) => ( + setToken(event.target.value)} + autoComplete="off" + spellCheck={false} + /> + )} + + + + ); +} 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..56dd3db3 --- /dev/null +++ b/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts @@ -0,0 +1,67 @@ +import { err, ok, type Result } from "@/core/result/result"; +import { toSep24TransactionStatusTrackerErrorCode } 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..c8e74df5 --- /dev/null +++ b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts @@ -0,0 +1,9 @@ +import type { Sep24TransactionStatusTrackerErrorCode } from "@/features/sep24-transaction-status-tracker/types"; + +export function toSep24TransactionStatusTrackerErrorCode( + status: number +): Sep24TransactionStatusTrackerErrorCode { + 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"; From 217efec4fc0f79d154c53fa7571feb4ad3644ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Caner=20Yakupo=C4=9Flu?= Date: Wed, 21 Oct 2026 22:36:15 +0300 Subject: [PATCH 2/2] fix(sep24-status): type fetch errors as the HTTP codes only --- .../lib/sep24-status-fetch.ts | 7 +++++-- .../lib/sep24TransactionStatusTracker.errors.ts | 7 +++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts b/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts index 56dd3db3..d511c504 100644 --- a/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts +++ b/features/sep24-transaction-status-tracker/lib/sep24-status-fetch.ts @@ -1,5 +1,8 @@ import { err, ok, type Result } from "@/core/result/result"; -import { toSep24TransactionStatusTrackerErrorCode } from "@/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors"; +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 { @@ -18,7 +21,7 @@ export async function fetchSep24Transaction( token: string, fetchImpl: typeof fetch = globalThis.fetch, signal?: AbortSignal -): Promise> { +): Promise> { let response: Response; try { response = await fetchImpl(`${transferServer}/transactions/${encodeURIComponent(transactionId)}`, { diff --git a/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts index c8e74df5..18fd701e 100644 --- a/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts +++ b/features/sep24-transaction-status-tracker/lib/sep24TransactionStatusTracker.errors.ts @@ -1,8 +1,7 @@ -import type { Sep24TransactionStatusTrackerErrorCode } from "@/features/sep24-transaction-status-tracker/types"; +/** 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 -): Sep24TransactionStatusTrackerErrorCode { +export function toSep24TransactionStatusTrackerErrorCode(status: number): Sep24FetchErrorCode { if (status === 401 || status === 403) return "unauthorized"; if (status === 404) return "transaction_not_found"; return "request_failed";