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
33 changes: 33 additions & 0 deletions apps/web/app/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,39 @@ export default function AccountsPage() {
}
/>
)}
{lookupDiag && lookupDiag.balances.state === "known" && (
<>
<DataRow
label="Spendable"
value={
<span title={lookupDiag.balances.explanation}>
{lookupDiag.balances.spendable} XLM
</span>
}
/>
<DataRow
label="Unavailable"
value={
<span
className="text-amber-600 dark:text-amber-400"
title={lookupDiag.balances.explanation}
>
{lookupDiag.balances.unavailable} XLM — locked by the minimum balance
</span>
}
/>
</>
)}
{lookupDiag && lookupDiag.balances.state === "unknown" && (
<DataRow
label="Spendable"
value={
<span className="text-amber-600 dark:text-amber-400">
Unknown — {lookupDiag.balances.explanation}
</span>
}
/>
)}
{lookupDiag?.state === "invalid" && (
<DataRow
label="Diagnostic"
Expand Down
66 changes: 65 additions & 1 deletion docs/account-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface AccountDiagnostic {
isValidPublicKey: boolean; // structural validity of the key
expertUrl: string | null; // Stellar Expert link (null when key invalid)
reserve: ReserveInfo | null;// min-balance awareness (funded accounts only)
balances: AccountBalanceModel; // total / reserve / spendable / unavailable
account: AccountInfo | null;// raw account data (never includes secrets)
error: string | null; // user-safe error message
}
Expand All @@ -29,11 +30,74 @@ States go beyond the raw `AccountStatus`:
`computeReserve(subentryCount)` returns the Stellar minimum balance:

```
minimumBalanceXlm = 2 (base) + (subentryCount + 2) × 0.5
minimumBalanceXlm = (BASE_ENTRY_COUNT + subentryCount) × STELLAR_BASE_RESERVE_XLM
= (2 + subentryCount) × 0.5
```

plus a human-readable `explanation` string for UI tooltips.

A bare account therefore reserves **1 XLM** (2 base entries × 0.5), and each
additional subentry — a trustline, offer, signer, or data entry — adds 0.5 XLM.

### Reserve assumptions

- The base reserve is treated as a constant 0.5 XLM. It is a network parameter
that validators can change; this model does not read it from the ledger.
- The two base entries are part of the entry count, not an extra flat charge on
top of it.
- Sponsored reserves are not modelled: an account whose entries are sponsored by
another account has a lower effective minimum balance than reported here.

## Spendable balance model

`computeBalanceModel(info)` splits the native balance into what can actually be
spent:

```ts
interface AccountBalanceModel {
state: "known" | "unknown";
total: string | null; // full native balance
reserve: string | null; // locked by the minimum balance
spendable: string | null; // total - reserve, never negative
unavailable: string | null; // the locked portion
explanation: string; // carries no amounts when state is "unknown"
}
```

Amounts are decimal strings normalized to 7 places. For every `"known"` model
the invariant `spendable + unavailable === total` holds.

| Account | Result |
|---|---|
| Funded, 100 XLM, 3 subentries | `total 100`, `reserve 2.5`, `spendable 97.5` |
| Funded, 0.5 XLM, no subentries | `spendable 0` — clamped, never negative |
| Unfunded | all amounts `0`, with the amount needed to exist |
| Network error / no balances | `state: "unknown"`, **every amount `null`** |

### Do not overstate

Two limits are deliberate, and both are why `unknown` carries no numbers:

- **`unknown` is not zero.** When account data is unavailable the model reports
`null`, never a placeholder figure a user might act on.
- **Selling liabilities are not subtracted.** Horizon reports them but
`AccountBalances` does not carry them, so for an account with open offers the
real spendable amount is lower. Treat `spendable` as an upper bound.

## Payment readiness

`estimateTransactionReadinessSync` accepts an optional `sourceBalances` model.
It is opt-in: omit it and readiness behaves exactly as before.

- Native payment above the spendable balance → `INSUFFICIENT_FUNDS`, severity
`error`, so `ready` becomes `false`.
- Balance unknown → `SPENDABLE_UNKNOWN`, severity `info`. **Never an error** —
an unavailable balance is not evidence that funds are missing.
- Issued-asset payments are not checked against the XLM reserve.

The async `estimateTransactionReadiness` fills this in automatically by loading
the source account in full, at no extra network cost.

## Usage

```ts
Expand Down
139 changes: 139 additions & 0 deletions packages/stellar-kit/src/balances.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Account reserve and spendable balance model (issue #22).
*
* Stellar accounts cannot spend their entire balance: the protocol locks a
* minimum balance proportional to the number of ledger entries the account
* owns. This module derives that reserve and splits the native balance into
* what is spendable and what is not — or reports `unknown` without inventing
* a number when the underlying account data is unavailable.
*/

import type { AccountBalanceModel, AccountInfo } from "@anchorkit/types";
import { normalizeAmount } from "./payments";

/**
* Stellar's base reserve, in XLM. The minimum balance is this value multiplied
* by the number of ledger entries the account is charged for.
*/
export const STELLAR_BASE_RESERVE_XLM = 0.5;

/**
* Ledger entries every account is charged for before any subentries, per the
* protocol's `(2 + subentries)` rule.
*/
export const BASE_ENTRY_COUNT = 2;

export interface ReserveInfo {
/** Base reserve charged per ledger entry, in XLM. */
baseReserve: number;
/** Number of subentries counted against the reserve. */
subentryCount: number;
/** Total ledger entries charged: the base entries plus the subentries. */
entryCount: number;
/** Computed minimum balance in XLM: `entryCount × baseReserve`. */
minimumBalanceXlm: number;
/** Human-readable explanation suitable for UI. */
explanation: string;
}

/**
* Reserve awareness derived from an account's subentry count.
*
* Stellar's rule is `(2 + subentries) × base reserve`, where the base reserve
* is 0.5 XLM. The two base entries are part of the entry count — they are not
* an additional flat charge on top of it.
*/
export function computeReserve(subentryCount: number | undefined): ReserveInfo {
const subs = subentryCount ?? 0;
const entryCount = BASE_ENTRY_COUNT + subs;
const minimumBalanceXlm = entryCount * STELLAR_BASE_RESERVE_XLM;

return {
baseReserve: STELLAR_BASE_RESERVE_XLM,
subentryCount: subs,
entryCount,
minimumBalanceXlm,
explanation:
`Minimum balance is ${minimumBalanceXlm} XLM: ` +
`${entryCount} ledger entries (${BASE_ENTRY_COUNT} base + ${subs} subentries) ` +
`× ${STELLAR_BASE_RESERVE_XLM} XLM base reserve.`,
};
}

/**
* A balance model that carries no amounts, only a reason.
*
* Use this whenever a spendable figure cannot be backed by real account data —
* it is the only correct answer that does not overstate what a user can spend.
*/
export function unknownBalanceModel(explanation: string): AccountBalanceModel {
return {
state: "unknown",
total: null,
reserve: null,
spendable: null,
unavailable: null,
explanation,
};
}

/**
* Splits an account's native balance into spendable and unavailable parts.
*
* Returns a `"known"` model only when the account's balance is actually
* available. Anything else — a network failure, an errored lookup, or a funded
* account whose balances did not come through — yields `"unknown"` with every
* amount set to `null`, so callers cannot accidentally present a placeholder
* as a real spendable figure.
*
* `spendable` is clamped at zero: an account below its minimum balance has
* nothing to spend, not a negative amount.
*
* Note: `spendable` does not subtract selling liabilities, which Horizon
* reports but `AccountBalances` does not carry. For an account with open
* offers the real spendable amount is lower, so treat this as an upper bound.
*/
export function computeBalanceModel(info: AccountInfo): AccountBalanceModel {
if (info.status === "unknown" || info.status === "error") {
return unknownBalanceModel(
"Account data is unavailable, so the spendable balance cannot be determined."
);
}

if (info.status === "funded" && info.balances?.native === undefined) {
return unknownBalanceModel(
"Account balances were not returned, so the spendable balance cannot be determined."
);
}

const isUnfunded = info.status === "unfunded";
const totalXlm = isUnfunded ? 0 : Number(info.balances?.native);

if (!Number.isFinite(totalXlm)) {
return unknownBalanceModel(
"Account balance could not be parsed, so the spendable balance cannot be determined."
);
}

const reserve = computeReserve(isUnfunded ? 0 : info.subentryCount);
const spendableXlm = Math.max(0, totalXlm - reserve.minimumBalanceXlm);
const unavailableXlm = totalXlm - spendableXlm;

const explanation = isUnfunded
? "Account is not funded yet. It needs at least " +
`${reserve.minimumBalanceXlm} XLM to exist on the network.`
: `${normalizeAmount(String(unavailableXlm))} XLM of the ${normalizeAmount(
String(totalXlm)
)} XLM balance is locked by the ${reserve.minimumBalanceXlm} XLM minimum ` +
`balance and cannot be spent. Selling liabilities are not subtracted, so ` +
`accounts with open offers can spend less than this.`;

return {
state: "known",
total: normalizeAmount(String(totalXlm)),
reserve: normalizeAmount(String(reserve.minimumBalanceXlm)),
spendable: normalizeAmount(String(spendableXlm)),
unavailable: normalizeAmount(String(unavailableXlm)),
explanation,
};
}
61 changes: 30 additions & 31 deletions packages/stellar-kit/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,27 @@
* Stellar Expert link — without ever exposing secrets.
*/

import type { AccountInfo, AccountStatus, NetworkConfig, StellarPublicKey } from "@anchorkit/types";
import type {
AccountBalanceModel,
AccountInfo,
AccountStatus,
NetworkConfig,
StellarPublicKey,
} from "@anchorkit/types";
import { getNetworkConfig } from "@anchorkit/config";
import { isPublicKeyValid } from "./keys";
import { buildAccountLink } from "./explorer";
import { loadAccount } from "./accounts";
import { computeBalanceModel, computeReserve, unknownBalanceModel } from "./balances";
import type { ReserveInfo } from "./balances";

export {
BASE_ENTRY_COUNT,
STELLAR_BASE_RESERVE_XLM,
computeBalanceModel,
computeReserve,
} from "./balances";
export type { ReserveInfo } from "./balances";

/** Diagnostic states — superset of the raw `AccountStatus`. */
export type AccountDiagnosticState =
Expand All @@ -22,36 +38,6 @@ export type AccountDiagnosticState =
| "unavailable"
| "unknown";

/** Stellar base reserve (in XLM) as of the current protocol. */
export const BASE_RESERVE_XLM = 2;
/** Per-subentry reserve increment (in XLM). */
export const SUBENTRY_RESERVE_XLM = 0.5;

export interface ReserveInfo {
/** Base reserve in XLM. */
baseReserve: number;
/** Number of subentries counted against the reserve. */
subentryCount: number;
/** Computed minimum balance (base + subentry increments + 2 base accounts). */
minimumBalanceXlm: number;
/** Human-readable explanation suitable for UI. */
explanation: string;
}

/** Reserve awareness derived from an account's subentry count. */
export function computeReserve(subentryCount: number | undefined): ReserveInfo {
const subs = subentryCount ?? 0;
const minimumBalanceXlm = BASE_RESERVE_XLM + (subs + 2) * SUBENTRY_RESERVE_XLM;
return {
baseReserve: BASE_RESERVE_XLM,
subentryCount: subs,
minimumBalanceXlm,
explanation: `Minimum balance is ${minimumBalanceXlm} XLM: ${BASE_RESERVE_XLM} base reserve + ${
subs + 2
} entries × ${SUBENTRY_RESERVE_XLM} XLM (2 base entries + ${subs} subentries).`,
};
}

export interface AccountDiagnostic {
/** The (possibly invalid) input the user supplied. */
input: string;
Expand All @@ -63,6 +49,12 @@ export interface AccountDiagnostic {
expertUrl: string | null;
/** Reserve awareness (only meaningful for funded accounts). */
reserve: ReserveInfo | null;
/**
* Total / reserve / spendable / unavailable breakdown of the native balance.
* Always present; carries `state: "unknown"` with null amounts when the
* account data does not support a trustworthy figure.
*/
balances: AccountBalanceModel;
/** Raw account info when available (never includes secrets). */
account: AccountInfo | null;
/** User-safe error message, if any. */
Expand Down Expand Up @@ -104,6 +96,7 @@ export function diagnoseAccountInfo(
isValidPublicKey: valid,
expertUrl: valid ? buildAccountLink(info.publicKey, network) : null,
reserve,
balances: computeBalanceModel(info),
account: info,
error: info.error ?? null,
};
Expand All @@ -128,6 +121,9 @@ export async function diagnoseAccount(
isValidPublicKey: false,
expertUrl: null,
reserve: null,
balances: unknownBalanceModel(
"The public key is not valid, so no balance can be read for it."
),
account: null,
error: "Not a valid Stellar public key (must be 56 characters, start with G).",
};
Expand All @@ -146,6 +142,9 @@ export async function diagnoseAccount(
isValidPublicKey: true,
expertUrl: buildAccountLink(publicKey as StellarPublicKey, network),
reserve: null,
balances: unknownBalanceModel(
"The account could not be loaded, so the spendable balance is unknown."
),
account: null,
error: err instanceof Error ? err.message : "Account diagnostics unavailable.",
};
Expand Down
1 change: 1 addition & 0 deletions packages/stellar-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export * from "./transactions";
export * from "./escrowEvents";
export * from "./logging";
export * from "./explorer";
export * from "./balances";
export * from "./diagnostics";
export type { StellarKeypair } from "@anchorkit/types";
Loading