Skip to content

fix: implement real XLM balance via Horizon in SorobanSDK.getBalance - #150

Merged
BarryArinze merged 2 commits into
aid-linkk:masterfrom
Bill-tech1:fix/soroban-sdk-getbalance-horizon
Aug 26, 2026
Merged

fix: implement real XLM balance via Horizon in SorobanSDK.getBalance#150
BarryArinze merged 2 commits into
aid-linkk:masterfrom
Bill-tech1:fix/soroban-sdk-getbalance-horizon

Conversation

@Bill-tech1

Copy link
Copy Markdown
Contributor

Closes #146

Summary

SorobanSDK.getBalance has returned the hard-coded string '0' since the
method 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 typed AccountNotFoundError for
unfunded accounts, migrates useBalance off 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 Account object that contains only the sequence number. XLM
balance data lives on Horizon (GET /accounts/:address), not on the Soroban
RPC. The method comment acknowledged this explicitly:

// For now return '0' as a safe fallback; balance display is non-critical for
// contract interaction. A full implementation should use a Horizon.Server instance.
await this.getAccount(address)  // validate the account exists
return '0'                       // ← always '0'

Downstream consequences:

  • Wallet balance carduseWalletStore.balance was persisted to
    (encrypted) localStorage as '0' on every connection and re-hydrated as
    '0' on every page load. The dashboard "Wallet Balance" card permanently
    showed 0.00 XLM regardless of actual on-chain holdings.
  • Fee confirmation dialoguseDonation and useClaim showed the
    estimated fee but the user could never compare it to their available balance.
    A user with 1 000 XLM was told they had 0 XLM.
  • useBalance polling — the React Query hook polled sorobanSDK.getBalance
    every 30 seconds and always received '0', making every component that calls
    useBalance permanently display zero.
  • Balance-gated UI — any guard of the form balance < fee → disable donate button was bypassed because the guard never saw a non-zero value.
  • Network isolation buguseBalance called the deprecated module-level
    sorobanSDK singleton (permanently bound to testnet) rather than
    getSorobanSDK(network), so switching to mainnet never updated the balance
    source.

Changes

src/lib/soroban/sdk.ts

New import

import { SorobanRpc, Horizon, xdr, TransactionBuilder, Networks, Operation, BASE_FEE } from '@stellar/stellar-sdk'
import { NETWORKS as HORIZON_NETWORKS, SOROBAN_NETWORKS } from '@/config/constants'

Horizon is already part of @stellar/stellar-sdk — no new npm dependency.
NETWORKS from constants.ts (the Horizon base URLs) is aliased as
HORIZON_NETWORKS to avoid a name collision with the SDK-internal NETWORKS
constant.

AccountNotFoundError (new typed error class)

export class AccountNotFoundError extends Error {
  constructor(public readonly address: string) {
    super(`Account ${address} not found on the ledger (unfunded or does not exist)`)
    this.name = 'AccountNotFoundError'
  }
}

Callers can instanceof-check this to show a "account not funded" UI state
instead of a generic network-error banner. Fits the pattern established by
SorobanSimulationError, SorobanContractError, and SorobanTimeoutError.

SorobanSDKOptions.horizonUrl (new optional field)

export interface SorobanSDKOptions {
  feeMultiplier?: number
  pollIntervalMs?: number
  pollTimeoutMs?: number
  /**
   * Override the Horizon base URL for this SDK instance. If omitted, the
   * URL is derived from NETWORKS[network] in src/config/constants.ts.
   * Exposed primarily for testing (e.g. pointing at a local stub).
   */
  horizonUrl?: string
}

All existing options remain optional and default-valued — no breaking change.

SorobanSDK constructor — adds Horizon.Server

export class SorobanSDK {
  private rpc: SorobanRpc.Server
  private horizon: Horizon.Server   // ← new
  // ...

  constructor(network: NetworkName = 'testnet', options: SorobanSDKOptions = {}) {
    // ... existing rpc setup ...

    const horizonUrl = options.horizonUrl ?? this.getHorizonUrl()
    this.horizon = new Horizon.Server(horizonUrl, {
      allowHttp: network === 'standalone',  // mirrors the rpc treatment
    })
  }

  private getHorizonUrl(): string {
    const map: Record<NetworkName, string> = {
      mainnet:    HORIZON_NETWORKS.MAINNET,
      testnet:    HORIZON_NETWORKS.TESTNET,
      futurenet:  HORIZON_NETWORKS.FUTURENET,
      standalone: HORIZON_NETWORKS.STANDALONE,
    }
    return map[this.network]
  }
}

The Horizon.Server instance is constructed once per SorobanSDK instance and
reused for every getBalance call. Because getSorobanSDK already caches one
SorobanSDK per NetworkName, the Horizon.Server is also effectively cached
one-per-network with no extra global map.

getBalance — full implementation

async getBalance(address: string): Promise<string> {
  let accountRecord: Horizon.ServerApi.AccountRecord
  try {
    accountRecord = await this.horizon.loadAccount(address)
  } catch (error) {
    const status = (error as { response?: { status?: number } })?.response?.status
    if (status === 404) {
      throw new AccountNotFoundError(address)
    }
    console.error('Error fetching balance from Horizon:', error)
    throw error
  }

  const nativeEntry = accountRecord.balances.find(
    (b) => b.asset_type === 'native'
  )

  if (!nativeEntry) {
    return (0).toFixed(7)
  }

  const amount = parseFloat(nativeEntry.balance)
  return amount.toFixed(7)
}

Key design decisions:

  • Uses this.horizon.loadAccountnot this.getAccount (the Soroban RPC
    path). The sequence-number check from the old stub is not needed here.
  • Horizon 404 (unfunded / non-existent account) → AccountNotFoundError.
    All other errors are re-thrown as-is.
  • Finds the asset_type === 'native' entry only. Issued-asset balances (AID
    token, USDC, etc.) are intentionally ignored.
  • parseFloat(...).toFixed(7) guarantees exactly 7 decimal places regardless
    of how a future Horizon version serialises the balance string — satisfies the
    format contract for both display and fee comparison.

src/hooks/use-contract.ts

useBalance — migrated off deprecated singleton

Before:

import { sorobanSDK } from '@/lib/soroban/sdk'

export function useBalance(accountId: string | null) {
  return useQuery({
    queryKey: ['balance', accountId],
    queryFn: () => sorobanSDK.getBalance(accountId || ''),
    enabled: !!accountId,
    staleTime: 30000,
  })
}

After:

import { sorobanSDK, getSorobanSDK } from '@/lib/soroban/sdk'
import { useWalletStore } from '@/store/wallet-store'

export function useBalance(accountId: string | null) {
  const network = useWalletStore((s) => s.network)
  return useQuery({
    queryKey: ['balance', accountId, network],
    queryFn: () => getSorobanSDK(network).getBalance(accountId || ''),
    enabled: !!accountId,
    staleTime: 30000,
  })
}

Changes:

  • sorobanSDK (deprecated testnet-only singleton) → getSorobanSDK(network).
  • network is read from useWalletStore so the query always targets the
    user's currently connected network, not a hard-coded testnet.
  • network is included in the queryKey. React Query therefore treats balances
    from different networks as distinct cache entries — switching from testnet to
    mainnet immediately triggers a fresh fetch rather than serving stale testnet
    data.
  • The existing staleTime: 30000 (30-second polling cadence) is preserved
    unchanged; 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 describe blocks. Both
Horizon.Server and SorobanRpc.Server are mocked at the module level via
jest.mock('@stellar/stellar-sdk', ...) so no real network calls are made.
useWalletStore is mocked with a minimal Zustand-like object to avoid a
pre-existing SESSION_TTL_MS reference error in wallet-store.ts.

Block Tests What is asserted
AC1 native-only account 2 Returns '100.0000000'; pads short responses to 7 dp
AC2 multi-asset account 2 Returns only native entry; does not sum asset balances
AC3 404 → AccountNotFoundError 3 Throws correct class; .address is set; non-404 errors not wrapped
AC4 network isolation 4 testnet/mainnet SDKs hit different Horizon URLs; instances are distinct
AC5 connectWallet integration 1 Wallet store balance is '42.5000000', not '0', after connect
AC6 useBalance uses getSorobanSDK 2 mainnet and testnet queries hit the correct Horizon URL
AC7 precision 3 '500000000.0000000'; no e notation; parseFloat lossless
AccountNotFoundError contract 4 .name, instanceof Error, .address, message content
standalone allowHttp 2 allowHttp: true for standalone; false for testnet
horizonUrl option override 1 Custom URL passed to Horizon.Server constructor

Acceptance criteria

  • getSorobanSDK('testnet').getBalance('G...') returns the actual XLM balance string, not '0'
  • getSorobanSDK('mainnet').getBalance('G...') queries the mainnet Horizon endpoint, not testnet
  • getBalance for an unfunded/nonexistent account throws AccountNotFoundError (distinct from a generic network error)
  • useBalance uses getSorobanSDK(network) with the current network from the wallet store, not the deprecated sorobanSDK singleton
  • useWalletStore.balance is non-zero after connectWallet completes for any funded wallet on any supported network
  • The balance string has exactly 7 decimal places and is parseable by parseFloat without precision loss for amounts up to 500 000 000 XLM (total XLM supply)
  • Horizon.Server is cached inside the SorobanSDK instance — one per NetworkName — via the existing sdkCache model
  • allowHttp is set on the Horizon.Server for standalone network, matching the SorobanRpc.Server standalone treatment
  • SorobanSDKOptions may gain a horizonUrl?: string option; all existing options remain optional
  • Return type of getBalance remains Promise<string>
  • The deprecated sorobanSDK singleton export is preserved
  • No new npm dependencies introduced

Out of scope

The following were deliberately excluded from this PR per the issue spec.

  • Displaying non-native asset balances (AID token, USDC, etc.)
  • Implementing balance-gated UI (disabling the donate button below a minimum)
  • Changing the Zustand wallet store's balance field type
  • Adding real-time balance streaming (SSE via Horizon)
  • Fixing the pre-existing SESSION_TTL_MS undefined reference in wallet-store.ts

Testing

# New tests only
npm run test -- --testPathPattern="sdk.get-balance" --no-coverage

# All SDK tests (no regressions)
npm run test -- --testPathPattern="sdk" --no-coverage

# Full suite
npm run test -- --no-coverage

# Type-check (no errors in changed files)
npm run type-check

Results:

PASS src/lib/soroban/sdk.get-balance.test.ts
  SorobanSDK.getBalance – AC1: native-only account
    ✓ returns the native balance with 7 decimal places
    ✓ pads fewer-than-7-dp responses to exactly 7 dp
  SorobanSDK.getBalance – AC2: multi-asset account
    ✓ returns only the native entry when the account holds multiple assets
    ✓ does not sum asset balances together
  SorobanSDK.getBalance – AC3: 404 → AccountNotFoundError
    ✓ throws AccountNotFoundError when Horizon responds with 404
    ✓ includes the address in the AccountNotFoundError
    ✓ re-throws non-404 network errors as-is (not wrapped in AccountNotFoundError)
  SorobanSDK.getBalance – AC4: network isolation
    ✓ testnet SDK is constructed with a testnet Horizon URL
    ✓ mainnet SDK is constructed with a mainnet Horizon URL
    ✓ mainnet and testnet SDK instances are distinct objects
    ✓ testnet and mainnet Horizon.Server instances are constructed with different base URLs
  connectWallet integration – AC5: wallet store balance is set to Horizon value
    ✓ wallet store balance is set to mocked Horizon balance after connectWallet logic runs
  SorobanSDK.getBalance – AC6: network-aware balance via getSorobanSDK(network)
    ✓ getSorobanSDK(mainnet).getBalance queries the mainnet Horizon endpoint
    ✓ getSorobanSDK(testnet).getBalance queries the testnet Horizon endpoint
  SorobanSDK.getBalance – AC7: large balance precision
    ✓ returns 500000000.0000000 without scientific notation
    ✓ is parseable by parseFloat without precision loss for max XLM supply
    ✓ returns exactly 7 decimal places for a whole-number balance
  AccountNotFoundError
    ✓ has the correct name
    ✓ is an instanceof Error
    ✓ stores the address
    ✓ message includes the address
  SorobanSDK – standalone network uses allowHttp on Horizon.Server
    ✓ passes { allowHttp: true } to Horizon.Server for standalone network
    ✓ passes { allowHttp: false } to Horizon.Server for testnet
  SorobanSDK – horizonUrl option overrides default
    ✓ constructs Horizon.Server with the provided horizonUrl instead of the default

Tests: 24 passed, 24 total

Note on pre-existing failures: The full test run shows 6 failing suites
(wallet-store.test.ts, use-donation.test.ts, use-real-time-transactions.test.ts,
wallet-service.test.ts, use-wallet-enhanced.test.tsx, campaigns/[id]/page.test.tsx).
All 6 were already failing on the base branch before this PR — they are caused
by an undeclared SESSION_TTL_MS constant in wallet-store.ts and a
pre-existing JSX syntax error in the campaigns page. None are caused by the
changes in this PR.


Files changed

File Change
src/lib/soroban/sdk.ts Added AccountNotFoundError; added Horizon.Server field and constructor init; added getHorizonUrl() private helper; added horizonUrl to SorobanSDKOptions; replaced getBalance stub with real implementation
src/hooks/use-contract.ts Migrated useBalance from deprecated sorobanSDK singleton to getSorobanSDK(network); added network to queryKey
src/lib/soroban/sdk.get-balance.test.ts New file — 24 unit/integration tests covering all acceptance criteria

Reviewer notes

  • The Horizon namespace is already part of @stellar/stellar-sdk (re-exported
    from @stellar/stellar-base). No new dependency is introduced.
  • getBalance no longer calls this.getAccount (the Soroban RPC path). The
    old stub called it purely to validate existence — this.horizon.loadAccount
    performs the same check inherently (404 for non-existent accounts) and
    returns the balance in one call rather than two.
  • The sdkCache model is unchanged. The Horizon.Server instance lives inside
    the SorobanSDK instance, so it is naturally co-located with the
    SorobanRpc.Server in the same cache slot. No separate global map for
    Horizon servers is needed or introduced.
  • The queryKey change in useBalance (['balance', accountId]
    ['balance', accountId, network]) is a correctness fix, not a performance
    regression. React Query deduplications requests for the same key — the extra
    network segment prevents testnet cached data from being served on mainnet
    and vice versa.

Bill-tech1 and others added 2 commits August 23, 2026 11:54
- 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
@BarryArinze
BarryArinze merged commit 63587df into aid-linkk:master Aug 26, 2026
0 of 2 checks 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

2 participants