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
44 changes: 41 additions & 3 deletions apps/web/app/payments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,15 +224,53 @@ export default function PaymentsPage() {
<span className="text-sm text-ink-500 dark:text-ink-400">Overall</span>
<span
className={`rounded-full px-2.5 py-0.5 text-mono-xs font-medium ${
readiness.ready
readiness.state === "ready"
? "bg-green-100 text-green-700 border border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900"
: "bg-amber-100 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900"
: readiness.state === "warnings"
? "bg-amber-100 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900"
: readiness.state === "unsafe-network"
? "bg-red-100 text-red-700 border border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900"
: "bg-red-100 text-red-700 border border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900"
}`}
>
{readiness.ready ? "Ready" : "Not ready"}
{readiness.state === "ready"
? "Ready"
: readiness.state === "warnings"
? "Ready (with warnings)"
: readiness.state === "unsafe-network"
? "Unsafe network"
: "Blocked"}
</span>
</div>
<p className="text-sm">{readiness.summary}</p>
<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">
{readiness.stages.map((s) => (
<li
key={s.id}
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-mono-xs ${
s.status === "pass"
? "border-green-200 bg-green-50 text-green-800 dark:border-green-900 dark:bg-green-950/30 dark:text-green-200"
: s.status === "warn"
? "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200"
: "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
s.status === "pass"
? "bg-green-500"
: s.status === "warn"
? "bg-amber-500"
: "bg-red-500"
}`}
/>
{s.label}
</li>
))}
</ul>
</div>
<div>
<div className="mb-1 text-sm font-medium">Warnings ({readiness.warnings.length})</div>
{readiness.warnings.length === 0 ? (
Expand Down
91 changes: 91 additions & 0 deletions docs/transaction-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Transaction readiness engine (issue #21)

AnchorKit exposes one reusable, cross-package **transaction readiness engine**
so packages and UI screens validate account state, asset, amount, memo,
network mode, and submission safety consistently — before any transaction is
built. It is testnet-first and never submits real payments.

## Result shape

```ts
type ReadinessState = "ready" | "warnings" | "unsafe-network" | "blocked";

interface ReadinessStage {
id: string; // "account-source" | "asset" | "amount" | ...
label: string; // "Source account"
status: "pass" | "warn" | "fail";
warnings: ReadinessWarning[];
}

interface TransactionReadiness {
ready: boolean; // true when no error-severity warnings
state: ReadinessState; // typed aggregate outcome
warnings: ReadinessWarning[];
stages: ReadinessStage[]; // per-stage results, in execution order
summary: string;
}
```

The `state` field is the single source of truth for UI branching:

| state | meaning |
| --- | --- |
| `ready` | no warnings at all |
| `warnings` | only non-blocking warnings (e.g. same source/dest) |
| `unsafe-network` | a mainnet/network-safety blocker is present |
| `blocked` | one or more hard errors (bad key, bad asset, insufficient funds) |

## Validation stages

The engine runs these stages in order, each producing a typed result:

1. **account-source** — source public key validity.
2. **account-dest** — destination public key validity + same-source/dest check.
3. **asset** — asset configuration validity.
4. **amount** — amount validity / allowed range.
5. **memo** — memo value valid for its type.
6. **network** — mainnet-safety (disabled by default).
7. **balance** — funding (unfunded warnings) + native spendable-balance check.

## API

```ts
import {
estimateTransactionReadinessSync,
estimateTransactionReadiness, // async: loads accounts, computes balance model
getReadinessState,
mapReadinessToErrorCode,
} from "@anchorkit/stellar-kit";

const r = estimateTransactionReadinessSync(intent, { network: "testnet" });
if (r.state === "blocked" || r.state === "unsafe-network") {
// do not build/sign
}
const code = mapReadinessToErrorCode(r.warnings); // first error code, for logs
```

The async `estimateTransactionReadiness` additionally loads the source/dest
accounts and computes the spendable balance model, surfacing `SOURCE_UNFUNDED`
/ `DEST_UNFUNDED` / `INSUFFICIENT_FUNDS` / `SPENDABLE_UNKNOWN` warnings.

## UI

`apps/web/app/payments/page.tsx` uses the engine directly: it shows the typed
`state` badge (Ready / Ready with warnings / Unsafe network / Blocked) and a
per-stage status grid, plus the full warning list. Submission is disabled
unless `state === "ready" || "warnings"`.

## Fixtures & tests

- `examples/payment-readiness.example.json` — deterministic intent + expected
`state`/`stages` for docs and examples.
- `packages/stellar-kit/test/readiness.test.ts` — covers valid, invalid
(asset/amount), unfunded, insufficient-funds, and unsafe-network scenarios,
plus the typed `state` and `mapReadinessToErrorCode` helpers.

## Safety

- Testnet-first: `DEFAULT_ENV_CONFIG` disables mainnet; the engine emits
`MAINNET_DISABLED` (→ `unsafe-network`) when mainnet is requested without
explicit enablement.
- The engine only reads/validates; it never builds or submits a transaction.
23 changes: 23 additions & 0 deletions examples/payment-readiness.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"description": "Deterministic transaction-readiness fixture (testnet-first). Used by examples and docs to show the readiness engine output for a known intent.",
"network": "testnet",
"intent": {
"sourcePublicKey": "GA2C5RFPE6GCKMY3K7AIGZ5ZBBX26Z5B3E6G7V4MMSZ5L2R5YHMBFQJJ",
"destinationPublicKey": "GBMFNDXCRSOD7Y7FW5WJ6TZ6MMHCYQJK76Y5QM5T2DJG7QX4LM4LMFTO",
"asset": { "type": "native", "code": "XLM", "issuer": null },
"amount": "10.0000000"
},
"expected": {
"state": "ready",
"ready": true,
"stages": [
{ "id": "account-source", "status": "pass" },
{ "id": "account-dest", "status": "pass" },
{ "id": "asset", "status": "pass" },
{ "id": "amount", "status": "pass" },
{ "id": "memo", "status": "pass" },
{ "id": "network", "status": "pass" },
{ "id": "balance", "status": "pass" }
]
}
}
107 changes: 91 additions & 16 deletions packages/stellar-kit/src/intent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {
AccountBalanceModel,
PaymentIntent,
ReadinessStage,
ReadinessState,
ReadinessWarning,
StellarAsset,
TransactionReadiness,
Expand Down Expand Up @@ -51,6 +53,45 @@ export function isPaymentIntentValid(intent: unknown): boolean {
return validatePaymentIntent(intent).success;
}

/**
* Map a set of readiness warnings to a single `StellarErrorCode` so callers can
* report the most severe blocker as a typed error (useful for logging and
* programmatic branching). Returns "UNKNOWN" when there are no error-severity
* warnings.
*/
export function mapReadinessToErrorCode(warnings: ReadinessWarning[]): string {
const blocker = warnings.find((w) => w.severity === "error");
return blocker?.code ?? "UNKNOWN";
}

/** Derive the discrete readiness state from the collected warnings. */
export function getReadinessState(warnings: ReadinessWarning[]): ReadinessState {
const errors = warnings.filter((w) => w.severity === "error");
if (errors.length === 0) {
return warnings.length === 0 ? "ready" : "warnings";
}
if (errors.some((w) => w.code === "MAINNET_DISABLED")) {
return "unsafe-network";
}
return "blocked";
}

/** Build a single readiness stage from its warnings. */
function stage(
id: string,
label: string,
warnings: ReadinessWarning[]
): ReadinessStage {
const status: ReadinessStage["status"] = warnings.some(
(w) => w.severity === "error"
)
? "fail"
: warnings.length > 0
? "warn"
: "pass";
return { id, label, status, warnings };
}

export function estimateTransactionReadinessSync(
intent: PaymentIntent,
options: {
Expand All @@ -65,81 +106,95 @@ export function estimateTransactionReadinessSync(
sourceBalances?: AccountBalanceModel;
} = {}
): TransactionReadiness {
const warnings: ReadinessWarning[] = [];
const envConfig = options.envConfig ?? DEFAULT_ENV_CONFIG;
const network = options.network ?? envConfig.defaultNetwork;

const warnings: ReadinessWarning[] = [];

// ── Stage: account (source) ───────────────────────────────────────────────
const accountWarnings: ReadinessWarning[] = [];
if (!isPublicKeyValid(intent.sourcePublicKey)) {
warnings.push({
accountWarnings.push({
code: "SOURCE_INVALID",
message: "Source public key is invalid",
severity: "error",
});
}

// ── Stage: account (destination) ──────────────────────────────────────────
const destWarnings: ReadinessWarning[] = [];
if (!isPublicKeyValid(intent.destinationPublicKey)) {
warnings.push({
destWarnings.push({
code: "DEST_INVALID",
message: "Destination public key is invalid",
severity: "error",
});
}

if (
isPublicKeyValid(intent.sourcePublicKey) &&
isPublicKeyValid(intent.destinationPublicKey) &&
intent.sourcePublicKey === intent.destinationPublicKey
) {
warnings.push({
destWarnings.push({
code: "SAME_SOURCE_DEST",
message: "Source and destination accounts are the same",
severity: "warning",
});
}

// ── Stage: asset ──────────────────────────────────────────────────────────
const assetWarnings: ReadinessWarning[] = [];
if (!isAssetValid(intent.asset)) {
warnings.push({
assetWarnings.push({
code: "ASSET_INVALID",
message: "Asset configuration is invalid",
severity: "error",
});
}

// ── Stage: amount ────────────────────────────────────────────────────────
const amountWarnings: ReadinessWarning[] = [];
if (!isAmountValid(intent.amount)) {
warnings.push({
amountWarnings.push({
code: "AMOUNT_INVALID",
message: "Payment amount is invalid or outside allowed range",
severity: "error",
});
}

// ── Stage: memo ───────────────────────────────────────────────────────────
const memoWarnings: ReadinessWarning[] = [];
if (intent.memo && !isMemoValid(intent.memo)) {
warnings.push({
memoWarnings.push({
code: "MEMO_INVALID",
message: "Memo value is invalid for the selected memo type",
severity: "error",
});
}

// ── Stage: network safety ─────────────────────────────────────────────────
const networkWarnings: ReadinessWarning[] = [];
if (network === STELLAR_NETWORKS.MAINNET && !isMainnetAllowed(envConfig)) {
warnings.push({
networkWarnings.push({
code: "MAINNET_DISABLED",
message:
"Mainnet mode is disabled by default. Review security notes and explicitly enable mainnet if needed.",
severity: "error",
});
}

// ── Stage: balance / funding ──────────────────────────────────────────────
const balanceWarnings: ReadinessWarning[] = [];

if (options.sourceAccountFunded === false) {
warnings.push({
balanceWarnings.push({
code: "SOURCE_UNFUNDED",
message: "Source account is not funded on the network",
severity: "warning",
});
}

if (options.destAccountFunded === false) {
warnings.push({
balanceWarnings.push({
code: "DEST_UNFUNDED",
message:
"Destination account is not funded. Issued asset payments require the destination to have a trustline.",
Expand All @@ -154,7 +209,7 @@ export function estimateTransactionReadinessSync(
if (sourceBalances && isNativeAsset(intent.asset) && isAmountValid(intent.amount)) {
if (sourceBalances.state === "known" && sourceBalances.spendable !== null) {
if (compareAmounts(sourceBalances.spendable, intent.amount) < 0) {
warnings.push({
balanceWarnings.push({
code: "INSUFFICIENT_FUNDS",
message:
`Spendable balance is ${sourceBalances.spendable} XLM, below the ` +
Expand All @@ -165,20 +220,40 @@ export function estimateTransactionReadinessSync(
} else {
// Deliberately carries no figure: an unavailable balance must not be
// presented as a number the user could act on.
warnings.push({
balanceWarnings.push({
code: "SPENDABLE_UNKNOWN",
message: `Spendable balance could not be determined. ${sourceBalances.explanation}`,
severity: "info",
});
}
}

warnings.push(
...accountWarnings,
...destWarnings,
...assetWarnings,
...amountWarnings,
...memoWarnings,
...networkWarnings,
...balanceWarnings
);

const stages: ReadinessStage[] = [
stage("account-source", "Source account", accountWarnings),
stage("account-dest", "Destination account", destWarnings),
stage("asset", "Asset", assetWarnings),
stage("amount", "Amount", amountWarnings),
stage("memo", "Memo", memoWarnings),
stage("network", "Network safety", networkWarnings),
stage("balance", "Balance & funding", balanceWarnings),
];

const errorCount = warnings.filter((w) => w.severity === "error").length;
const ready = errorCount === 0;

const state = getReadinessState(warnings);
const summary = buildReadinessSummary(ready, warnings);

return { ready, warnings, summary };
return { ready, state, warnings, stages, summary };
}

export async function estimateTransactionReadiness(
Expand Down
Loading