Skip to content

feat(balances): migrate account balances to the v2 indexer endpoint - #935

Open
aristidesstaffieri wants to merge 14 commits into
mainfrom
feat/balances-v2-migration
Open

feat(balances): migrate account balances to the v2 indexer endpoint#935
aristidesstaffieri wants to merge 14 commits into
mainfrom
feat/balances-v2-migration

Conversation

@aristidesstaffieri

@aristidesstaffieri aristidesstaffieri commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

Migrates account balance fetching to the freighter-backend-v2 indexer endpoint (POST /accounts/balances), behind a new use_balances_v2 remote-config flag. This ports the browser extension's feat/balances-v2-migration work, adapted to mobile's zustand/axios architecture.

  • fetchBalancesV2 POSTs to freighter-backend-v2 /accounts/balances and is used on PUBLIC/TESTNET when the flag is on; Futurenet stays on v1.
  • mapAccountBalancesV2 normalizes the snake_case v2 wire shape into the app's BalanceMap:
    • native → "XLM"
    • classic/SAC → "CODE:ISSUER" (SAC kept classic-shaped so display doesn't double-scale the pre-formatted amount)
    • SEP-41 → Soroban shape with raw i128 amount + decimals
    • liquidity pool → "<poolId>:lp"
  • addBlockaidScanResults stamps benign defaults and merges mainnet bulk scan verdicts client-side via scanBulkTokens, matching the payload the v1 backend builds server-side. Scan failures never break balances.
  • The v2 path needs no contract_ids param: the wallet-backend returns all indexed holdings (trustlines, SACs, SEP-41s, pool shares) automatically.
  • The flag defaults to off (v1) since the wallet-backend indexer isn't deployed yet; Amplitude flips it on to roll out v2 without a release, and back off to roll back.

Why

The v1 account-balances endpoint is being replaced by the wallet-backend indexer, which serves balances from indexed data instead of querying Horizon/RPC per request. Gating the switch behind remote config lets us roll it out (and roll it back) without shipping a new app version.

Release blockers

This PR can merge, but the v2 path must not be enabled in production until:

  1. The use_balances_v2 feature flag is turned on in Amplitude for the rollout (the in-app default is off, so v1 stays in use until then).
  2. A wallet backend is deployed for every network — the indexer currently isn't deployed for all networks the app supports; as of 2026-07-15, POST /accounts/balances returns 500 on both prod and staging for all networks.
  3. Backend v2 accepts Futurenet requests — until then, Futurenet is hard-coded to the v1 path regardless of the flag.

Known limitations

  • Futurenet always uses the v1 endpoint (see release blockers above).
  • The v2 response carries no Blockaid data yet, so token scan results are merged client-side; this is an extra scanBulkTokens round trip on mainnet.

Checklist

PR structure

  • This PR does not mix refactoring changes with feature changes (break it down into smaller PRs if not).
  • This PR has reasonably narrow scope (break it down into smaller PRs if not).
  • This PR includes relevant before and after screenshots/videos highlighting these changes.
  • I took the time to review my own PR.

Testing

  • These changes have been tested and confirmed to work as intended on Android.
  • These changes have been tested and confirmed to work as intended on iOS.
  • These changes have been tested and confirmed to work as intended on small iOS screens.
  • These changes have been tested and confirmed to work as intended on small Android screens.
  • I have tried to break these changes while extensively testing them.
  • This PR adds tests for the new functionality or fixes.

Release

  • This is not a breaking change.
  • This PR updates existing JSDocs when applicable.
  • This PR adds JSDocs to new functionalities.
  • I've checked with the product team if we should add metrics to these changes.
  • I've shared relevant before and after screenshots/videos highlighting these changes with the design team and they've approved the changes.

  Adds a v2 balances path behind the new use_balances_v2 remote-config flag
  (defaults to on; Amplitude can flip it off to roll back to v1 without a
  release). Ports the browser extension's feat/balances-v2-migration work,
  adapted to mobile's zustand/axios architecture.

  - fetchBalancesV2 POSTs to freighter-backend-v2 /accounts/balances and
    routes there on PUBLIC/TESTNET when the flag is on; Futurenet stays on v1
  - mapAccountBalancesV2 normalizes the snake_case v2 wire shape into the
    app's BalanceMap: native -> "XLM", classic/SAC -> "CODE:ISSUER" (SAC kept
    classic-shaped so display doesn't double-scale the pre-formatted amount),
    SEP-41 -> Soroban shape with raw i128 + decimals, LP -> "<poolId>:lp"
  - addBlockaidScanResults stamps benign defaults and merges mainnet bulk
    scan verdicts client-side via scanBulkTokens, matching the v1 backend's
    server-side payload; scan failures never break balances
  - v2 needs no contract_ids: the wallet-backend returns all indexed
    holdings (trustlines, SACs, SEP-41s, pool shares) automatically
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-d6062dd951af5aacfa4c (SDF collaborators only — install instructions in the release description)

aristidesstaffieri and others added 2 commits July 15, 2026 10:58
…ployed

  The v2 /accounts/balances endpoint currently returns 500 on prod and
  staging for every network, so defaulting the flag to on broke balance
  fetching (and the send/swap e2e suites) wherever remote config falls
  back to code defaults. Default to the v1 path and let Amplitude flip
  the flag on once the wallet-backend indexer is live.
@aristidesstaffieri
aristidesstaffieri requested a review from a team July 15, 2026 17:40
@aristidesstaffieri
aristidesstaffieri marked this pull request as ready for review July 15, 2026 17:40
Copilot AI review requested due to automatic review settings July 15, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates balance fetching to the indexed v2 backend behind a remotely controlled rollout flag.

Changes:

  • Adds v2 balance routing and response normalization.
  • Adds client-side Blockaid scanning for v2 balances.
  • Adds remote-config integration and unit coverage.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/services/backend.ts Routes supported networks to v2.
src/helpers/mapAccountBalancesV2.ts Maps v2 balance wire types.
src/helpers/addBlockaidScanResults.ts Adds Blockaid verdicts.
src/ducks/remoteConfig.ts Defines the rollout flag.
src/ducks/balances.ts Passes the runtime flag.
__tests__/services/backend.test.ts Tests endpoint routing.
__tests__/helpers/mapAccountBalancesV2.test.ts Tests balance mapping.
__tests__/helpers/addBlockaidScanResults.test.ts Tests scan merging.
__tests__/ducks/remoteConfig.test.ts Tests flag behavior.
__tests__/ducks/balances.test.ts Updates store expectations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/services/backend.ts Outdated
Comment thread src/helpers/addBlockaidScanResults.ts Outdated
Comment thread src/helpers/mapAccountBalancesV2.ts Outdated
  The v2 backend contract returns one entry per requested address —
  unfunded accounts arrive as a matching entry with is_funded: false,
  never as an omission. Mapping an absent entry to "unfunded" masked
  malformed/partial responses and could replace a funded user's balances
  with the unfunded UI. Throw instead, so the store's error path keeps
  the existing balances, and tighten mapAccountBalancesV2 to require an
  account entry.
@piyalbasu

Copy link
Copy Markdown
Contributor

Pre-merge review — balances v2 migration

Reviewed against a whole-system checklist (invariants, flag state machine, replaced-behavior parity, error paths, test fidelity) and cross-checked against the sibling extension PR (freighter#2906). This is a faithful, well-tested port — and on two design calls it's the safer of the two platforms (flag defaults OFF, and missing-account is rejected rather than rendered as unfunded — nice). The blockers below are mostly shared with the extension (both clients copied the same wire assumption, so checking the two clients against each other looks clean — the bug is against the backend).

Verdict: safe to merge; must not enable the flag until #1 and #2 are resolved.


1. SEP-41 token balances are double-scaled by 1e7 — CRITICAL (shared with extension)

CRITICAL — merge blocker for enabling the flag. Every SEP-41 token balance would render 10,000,000× too small, and spendable/send math is wrong the same way.

TL;DR: The mapper treats the SEP-41 balance as a raw unscaled integer and re-scales it by decimals at display time. But wallet-backend already returns that amount as a human-readable decimal (it divides by 1e7 before serializing). So it gets scaled twice — a balance of 500 shows as 0.00005. The test fixtures encode the same wrong assumption, so they stay green.

Steps to reproduce:

  1. Hold any SEP-41 token on an account.
  2. Enable use_balances_v2 and load balances on pubnet/testnet.
  3. Observe the displayed balance is off by a factor of 1e7 vs. the v1 path.

Detailed explanation (for agents)

Root cause: wallet-backend's GraphQL resolver serializes the SEP-41 balance with amount.String128, which divides the i128 by One = 10_000_000 and returns a 7-decimal string (go-stellar-sdk .../amount/main.go String128rat.Quo(rat, bigOne); rat.FloatString(7)). See wallet-backend/internal/serve/graphql/resolvers/account_balances_utils.go parseSEP41BalancebalanceStr := amount.String128(i128Parts). freighter-backend-v2 passes it straight through (internal/services/account_balances_mapping.go, case *wbtypes.SEP41Balance; its "raw i128" comment describes spendability, not the serialized value).

The client mapper treats it as raw + decimals:

Also note amount.String128 is decimals-blind (always ÷1e7), so for a token whose true precision ≠ 7 the value is wrong even before the client touches it — a backend concern worth raising.

Caveat / confirm before acting: freighter-backend-v2's own comment says "raw i128", contradicting its resolver. The static evidence (String128 ÷1e7) is unambiguous, but please capture one real v2 response for a SEP-41 holder and add it as a recorded golden fixture before flipping the flag — it settles the contradiction and would have caught this.

Suggested fixes:

  1. If the wire value is confirmed pre-formatted: map SEP-41 like classic (pass the decimal string through, drop the re-scale).
  2. Root cause (preferred): make the contract unambiguous (backend emits a genuinely raw value, or client trusts the pre-formatted one) and add the recorded-response contract test on both platforms.

2. Flag ON against a failing v2 = no balances, no fallback — HIGH (shared with extension)

HIGH — not a merge blocker (default-OFF protects you today), but a rollout landmine.

TL;DR: Defaulting the flag OFF is the right call and keeps users safe on v1 until the backend is ready — good. But there's no fallback: the moment the flag is flipped ON while the endpoint is still returning 500 (per the PR body, it 500s on prod + staging today), every pubnet/testnet user gets an error state with no balances, recoverable only by flipping the flag back and waiting for the next config poll / restart.


Detailed explanation (for agents)

Root cause: fetchBalancesV2 rejection propagates straight to the duck catch (src/ducks/balances.ts L359), which sets error and leaves stale/empty balances; the working v1 path is never attempted. Default-OFF verified end-to-end: remoteConfig.ts L78/L103 + call-time read at balances.ts L318.

Suggested fix: fall back to v1 on v2 5xx/network error (response shapes are identical by design, so it's a small change), or stage the rollout on a small segment. Decide this symmetrically with the extension — a fallback on only one platform is itself a divergence. If fail-closed is deliberate, note it in the PR body.


3. User-added custom tokens can silently disappear on the v2 path — HIGH (shared with extension)

HIGH — not a merge blocker; needs an explicit decision before rollout.

TL;DR: The comment says indexed contract tokens "come back automatically", but v2 only returns SEP-41 balances the indexer has already ingested for that holder. A token the user explicitly added (v1 queried it directly) vanishes from the list, with no error, if the indexer hasn't ingested that contract/holder pair yet.


Detailed explanation (for agents)

Root cause: the v2 path sends no contract_ids (backend.ts L256-257); custom tokens persisted in CUSTOM_TOKEN_LIST / resolved by retrieveCustomTokens are no longer force-queried. wallet-backend only returns a SEP-41 balance for a contract it has a row for.

Suggested fix: verify indexer coverage for the add-token flow before rollout, or diff customTokensContractsIds against the v2 result and log/alert on gaps (cheap observability for exactly this regression).


4. Client-side bulk scan runs uncached on every 30s poll and blocks balance render — MEDIUM

MEDIUM — mobile-specific; the right caching chokepoint already exists in the repo.

TL;DR: addBlockaidScanResults calls scanBulkTokens raw, before balances are set — so on mainnet with the flag on, every 30s poll adds a second sequential HTTP round-trip (delaying balance display) and fires a Blockaid analytics event every 30s per active user. The repo already has a disk-backed, TTL'd cache (ensureTokenScans) for the same API that this bypasses.


Detailed explanation (for agents)

Root cause: addBlockaidScanResults.ts L58 scanBulkTokens(...) is awaited inside fetchBalancesV2 before the duck's set({ balances }). ducks/blockaidTokenScans.ts ensureTokenScans is a partial-hit-aware TTL cache over the same endpoint.

Suggested fixes:

  1. Route the scan through ensureTokenScans (kills the event spam and most requests).
  2. And/or stamp benign defaults, set balances, then merge scan verdicts asynchronously (as the store already does for prices) so raw balances never wait on the scan.

5. Freshly-funded account can render as "unfunded" under indexer lag — MEDIUM

MEDIUM — not client-fixable; flag it as a known/accepted rollout risk.

TL;DR: v2 is_funded comes from the indexer, not Horizon. The friendbot flow funds then immediately refetches; on v2 the refetch may still show unfunded (zero balances) until ingestion catches up — same window after a first deposit on mainnet. State the indexer's lag SLO when enabling the flag.


6. The flag-ON duck wiring and the 500 path are untested — MEDIUM

MEDIUM — test gap on the one new load-bearing line.

TL;DR: Tests only ever assert useV2: false. Nothing sets the flag ON and asserts fetchBalances receives useV2: true — the exact line this PR exists to add. There's also no explicit v2-rejection (500) test through fetchBalances, notable given the endpoint currently 500s.


Detailed explanation (for agents)

The load-bearing line with no red-on-break coverage: balances.ts L318. Add: (a) a test with useRemoteConfigStore use_balances_v2: true asserting useV2: true reaches fetchBalances; (b) a v2-500 test asserting it rejects and locks in whatever #2's fallback decision is; (c) the unfunded path through fetchBalances (currently covered only in the mapper test).


7. SAC balances would be 1e7-inflated — MEDIUM (latent; shared with extension)

MEDIUM — latent, not currently reachable. Mirror image of #1.

TL;DR: SAC amounts arrive raw (DB passthrough, not ÷1e7), but the mapper treats them as pre-formatted decimals, so they'd display 1e7× too large. Unreachable today because SAC balances key on a C… holder and the v2 handler only accepts G… — but the mapper and tests pin the wrong behavior for when that changes.


Detailed explanation (for agents)

Root cause: wallet-backend/internal/data/sac_balances.go stores Balance as a raw i128 string and buildSACBalanceFromDB passes it through unformatted (unlike native/trustline). The client mapSac L182-196 treats it as pre-formatted classic. Resolve alongside #1 with a live fixture, or captureException on unexpected SAC arrival.


LOW / NIT — additional findings
  • Stale-response race widens under v2 — no abort/sequence guard in fetchAccountBalances; v2's second sequential request (the bulk scan, Add Typography and style shorthands #4) lengthens the last-write-wins window on account/network switch. scanBulkTokens accepts an AbortSignal this helper never passes. Follow-up ticket rather than a blocker.
  • Comment inaccuracyaddBlockaidScanResults.ts L35 says "v1 leaves them [LP entries] unstamped too" — false; v1's addScannedStatus stamps a benign blockaidData on every entry including LP. Skipping LP in the scan request is fine (an improvement); just fix the comment so the parity claim is true.
  • Minimal benign stamp — the helper stamps only { result_type: "Benign" } vs v1's full default object (malicious_score, attack_types, chain, …). Only result_type is consumed today, so fine — one sentence in the doc noting the deliberate slimming would prevent a future surprise.
  • Native available semantics change — v2 available = balance − minimum_balance − selling_liabilities vs v1's total − sellingLiabilities. Mobile recomputes native spendable from total in calculateSpendableAmount, so no consumer breaks (this is exactly why the extension has a max-send bug here and mobile doesn't) — but it is an observable BalanceMap value difference between paths; worth one line in the mapper doc.
  • mapClassic edge — with backend-optional code/issuer omitted, produces key ":" with credit_alphanum4; a if (!b.code) return null skip would be cheaper than a malformed entry if it ever occurs.
  • DRY — the { data: T } v2 envelope is inlined a 3rd time (fetchTokenPrices/fetchCollectibles/fetchBalancesV2) — a shared V2Envelope<T> would be the DRY move (optional).

Verified solid (coverage)
  • Secrets: v2 body carries only public keys ({ addresses: [publicKey] }); apiFactory redacts Authorization at any depth; error logging is status/body only; no key/mnemonic/seed material in requests, logs, or analytics.
  • Endpoint path: freighterBackendV2 base URL already carries /api/v1, so /accounts/balances composes to the same absolute route the extension uses — MATCH.
  • Flag default + read path: OFF in both dev and prod initial states, store not persisted, variant-gated updates, call-time getState() read; all 5 balance entry points route through the single fetchAccountBalances chokepoint. Unloaded/failed remote config ⇒ v1.
  • Missing-account handling is the CORRECT contractbackend.ts L287 rejects a 200 that omits the requested account as malformed, rather than rendering it as unfunded. (The extension does the opposite and caches it — flagged on that PR.)
  • v1 path untouched (v1 branch byte-identical to main; useV2 required param); Futurenet pinned to v1; routing matches the backend's PUBLIC/TESTNET-only validation.
  • Key formats (XLM, CODE:ISSUER, SYMBOL:CONTRACT, <poolId>:lp) match v1 conventions; dropped fields have no BalanceMap consumers.
  • Tests exercise real mapper/blockaid code (only HTTP client + scanBulkTokens mocked); no node-crypto misuse in tests (RN uses react-native-crypto); no new sync crypto on the JS thread.

🤖 Generated with Claude Code (Fable) — cross-platform pre-merge review, verified against wallet-backend + freighter-backend-v2 source.

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. SEP-41 token balances are double-scaled by 1e7 — CRITICAL (shared with extension)
    CRITICAL — merge blocker for enabling the flag. Every SEP-41 token balance would render 10,000,000× too small, and spendable/send math is wrong the same way.

This is incorrect, wallet backend already scales the value during ingestion.

  1. Live ingestion formats: internal/indexer/processors/sac_balances.go:144 — amount.String128(entries[0].Val.MustI128()) extracts the i128 from the SAC contract-data entry and formats it.
  2. String128 divides by 1e7: SDK go-stellar-sdk/amount/main.go:134-143 — rat.Quo(rat, bigOne) with bigOne = 10000000 (line 25/29), returns FloatString(7). Doc comment: "converts a signed 128-bit integer into a
  3. string, boldly assuming 7-decimal precision."
  4. Checkpoint/backfill path formats identically: internal/services/checkpoint.go:751 — same amount.String128.
  5. DB stores the formatted decimal (numeric column); resolver buildSACBalanceFromDB (account_balances_utils.go:60-71) passes it through — passthrough of a formatted value.
  6. Not a recent change: String128 has been there since PR 470 ("Remove redis dependency and store balances in postgres"), i.e. since the postgres-backed balances existed.
  7. SEP-41 raw is deliberate, and explicitly contrasted: internal/services/sep41/events.go:242-243 — "Unlike amount.String128, this preserves the raw integer (no 10^7 divisor), which is what SEP-41 events
  8. carry."

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. Flag ON against a failing v2 = no balances, no fallback — HIGH (shared with extension)

Same concern as on the extension - so is the suggestion that in the case of v2 failures that we try v1? I don't think we intend to rely on v1 for much longer and will aim to deprecate it so my gut reaction is that we should treat v2 as a critical dependency and ensure reliability there.

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. User-added custom tokens can silently disappear on the v2 path — HIGH (shared with extension)

This is incorrect, by design v2 relies on the wallet backend to index contract tokens.

  addBlockaidScanResults called scanBulkTokens raw on every 30s balance
  poll, adding a sequential HTTP round-trip before balances render and
  firing a BLOCKAID_BULK_TOKEN_SCAN analytics event per poll on mainnet.

  Use the blockaidTokenScans duck's scanBulkWithCache instead (disk-backed,
  30-min TTL, per-token) — the same chokepoint the swap flow uses, with a
  compatible CODE-ISSUER key format so both surfaces share cache entries.
  Warm polls become a local read with no network call and no analytics
  event; real scans drop to one per token per 30 minutes.

  Tests now mock the raw scan API + storage underneath the real duck so
  the cache path is exercised, with a new case pinning that fresh cache
  entries produce zero scanBulkTokens calls.
@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. Client-side bulk scan runs uncached on every 30s poll and blocks balance render — MEDIUM
    MEDIUM — mobile-specific; the right caching chokepoint already exists in the repo.

TL;DR: addBlockaidScanResults calls scanBulkTokens raw, before balances are set — so on mainnet with the flag on, every 30s poll adds a second sequential HTTP round-trip (delaying balance display) and fires a Blockaid analytics event every 30s per active user. The repo already has a disk-backed, TTL'd cache (ensureTokenScans) for the same API that this bypasses.

This is fixed by 27f88e7

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. Freshly-funded account can render as "unfunded" under indexer lag — MEDIUM
    MEDIUM — not client-fixable; flag it as a known/accepted rollout risk.

TL;DR: v2 is_funded comes from the indexer, not Horizon. The friendbot flow funds then immediately refetches; on v2 the refetch may still show unfunded (zero balances) until ingestion catches up — same window after a first deposit on mainnet. State the indexer's lag SLO when enabling the flag.

Im not sure what we can do about this, the clients rely on the indexer as the source of truth here and the SLO is that it is caught up to the tip at all times.

  Review feedback flagged that the duck tests only ever asserted
  useV2: false — a hardcoded value at the fetchBalances call site would
  have passed the suite. Add a test that flips use_balances_v2 on after
  render and asserts the duck passes useV2: true, pinning the
  read-at-call-time behavior. Reset the flag in beforeEach so it can't
  leak between tests.

  Also pin that a v2 server error propagates without silently falling
  back to v1, which would mask indexer outages behind v1 data.
@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. The flag-ON duck wiring and the 500 path are untested — MEDIUM
    MEDIUM — test gap on the one new load-bearing line.

TL;DR: Tests only ever assert useV2: false. Nothing sets the flag ON and asserts fetchBalances receives useV2: true — the exact line this PR exists to add. There's also no explicit v2-rejection (500) test through fetchBalances, notable given the endpoint currently 500s.

this is fixed by 5846c4a

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. SAC balances would be 1e7-inflated — MEDIUM (latent; shared with extension)
    MEDIUM — latent, not currently reachable. Mirror image of Default React Native project #1.

TL;DR: SAC amounts arrive raw (DB passthrough, not ÷1e7), but the mapper treats them as pre-formatted decimals, so they'd display 1e7× too large. Unreachable today because SAC balances key on a C… holder and the v2 handler only accepts G… — but the mapper and tests pin the wrong behavior for when that changes.

This is incorrect for the same reason as issue 1 is

  freighter-backend-v2#138 renamed `balance` to `total` and added
  server-derived `key` and `token` fields to every balance entry, so the
  mapper no longer derives asset identity client-side.

  - mapAccountBalancesV2.ts: rename `balance` → `total` in the wire types;
    add `V2Token`/`V2TokenIssuer` and `key`/`token` to the base, narrowed
    per variant (trustline type verbatim on CLASSIC, no `type` on SEP-41,
    no token on LP entries)
  - mapper: pass server `key`/`token` through verbatim and drop
    `classicAssetType`; the native entry alone is still re-keyed from the
    server's "native" to "XLM", the app convention
  - tests: fixtures updated to the new wire shape; the code-length type
    derivation test is now a key/token pass-through assertion; backend
    routing fixture carries `key`/`token`/`total`
@piyalbasu

piyalbasu commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Native XLM max-send is overstated for accounts with open sell offers

MEDIUM — not a merge blocker. Pre-existing logic (not introduced by this PR), but worth fixing here since the sibling extension PR (freighter#2906) already handles this correctly, and this PR is touching the balance-mapping plumbing.

TL;DR: When computing spendable XLM, the app subtracts the base reserve and the fee but not the XLM locked by the account's open sell offers (selling liabilities). So for an account with active sell offers, "send max" offers more XLM than is actually spendable, and the transaction fails at submit.

Steps to reproduce:

  1. Use an account with an open offer selling XLM (non-zero selling liabilities).
  2. Open Send XLM and tap "max" (or send an amount near the max).
  3. Submitting fails with tx_insufficient_balance / op_underfunded — the offered amount exceeds truly spendable XLM.

Detailed explanation (for agents)

Root cause: the native branch of calculateSpendableAmount recomputes the reserve locally and never subtracts sellingLiabilities (nor does it read the mapped minimumBalance):

And the v2 mapper passes the bare base reserve through, without folding liabilities in:

How the extension does it (the pattern to mirror): it keys off minimumBalance, not available, and normalizes minimumBalance at the v2 mapper to fold in selling liabilities, then the consumer subtracts that field:

  • v2 mapper folds: minimumBalance = minimum_balance + selling_liabilities (@shared/api/helpers/mapAccountBalancesV2.ts mapNative).
  • consumer: available = total − minimumBalance − fee (extension/src/popup/helpers/soroban.ts getAvailableBalance).

Why minimumBalance and not the server available: available is defined inconsistently across paths — v2 native available = total − minimum_balance − selling_liabilities (reserve subtracted), but the v1 backend's native available = total − selling_liabilities (reserve not subtracted; freighter-backend horizon-rpc.ts:49 even notes it "should also subtract the minimumBalance"). Since calculateSpendableAmount is shared by both the v1 and v2 balance paths and v1 is not being changed, switching the native branch to available − fee would regress v1 (users could spend into their base reserve). By contrast, both paths already agree that native minimumBalance = reserve + sellingLiabilities** (v1 folds it too — freighter-backend transformers.ts:138/horizon-rpc.ts:141 .plus(sellingLiabilities)), so keying off minimumBalance` is correct on both without touching v1.

Suggested fix (mirror the extension, v1-safe):

  1. Fold selling liabilities into the mapped native minimumBalance (parity with the extension mapper):
    minimumBalance: new BigNumber(b.minimum_balance).plus(b.selling_liabilities),
  2. In calculateSpendableAmount's native branch, use the mapped field instead of recomputing the reserve:
    const spendableAmount = totalBalance.minus(balance.minimumBalance).minus(fee);
    This works on both v1 and v2 (both supply minimumBalance = reserve + sellingLiabilities) and, as a bonus over the local recompute, picks up sponsorship netting that the server already reflects in the reserve. Add a test with selling_liabilities > 0 asserting the reduced max-send.

aristidesstaffieri and others added 3 commits August 4, 2026 11:18
    The v1 balances endpoint took the user's locally saved custom-token contract
    IDs as `contract_ids` hints and returned a contract-token balance only for an
    ID it was handed. The v2 endpoint takes account addresses alone and lets the
    wallet-backend indexer decide what comes back, and the migration chose to
    trust it. That is wrong for zero-balance tokens: the indexer only knows about
    tokens an account holds a balance for, so a SEP-41 token added through Add
    Token with no balance never comes back and silently disappears. It also broke
    the "already added" state, which hasExistingTrustline infers from balances
    alone — so the token showed as not added and could be added repeatedly.

    Merge the local list back in client-side. injectLocalTokenBalances runs in
    fetchBalancesV2 between the mapper and the Blockaid scan and adds an entry for
    every local contract ID the response omits, resolving metadata and the real
    balance through getTokenDetails. The duck already reads the local list for the
    v1 path, so the contract IDs are simply threaded through rather than re-read
    from storage. Merging before the scan means locally added tokens get Blockaid
    verdicts, as they did on v1. Skipped for unfunded accounts, where the
    not-funded UI renders instead of balances.

    Dedupe takes two checks. Matching on token_id covers SAC and SEP41 entries,
    whose token_id is the contract, but a CLASSIC balance's token_id is the
    CODE:ISSUER asset string — so a locally added SAC already on screen as a
    trustline is caught by resolving the SAC's name to CODE:ISSUER and skipping if
    that key exists. This needed a stricter SAC check than the existing
    isSacContract, which only verifies that a name parses as CODE:ISSUER and so
    would dedupe away any SEP-41 token that happens to be named that way. The new
    isSacContractForAsset derives the SAC address and compares it to the contract
    in hand; the loose helper and its history-mapper callers are untouched.

    Removal is now narrower than before by design: the result carries
    localOnlyTokenIds, and SimpleBalancesList offers removal only for those.
    Once the backend returns a token on its own, dropping the local entry would
    not stop it coming back, so it is hide-only — a new CannotRemoveType variant
    explains that. Classic trustline removal is unchanged, since the gate only
    fires for CUSTOM_TOKEN, and the v1 path reports its full local list, so
    behavior with the flag off is untouched.

    Also wires shouldFetchBalance through getTokenDetails. It was already declared
    on GetTokenDetailsParams but never forwarded as should_fetch_balance, so every
    merged token would have rendered as zero. Left off by default: the balance
    costs the backend an extra contract call that other callers do not need.
@piyalbasu

Copy link
Copy Markdown
Contributor

Blockaid verdicts are dropped for tokens with a hyphen in the symbol (fails open to "Benign")

Medium — reachable on demand by anyone who deploys a SEP-41 token, and it fails open rather than closed. Your call on merge-blocking; the fix is a few lines.

TL;DR: We ask Blockaid about tokens using one naming format and match the answers back using another, and the conversion back is ambiguous when a token's symbol contains a hyphen. For those tokens the scan result is silently thrown away — and because every token is pre-stamped "Benign" before scanning, a token Blockaid flagged as malicious will display as safe. A scammer can trigger this deliberately by putting a hyphen in their token's symbol.

Steps to reproduce:

  1. Hold a SEP-41 token whose symbol contains a hyphen (e.g. MY-TOKEN) on mainnet.
  2. Have Blockaid classify it as malicious or spam.
  3. Load the balances list. The token renders with the benign default — no spam/scam badge — and nothing is logged.

The same token with a hyphen-free symbol badges correctly.


Detailed explanation (for agents)

Root cause: String.prototype.replace with a string (not regex) first argument replaces only the first occurrence. The outbound conversion is safe because a balance-map key contains exactly one :, so the first colon is always the separator. The inbound conversion is not: the hyphen is no longer unique once the symbol contains one.

Outbound — correct:

const { results } = await useBlockaidTokenScansStore
.getState()
.scanBulkWithCache({
addressList: scannableIds.map((id) => id.replace(":", "-")),
network,
});

Inbound — eats the wrong hyphen, so the lookup misses and the verdict is discarded by the if guard:

Object.entries(results || {}).forEach(([assetId, scanResult]) => {
const balanceKey = assetId.replace("-", ":");
if (balances[balanceKey]) {
(balances[balanceKey] as ScannableBalance).blockaidData = scanResult;
}
});

Traced concretely:

balance key    MY-TOKEN:CDWDNK6ISCQ…
→ Blockaid id  MY-TOKEN-CDWDNK6ISCQ…   correct
→ back to key  MY:TOKEN-CDWDNK6ISCQ…   wrong — first hyphen consumed
balances["MY:TOKEN-CDWDNK6ISCQ…"] === undefined  → scanResult dropped

Why it fails open rather than closed: every entry is stamped with the benign default before the scan runs, so a dropped verdict is not a missing badge — it is an affirmative "Benign" claim.

keys.forEach((key) => {
// LP-share entries have no token identity to scan and no blockaidData
// field on their type — skip them (v1 leaves them unstamped too).
if (key.endsWith(":lp")) {
return;
}
(balances[key] as ScannableBalance).blockaidData = {
result_type: "Benign",
} as Blockaid.Token.TokenScanResponse;
});

Why it's attacker-reachable: classic asset codes are alphanumeric, so they can't hit this. But the Soroban key is <symbol>:<contractId> with the symbol passed through verbatim from the contract's own symbol(), which is unconstrained:

// A pure SEP-41 token maps to the Soroban shape: `total` is a raw i128 that
// display logic scales by `decimals`. `token.issuer.key` is the contract id,
// matching the v1 custom-token convention.
const mapSep41 = (b: V2Sep41Balance): MappedEntry => ({
key: b.key,
value: {
token: b.token,
contractId: b.token_id,
total: new BigNumber(b.total),
available: new BigNumber(b.available),
symbol: b.symbol || "",

So suppressing your own spam/scam badge costs one character in a token name.

Deterministic repro: call addBlockaidScanResults directly with a balance map keyed "MY-TOKEN:C…" and a stubbed scanBulkWithCache returning { "MY-TOKEN-C…": { result_type: "Malicious" } }. The returned balance still carries result_type: "Benign".

Suggested fixes (in increasing order of depth):

  1. Correct the inbound split — separate on the last hyphen, which is unambiguous since contract ids and issuer keys never contain one:
    const idx = assetId.lastIndexOf("-");
    const balanceKey = `${assetId.slice(0, idx)}:${assetId.slice(idx + 1)}`;
  2. Stop round-tripping altogether — build a Map<blockaidId, balanceKey> when constructing addressList, then look the verdict up by that map. The reverse conversion disappears, so no parsing rule can drift.
  3. Make a miss visible — add an else branch logging any returned assetId that matched no balance. Right now a dropped verdict is indistinguishable from a clean scan, which is why this can regress unnoticed.

Note on provenance: the v1 path has the same first-occurrence pattern, so this is inherited rather than newly introduced. It's worth fixing here anyway — v2 is new code, and the pre-stamped benign default makes the failure mode a false assurance rather than an absent one.

Cross-platform: the extension has the identical defect on its own v2 path, at @shared/api/helpers/addBlockaidScanResults.ts — filed at stellar/freighter#2906. Worth fixing both together so the two clients don't diverge.

@piyalbasu

Copy link
Copy Markdown
Contributor

publicKey interpolated into a thrown Error ends up as the Sentry issue title

Nit — not a merge blocker, but it breaks a promise the codebase makes deliberately elsewhere, and the fix is one line.

TL;DR: The new v2 balances error message embeds the account's public key. That message becomes the Sentry issue title verbatim, bypassing the redaction layer that exists specifically to keep public keys out of Sentry for users who opted out of analytics. As a side effect it also gives every affected account its own Sentry issue, so the error can't be grouped or alerted on.

Steps to reproduce:

  1. Make the v2 balances endpoint return a payload whose address doesn't match the requested account (a network mismatch or a partial response does it).
  2. Load balances.
  3. The resulting Sentry issue is titled with the account's public key in plain text — regardless of the user's analytics opt-in state.

Detailed explanation (for agents)

Root cause: the key is interpolated into the message rather than passed as a structured arg.

);
if (!account) {
throw new Error(
`v2 balances response is missing the requested account ${publicKey}`,
);
}

Path to Sentry, verified end to end. This throws a plain Error, not the axios interceptor's ApiError, so the apiError narrowing at L380-L385 yields null and the raw error is forwarded:

const message =
apiError?.message ??
(error instanceof Error ? error.message : "Failed to fetch balances");
logApiError(
"balances.fetchAccountBalances",
"Network unreachable while fetching account balances",
"Failed to fetch account balances",
error,
{

isApiNetworkError is false for a plain Error, so this takes the logger.error branch:

https://github.com/stellar/freighter-mobile/blob/f4520c485bf299bc55f1ca2523eff8cc62eebeb2/src/services/apiFactory.ts#L526-L539

…and the error object is handed to Sentry.captureException directly. Per the comment on L510-L513, the issue title comes from the Error's own message:

const normalizedError = normalizeError(error);
// Include the caller-supplied `message` in the event extras so the
// intent isn't lost - Sentry's issue title still comes from the
// Error's own message (preserves grouping), but the message arg is
// inspectable in the event payload alongside any extra args.
const extra: Record<string, unknown> = { message };
if (args.length > 0) {
extra.args = sanitizeLogData(args);
}
Sentry.captureException(normalizedError, {
tags: { context },
extra,
});

Why the redactor doesn't catch it. sanitizeLogData only runs over extra.args, and its Error branch returns message unmodified by design — the redaction walk is key-based and never inspects message text:

// Error instances need explicit handling - `name`, `message`, and
// `stack` are non-enumerable per spec, so a generic `Object.entries`
// walk would drop them and produce `{}`. That silently strips the
// diagnostic detail every time a caller passes an error as a warn arg.
if (data instanceof Error) {
return {
name: data.name,
message: data.message,
stack: data.stack,
};
}

PII_FIELDS_LOWER does work correctly for structured payloads (it's normalized with .map((f) => f.toLowerCase()) at the array close, so the camelCase entries do match). Interpolation is the one path around it.

Why this is a real rule, not a style preference. publicKey is in the PII list for a stated reason:

//
// publicKey is included because, while Stellar public keys are not
// strictly secret, this codebase deliberately gates them on the
// analytics opt-in (see buildSentryContext in sentryConfig.ts).
// Redacting them in logger payloads keeps that opt-out promise on
// breadcrumbs and event extras as well as the appContext block.

And there's already a comment in the codebase naming this exact failure mode and the exact fix:

} catch (error) {
// Let's not block the user from logging out if this fails.
// publicKey goes through the args extras so sanitizeLogData can
// redact it for opt-out users (interpolating it into the message
// would bypass the redactor and ship it to Sentry verbatim).
logger.error(
"disconnectAllSessions",
"Failed to disconnect all sessions",
error,

Secondary consequence — Sentry grouping. Because the title carries a unique key, every affected account produces a distinct Sentry issue instead of one aggregated issue with N events. That makes this error effectively un-alertable and un-triageable, which matters most during a v2 rollout, when it's the signal you'd want to watch.

Suggested fixes (in increasing order of depth):

  1. One line: drop the key from the message and pass it as a structured extra, matching walletKitUtil:
    throw new Error("v2 balances response is missing the requested account");
    and add { publicKey } to the logApiError args in the duck, where the redactor can reach it. This also restores grouping. Note the assertion in __tests__/services/backend.test.ts asserts on the interpolated string and will need updating with it.
  2. Belt and braces: since message at balances.ts:387-389 also flows into the store's user-facing error state, the same interpolation reaches the UI. Worth confirming that string isn't rendered anywhere a screenshot would capture it.

  Spendable XLM was computed as total - reserve - fee, ignoring selling
  liabilities. Accounts with an open XLM sell offer were offered a "max"
  larger than they could actually send, and the transaction failed at
  submit with tx_insufficient_balance / op_underfunded.

  Two changes, both required:

  - mapAccountBalancesV2: the v2 server reports `minimum_balance` as the
    pure base reserve and keeps selling liabilities as a separate
    subtrahend. Fold them together so the mapped `minimumBalance` matches
    the v1 contract, which already folds both.

  - calculateSpendableAmount: use the server-derived `minimumBalance`
    instead of recomputing (2 + subentryCount) * BASE_RESERVE locally.
    Both balance paths now agree on its meaning, so this is correct on v1
    and v2 without touching v1. The local formula stays as a fallback when
    the field is absent, keeping a reserve-safe floor.

  Keying off `minimumBalance` rather than `available` is deliberate:
  `available` is defined inconsistently across paths (v1 native does not
  subtract the reserve), and this helper is shared by both.

  Side effect worth noting: the server value also reflects sponsorship
  netting (num_sponsoring / num_sponsored) that the local formula ignored,
  so sponsoring accounts will see a lower max and sponsored accounts a
  higher one.

  Two isAmountSpendable fixtures declared minimumBalance: 1 while passing
  subentryCount: 3 (a 2.5 reserve). The field was inert before this change
  so nothing caught the contradiction; made coherent, assertions unchanged.
  Outbound, a balance-map key `<symbol>:<contractId>` was converted to the
  Blockaid id `<symbol>-<contractId>`. Inbound, the id was converted back
  with `replace("-", ":")`, which replaces only the first occurrence. Once
  the symbol itself contains a hyphen the wrong one is consumed, the
  lookup misses, and the verdict is discarded.

  This fails open, not closed: every entry is pre-stamped with a benign
  default before the scan runs, so a dropped verdict renders as an
  affirmative "Benign" rather than an absent badge. A SEP-41 `symbol()`
  returns an unconstrained String and the v2 backend builds the key as
  `symbol + ":" + tokenID`, so suppressing your own spam/scam badge costs
  one character in a token name. Classic asset codes are alphanumeric and
  cannot reach it.

  Rather than correcting the split, drop the round-trip: record which
  balance key produced each Blockaid id when building the address list and
  look the verdict up by that map. `scanBulkWithCache` keys its results by
  exactly the strings it is given, on both the cache and network paths, so
  the mapping is correct by construction and there is no parsing rule left
  to drift.

  Also log any returned asset id that matches no balance. A discarded
  verdict was previously indistinguishable from a clean scan, which is why
  this could regress unnoticed.
  The v2 balances contract-violation error interpolated the account's
  public key into the thrown message. That message becomes the Sentry
  issue title verbatim, which bypasses sanitizeLogData: the redaction walk
  is key-based and its Error branch returns `message` untouched. The key
  shipped to Sentry regardless of the user's analytics opt-in, breaking
  the promise PII_FIELDS_LOWER is there to keep.

  It also broke grouping — a unique title per account meant one Sentry
  issue per affected user instead of one issue with N events, leaving the
  error un-alertable during exactly the rollout it was meant to signal.

  Drop the key from the message and pass it through the logApiError extras
  instead, where the redactor can reach it. Mirrors the existing pattern
  in walletKitUtil's disconnectAllSessions, which documents this same
  failure mode.

  The message also flows into the balances store's `error` state, but that
  value is only read as a truthy flag — both render sites print the static
  `balancesList.error` translation — so there is no UI exposure to fix.
@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

Native XLM max-send is overstated for accounts with open sell offers

MEDIUM — not a merge blocker. Pre-existing logic (not introduced by this PR), but worth fixing here since the sibling extension PR (freighter#2906) already handles this correctly, and this PR is touching the balance-mapping plumbing.

TL;DR: When computing spendable XLM, the app subtracts the base reserve and the fee but not the XLM locked by the account's open sell offers (selling liabilities). So for an account with active sell offers, "send max" offers more XLM than is actually spendable, and the transaction fails at submit.

Steps to reproduce:

  1. Use an account with an open offer selling XLM (non-zero selling liabilities).
  2. Open Send XLM and tap "max" (or send an amount near the max).
  3. Submitting fails with tx_insufficient_balance / op_underfunded — the offered amount exceeds truly spendable XLM.

Detailed explanation (for agents)

this is fixed in d654485

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

Blockaid verdicts are dropped for tokens with a hyphen in the symbol (fails open to "Benign")

Medium — reachable on demand by anyone who deploys a SEP-41 token, and it fails open rather than closed. Your call on merge-blocking; the fix is a few lines.

TL;DR: We ask Blockaid about tokens using one naming format and match the answers back using another, and the conversion back is ambiguous when a token's symbol contains a hyphen. For those tokens the scan result is silently thrown away — and because every token is pre-stamped "Benign" before scanning, a token Blockaid flagged as malicious will display as safe. A scammer can trigger this deliberately by putting a hyphen in their token's symbol.

Steps to reproduce:

  1. Hold a SEP-41 token whose symbol contains a hyphen (e.g. MY-TOKEN) on mainnet.
  2. Have Blockaid classify it as malicious or spam.
  3. Load the balances list. The token renders with the benign default — no spam/scam badge — and nothing is logged.

The same token with a hyphen-free symbol badges correctly.

Detailed explanation (for agents)

the mapping has changed in e9d51e6 to not assume any format

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author

publicKey interpolated into a thrown Error ends up as the Sentry issue title

Nit — not a merge blocker, but it breaks a promise the codebase makes deliberately elsewhere, and the fix is one line.

TL;DR: The new v2 balances error message embeds the account's public key. That message becomes the Sentry issue title verbatim, bypassing the redaction layer that exists specifically to keep public keys out of Sentry for users who opted out of analytics. As a side effect it also gives every affected account its own Sentry issue, so the error can't be grouped or alerted on.

Steps to reproduce:

  1. Make the v2 balances endpoint return a payload whose address doesn't match the requested account (a network mismatch or a partial response does it).
  2. Load balances.
  3. The resulting Sentry issue is titled with the account's public key in plain text — regardless of the user's analytics opt-in state.

Detailed explanation (for agents)

thanks, this is fixed in d0c3654

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.

3 participants