feat(src): balances v2 migration - #2906
Conversation
Fetch account balances from freighter-backend-v2
(POST /accounts/balances) behind a new Amplitude boolean flag
use_balances_v2 (default ON — flip off to roll back to v1 without a
release, mirroring use_token_prices_v2).
- Add snake_case v2 wire types mirroring freighter-backend-v2's
internal/types/account_balances.go (verified against the live dev
deployment), including the LIQUIDITY_POOL variant and the
server-computed `available` on every balance
- Add mapAccountBalancesV2 to normalize the v2 response to the legacy
AccountBalancesInterface shape, so downstream consumers, the cache
duck, and balance helpers need zero changes (keys: native,
CODE:ISSUER, SYMBOL:CONTRACT_ID, <poolId>:lp)
- Add addBlockaidScanResults to replicate the v1 backend's Blockaid
scan-and-merge client-side: benign default on every entry, then a
scan-asset-bulk pass on PUBLIC (honoring shouldSkipScan) overwrites
scannable entries — v2 returns the same payload as v1
- Route in getAccountBalances: custom networks → standalone (unchanged),
flag on + PUBLIC/TESTNET → v2, Futurenet or flag off → v1
- Read the flag at fetch time in useGetBalances via balancesV2Selector
Replace `MappedEntry.value: any` in mapAccountBalancesV2 with per-variant
output types derived from the runtime `AssetType` shapes in
account-balance.ts — the same shapes the type guards in
popup/helpers/balance.ts discriminate on. Each mapX function is now
annotated with its specific variant, so a missing or typo'd field fails
compilation.
Deltas from the declared types are explicit rather than hidden behind
`any`:
- `blockaidData` is `undefined` at mapping time (stamped afterward by
addBlockaidScanResults)
- `limit` is optional on classic (v1 wire parity, never read) and
omitted for SAC/LP where no trustline exists
The one remaining cast (`as unknown as BalanceMap`) is confined to the
return boundary with a comment: the legacy BalanceMap declarations
over-promise relative to every runtime path, v1 included.
…at/balances-v2-migration
|
PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-45fd914b290ce071f776 (SDF collaborators only — install instructions in the release description) |
…ub v2 in e2e Merging master brought #2879, which routes all freighter-backend-v2 calls through the background JWT chokepoint (fetchBackendV2/callBackendV2) and removed the direct INDEXER_V2_URL usage getAccountBalancesV2 relied on. - route getAccountBalancesV2 through fetchBackendV2 so the request carries the per-request JWT; error handling mirrors getTokenPrices v2 - rework getAccountBalancesV2 tests to mock the background messaging layer (FETCH_BACKEND_V2) instead of global fetch - e2e: add stubAccountBalancesV2 (context-routed — v2 balances are fetched from the background service worker) and register it alongside every v1 account-balances stub, converting each fixture to the v2 wire shape - e2e: make balance response waiters/counters endpoint-agnostic and listen on the context so they observe service-worker requests - e2e: stub /rpc-health (background chokepoint call) — unstubbed it surfaces a "Soroban is temporarily experiencing issues" toast that intercepts clicks - build: unwrap the ESM default export of i18next-scanner-webpack (Node 22 require(esm) returns the module namespace)
…aware
- the destination balance lookup check relied on should_skip_scan=true in
the v1 URL; on v2 the address travels in the POST body and shouldSkipScan
has no wire marker, so detect the v2 destination lookup instead and listen
on the context (v2 requests come from the background service worker)
- declare the E2E token balance as a raw amount with explicit decimals
(500000 / 3 = 500.000 E2E), matching the contract-token wire shape; the
formatted string with no decimals rendered as 0.00005 on the v2 path and
left Review Send disabled
There was a problem hiding this comment.
Pull request overview
Migrates account-balance fetching to the authenticated backend-v2 API while preserving legacy balance consumers.
Changes:
- Adds v2 wire types, mapping, Blockaid enrichment, and endpoint routing.
- Introduces an Amplitude rollback flag.
- Updates unit and E2E coverage for both balance endpoints.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
extension/src/popup/ducks/remoteConfig.ts |
Adds the balances-v2 flag. |
extension/src/popup/ducks/__tests__/remoteConfig.test.ts |
Tests flag defaults and variants. |
extension/src/popup/components/__tests__/maintenanceMode.test.tsx |
Updates test state. |
extension/src/helpers/hooks/useGetBalances.tsx |
Passes the live flag value. |
extension/src/helpers/__tests__/useGetBalances.test.tsx |
Tests flag routing. |
extension/src/background/helpers/callBackendV2.ts |
Formatting-only change. |
extension/src/background/helpers/__tests__/callBackendV2.test.ts |
Formatting-only changes. |
extension/e2e-tests/swap.test.ts |
Adds v2 balance stubs. |
extension/e2e-tests/sendPayment.test.ts |
Adds v2 fixtures and stubs. |
extension/e2e-tests/loadAccount.test.ts |
Makes balance tests endpoint-agnostic. |
extension/e2e-tests/integration-tests/sendIntegration.test.ts |
Observes and stubs v2 requests. |
extension/e2e-tests/helpers/stubs.ts |
Adds v2 balance and health stubs. |
extension/e2e-tests/helpers/login.ts |
Waits for either balance endpoint. |
extension/e2e-tests/changeTrustDetails.test.ts |
Adds v2 balance stubbing. |
extension/e2e-tests/buyWithOnramp.test.ts |
Shares unfunded fixtures across endpoints. |
extension/e2e-tests/blockaidScan.unable.test.ts |
Adds v2 balance stubbing. |
extension/e2e-tests/blockaidScan.suspicious.test.ts |
Adds v2 balance stubbing. |
extension/e2e-tests/blockaidScan.safe.test.ts |
Adds v2 balance stubbing. |
extension/e2e-tests/blockaidScan.malicious.test.ts |
Adds v2 balance stubbing. |
extension/e2e-tests/blockaidScan.errors.test.ts |
Adds v2 balance stubbing. |
@shared/api/types/backend-api.ts |
Defines backend-v2 wire types. |
@shared/api/internal.ts |
Implements v2 fetching and routing. |
@shared/api/helpers/mapAccountBalancesV2.ts |
Adapts v2 balances to legacy shapes. |
@shared/api/helpers/addBlockaidScanResults.ts |
Adds client-side Blockaid enrichment. |
@shared/api/helpers/__tests__/mapAccountBalancesV2.test.ts |
Tests balance mapping. |
@shared/api/helpers/__tests__/addBlockaidScanResults.test.ts |
Tests Blockaid enrichment. |
@shared/api/__tests__/getAccountBalancesV2.test.ts |
Tests fetching and routing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| value: MappedBalance; | ||
| } | ||
|
|
||
| const mapNative = ( |
There was a problem hiding this comment.
I see we're doing some similar mapping in mobile, as well. I think we should do this at the Freighter BE v2 level so we don't have to repeat this mapping in both repos (and keep them in sync)
There was a problem hiding this comment.
the mapping serves mostly to map the response to wire incompatible types like BigNumber and to map the fields to the casing expected in the client here. If we just changed the casing on the backend then we would either have one route with inconsistent casing to the others or we would need to then port all other consumers to the new casing across all routes, and we would still need mappers to use type that are not valid JSON like BigNumber.
There was a problem hiding this comment.
Yeah, BigNumber is probably off the table. But is it possible to format the json response with things like key and token already mapped to the correct value for the asset?
You would still need to convert some fields to BigNumber and convert all keys to camelCase, but that's maybe a bit simpler mapping.
There was a problem hiding this comment.
yeah I see no reason why we couldn't change the token field, is it just that field that you are looking to update in the API?
There was a problem hiding this comment.
Yeah, I think it's just key, token, and total - and then you would be able to apply a generic mapping helper that converts snake case to camel case and converts numbers to bigNumber for every balance without having to key off of asset type, right?
There was a problem hiding this comment.
I think you would still need the mapper to key from the asset type because different asset type objects have different expected keys like LP shares for example but they would just map fewer fields.
I opened https://github.com/stellar/freighter-backend/issues/319 in case you want to add any other details about the API change for this.
…ance v2's minimum_balance is the bare base-reserve requirement — wallet-backend excludes liabilities and expects consumers to subtract selling liabilities themselves. The legacy contract folds them into minimumBalance, and getAvailableBalance (popup/helpers/soroban.ts) computes spendable XLM as total − minimumBalance, so passing the wire value through let max-send/swap exceed spendable XLM by the selling-liabilities amount.
Clicking "Test Net" starts the balances request, but login() registered its
waitForEvent("response") listener after the click. A context-routed v2 stub
responds instantly, so the response could fire before the listener existed,
leaving login() blocked until timeout. Create the promise before both
network-selection clicks and await it after.
The race predates the v2 migration (page.waitForResponse had the same
ordering) but was masked by real-network latency.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Pre-merge review — balances v2 migrationReviewed against a whole-system checklist (invariants, flag state machine, replaced-behavior parity, error paths, test fidelity) and cross-checked against the sibling mobile PR (freighter-mobile#935). The adapter/flag/chokepoint engineering is careful and well-tested, and the v1 path is genuinely untouched — nice. But there are a few merge/release blockers below, two of which are shared with mobile (the two clients copied the same wire assumption, so checking them against each other looks clean — the bug is against the backend). Verdict: safe to merge only if the flag default flips to OFF (see stellar/freighter-backend#2); must not be released / flag-enabled until stellar/freighter-backend#1, stellar/freighter-backend#2, stellar/freighter-backend#3 are resolved. 1. SEP-41 token balances are double-scaled by 1e7 — CRITICAL (shared with mobile)CRITICAL — merge blocker for enabling the flag. Every SEP-41 token balance would render 10,000,000× too small, and spendable/max-send math is wrong the same way. TL;DR: The mapper treats the SEP-41 Steps to reproduce:
Detailed explanation (for agents)Root cause: wallet-backend's GraphQL resolver serializes the SEP-41 balance with The client mapper, however, treats it as raw + Caveat / confirm before acting: freighter-backend-v2's own code comment says "raw i128", which contradicts 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 — this single fixture would have caught it and settles the contradiction definitively. Suggested fixes:
2. Flag defaults ON + prod backend not configured + no fallback = balances outage — CRITICALCRITICAL — release blocker; I'd flip the default before merge. This is the direct violation of the review invariant "users can retrieve balances regardless of flag state." Mobile made the opposite (safe) choice here — it defaults the flag OFF. TL;DR: The flag defaults on in code, and the production freighter-backend-v2 has no wallet-backend configured for any network (so the endpoint 500s). Because there's no fallback to v1, a released build — or any user whose remote-config fetch fails — hits the broken v2 path and sees no balances, with no automatic recovery. The Amplitude kill-switch can't save the first balance fetch of a popup session (it races config load) or users whose config fetch fails outright. Steps to reproduce:
Detailed explanation (for agents)Root cause / evidence chain:
Suggested fixes:
3. No automatic v2→v1 fallback on error — HIGH (shared with mobile)HIGH — not a hard merge blocker, but strongly recommended given balances is the wallet's core screen. TL;DR: Any non-200 / missing-data from v2 throws and blanks the account view; the fully-working v1 endpoint is right there but never tried. A transient v2 outage becomes a user-visible "no balances" instead of a silent degradation. Detailed explanation (for agents)Root cause: Suggested fix: wrap the v2 call: 4. A missing account in a fan-out 200 is rendered — and cached — as "unfunded" — HIGHHIGH — not a merge blocker, but can show a funded wallet the empty/"fund your account" state. Mobile handles this case correctly (it rejects); the two PRs' test suites currently assert opposite contracts. TL;DR: If a 200 response omits the requested account (a malformed/partial backend response), the extension maps it to Detailed explanation (for agents)Root cause: Mobile's contract (the correct one): freighter-mobile Suggested fix: throw/ 5. Native XLM max-send is overstated for accounts with open sell offers — HIGHHIGH — not a merge blocker, but produces failed transactions. Mobile is unaffected here because it recomputes spendable locally — this is extension-specific. TL;DR: v1 folded selling-liabilities into the Steps to reproduce:
Detailed explanation (for agents)Root cause:
Suggested fixes:
6. User-added custom tokens can silently disappear on the v2 path — HIGH (shared with mobile)HIGH — not a merge blocker; needs an explicit decision before rollout. TL;DR: v1 sent the user's locally-added Detailed explanation (for agents)Root cause: the v1 path passed Suggested fix: either keep a client-side merge (local 7. SAC balances would be 1e7-inflated — MEDIUM (latent; shared with mobile)MEDIUM — latent, not currently reachable. Mirror image of stellar/freighter-backend#1. TL;DR: SAC amounts arrive raw (DB passthrough, not divided by 1e7), but the mapper treats them as pre-formatted decimals, so they'd display 1e7× too large. It's currently unreachable because SAC balances key on a Detailed explanation (for agents)Root cause: Suggested fix: resolve alongside stellar/freighter-backend#1 with a live fixture; either scale by MEDIUM / LOW / NIT — additional findingsMEDIUM
LOW / NIT
Verified solid (coverage)
🤖 Generated with Claude Code (Fable) — cross-platform pre-merge review, verified against wallet-backend + freighter-backend-v2 source. |
TL;DR: The mapper treats the SEP-41 balance as a raw unscaled integer and re-scales it by the token's 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 unit-test fixtures encode the same wrong assumption, so they stay green. Steps to reproduce: Hold any SEP-41 token on an account. @piyalbasu This is incorrect afaict. The value is not already scaled for sep-41 tokens. Here is an example for pub key You can match with the storage value for the balance here - The UI correctly display the value as "1" by scaling |
This is already noted as a blocker in the description, we are in the process of standing up the deployments. |
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. |
The backend includes every requested address in the fan-out result, with
is_funded=false for unfunded accounts — a 200 that omits the requested
address is a malformed response, not an unfunded account. Previously
getAccountBalancesV2 mapped that case to {isFunded: false, balances: {}},
which was cached and rendered as the "fund your account" state for a
funded wallet.
- getAccountBalancesV2 now throws (with captureException) when the
requested account is missing from the fan-out payload; the hook's error
path leaves previously cached balances intact
- mapAccountBalancesV2 requires a non-optional account and reads
is_funded/subentry_count directly instead of defaulting
- tests flipped to assert the rejection, matching the mobile contract
This is fixed in 880e519 |
This is incorrect, by design v2 relies on the wallet backend to index contract tokens. |
This is incorrect, wallet backend already scales the value during ingestion.
|
… contract The stubAccountBalancesV2 helper translated a null fixture into omitting the address from the fan-out result, which the client used to map to an unfunded account. Since 880e519 the client rejects a 200 that omits the requested address as malformed — the backend always includes every requested address, with is_funded=false when unfunded — so the stub was now simulating a malformed response and "Swap doesn't throw error when account is unfunded" failed on every retry (swap-sell-card never rendered). A null fixture now serves the backend-faithful unfunded entry ({ is_funded: false, subentry_count: 0, balances: [] }) and the stale doc comments at both null call sites are updated to match.
Ah okay. I had my reviewer check the Freighter BE v2 implementation, but it likely made a mistake here. If the balance is the raw value as noted, we can disregard |
This is actually probably something worth figuring out: in the immediate future (while we're still supporting v1), what should fallback look like in the case WB does go down unexpectedly under live traffic? We have the Amplitude flag we can switch, but I think we'd have to manage that manually. Is it worth having an automatic fallback to v1 in code that we remove once v1 is officially decommissioned? |
Quick question about this: I think WB would only index contract tokens the user has a balance for, but would NOT index tokens that the user added but has no balance for, right? It's an edge case that likely isn't worth supporting, but I was just curious |
imo the fallback we already planned for is the right one. We have the feature flag which can be flipped instantly and we have no reason to think v2 will go down unexpectedly any more than v1 does and there is no fallback for v1. |
this is correct, if you have no balance or trustline for a token then wallet backend will not return it which is the intended view of balances for that service. Being able to add a contract token to your balances when you have no balance for it is just an artifact of the client side tracking and not really a use case imo. |
…→ total
freighter-backend-v2#138 renamed `balance` to `total` and added
server-derived `key` and `token` fields to every balance entry, so the
adapter no longer derives asset identity client-side.
- backend-api.ts: rename `balance` → `total`; add `V2Token`/`V2TokenIssuer`
and `key`/`token` to the base, narrowed per variant (no `type` on SEP-41,
no token on LP entries)
- mapAccountBalancesV2.ts: pass server `key`/`token` through verbatim;
drop classicAssetType and all per-variant key formatting
- tests: fixtures updated to the new wire shape; the code-length type
derivation test is now a key/token pass-through assertion
- e2e stubs: toV2WireBalance emits `key`/`token`/`total`
…otal (#138) Aligns the POST /api/v1/accounts/balances response with the v1 backend pattern (stellar/freighter-backend#319) so the extension and mobile clients no longer re-derive per-asset identity when mapping to the legacy shape (see stellar/freighter#2906 discussion). Every balance entry now carries: - key: the v1 balance-map key — "native", "CODE:ISSUER" (classic/SAC), "SYMBOL:CONTRACT_ID" (SEP-41), "POOLID:lp" (LP shares) - token: the v1 token identity — {type,code} for native, {type,code,issuer:{key}} for classic (type verbatim from the trustline) and SAC (type derived from code length; the SDK carries none), {code,issuer:{key}} without type for SEP-41 (v1 Mercury parity), and omitted for LP entries (v1 has no token there) - total: renamed from "balance"; same raw on-ledger value (v1 exposes total/available, never balance — verified against v1 source and a live prd response) available, token_id, token_type, and all per-variant fields are unchanged; minimum_balance stays the bare base reserve. Covers edge cases: >4-char SAC codes map to credit_alphanum12, nil trustline code/issuer and nil SEP-41 symbol degrade to empty key parts, mirroring the client-side fallbacks this replaces.
piyalbasu
left a comment
There was a problem hiding this comment.
This code is looking good to me. I wasn't able to load balances on v2 to test locally yet (looks like we're still waiting on WB to release). Once that happens, I'd just like to quickly test locally before giving the 👍


Summary
Migrates the extension's account-balances fetch from the v1 indexer (
GET /account-balances/{publicKey}) to the freighter-backend-v2 endpoint (POST /api/v1/accounts/balances), gated by a newuse_balances_v2Amplitude flag. The flag defaults to on in code, so Amplitude can roll back to the v1 endpoint without a release.The v2 response is a different wire shape (snake_case, typed token variants, multi-address fan-out), so this PR adds an adapter that normalizes each account into the legacy
AccountBalancesInterfaceshape. Keeping the output identical to the v1 path means the downstream consumers, the cache duck, and the balance helpers need no changes. v2 also doesn't return Blockaid data yet, so the v1 backend's scan-and-merge is replicated client-side to keep both payloads identical.Scope: pubnet and testnet only. Futurenet stays on the v1 indexer regardless of the flag, and custom networks stay on the standalone path.
What's in this PR
@shared/api/types/backend-api.ts— wire types for the v2 response (NATIVE/CLASSIC/SAC/SEP41/LIQUIDITY_POOLvariants), mirroring the backend-v2 Go types verbatim.@shared/api/helpers/mapAccountBalancesV2.ts— adapter from the v2 wire format toAccountBalancesInterface. SAC balances map to the classic shape (server pre-formats the amount, so the Soroban display path would double-scale it); unknown token types are skipped.@shared/api/helpers/addBlockaidScanResults.ts— client-side bulk Blockaid scan on PUBLIC that stampsblockaidDataonto every entry, matching what the v1 backend does server-side. Scan failures keep the benign default and never break balances.@shared/api/internal.ts— newgetAccountBalancesV2fetcher, routed through thefetchBackendV2JWT chokepoint from [Extension] Route all freighter-backend-v2 calls through the authed fetch wrapper #2879;getAccountBalancesroutes between v1 and v2 based on the flag and network.extension/src/popup/ducks/remoteConfig.ts— newuse_balances_v2boolean flag andbalancesV2Selector, default on.extension/src/helpers/hooks/useGetBalances.tsx— reads the flag from the store at call time (not a render-captured value) and passes it through, mirroringuseGetTokenPrices.extension/e2e-tests/**— newstubAccountBalancesV2helper (context-routed, since v2 balances are fetched from the background service worker) registered alongside every v1 balances stub, converting each fixture to the v2 wire shape; plus anrpc-healthstub and endpoint-agnostic balance waiters.Release blockers
Merging this is safe (the v1 path is untouched and the flag provides rollback), but it must not ship in a release until:
use_balances_v2feature flag is turned on in Amplitude.Test plan