From 5564c34c0b3db87db7050bfacc68368ee03ef217 Mon Sep 17 00:00:00 2001 From: panditdhamdhere Date: Tue, 28 Jul 2026 14:22:51 +0530 Subject: [PATCH] feat: add reusable transaction receipt model across payment, anchor, and escrow (Closes #91) Adds typed transaction receipt states, network-aware explorer links, shared UI integration, fixtures, tests, and documentation for consistent post-submit transaction outcomes. --- apps/web/app/anchors/page.tsx | 10 + apps/web/app/docs/page.tsx | 2 + apps/web/app/escrow/page.tsx | 10 +- apps/web/app/payments/page.tsx | 39 ++- .../components/TransactionReceiptPanel.tsx | 75 ++++ apps/web/components/ui.tsx | 29 +- docs/transaction-receipts.md | 117 +++++++ examples/registry.ts | 13 +- examples/transaction-receipts.example.json | 59 ++++ packages/stellar-kit/src/index.ts | 1 + packages/stellar-kit/src/receipt.ts | 324 ++++++++++++++++++ packages/stellar-kit/test/receipt.test.ts | 223 ++++++++++++ packages/types/src/index.ts | 53 +++ packages/validators/src/index.ts | 1 + packages/validators/src/schemas/receipt.ts | 40 +++ packages/validators/test/examples.test.ts | 8 +- scripts/check-examples.mts | 8 +- 17 files changed, 1006 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/TransactionReceiptPanel.tsx create mode 100644 docs/transaction-receipts.md create mode 100644 examples/transaction-receipts.example.json create mode 100644 packages/stellar-kit/src/receipt.ts create mode 100644 packages/stellar-kit/test/receipt.test.ts create mode 100644 packages/validators/src/schemas/receipt.ts diff --git a/apps/web/app/anchors/page.tsx b/apps/web/app/anchors/page.tsx index 253e21f..7764eb6 100644 --- a/apps/web/app/anchors/page.tsx +++ b/apps/web/app/anchors/page.tsx @@ -29,7 +29,9 @@ import { transition, ALLOWED_TRANSITIONS, } from "@anchorkit/anchor-utils"; +import { anchorRecordToReceipt } from "@anchorkit/stellar-kit"; import { validateCallbackUrl } from "@anchorkit/validators"; +import { TransactionReceiptPanel } from "@/components/TransactionReceiptPanel"; import type { AnchorAssetConfig, AnchorTransactionKind, @@ -37,6 +39,7 @@ import type { AnchorTransactionStatus, DepositRequestMetadata, StellarPublicKey, + StellarTransactionHash, WithdrawalRequestMetadata, } from "@anchorkit/types"; @@ -121,10 +124,16 @@ export default function AnchorsPage() { assetCode: mockAsset, amountIn: mockAmount, stellarAccount: FRIENDBOT, + stellarTransactionId: + mockStatus === "completed" || mockStatus === "pending_stellar" + ? ("c".repeat(64) as StellarTransactionHash) + : undefined, }), [mockKind, mockStatus, mockAsset, mockAmount] ); + const mockReceipt = useMemo(() => anchorRecordToReceipt(mockRecord, "testnet"), [mockRecord]); + return ( {mockRecord.amountIn} {mockRecord.assetCode}} /> + diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index e5f7923..aad70e8 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -22,6 +22,8 @@ const docsNav = [ { title: "Secret key handling rules", file: "SECRET_KEY_HANDLING.md" }, { title: "Account utilities", file: "ACCOUNT_UTILITIES.md" }, { title: "Payment intent utilities", file: "PAYMENT_INTENT_UTILITIES.md" }, + { title: "Transaction readiness", file: "transaction-readiness.md" }, + { title: "Transaction receipts", file: "transaction-receipts.md" }, ], }, { diff --git a/apps/web/app/escrow/page.tsx b/apps/web/app/escrow/page.tsx index ed31ef9..bafd53c 100644 --- a/apps/web/app/escrow/page.tsx +++ b/apps/web/app/escrow/page.tsx @@ -3,8 +3,9 @@ import { useMemo, useState } from "react"; import { PageShell } from "@/components/PageShell"; import { Alert, Button, Card, DataRow, Input, Label, MilestoneStatusBadge } from "@/components/ui"; -import { parseEscrowEvents } from "@anchorkit/stellar-kit"; +import { parseEscrowEvents, escrowReleaseToReceipt } from "@anchorkit/stellar-kit"; import { escrowEventExample } from "@/lib/escrowEventExample"; +import { TransactionReceiptPanel } from "@/components/TransactionReceiptPanel"; import type { EscrowEventV1, EscrowSummary, Milestone, MilestoneStatus } from "@anchorkit/types"; const FRIENDBOT = "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR"; @@ -34,6 +35,11 @@ export default function EscrowPage() { [] ); + const releaseReceipt = useMemo(() => { + const released = mappedEvents.find((e) => e.type === "released"); + return released ? escrowReleaseToReceipt(released, "testnet") : null; + }, [mappedEvents]); + const demoMilestone: Milestone = useMemo(() => { const status = LIFECYCLE[step] ?? "draft"; return { @@ -215,6 +221,8 @@ export default function EscrowPage() { + +

Contract quick-links

    diff --git a/apps/web/app/payments/page.tsx b/apps/web/app/payments/page.tsx index f489436..b80f58c 100644 --- a/apps/web/app/payments/page.tsx +++ b/apps/web/app/payments/page.tsx @@ -5,12 +5,14 @@ import { PageShell } from "@/components/PageShell"; import { Alert, Button, Card, Input, Label, Select } from "@/components/ui"; import { createPaymentIntent, + createMockTransactionReceipt, estimateTransactionReadinessSync, getStellarExpertAccountUrl, isPublicKeyValid, } from "@anchorkit/stellar-kit"; -import type { AssetCode, MemoType, PaymentIntent, StellarAsset, StellarPublicKey, TransactionReadiness } from "@anchorkit/types"; +import type { AssetCode, MemoType, PaymentIntent, StellarAsset, StellarPublicKey, TransactionReadiness, TransactionReceiptStatus } from "@anchorkit/types"; import { DEFAULT_NETWORK } from "@anchorkit/config"; +import { TransactionReceiptPanel } from "@/components/TransactionReceiptPanel"; const FRIENDBOT = "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR"; const DEMO_DEST = "GDQJUTQYK2MQ32ZGMMB7Q3UKTJLNTMZI2QYHW7OK2TK2DZI3X5IGQH6U"; @@ -29,6 +31,17 @@ export default function PaymentsPage() { const [simulateSource, setSimulateSource] = useState<"funded" | "unfunded" | "unknown">("funded"); const [simulateDest, setSimulateDest] = useState<"funded" | "unfunded" | "unknown">("funded"); + const [mockReceiptStatus, setMockReceiptStatus] = useState("pending"); + + const mockReceipt = useMemo( + () => + createMockTransactionReceipt({ + status: mockReceiptStatus, + source: "payment", + network: DEFAULT_NETWORK, + }), + [mockReceiptStatus] + ); const asset: StellarAsset = useMemo(() => { if (assetMode === "native") { @@ -340,6 +353,30 @@ export default function PaymentsPage() { )} + +
    + +

    Mock transaction receipt

    +

    + Preview the normalized receipt model for each post-submit outcome. The MVP does not + submit real transactions — this selector demonstrates the shared receipt UI. +

    +
    + + +
    +
    + +
    ); } diff --git a/apps/web/components/TransactionReceiptPanel.tsx b/apps/web/components/TransactionReceiptPanel.tsx new file mode 100644 index 0000000..3d3f261 --- /dev/null +++ b/apps/web/components/TransactionReceiptPanel.tsx @@ -0,0 +1,75 @@ +import type { TransactionReceipt } from "@anchorkit/types"; +import { Alert, Card, DataRow, TransactionReceiptBadge } from "@/components/ui"; + +export function TransactionReceiptPanel({ + receipt, + title = "Transaction receipt", +}: { + receipt: TransactionReceipt | null; + title?: string; +}) { + if (!receipt) { + return ( + + A transaction receipt will appear here after submission or when mapped from anchor/escrow + data. + + ); + } + + const tone = + receipt.status === "confirmed" + ? "success" + : receipt.status === "failed" + ? "error" + : receipt.status === "rejected" || receipt.status === "unknown" + ? "warning" + : "info"; + + return ( + +
    +

    {title}

    + +
    + + {receipt.detail} + +
    + {receipt.id}} /> + + + {receipt.transactionHash && ( + {receipt.transactionHash} + } + /> + )} + {receipt.explorerUrl && ( + + View on Stellar Expert ↗ + + } + /> + )} + {receipt.submittedAt && ( + + )} + {receipt.finalizedAt && ( + + )} + {receipt.errorCode && } +
    +
    + ); +} diff --git a/apps/web/components/ui.tsx b/apps/web/components/ui.tsx index be2c6d1..163f40d 100644 --- a/apps/web/components/ui.tsx +++ b/apps/web/components/ui.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; -import type { AnchorTransactionStatus, MilestoneStatus } from "@anchorkit/types"; +import type { AnchorTransactionStatus, MilestoneStatus, TransactionReceiptStatus } from "@anchorkit/types"; +import { receiptStatusBadge } from "@anchorkit/stellar-kit"; export function AnchorStatusBadge({ status }: { status: AnchorTransactionStatus }) { const styles: Record = { @@ -62,6 +63,32 @@ export function MilestoneStatusBadge({ status }: { status: MilestoneStatus }) { ); } +const RECEIPT_BADGE_STYLES: Record< + ReturnType["tone"], + string +> = { + green: "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900", + blue: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900", + red: "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900", + amber: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900", + neutral: "bg-ink-100 text-ink-700 border-ink-200 dark:bg-ink-900 dark:text-ink-300 dark:border-ink-800", +}; + +export function TransactionReceiptBadge({ status }: { status: TransactionReceiptStatus }) { + const badge = receiptStatusBadge(status); + return ( + + + {badge.label} + + ); +} + export function AccountStatusBadge({ status }: { status: "funded" | "unfunded" | "unknown" | "error" | "checking" }) { const map = { funded: { label: "Funded", cls: "bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900" }, diff --git a/docs/transaction-receipts.md b/docs/transaction-receipts.md new file mode 100644 index 0000000..b357301 --- /dev/null +++ b/docs/transaction-receipts.md @@ -0,0 +1,117 @@ +# Transaction receipts (issue #91) + +AnchorKit exposes a reusable **transaction receipt** model so payment, anchor, +and escrow surfaces display confirmed, pending, failed, rejected, and unknown +outcomes consistently. Receipts are network-aware and include optional Stellar +Expert explorer links. + +## Result shape + +```ts +type TransactionReceiptStatus = + | "confirmed" + | "pending" + | "failed" + | "rejected" + | "unknown"; + +interface TransactionReceipt { + id: string; + status: TransactionReceiptStatus; + network: StellarNetwork; + headline: string; + detail?: string; + source: "payment" | "anchor" | "escrow" | "other"; + transactionHash?: StellarTransactionHash; + explorerUrl?: string; // network-aware Stellar Expert link + submittedAt?: string; + finalizedAt?: string; + errorCode?: string; + errorMessage?: string; + metadata?: Record; +} +``` + +Receipt statuses are distinct from: + +| concept | when to use | +| --- | --- | +| `ReadinessState` | **before** submit (payments page) | +| `AnchorTransactionStatus` | SEP-style anchor lifecycle | +| `TransactionReceiptStatus` | **after** submit — normalized UI outcome | + +## Status mapping + +| receipt status | typical meaning | +| --- | --- | +| `confirmed` | On-chain success or anchor `completed` | +| `pending` | Submitted, awaiting confirmation | +| `failed` | Hard failure (anchor `failed`, tx error) | +| `rejected` | Reversed / refunded / user rejected | +| `unknown` | Outcome not yet determined | + +Anchor statuses map via `mapAnchorStatusToReceiptStatus`: + +- `pending_user` / `pending_anchor` / `pending_stellar` → `pending` +- `completed` → `confirmed` +- `failed` → `failed` +- `refunded` → `rejected` + +## API + +```ts +import { + buildTransactionReceipt, + attachExplorerLink, + anchorRecordToReceipt, + escrowReleaseToReceipt, + createMockTransactionReceipt, + parseTransactionReceipt, + receiptStatusToUserMessage, + receiptStatusBadge, +} from "@anchorkit/stellar-kit"; + +// Build from scratch (explorer link attached automatically) +const receipt = buildTransactionReceipt({ + id: "pay_123", + status: "confirmed", + network: "testnet", + source: "payment", + transactionHash: "a".repeat(64), +}); + +// Map existing anchor record +const anchorReceipt = anchorRecordToReceipt(anchorRecord, "testnet"); + +// Map escrow release event +const escrowReceipt = escrowReleaseToReceipt(releasedEvent, "testnet"); + +// Safe parse for integrations +const parsed = parseTransactionReceipt(json); +if (parsed.success) console.log(parsed.data.explorerUrl); +``` + +Explorer links are built through `buildTransactionLink` in `explorer.ts` — +never hardcode stellar.expert URLs in application code. + +## UI + +`apps/web/components/TransactionReceiptPanel.tsx` renders any +`TransactionReceipt` with a status badge, headline, detail, timestamps, and +an explorer link. The panel is used on: + +- **Payments** — mock post-submit receipt preview +- **Anchors** — receipt derived from the mock anchor record +- **Escrow** — receipt from the released milestone event + +## Fixtures & tests + +- `examples/transaction-receipts.example.json` — one receipt per status. +- `packages/stellar-kit/test/receipt.test.ts` — status mapping, explorer links, + anchor/escrow converters, and parse validation. + +## Alignment with readiness & diagnostics + +Use **readiness** (`estimateTransactionReadinessSync`) before submission and +**receipts** after submission. Account diagnostics (`diagnoseAccount`) remain +independent — they describe account state, not transaction outcomes. diff --git a/examples/registry.ts b/examples/registry.ts index ed7ae0e..63fc191 100644 --- a/examples/registry.ts +++ b/examples/registry.ts @@ -25,11 +25,14 @@ export interface ExampleEntry { | "StellarAsset" | "AnchorTransactionRecord" | "Milestone" - | "StellarPublicKeyArray"; + | "StellarPublicKeyArray" + | "TransactionReceipt"; /** Whether the example must pass or must fail schema validation. */ expect: ExampleExpectation; /** When the file is a JSON array, validate each element. */ isArray?: boolean; + /** When the array lives under a property (e.g. `{ receipts: [...] }`). */ + arrayKey?: string; } export const EXAMPLE_REGISTRY: ExampleEntry[] = [ @@ -92,4 +95,12 @@ export const EXAMPLE_REGISTRY: ExampleEntry[] = [ expect: "valid", isArray: true, }, + { + id: "transaction-receipts", + path: "examples/transaction-receipts.example.json", + schema: "TransactionReceipt", + expect: "valid", + isArray: true, + arrayKey: "receipts", + }, ]; diff --git a/examples/transaction-receipts.example.json b/examples/transaction-receipts.example.json new file mode 100644 index 0000000..9447502 --- /dev/null +++ b/examples/transaction-receipts.example.json @@ -0,0 +1,59 @@ +{ + "description": "Transaction receipt fixtures covering every normalized outcome status (issue #91).", + "network": "testnet", + "receipts": [ + { + "id": "fixture_receipt_confirmed", + "status": "confirmed", + "network": "testnet", + "headline": "Transaction confirmed", + "detail": "The transaction completed successfully on the Stellar network.", + "source": "payment", + "transactionHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "submittedAt": "2026-01-15T10:00:00.000Z", + "finalizedAt": "2026-01-15T10:00:05.000Z" + }, + { + "id": "fixture_receipt_pending", + "status": "pending", + "network": "testnet", + "headline": "Transaction pending", + "detail": "The transaction has been submitted and is awaiting confirmation.", + "source": "payment", + "transactionHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "submittedAt": "2026-01-15T10:00:00.000Z" + }, + { + "id": "fixture_receipt_failed", + "status": "failed", + "network": "testnet", + "headline": "Transaction failed", + "detail": "Simulated submission failure for demo purposes.", + "source": "payment", + "submittedAt": "2026-01-15T10:00:00.000Z", + "finalizedAt": "2026-01-15T10:00:02.000Z", + "errorCode": "TRANSACTION_FAILED", + "errorMessage": "Simulated submission failure for demo purposes." + }, + { + "id": "fixture_receipt_rejected", + "status": "rejected", + "network": "testnet", + "headline": "Transaction rejected", + "detail": "Simulated rejection for demo purposes.", + "source": "anchor", + "submittedAt": "2026-01-15T10:00:00.000Z", + "finalizedAt": "2026-01-15T10:00:01.000Z", + "errorCode": "TRANSACTION_REJECTED", + "errorMessage": "Simulated rejection for demo purposes." + }, + { + "id": "fixture_receipt_unknown", + "status": "unknown", + "network": "testnet", + "headline": "Transaction status unknown", + "detail": "The outcome could not be determined. Check the explorer or try again later.", + "source": "escrow" + } + ] +} diff --git a/packages/stellar-kit/src/index.ts b/packages/stellar-kit/src/index.ts index d82628f..82c9ec2 100644 --- a/packages/stellar-kit/src/index.ts +++ b/packages/stellar-kit/src/index.ts @@ -8,6 +8,7 @@ export * from "./transactions"; export * from "./escrowEvents"; export * from "./logging"; export * from "./explorer"; +export * from "./receipt"; export * from "./balances"; export * from "./diagnostics"; export * from "./assetRegistry"; diff --git a/packages/stellar-kit/src/receipt.ts b/packages/stellar-kit/src/receipt.ts new file mode 100644 index 0000000..d20dfa2 --- /dev/null +++ b/packages/stellar-kit/src/receipt.ts @@ -0,0 +1,324 @@ +/** + * Transaction receipt model (issue #91). + * + * Reusable, network-aware receipt types and mappers so payment, anchor, and + * escrow surfaces display confirmed / pending / failed / rejected / unknown + * outcomes consistently. Explorer links route through `explorer.ts`. + */ + +import type { + AnchorTransactionRecord, + AnchorTransactionStatus, + EscrowEventV1, + ReleasedEvent, + StellarNetwork, + StellarTransactionHash, + TransactionReceipt, + TransactionReceiptSource, + TransactionReceiptStatus, +} from "@anchorkit/types"; +import { TRANSACTION_RECEIPT_STATUSES } from "@anchorkit/types"; +import { TransactionReceiptSchema } from "@anchorkit/validators"; +import type { SafeParseReturnType } from "zod"; +import { buildTransactionLink } from "./explorer"; + +export interface ReceiptStatusUserMessage { + headline: string; + detail: string; + severity: "info" | "warning" | "error" | "success"; +} + +export interface ReceiptStatusBadgeStyle { + label: string; + tone: "neutral" | "amber" | "blue" | "green" | "red"; +} + +export function isTransactionReceiptStatus( + value: string +): value is TransactionReceiptStatus { + return (TRANSACTION_RECEIPT_STATUSES as unknown as string[]).includes(value); +} + +export function receiptStatusToUserMessage( + status: TransactionReceiptStatus +): ReceiptStatusUserMessage { + switch (status) { + case "confirmed": + return { + headline: "Transaction confirmed", + detail: "The transaction completed successfully on the Stellar network.", + severity: "success", + }; + case "pending": + return { + headline: "Transaction pending", + detail: "The transaction has been submitted and is awaiting confirmation.", + severity: "info", + }; + case "failed": + return { + headline: "Transaction failed", + detail: "The transaction could not be completed. Review the error details and retry if appropriate.", + severity: "error", + }; + case "rejected": + return { + headline: "Transaction rejected", + detail: "The transaction was rejected or reversed before completion.", + severity: "warning", + }; + case "unknown": + return { + headline: "Transaction status unknown", + detail: "The outcome could not be determined. Check the explorer or try again later.", + severity: "warning", + }; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +export function receiptStatusBadge(status: TransactionReceiptStatus): ReceiptStatusBadgeStyle { + switch (status) { + case "confirmed": + return { label: "Confirmed", tone: "green" }; + case "pending": + return { label: "Pending", tone: "blue" }; + case "failed": + return { label: "Failed", tone: "red" }; + case "rejected": + return { label: "Rejected", tone: "amber" }; + case "unknown": + return { label: "Unknown", tone: "neutral" }; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +/** Map SEP-style anchor statuses to normalized receipt statuses. */ +export function mapAnchorStatusToReceiptStatus( + status: AnchorTransactionStatus +): TransactionReceiptStatus { + switch (status) { + case "pending_user": + case "pending_anchor": + case "pending_stellar": + return "pending"; + case "completed": + return "confirmed"; + case "failed": + return "failed"; + case "refunded": + return "rejected"; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +export interface BuildTransactionReceiptParams { + id: string; + status: TransactionReceiptStatus; + network?: StellarNetwork; + headline?: string; + detail?: string; + source: TransactionReceiptSource; + transactionHash?: StellarTransactionHash | string; + submittedAt?: string; + finalizedAt?: string; + errorCode?: string; + errorMessage?: string; + metadata?: Record; +} + +/** + * Build a receipt and attach a network-aware explorer link when a valid hash + * is present. + */ +export function buildTransactionReceipt( + params: BuildTransactionReceiptParams +): TransactionReceipt { + const network = params.network ?? "testnet"; + const message = receiptStatusToUserMessage(params.status); + const receipt: TransactionReceipt = { + id: params.id, + status: params.status, + network, + headline: params.headline ?? message.headline, + detail: params.detail ?? params.errorMessage ?? message.detail, + source: params.source, + transactionHash: params.transactionHash as StellarTransactionHash | undefined, + submittedAt: params.submittedAt, + finalizedAt: params.finalizedAt, + errorCode: params.errorCode, + errorMessage: params.errorMessage, + metadata: params.metadata, + }; + return attachExplorerLink(receipt); +} + +/** Attach or refresh the network-aware explorer URL on an existing receipt. */ +export function attachExplorerLink(receipt: TransactionReceipt): TransactionReceipt { + if (!receipt.transactionHash) { + return { ...receipt, explorerUrl: undefined }; + } + try { + const explorerUrl = buildTransactionLink(receipt.transactionHash, receipt.network); + return { ...receipt, explorerUrl }; + } catch { + return { ...receipt, explorerUrl: undefined }; + } +} + +/** Convert an anchor transaction record into a normalized receipt. */ +export function anchorRecordToReceipt( + record: AnchorTransactionRecord, + network: StellarNetwork = "testnet" +): TransactionReceipt { + const status = mapAnchorStatusToReceiptStatus(record.status); + const anchorMessage = record.message; + return buildTransactionReceipt({ + id: record.id, + status, + network, + headline: receiptStatusToUserMessage(status).headline, + detail: anchorMessage ?? receiptStatusToUserMessage(status).detail, + source: "anchor", + transactionHash: record.stellarTransactionId, + submittedAt: record.startedAt, + finalizedAt: record.completedAt, + errorCode: status === "failed" ? "ANCHOR_TRANSACTION_FAILED" : undefined, + errorMessage: status === "failed" ? record.message : undefined, + metadata: { + anchorKind: record.kind, + anchorStatus: record.status, + assetCode: record.assetCode, + amountIn: record.amountIn, + externalTransactionId: record.externalTransactionId, + ...record.metadata, + }, + }); +} + +/** Map a released escrow event to a receipt when funds were disbursed. */ +export function escrowReleaseToReceipt( + event: ReleasedEvent | EscrowEventV1, + network: StellarNetwork = "testnet" +): TransactionReceipt { + if (event.type !== "released") { + return buildTransactionReceipt({ + id: `escrow_${event.milestoneId}_${event.type}`, + status: "unknown", + network, + source: "escrow", + detail: `Escrow event "${event.type}" does not represent a finalized release.`, + metadata: { milestoneId: event.milestoneId, eventType: event.type }, + }); + } + + const released = event as ReleasedEvent; + const hasHash = Boolean(released.transactionHash); + return buildTransactionReceipt({ + id: `escrow_release_${released.milestoneId}`, + status: hasHash ? "confirmed" : "pending", + network, + headline: hasHash ? "Escrow release confirmed" : "Escrow release pending", + detail: hasHash + ? `Milestone funds (${released.amount}) were released on Stellar.` + : `Milestone release (${released.amount}) is awaiting an on-chain transaction hash.`, + source: "escrow", + transactionHash: released.transactionHash as StellarTransactionHash | undefined, + submittedAt: released.timestamp, + finalizedAt: hasHash ? released.timestamp : undefined, + metadata: { + milestoneId: released.milestoneId, + amount: released.amount, + contractId: released.contractId, + ledger: released.ledger, + }, + }); +} + +export function parseTransactionReceipt( + input: unknown +): SafeParseReturnType { + const parsed = TransactionReceiptSchema.safeParse(input); + if (!parsed.success) { + return parsed; + } + return { success: true, data: attachExplorerLink(parsed.data) }; +} + +export function isTransactionReceiptValid(input: unknown): boolean { + return parseTransactionReceipt(input).success; +} + +export interface CreateMockReceiptParams { + id?: string; + status?: TransactionReceiptStatus; + network?: StellarNetwork; + source?: TransactionReceiptSource; + transactionHash?: StellarTransactionHash | string; + headline?: string; + detail?: string; + errorCode?: string; + errorMessage?: string; + metadata?: Record; +} + +/** Deterministic-enough mock receipt for UI demos and tests. */ +export function createMockTransactionReceipt( + params: CreateMockReceiptParams = {} +): TransactionReceipt { + const status = params.status ?? "pending"; + const now = new Date().toISOString(); + const id = params.id ?? `mock_receipt_${status}_${Math.random().toString(36).slice(2, 8)}`; + const hash = + params.transactionHash ?? + (status === "confirmed" || status === "pending" + ? ("a".repeat(64) as StellarTransactionHash) + : undefined); + + return buildTransactionReceipt({ + id, + status, + network: params.network ?? "testnet", + source: params.source ?? "payment", + headline: params.headline, + detail: params.detail, + transactionHash: hash, + submittedAt: now, + finalizedAt: + status === "confirmed" || status === "failed" || status === "rejected" + ? now + : undefined, + errorCode: params.errorCode ?? (status === "failed" ? "TRANSACTION_FAILED" : undefined), + errorMessage: + params.errorMessage ?? + (status === "failed" + ? "Simulated submission failure for demo purposes." + : status === "rejected" + ? "Simulated rejection for demo purposes." + : undefined), + metadata: params.metadata, + }); +} + +/** Fixture set covering every receipt status for docs and examples. */ +export function buildReceiptStatusFixtures( + network: StellarNetwork = "testnet" +): TransactionReceipt[] { + return TRANSACTION_RECEIPT_STATUSES.map((status) => + createMockTransactionReceipt({ + id: `fixture_receipt_${status}`, + status, + network, + source: "payment", + }) + ); +} diff --git a/packages/stellar-kit/test/receipt.test.ts b/packages/stellar-kit/test/receipt.test.ts new file mode 100644 index 0000000..f7c3a0a --- /dev/null +++ b/packages/stellar-kit/test/receipt.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "vitest"; +import type { + AnchorTransactionRecord, + ReleasedEvent, + StellarPublicKey, + StellarTransactionHash, + TransactionReceiptStatus, +} from "@anchorkit/types"; +import { + anchorRecordToReceipt, + attachExplorerLink, + buildReceiptStatusFixtures, + buildTransactionReceipt, + createMockTransactionReceipt, + escrowReleaseToReceipt, + isTransactionReceiptStatus, + isTransactionReceiptValid, + mapAnchorStatusToReceiptStatus, + parseTransactionReceipt, + receiptStatusBadge, + receiptStatusToUserMessage, +} from "../src/receipt"; + +const TX_HASH = "b".repeat(64) as StellarTransactionHash; +const ACCOUNT = + "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR" as StellarPublicKey; + +describe("receiptStatusToUserMessage", () => { + const statuses: TransactionReceiptStatus[] = [ + "confirmed", + "pending", + "failed", + "rejected", + "unknown", + ]; + + it.each(statuses)("returns a message for %s", (status) => { + const msg = receiptStatusToUserMessage(status); + expect(msg.headline).toBeTruthy(); + expect(msg.detail).toBeTruthy(); + expect(["info", "warning", "error", "success"]).toContain(msg.severity); + }); +}); + +describe("receiptStatusBadge", () => { + it("maps confirmed to green", () => { + expect(receiptStatusBadge("confirmed")).toEqual({ label: "Confirmed", tone: "green" }); + }); + + it("maps unknown to neutral", () => { + expect(receiptStatusBadge("unknown").tone).toBe("neutral"); + }); +}); + +describe("mapAnchorStatusToReceiptStatus", () => { + it("maps pending anchor states to pending", () => { + expect(mapAnchorStatusToReceiptStatus("pending_user")).toBe("pending"); + expect(mapAnchorStatusToReceiptStatus("pending_anchor")).toBe("pending"); + expect(mapAnchorStatusToReceiptStatus("pending_stellar")).toBe("pending"); + }); + + it("maps terminal anchor states", () => { + expect(mapAnchorStatusToReceiptStatus("completed")).toBe("confirmed"); + expect(mapAnchorStatusToReceiptStatus("failed")).toBe("failed"); + expect(mapAnchorStatusToReceiptStatus("refunded")).toBe("rejected"); + }); +}); + +describe("buildTransactionReceipt", () => { + it("attaches a network-aware explorer link for valid hashes", () => { + const receipt = buildTransactionReceipt({ + id: "tx_1", + status: "confirmed", + network: "testnet", + source: "payment", + transactionHash: TX_HASH, + }); + expect(receipt.explorerUrl).toContain("/testnet/tx/"); + expect(receipt.explorerUrl).toContain(TX_HASH); + }); + + it("omits explorer link when hash is absent", () => { + const receipt = buildTransactionReceipt({ + id: "tx_2", + status: "pending", + source: "payment", + }); + expect(receipt.explorerUrl).toBeUndefined(); + }); + + it("uses mainnet explorer base when requested", () => { + const receipt = buildTransactionReceipt({ + id: "tx_3", + status: "confirmed", + network: "mainnet", + source: "payment", + transactionHash: TX_HASH, + }); + expect(receipt.explorerUrl).toContain("/public/tx/"); + }); +}); + +describe("attachExplorerLink", () => { + it("refreshes the link when network changes", () => { + const base = buildTransactionReceipt({ + id: "tx_4", + status: "confirmed", + network: "testnet", + source: "payment", + transactionHash: TX_HASH, + }); + const mainnet = attachExplorerLink({ ...base, network: "mainnet" }); + expect(mainnet.explorerUrl).toContain("/public/tx/"); + }); +}); + +describe("anchorRecordToReceipt", () => { + it("maps a completed anchor record", () => { + const record: AnchorTransactionRecord = { + id: "anchor_1", + kind: "deposit", + status: "completed", + assetCode: "USDC", + amountIn: "100.0000000", + stellarAccount: ACCOUNT, + stellarTransactionId: TX_HASH, + startedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:05:00.000Z", + completedAt: "2026-01-01T00:05:00.000Z", + metadata: {}, + }; + const receipt = anchorRecordToReceipt(record, "testnet"); + expect(receipt.status).toBe("confirmed"); + expect(receipt.source).toBe("anchor"); + expect(receipt.explorerUrl).toContain(TX_HASH); + expect(receipt.metadata?.anchorKind).toBe("deposit"); + }); + + it("maps a failed anchor record", () => { + const record: AnchorTransactionRecord = { + id: "anchor_2", + kind: "withdrawal", + status: "failed", + assetCode: "XLM", + amountIn: "10.0000000", + stellarAccount: ACCOUNT, + startedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:01:00.000Z", + message: "Rail timeout", + metadata: {}, + }; + const receipt = anchorRecordToReceipt(record); + expect(receipt.status).toBe("failed"); + expect(receipt.errorCode).toBe("ANCHOR_TRANSACTION_FAILED"); + }); +}); + +describe("escrowReleaseToReceipt", () => { + const released: ReleasedEvent = { + type: "released", + milestoneId: "ms_1", + timestamp: "2026-01-01T00:00:00.000Z", + caller: ACCOUNT, + contractId: "C_CONTRACT", + amount: "500.0000000", + transactionHash: TX_HASH, + }; + + it("maps a released event with hash to confirmed", () => { + const receipt = escrowReleaseToReceipt(released, "testnet"); + expect(receipt.status).toBe("confirmed"); + expect(receipt.source).toBe("escrow"); + expect(receipt.explorerUrl).toContain(TX_HASH); + }); + + it("maps a released event without hash to pending", () => { + const receipt = escrowReleaseToReceipt({ ...released, transactionHash: undefined }); + expect(receipt.status).toBe("pending"); + expect(receipt.explorerUrl).toBeUndefined(); + }); + + it("returns unknown for non-release events", () => { + const receipt = escrowReleaseToReceipt({ + type: "approved", + milestoneId: "ms_1", + timestamp: "2026-01-01T00:00:00.000Z", + caller: ACCOUNT, + contractId: "C_CONTRACT", + }); + expect(receipt.status).toBe("unknown"); + }); +}); + +describe("parseTransactionReceipt", () => { + it("parses a valid receipt", () => { + const mock = createMockTransactionReceipt({ status: "confirmed" }); + const result = parseTransactionReceipt(mock); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.explorerUrl).toBeTruthy(); + } + }); + + it("rejects invalid receipts", () => { + expect(parseTransactionReceipt({ id: "" }).success).toBe(false); + expect(isTransactionReceiptValid({})).toBe(false); + }); +}); + +describe("isTransactionReceiptStatus", () => { + it("narrows valid status strings", () => { + expect(isTransactionReceiptStatus("confirmed")).toBe(true); + expect(isTransactionReceiptStatus("bogus")).toBe(false); + }); +}); + +describe("buildReceiptStatusFixtures", () => { + it("returns one fixture per status", () => { + const fixtures = buildReceiptStatusFixtures("testnet"); + expect(fixtures).toHaveLength(5); + expect(new Set(fixtures.map((f) => f.status)).size).toBe(5); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e784347..f520a1d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -159,6 +159,59 @@ export interface TransactionReadiness { summary: string; } +/** + * Normalized post-submit transaction outcome for cross-surface UI. + * + * Distinct from `AnchorTransactionStatus` (SEP lifecycle) and `ReadinessState` + * (pre-submit validation). Use this when displaying confirmed, pending, failed, + * rejected, or unknown outcomes consistently across payment, anchor, and escrow + * screens. + */ +export type TransactionReceiptStatus = + | "confirmed" + | "pending" + | "failed" + | "rejected" + | "unknown"; + +export const TRANSACTION_RECEIPT_STATUSES: readonly TransactionReceiptStatus[] = [ + "confirmed", + "pending", + "failed", + "rejected", + "unknown", +] as const; + +/** Where a receipt was derived from. */ +export type TransactionReceiptSource = "payment" | "anchor" | "escrow" | "other"; + +export interface TransactionReceipt { + /** Stable receipt id (anchor tx id, mock id, etc.). */ + id: string; + /** Normalized outcome status for cross-surface UI. */ + status: TransactionReceiptStatus; + /** Network the transaction was submitted on. */ + network: StellarNetwork; + /** Human-readable headline for UI. */ + headline: string; + /** Optional longer detail message. */ + detail?: string; + /** Where this receipt originated. */ + source: TransactionReceiptSource; + /** On-chain Stellar transaction hash, when known. */ + transactionHash?: StellarTransactionHash; + /** Network-aware Stellar Expert link, when `transactionHash` is present. */ + explorerUrl?: string; + /** ISO-8601 timestamps. */ + submittedAt?: string; + finalizedAt?: string; + /** Error classification when status is failed or rejected. */ + errorCode?: string; + errorMessage?: string; + /** Additional context (anchor id, milestone id, etc.). */ + metadata?: Record; +} + export type AnchorTransactionStatus = | "pending_user" | "pending_anchor" diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 9cc7950..c0535c3 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -4,6 +4,7 @@ export { z } from "zod"; export * from "./schemas/stellar"; export * from "./schemas/anchor"; export * from "./schemas/escrow"; +export * from "./schemas/receipt"; // ─── Validation engine (issue #6) ─────────────────────────────────────────── export * from "./validationEngine"; diff --git a/packages/validators/src/schemas/receipt.ts b/packages/validators/src/schemas/receipt.ts new file mode 100644 index 0000000..7803c23 --- /dev/null +++ b/packages/validators/src/schemas/receipt.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import { STELLAR_NETWORKS, TRANSACTION_RECEIPT_STATUSES } from "@anchorkit/types"; +import type { + StellarNetwork, + TransactionReceiptStatus, +} from "@anchorkit/types"; +import { StellarTransactionHashSchema } from "./stellar"; + +export const TransactionReceiptStatusSchema = z.enum( + TRANSACTION_RECEIPT_STATUSES as [TransactionReceiptStatus, ...TransactionReceiptStatus[]] +); + +export const TransactionReceiptSourceSchema = z.enum([ + "payment", + "anchor", + "escrow", + "other", +]); + +export const TransactionReceiptSchema = z.object({ + id: z.string().min(1), + status: TransactionReceiptStatusSchema, + network: z.enum([ + STELLAR_NETWORKS.TESTNET, + STELLAR_NETWORKS.MAINNET, + STELLAR_NETWORKS.FUTURENET, + ] as [StellarNetwork, ...StellarNetwork[]]), + headline: z.string().min(1), + detail: z.string().optional(), + source: TransactionReceiptSourceSchema, + transactionHash: StellarTransactionHashSchema.optional(), + explorerUrl: z.string().url().optional(), + submittedAt: z.string().datetime().optional(), + finalizedAt: z.string().datetime().optional(), + errorCode: z.string().optional(), + errorMessage: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export type ParsedTransactionReceipt = z.infer; diff --git a/packages/validators/test/examples.test.ts b/packages/validators/test/examples.test.ts index 07a2e47..d6809ac 100644 --- a/packages/validators/test/examples.test.ts +++ b/packages/validators/test/examples.test.ts @@ -15,6 +15,7 @@ import { AnchorTransactionRecordSchema, MilestoneSchema, StellarPublicKeySchema, + TransactionReceiptSchema, } from "../src/index"; import { EXAMPLE_REGISTRY } from "../../../examples/registry"; @@ -24,6 +25,7 @@ const SCHEMA_MAP = { AnchorTransactionRecord: AnchorTransactionRecordSchema, Milestone: MilestoneSchema, StellarPublicKeyArray: StellarPublicKeySchema, + TransactionReceipt: TransactionReceiptSchema, } as const; const ROOT = resolve(import.meta.dirname, "../../.."); @@ -40,7 +42,11 @@ describe.each(EXAMPLE_REGISTRY)("$id", (entry) => { it(`matches expectation (${entry.expect})`, () => { const raw = JSON.parse(readFileSync(resolve(ROOT, entry.path), "utf8")); const schema = SCHEMA_MAP[entry.schema]; - const items = entry.isArray && Array.isArray(raw) ? raw : [raw]; + const source = + entry.arrayKey && typeof raw === "object" && raw !== null + ? (raw as Record)[entry.arrayKey] + : raw; + const items = entry.isArray && Array.isArray(source) ? source : [source]; let failures = 0; for (const item of items) { diff --git a/scripts/check-examples.mts b/scripts/check-examples.mts index 4077ea0..c4cb55d 100644 --- a/scripts/check-examples.mts +++ b/scripts/check-examples.mts @@ -17,6 +17,7 @@ import { AnchorTransactionRecordSchema, MilestoneSchema, StellarPublicKeySchema, + TransactionReceiptSchema, } from "@anchorkit/validators"; import { EXAMPLE_REGISTRY } from "../examples/registry"; @@ -28,6 +29,7 @@ const SCHEMA_MAP = { AnchorTransactionRecord: AnchorTransactionRecordSchema, Milestone: MilestoneSchema, StellarPublicKeyArray: StellarPublicKeySchema, + TransactionReceipt: TransactionReceiptSchema, } as const; interface ReportRow { @@ -54,7 +56,11 @@ function validateEntry(entry: (typeof EXAMPLE_REGISTRY)[number]): ReportRow { } const schema = SCHEMA_MAP[entry.schema]; - const items = entry.isArray && Array.isArray(raw) ? raw : [raw]; + const source = + entry.arrayKey && typeof raw === "object" && raw !== null + ? (raw as Record)[entry.arrayKey] + : raw; + const items = entry.isArray && Array.isArray(source) ? source : [source]; let failures = 0; const messages: string[] = [];