feat: add Stellar account reserve and spendable balance model (Closes #22) - #77
Merged
El-swaggerito merged 1 commit intoJul 28, 2026
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a shared account reserve and spendable balance model, and fixes the reserve
calculation it had to be built on.
AccountBalanceModelsplits 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.
computeReserveinpackages/stellar-kit/src/diagnostics.ts:44computed:with
BASE_RESERVE_XLM = 2, documented as "Stellar base reserve (in XLM)".That
2is the base entry count, not an amount in XLM — and those twoentries are already inside
(subs + 2), so they were charged twice.Stellar's rule is
(2 + subentries) × 0.5 XLM:A constant +2 XLM overstatement, and it was already reaching users:
apps/web/app/accounts/page.tsx:347rendersminimumBalanceXlmdirectly.Building "spendable" on this helper would have understated every account by the
same 2 XLM.
The generated
explanationstring gave the bug away in its own text — for threesubentries 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 atpage.tsx:346, so fixing thenumber without rewriting it would have left the tooltip contradicting the figure.
Changes
packages/stellar-kit/src/balances.ts(new)computeReservemoved here with the corrected formula and a rewrittenexplanation. 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.tsAccountBalanceModelandBalanceModelState. Amounts are decimal stringsnormalized to 7 places, or
nullwhen unknown.packages/stellar-kit/src/diagnostics.tsAccountDiagnosticgainsbalances, populated on every path including theinvalid-key and network-failure branches.
packages/stellar-kit/src/intent.tsestimateTransactionReadinessSyncaccepts an opt-insourceBalances. A nativepayment above the spendable balance raises
INSUFFICIENT_FUNDS(severityerror); an unknown balance raisesSPENDABLE_UNKNOWN(severityinfo).estimateTransactionReadinessnow loads the source account withloadAccountinstead of
getAccountStatus.getAccountStatus(accounts.ts:90-95) fetchesthe whole account and then returns only
info.status, discarding thesubentryCountandbalancesthis model needs — they were already on the wire.No additional network call.
apps/web/app/accounts/page.tsxmodel is unknown the row says so in words and shows no figure.
docs/account-diagnostics.mdintegration.
INSUFFICIENT_FUNDSis the existing canonical code from the shared errortaxonomy (
packages/types/src/errors.ts:34, added in #58) rather than a newname —
MEMO_INVALIDandMAINNET_DISABLEDin this file already match thattaxonomy exactly.
Backwards compatibility
Breaking — reserve values change. Any consumer reading
minimumBalanceXlmnow 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.
BASE_RESERVE_XLM(was2)BASE_ENTRY_COUNT(2) — it was an entry count, not XLMSUBENTRY_RESERVE_XLM(0.5)STELLAR_BASE_RESERVE_XLM(0.5) — it is the base reserve, charged per entryMigration:
BASE_RESERVE_XLM + (n + 2) * SUBENTRY_RESERVE_XLMbecomes(BASE_ENTRY_COUNT + n) * STELLAR_BASE_RESERVE_XLM, or just callcomputeReserve(n).The names were deliberately not reused with new values: silently changing
BASE_RESERVE_XLMfrom2to0.5would break consumers with no signal, whileremoving the name breaks them at compile time.
ReserveInfo.baseReserveis now0.5(the actual base reserve) instead of2,and the interface gains
entryCount.Behaviour —
readycan now befalse. WithsourceBalancessupplied and anative payment above the spendable balance, readiness reports an error and
readybecomesfalse. It is opt-in: omittingsourceBalancesleaves readinessbyte-for-byte as before. An unknown balance never produces an error.
Tests
packages/stellar-kit/test/balances.test.ts(new, 17 cases):double-counts the base entries.
spendable + unavailable === totalacross fourbalance/subentry combinations.
0,never negative.
unparseable balance. Every amount
null, and the explanation asserted tocontain no digit at all.
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.5twice,3 → 1), with the stale comment rewritten.Verification
This repository has no CI that runs tests —
.github/workflows/contains onlytrigger-auto-merge.yml— so everything below is local, measured against abaseline taken on
mainat970aae9before any change.lint,typecheckandbuildpass for every TypeScript package.The two failures are pre-existing and unrelated, reported rather than hidden:
anchor-utils/test/public-boundary-integration.test.tstimes out against thedefault 5s limit. It is slow, not broken: with
--testTimeout=60000the filepasses 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-escrowfails to compile withthe trait bound 'ChaCha20Rng: ed25519_dalek::rand_core::CryptoRng' is not satisfiedinsoroban-env-host— a dependency resolution conflict, untouchedby this PR.
Scope
Limited to the reserve and native balance path. Deliberately left out:
total − reserve − selling liabilities.extractBalances(
accounts.ts:110-130) drops the liabilities Horizon reports andAccountBalanceshas no field for them. Rather than invent the data, this PRdocuments that
spendableis an upper bound for accounts with open offers.Carrying liabilities through would change the account types and belongs in its
own issue.
AMOUNT_INVALIDandASSET_INVALIDagainst the taxonomy'sINVALID_AMOUNTandINVALID_ASSET.Pre-existing, and renaming them would break consumers — reported here rather
than fixed.
apps/web/app/payments/page.tsxis unchanged: it already renders warningsgenerically by
severityandcode, so the new warnings appear without edits.