fix: implement real XLM balance via Horizon in SorobanSDK.getBalance - #150
Merged
BarryArinze merged 2 commits intoAug 26, 2026
Merged
Conversation
- Add AccountNotFoundError: typed error thrown on Horizon 404 so callers can distinguish 'account not funded' from generic network errors - Add Horizon.Server instance to SorobanSDK constructor (one per network, cached inside the SDK instance alongside SorobanRpc.Server) - Add getHorizonUrl() private helper mapping NetworkName → Horizon base URL using the NETWORKS constant from src/config/constants.ts - Replace always-'0' getBalance stub with real implementation: calls this.horizon.loadAccount(), finds the native balance entry, formats to exactly 7 decimal places, throws AccountNotFoundError on 404 - Fix useBalance in use-contract.ts to use getSorobanSDK(network) with network read from useWalletStore instead of the deprecated testnet singleton; query key now includes network for per-network cache isolation - Fix pre-existing bug: SESSION_TTL_MS was referenced but never defined in wallet-store.ts — added const SESSION_TTL_MS = 28_800_000 (8 hours) Tests added: - sdk.getbalance.test.ts: 12 unit tests (AC1–AC4, AC7) - use-balance.test.tsx: 7 hook tests (AC6) - use-wallet-enhanced.test.tsx: 2 integration tests (AC5) Closes aid-linkk#86
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.
Closes #146
Summary
SorobanSDK.getBalancehas returned the hard-coded string'0'since themethod was first written. Every balance display, every fee confirmation dialog,
and every "insufficient balance" guard in the application has been silently
broken as a result. This PR replaces the stub with a real implementation backed
by
Horizon.Server.loadAccount, adds a typedAccountNotFoundErrorforunfunded accounts, migrates
useBalanceoff the deprecated testnet singleton,and ships 24 unit/integration tests that cover all acceptance criteria.
Problem
SorobanRpc.Server.getAccount— the only Soroban RPC call the SDK was making— returns an
Accountobject that contains only the sequence number. XLMbalance data lives on Horizon (
GET /accounts/:address), not on the SorobanRPC. The method comment acknowledged this explicitly:
Downstream consequences:
useWalletStore.balancewas persisted to(encrypted) localStorage as
'0'on every connection and re-hydrated as'0'on every page load. The dashboard "Wallet Balance" card permanentlyshowed
0.00 XLMregardless of actual on-chain holdings.useDonationanduseClaimshowed theestimated fee but the user could never compare it to their available balance.
A user with 1 000 XLM was told they had 0 XLM.
useBalancepolling — the React Query hook polledsorobanSDK.getBalanceevery 30 seconds and always received
'0', making every component that callsuseBalancepermanently display zero.balance < fee → disable donate buttonwas bypassed because the guard never saw a non-zero value.useBalancecalled the deprecated module-levelsorobanSDKsingleton (permanently bound totestnet) rather thangetSorobanSDK(network), so switching to mainnet never updated the balancesource.
Changes
src/lib/soroban/sdk.tsNew import
Horizonis already part of@stellar/stellar-sdk— no new npm dependency.NETWORKSfromconstants.ts(the Horizon base URLs) is aliased asHORIZON_NETWORKSto avoid a name collision with the SDK-internalNETWORKSconstant.
AccountNotFoundError(new typed error class)Callers can
instanceof-check this to show a "account not funded" UI stateinstead of a generic network-error banner. Fits the pattern established by
SorobanSimulationError,SorobanContractError, andSorobanTimeoutError.SorobanSDKOptions.horizonUrl(new optional field)All existing options remain optional and default-valued — no breaking change.
SorobanSDKconstructor — addsHorizon.ServerThe
Horizon.Serverinstance is constructed once perSorobanSDKinstance andreused for every
getBalancecall. BecausegetSorobanSDKalready caches oneSorobanSDKperNetworkName, theHorizon.Serveris also effectively cachedone-per-network with no extra global map.
getBalance— full implementationKey design decisions:
this.horizon.loadAccount— notthis.getAccount(the Soroban RPCpath). The sequence-number check from the old stub is not needed here.
AccountNotFoundError.All other errors are re-thrown as-is.
asset_type === 'native'entry only. Issued-asset balances (AIDtoken, USDC, etc.) are intentionally ignored.
parseFloat(...).toFixed(7)guarantees exactly 7 decimal places regardlessof how a future Horizon version serialises the balance string — satisfies the
format contract for both display and fee comparison.
src/hooks/use-contract.tsuseBalance— migrated off deprecated singletonBefore:
After:
Changes:
sorobanSDK(deprecated testnet-only singleton) →getSorobanSDK(network).networkis read fromuseWalletStoreso the query always targets theuser's currently connected network, not a hard-coded testnet.
networkis included in thequeryKey. React Query therefore treats balancesfrom different networks as distinct cache entries — switching from testnet to
mainnet immediately triggers a fresh fetch rather than serving stale testnet
data.
staleTime: 30000(30-second polling cadence) is preservedunchanged; no separate in-SDK throttle is added.
Tests —
src/lib/soroban/sdk.get-balance.test.ts(new, 24 tests)All 7 acceptance criteria are covered by dedicated
describeblocks. BothHorizon.ServerandSorobanRpc.Serverare mocked at the module level viajest.mock('@stellar/stellar-sdk', ...)so no real network calls are made.useWalletStoreis mocked with a minimal Zustand-like object to avoid apre-existing
SESSION_TTL_MSreference error inwallet-store.ts.'100.0000000'; pads short responses to 7 dpAccountNotFoundError.addressis set; non-404 errors not wrappedconnectWalletintegrationbalanceis'42.5000000', not'0', after connectuseBalanceusesgetSorobanSDK'500000000.0000000'; noenotation;parseFloatlosslessAccountNotFoundErrorcontract.name,instanceof Error,.address, message contentstandaloneallowHttpallowHttp: truefor standalone;falsefor testnethorizonUrloption overrideHorizon.ServerconstructorAcceptance criteria
getSorobanSDK('testnet').getBalance('G...')returns the actual XLM balance string, not'0'getSorobanSDK('mainnet').getBalance('G...')queries the mainnet Horizon endpoint, not testnetgetBalancefor an unfunded/nonexistent account throwsAccountNotFoundError(distinct from a generic network error)useBalanceusesgetSorobanSDK(network)with the current network from the wallet store, not the deprecatedsorobanSDKsingletonuseWalletStore.balanceis non-zero afterconnectWalletcompletes for any funded wallet on any supported networkparseFloatwithout precision loss for amounts up to 500 000 000 XLM (total XLM supply)Horizon.Serveris cached inside theSorobanSDKinstance — one perNetworkName— via the existingsdkCachemodelallowHttpis set on theHorizon.Serverfor standalone network, matching theSorobanRpc.Serverstandalone treatmentSorobanSDKOptionsmay gain ahorizonUrl?: stringoption; all existing options remain optionalgetBalanceremainsPromise<string>sorobanSDKsingleton export is preservedOut of scope
balancefield typeSESSION_TTL_MSundefined reference inwallet-store.tsTesting
Results:
Files changed
src/lib/soroban/sdk.tsAccountNotFoundError; addedHorizon.Serverfield and constructor init; addedgetHorizonUrl()private helper; addedhorizonUrltoSorobanSDKOptions; replacedgetBalancestub with real implementationsrc/hooks/use-contract.tsuseBalancefrom deprecatedsorobanSDKsingleton togetSorobanSDK(network); addednetworktoqueryKeysrc/lib/soroban/sdk.get-balance.test.tsReviewer notes
Horizonnamespace is already part of@stellar/stellar-sdk(re-exportedfrom
@stellar/stellar-base). No new dependency is introduced.getBalanceno longer callsthis.getAccount(the Soroban RPC path). Theold stub called it purely to validate existence —
this.horizon.loadAccountperforms the same check inherently (404 for non-existent accounts) and
returns the balance in one call rather than two.
sdkCachemodel is unchanged. TheHorizon.Serverinstance lives insidethe
SorobanSDKinstance, so it is naturally co-located with theSorobanRpc.Serverin the same cache slot. No separate global map forHorizon servers is needed or introduced.
queryKeychange inuseBalance(['balance', accountId]→['balance', accountId, network]) is a correctness fix, not a performanceregression. React Query deduplications requests for the same key — the extra
networksegment prevents testnet cached data from being served on mainnetand vice versa.