diff --git a/apps/web/app/payments/page.tsx b/apps/web/app/payments/page.tsx
index 2de1346..f489436 100644
--- a/apps/web/app/payments/page.tsx
+++ b/apps/web/app/payments/page.tsx
@@ -224,15 +224,53 @@ export default function PaymentsPage() {
Overall
- {readiness.ready ? "Ready" : "Not ready"}
+ {readiness.state === "ready"
+ ? "Ready"
+ : readiness.state === "warnings"
+ ? "Ready (with warnings)"
+ : readiness.state === "unsafe-network"
+ ? "Unsafe network"
+ : "Blocked"}
Warnings ({readiness.warnings.length})
{readiness.warnings.length === 0 ? (
diff --git a/docs/transaction-readiness.md b/docs/transaction-readiness.md
new file mode 100644
index 0000000..2aa2bf1
--- /dev/null
+++ b/docs/transaction-readiness.md
@@ -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.
diff --git a/examples/payment-readiness.example.json b/examples/payment-readiness.example.json
new file mode 100644
index 0000000..cadec5f
--- /dev/null
+++ b/examples/payment-readiness.example.json
@@ -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" }
+ ]
+ }
+}
diff --git a/packages/stellar-kit/src/intent.ts b/packages/stellar-kit/src/intent.ts
index b390ee9..e257c37 100644
--- a/packages/stellar-kit/src/intent.ts
+++ b/packages/stellar-kit/src/intent.ts
@@ -1,6 +1,8 @@
import type {
AccountBalanceModel,
PaymentIntent,
+ ReadinessStage,
+ ReadinessState,
ReadinessWarning,
StellarAsset,
TransactionReadiness,
@@ -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: {
@@ -65,64 +106,76 @@ 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.",
@@ -130,16 +183,18 @@ export function estimateTransactionReadinessSync(
});
}
+ // ── 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.",
@@ -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 ` +
@@ -165,7 +220,7 @@ 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",
@@ -173,12 +228,32 @@ export function estimateTransactionReadinessSync(
}
}
+ 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(
diff --git a/packages/stellar-kit/test/readiness.test.ts b/packages/stellar-kit/test/readiness.test.ts
new file mode 100644
index 0000000..cb6ab3a
--- /dev/null
+++ b/packages/stellar-kit/test/readiness.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from "vitest";
+import {
+ createPaymentIntent,
+ estimateTransactionReadinessSync,
+ getReadinessState,
+ mapReadinessToErrorCode,
+} from "../src/intent";
+import type { ReadinessState, ReadinessWarning } from "@anchorkit/types";
+
+const SRC = "GA2C5RFPE6GCKMY3K7AIGZ5ZBBX26Z5B3E6G7V4MMSZ5L2R5YHMBFQJJ";
+const DST = "GBMFNDXCRSOD7Y7FW5WJ6TZ6MMHCYQJK76Y5QM5T2DJG7QX4LM4LMFTO";
+
+function intent(overrides: Partial
[0]> = {}) {
+ return createPaymentIntent({
+ sourcePublicKey: SRC,
+ destinationPublicKey: DST,
+ asset: { type: "native", code: "XLM", issuer: null },
+ amount: "10.0000000",
+ ...overrides,
+ });
+}
+
+describe("readiness engine — typed state", () => {
+ it("returns 'ready' for a fully valid intent with no warnings", () => {
+ const r = estimateTransactionReadinessSync(intent(), { network: "testnet" });
+ expect(r.ready).toBe(true);
+ expect(r.state).toBe("ready");
+ expect(r.warnings).toHaveLength(0);
+ expect(r.stages.every((s) => s.status === "pass")).toBe(true);
+ });
+
+ it("returns 'warnings' when only non-blocking warnings exist (same src/dest)", () => {
+ const r = estimateTransactionReadinessSync(
+ intent({ destinationPublicKey: SRC }),
+ { network: "testnet" }
+ );
+ expect(r.ready).toBe(true);
+ expect(r.state).toBe("warnings");
+ const same = r.stages.find((s) => s.id === "account-dest");
+ expect(same?.status).toBe("warn");
+ });
+
+ it("returns 'blocked' for an invalid asset/amount", () => {
+ const badIntent = {
+ sourcePublicKey: SRC,
+ destinationPublicKey: DST,
+ asset: { type: "native", code: "", issuer: null } as any,
+ amount: "not-a-number",
+ } as any;
+ const r = estimateTransactionReadinessSync(badIntent, { network: "testnet" });
+ expect(r.ready).toBe(false);
+ expect(r.state).toBe("blocked");
+ expect(r.stages.some((s) => s.id === "asset" && s.status === "fail")).toBe(true);
+ expect(r.stages.some((s) => s.id === "amount" && s.status === "fail")).toBe(true);
+ });
+
+ it("returns 'unsafe-network' when mainnet is disabled", () => {
+ const r = estimateTransactionReadinessSync(intent(), { network: "mainnet" });
+ expect(r.state).toBe("unsafe-network");
+ const net = r.stages.find((s) => s.id === "network");
+ expect(net?.status).toBe("fail");
+ });
+
+ it("flags mainnet-disabled as unsafe-network even with other errors", () => {
+ const badIntent = {
+ sourcePublicKey: "bad-key",
+ destinationPublicKey: DST,
+ asset: { type: "native", code: "XLM", issuer: null },
+ amount: "10.0000000",
+ } as any;
+ const r = estimateTransactionReadinessSync(badIntent, { network: "mainnet" });
+ expect(r.state).toBe("unsafe-network");
+ expect(r.stages.some((s) => s.id === "network" && s.status === "fail")).toBe(true);
+ });
+});
+
+describe("readiness engine — funding/unfunded", () => {
+ it("marks source unfunded as a warning stage", () => {
+ const r = estimateTransactionReadinessSync(intent(), {
+ network: "testnet",
+ sourceAccountFunded: false,
+ });
+ expect(r.ready).toBe(true);
+ const bal = r.stages.find((s) => s.id === "balance");
+ expect(bal?.status).toBe("warn");
+ expect(bal?.warnings.some((w) => w.code === "SOURCE_UNFUNDED")).toBe(true);
+ });
+
+ it("flags insufficient native funds as a blocking error", () => {
+ const r = estimateTransactionReadinessSync(intent({ amount: "999.0000000" }), {
+ network: "testnet",
+ sourceBalances: {
+ state: "known",
+ total: "10.0000000",
+ reserve: "2.0000000",
+ spendable: "10.0000000",
+ unavailable: "0.0000000",
+ explanation: "Min balance 2 XLM + 1 subentry.",
+ },
+ });
+ expect(r.ready).toBe(false);
+ const bal = r.stages.find((s) => s.id === "balance");
+ expect(bal?.status).toBe("fail");
+ expect(bal?.warnings.some((w) => w.code === "INSUFFICIENT_FUNDS")).toBe(true);
+ });
+});
+
+describe("readiness helpers", () => {
+ it("getReadinessState classifies warning-only input", () => {
+ const ws: ReadinessWarning[] = [
+ { code: "SAME_SOURCE_DEST", message: "x", severity: "warning" },
+ ];
+ expect(getReadinessState(ws)).toBe("warnings");
+ });
+
+ it("getReadinessState classifies mainnet-disabled as unsafe-network", () => {
+ const ws: ReadinessWarning[] = [
+ { code: "MAINNET_DISABLED", message: "x", severity: "error" },
+ ];
+ expect(getReadinessState(ws)).toBe("unsafe-network");
+ });
+
+ it("mapReadinessToErrorCode returns the first error code", () => {
+ const ws: ReadinessWarning[] = [
+ { code: "SOURCE_INVALID", message: "x", severity: "error" },
+ { code: "AMOUNT_INVALID", message: "y", severity: "error" },
+ ];
+ expect(mapReadinessToErrorCode(ws)).toBe("SOURCE_INVALID");
+ });
+
+ it("mapReadinessToErrorCode returns UNKNOWN with no errors", () => {
+ expect(mapReadinessToErrorCode([])).toBe("UNKNOWN");
+ });
+});
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 64e084c..e784347 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -126,9 +126,36 @@ export interface ReadinessWarning {
severity: "error" | "warning" | "info";
}
+/**
+ * Discrete readiness outcome. Superset of the boolean `ready` flag so callers
+ * and UI can branch on a single typed value:
+ * - "ready" — no warnings at all.
+ * - "warnings" — only non-blocking warnings (e.g. same src/dest).
+ * - "unsafe-network"— a mainnet/network-safety blocker is present.
+ * - "blocked" — one or more hard errors (invalid key, bad asset, etc.).
+ */
+export type ReadinessState = "ready" | "warnings" | "unsafe-network" | "blocked";
+
+/** A single validation stage of the readiness engine. */
+export interface ReadinessStage {
+ /** Stable stage id, e.g. "account-source". */
+ id: string;
+ /** Human-readable label for UI. */
+ label: string;
+ /** Stage outcome derived from its warnings. */
+ status: "pass" | "warn" | "fail";
+ /** Warnings produced by this stage. */
+ warnings: ReadinessWarning[];
+}
+
export interface TransactionReadiness {
+ /** Deprecated-friendly boolean: true when there are no error-severity warnings. */
ready: boolean;
+ /** Typed aggregate state. */
+ state: ReadinessState;
warnings: ReadinessWarning[];
+ /** Per-stage results, in execution order. */
+ stages: ReadinessStage[];
summary: string;
}