Skip to content
Merged
108 changes: 86 additions & 22 deletions apps/web/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import { TransactionReceiptPanel } from "@/components/TransactionReceiptPanel";
const FRIENDBOT = "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR";
const DEMO_DEST = "GDQJUTQYK2MQ32ZGMMB7Q3UKTJLNTMZI2QYHW7OK2TK2DZI3X5IGQH6U";

type SimulationStatus = "funded" | "unfunded" | "unknown";

export default function PaymentsPage() {
const [source, setSource] = useState(FRIENDBOT);
const [dest, setDest] = useState(DEMO_DEST);
const [network, setNetwork] = useState<StellarNetwork>("testnet");
const [assetMode, setAssetMode] = useState<"native" | "issued">("native");
const [assetCode, setAssetCode] = useState("USDC");
const [assetIssuer, setAssetIssuer] = useState(
Expand Down Expand Up @@ -60,6 +63,7 @@ export default function PaymentsPage() {
);

const intent: PaymentIntent | null = useMemo(() => {
const memo = memoType === "none" ? undefined : { type: memoType, value: memoValue };
try {
return createPaymentIntent({
sourcePublicKey: source as StellarPublicKey,
Expand All @@ -71,12 +75,12 @@ export default function PaymentsPage() {
} catch {
return null;
}
}, [source, dest, asset, amount, memo]);
}, [source, dest, asset, amount, memoType, memoValue]);

const readiness: TransactionReadiness | null = useMemo(() => {
if (!intent) return null;
return estimateTransactionReadinessSync(intent, {
network: DEFAULT_NETWORK,
return evaluateTransactionReadinessSync(intent, {
network,
sourceAccountFunded:
simulateSource === "funded"
? true
Expand All @@ -89,19 +93,51 @@ export default function PaymentsPage() {
: simulateDest === "unfunded"
? false
: undefined,
sourceBalanceXlm: sourceBalance,
});
}, [intent, simulateSource, simulateDest]);
}, [intent, network, simulateSource, simulateDest, sourceBalance]);

const getStateBadgeStyle = (state: TransactionReadinessState) => {
switch (state) {
case "valid":
return "bg-green-100 text-green-800 border-green-300 dark:bg-green-950/40 dark:text-green-300 dark:border-green-800";
case "warning":
return "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-800";
case "blocked":
return "bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-800";
case "invalid":
return "bg-red-100 text-red-800 border-red-300 dark:bg-red-950/40 dark:text-red-300 dark:border-red-800";
case "unavailable":
return "bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-800";
}
};

return (
<PageShell
eyebrow="Payments"
title="Payment intent builder & readiness"
subtitle="Compose a basic payment intent, validate each field, and inspect typed readiness warnings before any network call is made."
warning="The MVP does not submit real Stellar transactions. Readiness checks use simulated account statuses by default. A testnet-only mock submit toggle can be wired in by contributors."
title="Payment intent builder & transaction readiness pipeline"
subtitle="Compose a Stellar payment intent, validate input stages, and inspect typed transaction readiness states across the monorepo pipeline."
warning="AnchorKit operates testnet-first and does not submit real payments. Readiness checks use the shared readiness pipeline."
>
<div className="grid gap-6 lg:grid-cols-5">
<Card className="lg:col-span-3 space-y-4">
<h2 className="text-base font-semibold tracking-tight">Intent fields</h2>
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold tracking-tight">Intent fields</h2>
<div className="flex items-center gap-2">
<Label htmlFor="net">Network</Label>
<Select
id="net"
value={network}
onChange={(e) => setNetwork(e.target.value as StellarNetwork)}
className="py-1 text-xs"
>
<option value="testnet">Testnet (default)</option>
<option value="mainnet">Mainnet (disabled by default)</option>
<option value="futurenet">Futurenet</option>
</Select>
</div>
</div>

<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="src" required>Source public key</Label>
Expand Down Expand Up @@ -226,15 +262,15 @@ export default function PaymentsPage() {
</Card>

<Card className="lg:col-span-2 space-y-4">
<h2 className="text-base font-semibold tracking-tight">Readiness result</h2>
<h2 className="text-base font-semibold tracking-tight">Readiness pipeline result</h2>
{!readiness ? (
<Alert tone="warning" title="No intent yet">
<Alert tone="warning" title="No valid intent">
Fix the invalid fields above to generate a typed readiness report.
</Alert>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-ink-500 dark:text-ink-400">Overall</span>
<span className="text-sm text-ink-500 dark:text-ink-400">Overall State</span>
<span
className={`rounded-full px-2.5 py-0.5 text-mono-xs font-medium ${
readiness.state === "ready"
Expand All @@ -255,7 +291,35 @@ export default function PaymentsPage() {
: "Blocked"}
</span>
</div>
<p className="text-sm">{readiness.summary}</p>
<p className="text-sm font-medium">{readiness.summary}</p>

{/* 5-Stage Pipeline Breakdown */}
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wider text-ink-500">
Validation Stages
</div>
<div className="grid gap-2">
{Object.values(readiness.stages).map((stg) => (
<div
key={stg.stage}
className="flex items-center justify-between rounded-md border border-ink-200 p-2 text-xs dark:border-ink-800"
>
<div className="flex items-center gap-2">
<span className="font-mono font-bold uppercase">{stg.stage}</span>
<span className="text-ink-600 dark:text-ink-300">{stg.message}</span>
</div>
<span
className={`rounded-full border px-2 py-0.5 text-mono-xs uppercase ${getStateBadgeStyle(
stg.state
)}`}
>
{stg.state}
</span>
</div>
))}
</div>
</div>

<div>
<div className="mb-1 text-sm font-medium">Validation stages</div>
<ul className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
Expand Down Expand Up @@ -288,36 +352,35 @@ export default function PaymentsPage() {
<div className="mb-1 text-sm font-medium">Warnings ({readiness.warnings.length})</div>
{readiness.warnings.length === 0 ? (
<p className="text-sm text-ink-500 dark:text-ink-400">
No warnings. Intent structure looks good.
No warnings or blockers. Intent structure is fully valid.
</p>
) : (
<ul className="space-y-1.5">
{readiness.warnings.map((w) => (
{readiness.issues.map((w) => (
<li
key={w.code}
className={`rounded-md border px-3 py-2 text-sm ${
w.severity === "error"
? "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200"
: w.severity === "warning"
? "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200"
: "border-ink-200 bg-ink-50 text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200"
: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200"
}`}
>
<span className="font-semibold text-mono-xs uppercase tracking-wider">
{w.severity} · {w.code}
[{w.stage}] {w.severity} · {w.code}
</span>
<div className="mt-0.5">{w.message}</div>
</li>
))}
</ul>
)}
</div>

<div className="rounded-lg border border-ink-200 p-3 text-sm dark:border-ink-800">
<div className="mb-2 font-medium">Review links (testnet)</div>
<div className="mb-2 font-medium">Review links ({network})</div>
<div className="flex flex-wrap gap-2 text-mono-xs">
{isPublicKeyValid(source) && (
<a
href={getStellarExpertAccountUrl(source, "testnet") ?? "#"}
href={getStellarExpertAccountUrl(source, network) ?? "#"}
target="_blank"
rel="noreferrer"
className="underline"
Expand All @@ -327,7 +390,7 @@ export default function PaymentsPage() {
)}
{isPublicKeyValid(dest) && (
<a
href={getStellarExpertAccountUrl(dest, "testnet") ?? "#"}
href={getStellarExpertAccountUrl(dest, network) ?? "#"}
target="_blank"
rel="noreferrer"
className="underline"
Expand All @@ -347,7 +410,7 @@ export default function PaymentsPage() {
</div>
<p className="text-mono-xs text-ink-500 dark:text-ink-400">
Note: mainnet mode is explicitly disabled by default. Override only in advanced
config and after reviewing the security notes.
config and after reviewing security notes.
</p>
</>
)}
Expand Down Expand Up @@ -380,3 +443,4 @@ export default function PaymentsPage() {
</PageShell>
);
}

21 changes: 4 additions & 17 deletions docs/PAYMENT_INTENT_UTILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,13 @@ const intent = createPaymentIntent({
});
```

Check readiness in two modes:
Check readiness using the unified transaction readiness pipeline:

- `estimateTransactionReadinessSync(intent, options)` — pure. Pass simulated
`sourceAccountFunded`/`destAccountFunded` flags for fast UI feedback.
- `estimateTransactionReadiness(intent, options)` — async, actually calls Horizon via
`getAccountStatus` on source and destination.
- `evaluateTransactionReadinessSync(intent, options)` — pure synchronous evaluation across all 5 stages (`network`, `account`, `asset`, `amount`, `memo`).
- `evaluateTransactionReadiness(intent, options)` — async evaluation fetching live account diagnostics.

Return shape:
Return shape includes typed state (`valid` | `warning` | `blocked` | `invalid` | `unavailable`), 5 stage results, issue list, reserve calculations, and diagnostics. See [TRANSACTION_READINESS.md](./TRANSACTION_READINESS.md) for full documentation.

```ts
{
ready: boolean,
warnings: Array<{ code, message, severity: "error" | "warning" | "info" }>,
summary: string,
}
```

Readiness error codes surfaced today: `SOURCE_INVALID`, `DEST_INVALID`, `ASSET_INVALID`,
`AMOUNT_INVALID`, `MEMO_INVALID`, `MAINNET_DISABLED`, `SAME_SOURCE_DEST`, `SOURCE_UNFUNDED`,
`DEST_UNFUNDED`.

## Submission

Expand Down
95 changes: 95 additions & 0 deletions docs/TRANSACTION_READINESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Transaction Readiness Pipeline

AnchorKit exposes a unified, cross-monorepo **Transaction Readiness Pipeline** in `@anchorkit/stellar-kit` (`readiness.ts`). It coordinates network safety checks, account diagnostics, asset validation, amount limits, minimum reserve calculations, memo rules, and UI readiness states across packages and the Next.js web application.

---

## 1. Overview & Architecture

Transaction readiness is evaluated across **5 distinct validation stages**:

1. **`network`**: Validates network configuration, passphrase resolution, and network safety rules (e.g. mainnet access is explicitly disabled by default via `allowMainnet: false`).
2. **`account`**: Integrates `diagnoseAccount` / `diagnoseAccountInfo` diagnostics for source and destination accounts. Validates public key formats, funded states, reserve requirements (base reserve + subentries), and balance sufficiency.
3. **`asset`**: Validates native vs issued Stellar assets, code length (1-12 alphanumeric characters), issuer key validity, and destination trustline requirements for issued assets.
4. **`amount`**: Validates decimal precision (up to 7 decimals), positivity (`> 0`), and configured minimum/maximum bounds (`minimumPaymentAmount` / `maximumPaymentAmount`). Checks that source XLM balance covers payment amount plus minimum reserve requirements.
5. **`memo`**: Validates memo type (`none`, `text`, `id`, `hash`, `return`), UTF-8 byte limits (≤ 28 bytes for text), numeric bounds for ID memos, and 64 hex characters for hash/return memos.

---

## 2. Typed Readiness States

Every readiness assessment resolves to one of **5 typed readiness states**:

| State | Status | Description | `ready` |
|---|---|---|---|
| **`valid`** | Fully Ready | All 5 validation stages passed with zero errors or warnings. | `true` |
| **`warning`** | Ready w/ Warnings | Payment intent can proceed, but contains non-blocking warnings (e.g. same source/dest account or unfunded dest for XLM payment). | `true` |
| **`blocked`** | Blocked | Critical business/account requirements prevent execution (e.g. unfunded source, missing trustline, mainnet access disabled, or insufficient reserve). | `false` |
| **`invalid`** | Invalid Input | Malformed public keys, invalid amount format, malformed asset code/issuer, or invalid memo. | `false` |
| **`unavailable`** | Diagnostics Unavailable | Network or RPC failure prevented loading account diagnostics. | `false` |

---

## 3. Account Reserve Awareness

The account stage incorporates protocol reserve requirements:

$$\text{Minimum Balance (XLM)} = 2.0 + (\text{subentries} + 2) \times 0.5$$

If the payment asset is native XLM, the pipeline verifies:

$$\text{Source Balance} \ge \text{Payment Amount} + \text{Minimum Balance Requirement}$$

If the source balance is insufficient to cover the payment amount plus minimum reserve, the account stage resolves to `blocked` with an `INSUFFICIENT_BALANCE` issue code.

---

## 4. Usage Examples

### Synchronous Evaluation (Simulated or Pre-loaded Diagnostics)

```ts
import { createPaymentIntent, evaluateTransactionReadinessSync } from "@anchorkit/stellar-kit";

const intent = createPaymentIntent({
sourcePublicKey: "GAIH3ULLFQ4DGSECF2AR555KZ4KNDGEKN4AFI4SU2M7B43MGK3QJZNSR",
destinationPublicKey: "GDQJUTQYK2MQ32ZGMMB7Q3UKTJLNTMZI2QYHW7OK2TK2DZI3X5IGQH6U",
asset: { type: "native", code: "XLM", issuer: null },
amount: "100.5000000",
});

const readiness = evaluateTransactionReadinessSync(intent, {
network: "testnet",
sourceAccountFunded: true,
destAccountFunded: true,
sourceBalanceXlm: "1000.0000000",
});

console.log(readiness.state); // "valid"
console.log(readiness.ready); // true
console.log(readiness.stages.account.state); // "valid"
```

### Asynchronous Evaluation (Network Account Diagnostics)

```ts
import { createPaymentIntent, evaluateTransactionReadiness } from "@anchorkit/stellar-kit";

const readiness = await evaluateTransactionReadiness(intent, {
network: "testnet",
});

if (readiness.ready) {
console.log("Ready to sign payment intent.");
} else {
console.warn("Transaction readiness blocked or invalid:", readiness.summary);
}
```

---

## 5. Testnet-First Safety & Limitations

- **Testnet-First**: Mainnet access is disabled by default for safety. Set `allowMainnet: true` in config only after reviewing security notes.
- **No Real Payments**: The readiness pipeline performs diagnostics and validation only. It does **not** sign or submit transactions to the Stellar network.
- **Graceful Degradation**: Network connectivity failures gracefully transition the state to `unavailable` rather than throwing uncaught exceptions.
1 change: 1 addition & 0 deletions packages/stellar-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "./accounts";
export * from "./assets";
export * from "./payments";
export * from "./intent";
export * from "./readiness";
export * from "./transactions";
export * from "./escrowEvents";
export * from "./logging";
Expand Down
10 changes: 0 additions & 10 deletions packages/stellar-kit/src/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,13 +296,3 @@ export async function estimateTransactionReadiness(
});
}

function buildReadinessSummary(ready: boolean, warnings: ReadinessWarning[]): string {
if (ready && warnings.length === 0) {
return "Payment intent is fully ready. Review warnings above for optional checks.";
}
if (ready && warnings.length > 0) {
return `Payment intent is ready with ${warnings.length} warning(s). Resolve warnings before signing.`;
}
const errors = warnings.filter((w) => w.severity === "error").length;
return `Payment intent has ${errors} blocker(s) and ${warnings.length - errors} warning(s). Fix blockers before building the transaction.`;
}
Loading