From 0890e9d1125a670d482bd7e18b19ee2ba6bbc870 Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Tue, 28 Jul 2026 09:42:31 +0530 Subject: [PATCH] feat: add cross-package transaction readiness engine with typed states and stages (Closes #21) --- apps/web/app/payments/page.tsx | 44 ++++++- docs/transaction-readiness.md | 91 +++++++++++++ examples/payment-readiness.example.json | 23 ++++ packages/stellar-kit/src/intent.ts | 107 +++++++++++++--- packages/stellar-kit/test/readiness.test.ts | 134 ++++++++++++++++++++ packages/types/src/index.ts | 27 ++++ 6 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 docs/transaction-readiness.md create mode 100644 examples/payment-readiness.example.json create mode 100644 packages/stellar-kit/test/readiness.test.ts 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"}

{readiness.summary}

+
+
Validation stages
+ +
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; }