Skip to content

feat: add Stellar account reserve and spendable balance model (Closes #22) - #77

Merged
El-swaggerito merged 1 commit into
Axionvera:mainfrom
XxHugheadxX:feat/22-reserve-spendable-balance-model
Jul 28, 2026
Merged

feat: add Stellar account reserve and spendable balance model (Closes #22)#77
El-swaggerito merged 1 commit into
Axionvera:mainfrom
XxHugheadxX:feat/22-reserve-spendable-balance-model

Conversation

@XxHugheadxX

Copy link
Copy Markdown
Contributor

Summary

Adds a shared account reserve and spendable balance model, and fixes the reserve
calculation it had to be built on.

AccountBalanceModel splits an account's native balance into total,
reserve, spendable, unavailable, and an explicit unknown state.
Payment readiness consumes it, and the accounts screen explains what is locked
instead of showing a bare balance.

Closes #22

Root cause

The reserve half of this feature already existed, and it was wrong by a fixed
2 XLM.

computeReserve in packages/stellar-kit/src/diagnostics.ts:44 computed:

const minimumBalanceXlm = BASE_RESERVE_XLM + (subs + 2) * SUBENTRY_RESERVE_XLM;

with BASE_RESERVE_XLM = 2, documented as "Stellar base reserve (in XLM)".
That 2 is the base entry count, not an amount in XLM — and those two
entries are already inside (subs + 2), so they were charged twice.

Stellar's rule is (2 + subentries) × 0.5 XLM:

Subentries Before Correct Error
0 3 XLM 1 XLM +2
3 4.5 XLM 2.5 XLM +2

A constant +2 XLM overstatement, and it was already reaching users:
apps/web/app/accounts/page.tsx:347 renders minimumBalanceXlm directly.
Building "spendable" on this helper would have understated every account by the
same 2 XLM.

The generated explanation string gave the bug away in its own text — for three
subentries it read "…: 2 base reserve + 5 entries × 0.5 XLM (2 base entries +
3 subentries)"
, counting the base entries both as a flat charge and inside the
entry count. That string is the title= tooltip at page.tsx:346, so fixing the
number without rewriting it would have left the tooltip contradicting the figure.

Changes

packages/stellar-kit/src/balances.ts (new)

  • computeReserve moved here with the corrected formula and a rewritten
    explanation. Re-exported from diagnostics.ts, so existing imports keep working.
  • computeBalanceModel(info) derives the five-state model.
  • unknownBalanceModel(reason) for callers with no account data.

packages/types/src/index.ts

  • AccountBalanceModel and BalanceModelState. Amounts are decimal strings
    normalized to 7 places, or null when unknown.

packages/stellar-kit/src/diagnostics.ts

  • AccountDiagnostic gains balances, populated on every path including the
    invalid-key and network-failure branches.

packages/stellar-kit/src/intent.ts

  • estimateTransactionReadinessSync accepts an opt-in sourceBalances. A native
    payment above the spendable balance raises INSUFFICIENT_FUNDS (severity
    error); an unknown balance raises SPENDABLE_UNKNOWN (severity info).
  • estimateTransactionReadiness now loads the source account with loadAccount
    instead of getAccountStatus. getAccountStatus (accounts.ts:90-95) fetches
    the whole account and then returns only info.status, discarding the
    subentryCount and balances this model needs — they were already on the wire.
    No additional network call.

apps/web/app/accounts/page.tsx

  • Spendable and unavailable rows, with the locked amount called out. When the
    model is unknown the row says so in words and shows no figure.

docs/account-diagnostics.md

  • Corrected formula, reserve assumptions, the balance model, and the readiness
    integration.

INSUFFICIENT_FUNDS is the existing canonical code from the shared error
taxonomy (packages/types/src/errors.ts:34, added in #58) rather than a new
name — MEMO_INVALID and MAINNET_DISABLED in this file already match that
taxonomy exactly.

Backwards compatibility

Breaking — reserve values change. Any consumer reading minimumBalanceXlm
now gets a figure 2 XLM lower. This is the bug fix, not a regression; three live
assertions pinned the incorrect values and were updated in this PR.

Breaking — public constants renamed.

Removed Replacement
BASE_RESERVE_XLM (was 2) BASE_ENTRY_COUNT (2) — it was an entry count, not XLM
SUBENTRY_RESERVE_XLM (0.5) STELLAR_BASE_RESERVE_XLM (0.5) — it is the base reserve, charged per entry

Migration: BASE_RESERVE_XLM + (n + 2) * SUBENTRY_RESERVE_XLM becomes
(BASE_ENTRY_COUNT + n) * STELLAR_BASE_RESERVE_XLM, or just call
computeReserve(n).

The names were deliberately not reused with new values: silently changing
BASE_RESERVE_XLM from 2 to 0.5 would break consumers with no signal, while
removing the name breaks them at compile time.

ReserveInfo.baseReserve is now 0.5 (the actual base reserve) instead of 2,
and the interface gains entryCount.

Behaviour — ready can now be false. With sourceBalances supplied and a
native payment above the spendable balance, readiness reports an error and
ready becomes false. It is opt-in: omitting sourceBalances leaves readiness
byte-for-byte as before. An unknown balance never produces an error.

Tests

packages/stellar-kit/test/balances.test.ts (new, 17 cases):

  • Corrected formula at 0, 1 and 3 subentries, and that the explanation no longer
    double-counts the base entries.
  • Funded split; the invariant spendable + unavailable === total across four
    balance/subentry combinations.
  • Low balance — 0.5 XLM against a 1 XLM minimum: spendable clamps to 0,
    never negative.
  • Unfunded — zeroes plus what the account needs to exist.
  • Unknown — network error, errored lookup, funded-without-balances, and an
    unparseable balance. Every amount null, and the explanation asserted to
    contain no digit at all.
  • Readiness: blocks above spendable, allows within it, a case where the raw
    balance would pass but the spendable balance does not, unknown never errors,
    issued assets are not checked against the XLM reserve, and omitting the model
    changes nothing.

test/diagnostics.test.ts: the three assertions pinning the old values updated
(4.5 → 2.5 twice, 3 → 1), with the stale comment rewritten.

Verification

This repository has no CI that runs tests — .github/workflows/ contains only
trigger-auto-merge.yml — so everything below is local, measured against a
baseline taken on main at 970aae9 before any change.

Package Baseline After
stellar-kit 110/110 127/127
types · config · validators · web 5 · 20 · 26 · 2, all green unchanged
anchor-utils 38/39 38/39
treasury-escrow (Rust) fails to compile fails to compile

lint, typecheck and build pass for every TypeScript package.

The two failures are pre-existing and unrelated, reported rather than hidden:

  • anchor-utils/test/public-boundary-integration.test.ts times out against the
    default 5s limit. It is slow, not broken: with --testTimeout=60000 the file
    passes in 9.3s and the package is 39/39. Since that test verifies
    stellar-kit's public utilities are importable from the package root, this also
    confirms the new exports do not break the package boundary.
  • contracts/treasury-escrow fails to compile with
    the trait bound 'ChaCha20Rng: ed25519_dalek::rand_core::CryptoRng' is not satisfied in soroban-env-host — a dependency resolution conflict, untouched
    by this PR.

Scope

Limited to the reserve and native balance path. Deliberately left out:

  • Selling liabilities. Real spendable balance is
    total − reserve − selling liabilities. extractBalances
    (accounts.ts:110-130) drops the liabilities Horizon reports and
    AccountBalances has no field for them. Rather than invent the data, this PR
    documents that spendable is an upper bound for accounts with open offers.
    Carrying liabilities through would change the account types and belongs in its
    own issue.
  • Sponsored reserves are not modelled; documented as an assumption.
  • Two readiness codes drift from the shared taxonomy: AMOUNT_INVALID and
    ASSET_INVALID against the taxonomy's INVALID_AMOUNT and INVALID_ASSET.
    Pre-existing, and renaming them would break consumers — reported here rather
    than fixed.
  • apps/web/app/payments/page.tsx is unchanged: it already renders warnings
    generically by severity and code, so the new warnings appear without edits.

@El-swaggerito
El-swaggerito merged commit 44cdc00 into Axionvera:main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Stellar account reserve and spendable balance model

2 participants