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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/app/anchors/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,17 @@ 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,
AnchorTransactionRecord,
AnchorTransactionStatus,
DepositRequestMetadata,
StellarPublicKey,
StellarTransactionHash,
WithdrawalRequestMetadata,
} from "@anchorkit/types";

Expand Down Expand Up @@ -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 (
<PageShell
eyebrow="Anchors"
Expand Down Expand Up @@ -340,6 +349,7 @@ export default function AnchorsPage() {
<DataRow label="Amount in" value={<span className="text-mono-sm">{mockRecord.amountIn} {mockRecord.assetCode}</span>} />
</dl>
</div>
<TransactionReceiptPanel receipt={mockReceipt} title="Normalized anchor receipt" />
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
{
Expand Down
10 changes: 9 additions & 1 deletion apps/web/app/escrow/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -215,6 +221,8 @@ export default function EscrowPage() {
</div>
</Card>

<TransactionReceiptPanel receipt={releaseReceipt} title="Escrow release receipt" />

<Card>
<h2 className="text-base font-semibold tracking-tight">Contract quick-links</h2>
<ul className="mt-3 space-y-2 text-sm">
Expand Down
39 changes: 38 additions & 1 deletion apps/web/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<TransactionReceiptStatus>("pending");

const mockReceipt = useMemo(
() =>
createMockTransactionReceipt({
status: mockReceiptStatus,
source: "payment",
network: DEFAULT_NETWORK,
}),
[mockReceiptStatus]
);

const asset: StellarAsset = useMemo(() => {
if (assetMode === "native") {
Expand Down Expand Up @@ -340,6 +353,30 @@ export default function PaymentsPage() {
)}
</Card>
</div>

<div className="grid gap-6 lg:grid-cols-2">
<Card className="space-y-4">
<h2 className="text-base font-semibold tracking-tight">Mock transaction receipt</h2>
<p className="text-sm text-ink-500 dark:text-ink-400">
Preview the normalized receipt model for each post-submit outcome. The MVP does not
submit real transactions — this selector demonstrates the shared receipt UI.
</p>
<div>
<Label>Receipt status</Label>
<Select
value={mockReceiptStatus}
onChange={(e) => setMockReceiptStatus(e.target.value as TransactionReceiptStatus)}
>
{(["confirmed", "pending", "failed", "rejected", "unknown"] as const).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</Select>
</div>
</Card>
<TransactionReceiptPanel receipt={mockReceipt} title="Payment receipt preview" />
</div>
</PageShell>
);
}
75 changes: 75 additions & 0 deletions apps/web/components/TransactionReceiptPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert tone="warning" title="No receipt">
A transaction receipt will appear here after submission or when mapped from anchor/escrow
data.
</Alert>
);
}

const tone =
receipt.status === "confirmed"
? "success"
: receipt.status === "failed"
? "error"
: receipt.status === "rejected" || receipt.status === "unknown"
? "warning"
: "info";

return (
<Card className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold tracking-tight">{title}</h3>
<TransactionReceiptBadge status={receipt.status} />
</div>
<Alert tone={tone} title={receipt.headline}>
{receipt.detail}
</Alert>
<dl className="divide-y divide-ink-100 dark:divide-ink-800">
<DataRow label="Receipt id" value={<span className="font-mono text-xs">{receipt.id}</span>} />
<DataRow label="Source" value={receipt.source} />
<DataRow label="Network" value={receipt.network} />
{receipt.transactionHash && (
<DataRow
label="Transaction hash"
value={
<span className="font-mono text-xs break-all">{receipt.transactionHash}</span>
}
/>
)}
{receipt.explorerUrl && (
<DataRow
label="Explorer"
value={
<a
href={receipt.explorerUrl}
target="_blank"
rel="noreferrer"
className="text-stellar-600 underline dark:text-stellar-400"
>
View on Stellar Expert ↗
</a>
}
/>
)}
{receipt.submittedAt && (
<DataRow label="Submitted" value={new Date(receipt.submittedAt).toLocaleString()} />
)}
{receipt.finalizedAt && (
<DataRow label="Finalized" value={new Date(receipt.finalizedAt).toLocaleString()} />
)}
{receipt.errorCode && <DataRow label="Error code" value={receipt.errorCode} />}
</dl>
</Card>
);
}
29 changes: 28 additions & 1 deletion apps/web/components/ui.tsx
Original file line number Diff line number Diff line change
@@ -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";
import { anchorStatusBadge } from "@anchorkit/anchor-utils";

/**
Expand Down Expand Up @@ -64,6 +65,32 @@ export function MilestoneStatusBadge({ status }: { status: MilestoneStatus }) {
);
}

const RECEIPT_BADGE_STYLES: Record<
ReturnType<typeof receiptStatusBadge>["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 (
<span
className={clsx(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-mono-xs font-medium",
RECEIPT_BADGE_STYLES[badge.tone]
)}
>
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-80" />
{badge.label}
</span>
);
}

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" },
Expand Down
117 changes: 117 additions & 0 deletions docs/transaction-receipts.md
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}
```

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.
Loading