diff --git a/apps/web/app/accounts/page.tsx b/apps/web/app/accounts/page.tsx index eba323b..92bc481 100644 --- a/apps/web/app/accounts/page.tsx +++ b/apps/web/app/accounts/page.tsx @@ -349,6 +349,39 @@ export default function AccountsPage() { } /> )} + {lookupDiag && lookupDiag.balances.state === "known" && ( + <> + + {lookupDiag.balances.spendable} XLM + + } + /> + + {lookupDiag.balances.unavailable} XLM — locked by the minimum balance + + } + /> + + )} + {lookupDiag && lookupDiag.balances.state === "unknown" && ( + + Unknown — {lookupDiag.balances.explanation} + + } + /> + )} {lookupDiag?.state === "invalid" && ( w.severity === "error").length; const ready = errorCount === 0; @@ -163,13 +196,19 @@ export async function estimateTransactionReadiness( assertNetworkAllowed(network, envConfig); } - const [sourceStatus, destStatus] = await Promise.allSettled([ - getAccountStatus(intent.sourcePublicKey, { networkConfig, envConfig }), + // The source is loaded in full rather than status-only: `getAccountStatus` + // fetches the whole account and then discards the balances and subentry + // count, which are exactly what the spendable model needs. Reading them here + // costs no extra network call. The destination only needs its status. + const [sourceInfo, destStatus] = await Promise.allSettled([ + loadAccount(intent.sourcePublicKey, { networkConfig, envConfig }), getAccountStatus(intent.destinationPublicKey, { networkConfig, envConfig }), ]); const sourceFunded = - sourceStatus.status === "fulfilled" ? sourceStatus.value === "funded" : undefined; + sourceInfo.status === "fulfilled" ? sourceInfo.value.status === "funded" : undefined; + const sourceBalances = + sourceInfo.status === "fulfilled" ? computeBalanceModel(sourceInfo.value) : undefined; const destFunded = destStatus.status === "fulfilled" ? destStatus.value === "funded" : undefined; @@ -178,6 +217,7 @@ export async function estimateTransactionReadiness( envConfig, sourceAccountFunded: sourceFunded, destAccountFunded: destFunded, + sourceBalances, }); } diff --git a/packages/stellar-kit/test/balances.test.ts b/packages/stellar-kit/test/balances.test.ts new file mode 100644 index 0000000..cb14ced --- /dev/null +++ b/packages/stellar-kit/test/balances.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { Keypair } from "@stellar/stellar-base"; +import { + BASE_ENTRY_COUNT, + STELLAR_BASE_RESERVE_XLM, + computeBalanceModel, + computeReserve, + unknownBalanceModel, +} from "../src/balances"; +import { estimateTransactionReadinessSync } from "../src/intent"; +import type { AccountInfo, PaymentIntent, StellarAsset } from "@anchorkit/types"; + +const key = () => Keypair.random().publicKey() as AccountInfo["publicKey"]; + +const NATIVE: StellarAsset = { type: "native", code: "XLM", issuer: null }; + +function fundedInfo(native: string, subentryCount = 0): AccountInfo { + return { + publicKey: key(), + status: "funded", + sequence: "1", + subentryCount, + balances: { native, assets: [] }, + }; +} + +function intentFor(amount: string, asset: StellarAsset = NATIVE): PaymentIntent { + return { + sourcePublicKey: key(), + destinationPublicKey: key(), + asset, + amount, + }; +} + +describe("computeReserve", () => { + it("applies Stellar's (2 + subentries) x base reserve rule", () => { + expect(computeReserve(0).minimumBalanceXlm).toBe(1); + expect(computeReserve(1).minimumBalanceXlm).toBe(1.5); + expect(computeReserve(3).minimumBalanceXlm).toBe(2.5); + expect(BASE_ENTRY_COUNT).toBe(2); + expect(STELLAR_BASE_RESERVE_XLM).toBe(0.5); + }); + + it("does not charge the base entries twice in its explanation", () => { + const r = computeReserve(3); + // The old explanation read "2 base reserve + 5 entries x 0.5", counting the + // two base entries both as a flat charge and inside the entry count. + expect(r.explanation).toContain("2.5 XLM"); + expect(r.explanation).toContain("5 ledger entries"); + expect(r.explanation).not.toMatch(/2 base reserve/); + }); +}); + +describe("computeBalanceModel — funded", () => { + it("splits total into spendable and unavailable", () => { + const m = computeBalanceModel(fundedInfo("100", 3)); + expect(m.state).toBe("known"); + expect(m.total).toBe("100.0000000"); + expect(m.reserve).toBe("2.5000000"); + expect(m.spendable).toBe("97.5000000"); + expect(m.unavailable).toBe("2.5000000"); + }); + + it("keeps spendable + unavailable equal to total", () => { + for (const [native, subs] of [ + ["100", 3], + ["1.5", 0], + ["0.25", 2], + ["12345.6789012", 7], + ] as const) { + const m = computeBalanceModel(fundedInfo(native, subs)); + expect(Number(m.spendable) + Number(m.unavailable)).toBeCloseTo(Number(m.total), 7); + } + }); +}); + +describe("computeBalanceModel — low balance", () => { + it("clamps spendable at zero instead of reporting a negative amount", () => { + // 0.5 XLM held against a 1 XLM minimum balance. + const m = computeBalanceModel(fundedInfo("0.5", 0)); + expect(m.state).toBe("known"); + expect(m.spendable).toBe("0.0000000"); + expect(Number(m.spendable)).toBeGreaterThanOrEqual(0); + expect(m.unavailable).toBe("0.5000000"); + expect(m.total).toBe("0.5000000"); + }); +}); + +describe("computeBalanceModel — unfunded", () => { + it("reports zero rather than unknown, and states what the account needs", () => { + const m = computeBalanceModel({ publicKey: key(), status: "unfunded" }); + expect(m.state).toBe("known"); + expect(m.total).toBe("0.0000000"); + expect(m.spendable).toBe("0.0000000"); + expect(m.explanation).toContain("not funded"); + }); +}); + +describe("computeBalanceModel — unknown", () => { + const cases: Array<[string, AccountInfo]> = [ + ["network error", { publicKey: key(), status: "unknown", error: "timed out" }], + ["errored lookup", { publicKey: key(), status: "error", error: "boom" }], + ["funded without balances", { publicKey: key(), status: "funded", subentryCount: 2 }], + ]; + + for (const [label, info] of cases) { + it(`carries no amounts at all for a ${label}`, () => { + const m = computeBalanceModel(info); + expect(m.state).toBe("unknown"); + expect(m.total).toBeNull(); + expect(m.reserve).toBeNull(); + expect(m.spendable).toBeNull(); + expect(m.unavailable).toBeNull(); + // An unavailable balance must never be dressed up as a figure. + expect(m.explanation).not.toMatch(/\d/); + }); + } + + it("treats an unparseable balance as unknown rather than zero", () => { + const m = computeBalanceModel(fundedInfo("not-a-number", 0)); + expect(m.state).toBe("unknown"); + expect(m.spendable).toBeNull(); + }); + + it("exposes a helper that never carries amounts", () => { + expect(unknownBalanceModel("no data").spendable).toBeNull(); + }); +}); + +describe("payment readiness uses spendable balance", () => { + it("blocks a payment above the spendable balance", () => { + const r = estimateTransactionReadinessSync(intentFor("99"), { + sourceBalances: computeBalanceModel(fundedInfo("100", 3)), // spendable 97.5 + }); + const w = r.warnings.find((x) => x.code === "INSUFFICIENT_FUNDS"); + expect(w).toBeDefined(); + expect(w?.severity).toBe("error"); + expect(r.ready).toBe(false); + }); + + it("allows a payment within the spendable balance", () => { + const r = estimateTransactionReadinessSync(intentFor("97"), { + sourceBalances: computeBalanceModel(fundedInfo("100", 3)), + }); + expect(r.warnings.find((x) => x.code === "INSUFFICIENT_FUNDS")).toBeUndefined(); + expect(r.ready).toBe(true); + }); + + it("would pass the reserve-only check but fails on the amount it cannot cover", () => { + // 98 XLM held, 2.5 locked: the raw balance covers the payment, the + // spendable balance does not. + const r = estimateTransactionReadinessSync(intentFor("97.6"), { + sourceBalances: computeBalanceModel(fundedInfo("98", 3)), // spendable 95.5 + }); + expect(r.ready).toBe(false); + }); + + it("never raises an error when the balance is unknown", () => { + const r = estimateTransactionReadinessSync(intentFor("99"), { + sourceBalances: unknownBalanceModel("Account data is unavailable."), + }); + expect(r.warnings.find((x) => x.code === "INSUFFICIENT_FUNDS")).toBeUndefined(); + const info = r.warnings.find((x) => x.code === "SPENDABLE_UNKNOWN"); + expect(info?.severity).toBe("info"); + expect(r.ready).toBe(true); + }); + + it("does not apply the XLM reserve to an issued-asset payment", () => { + const issued: StellarAsset = { + type: "issued", + code: "USDC" as never, + issuer: Keypair.random().publicKey() as never, + }; + const r = estimateTransactionReadinessSync(intentFor("99", issued), { + sourceBalances: computeBalanceModel(fundedInfo("100", 3)), + }); + expect(r.warnings.find((x) => x.code === "INSUFFICIENT_FUNDS")).toBeUndefined(); + }); + + it("is opt-in: omitting the balance model leaves readiness unchanged", () => { + const r = estimateTransactionReadinessSync(intentFor("99")); + expect(r.warnings.find((x) => x.code === "INSUFFICIENT_FUNDS")).toBeUndefined(); + expect(r.warnings.find((x) => x.code === "SPENDABLE_UNKNOWN")).toBeUndefined(); + expect(r.ready).toBe(true); + }); +}); diff --git a/packages/stellar-kit/test/diagnostics.test.ts b/packages/stellar-kit/test/diagnostics.test.ts index e6bc3df..7062e74 100644 --- a/packages/stellar-kit/test/diagnostics.test.ts +++ b/packages/stellar-kit/test/diagnostics.test.ts @@ -28,13 +28,16 @@ const networkErrorInfo: AccountInfo = { describe("computeReserve", () => { it("computes minimum balance from subentry count", () => { const r = computeReserve(3); - // 2 + (3 + 2) * 0.5 = 4.5 - expect(r.minimumBalanceXlm).toBe(4.5); + // Stellar's rule: (2 base entries + 3 subentries) * 0.5 XLM = 2.5 + expect(r.minimumBalanceXlm).toBe(2.5); expect(r.subentryCount).toBe(3); + expect(r.entryCount).toBe(5); + expect(r.baseReserve).toBe(0.5); }); it("handles undefined subentry count", () => { - expect(computeReserve(undefined).minimumBalanceXlm).toBe(3); + // A bare account owns only the 2 base entries: 2 * 0.5 = 1 XLM + expect(computeReserve(undefined).minimumBalanceXlm).toBe(1); }); }); @@ -44,7 +47,7 @@ describe("diagnoseAccountInfo (sync)", () => { expect(d.state).toBe("funded"); expect(d.isValidPublicKey).toBe(true); expect(d.expertUrl).toContain(fundedKey); - expect(d.reserve?.minimumBalanceXlm).toBe(4.5); + expect(d.reserve?.minimumBalanceXlm).toBe(2.5); }); it("maps unfunded account without reserve", () => { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f0832b3..002f7aa 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -71,6 +71,40 @@ export interface AccountInfo { error?: string; } +/** + * Whether the spendable balance model could be derived at all. + * + * `unknown` is not "zero": it means the account data needed to compute a + * balance is missing or unreliable, so every amount is `null` rather than a + * number that would overstate what the user can actually spend. + */ +export type BalanceModelState = "known" | "unknown"; + +/** + * Native (XLM) balance broken down into what is actually spendable. + * + * All amounts are decimal strings normalized to 7 decimal places (Stellar's + * precision), or `null` when `state` is `"unknown"`. The invariant + * `spendable + unavailable === total` holds for every `"known"` model. + * + * `spendable` excludes the minimum balance but NOT selling liabilities, which + * Horizon reports but `AccountBalances` does not carry. It is therefore an + * upper bound for accounts with open offers. + */ +export interface AccountBalanceModel { + state: BalanceModelState; + /** Total native balance held by the account. */ + total: string | null; + /** Minimum balance locked by the protocol reserve. */ + reserve: string | null; + /** Total minus reserve, never negative. */ + spendable: string | null; + /** The portion of the total that cannot be spent (equals the locked reserve). */ + unavailable: string | null; + /** Human-readable explanation suitable for UI. Contains no amounts when unknown. */ + explanation: string; +} + export interface PaymentIntent { sourcePublicKey: StellarPublicKey; destinationPublicKey: StellarPublicKey;