Skip to content

feat(src): balances v2 migration - #2906

Open
aristidesstaffieri wants to merge 13 commits into
masterfrom
feat/balances-v2-migration
Open

feat(src): balances v2 migration#2906
aristidesstaffieri wants to merge 13 commits into
masterfrom
feat/balances-v2-migration

Conversation

@aristidesstaffieri

@aristidesstaffieri aristidesstaffieri commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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 new use_balances_v2 Amplitude 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 AccountBalancesInterface shape. 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_POOL variants), mirroring the backend-v2 Go types verbatim.
  • @shared/api/helpers/mapAccountBalancesV2.ts — adapter from the v2 wire format to AccountBalancesInterface. 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 stamps blockaidData onto every entry, matching what the v1 backend does server-side. Scan failures keep the benign default and never break balances.
  • @shared/api/internal.ts — new getAccountBalancesV2 fetcher, routed through the fetchBackendV2 JWT chokepoint from [Extension] Route all freighter-backend-v2 calls through the authed fetch wrapper #2879; getAccountBalances routes between v1 and v2 based on the flag and network.
  • extension/src/popup/ducks/remoteConfig.ts — new use_balances_v2 boolean flag and balancesV2Selector, 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, mirroring useGetTokenPrices.
  • Unit tests for the mapper, the Blockaid helper, the v2 fetcher and routing, the flag selector, and the hook.
  • extension/e2e-tests/** — new stubAccountBalancesV2 helper (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 an rpc-health stub 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:

  • The use_balances_v2 feature flag is turned on in Amplitude.
  • A wallet-backend is deployed for every network backing freighter-backend-v2 (not just pubnet).
  • freighter-backend-v2 accepts Futurenet requests. Until then the extension hard-routes Futurenet to the v1 indexer, so v1 must stay up.

Test plan

  • Unit tests added for the mapper, Blockaid helper, fetcher/routing, flag selector, and hook
  • CI green
  • Manual smoke test against the deployed v2 dev backend (pubnet + testnet, funded and unfunded accounts)
  • Verify Blockaid spam/scam badges still render on mainnet before the flag flips

  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.
@aristidesstaffieri aristidesstaffieri self-assigned this Jul 15, 2026
@aristidesstaffieri aristidesstaffieri changed the title Feat/balances v2 migration feat(balances): migrate account balances to the freighter-backend-v2 endpoint Jul 15, 2026
@aristidesstaffieri aristidesstaffieri changed the title feat(balances): migrate account balances to the freighter-backend-v2 endpoint feat(src): balances v2 migration Jul 15, 2026
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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
@aristidesstaffieri
aristidesstaffieri marked this pull request as ready for review July 15, 2026 18:45
Copilot AI review requested due to automatic review settings July 15, 2026 18:45

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 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.

Comment thread @shared/api/helpers/mapAccountBalancesV2.ts Outdated
Comment thread @shared/api/helpers/mapAccountBalancesV2.ts Outdated
Comment thread @shared/api/helpers/addBlockaidScanResults.ts Outdated
Comment thread extension/e2e-tests/helpers/login.ts
value: MappedBalance;
}

const mapNative = (

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.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

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.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

aristidesstaffieri and others added 3 commits July 15, 2026 13:47
…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>
@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 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 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:

  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 then passes that value straight through (internal/services/account_balances_mapping.go — the case *wbtypes.SEP41Balance arm; note its comment "raw i128 amounts" describes spendability, not the serialized value).

The client mapper, however, treats it as raw + decimals and the display layer re-scales:

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:

  1. If the wire value is confirmed pre-formatted: map SEP-41 like classic (pass the decimal string through, drop the display re-scale) — mirrors what the SAC branch already (correctly, for a different reason) does.
  2. Root cause (preferred): fix the contract so it's unambiguous — either wallet-backend emits a genuinely raw value via a String128Raw-style path and the client keeps raw+decimals, or the client trusts the pre-formatted value. Whichever side, add the recorded-response contract test on both platforms.

2. Flag defaults ON + prod backend not configured + no fallback = balances outage — CRITICAL

CRITICAL — 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:

  1. With v2 backend unconfigured/500ing (current prod state), install the extension.
  2. Open the popup on mainnet. First paint uses the ON default before flags resolve → v2 request → 500 → error state, zero balances.
  3. Block the Amplitude config request → every subsequent fetch also stays on v2 (rejected config keeps the ON default).

Detailed explanation (for agents)

Root cause / evidence chain:

  • Code default ON: remoteConfig.ts L90 and internal.ts useV2 = true L1072.
  • Prod backend unconfigured: kube001-prd-eks/namespaces/wallet-eng-prd/freighter/freighter-backend-v2.yaml has no WALLET_BACKEND_* env (dev sets WALLET_BACKEND_PUBNET_URL/_TESTNET_URL); freighter-backend-v2 configureNetworkClient with an unconfigured client → wallet backend client not configured for network → 500.
  • First-paint race: flags are fetched once per popup open in parallel with the balance fetch, and useGetBalances reads the flag at call time — at first paint the store still holds the ON default. Failed fetchFeatureFlags keeps the ON default (no reset to a safe value).

Suggested fixes:

  1. Cheap + aligns with mobile: flip the code default to false; enable via Amplitude only after the backend is live per network.
  2. Defense in depth: also gate the first balances fetch on remote-config-initialized, or refetch balances when init flips.
  3. Combine with [FEAT] adds call and route to index token account balance freighter-backend#3 (fallback) so a v2 outage is invisible regardless of default.

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: getAccountBalancesV2 throws on status !== 200 || !parsedResponse?.data (L635) and getAccountBalances L1065+ does not catch it to retry v1.

Suggested fix: wrap the v2 call: try { return await getAccountBalancesV2(...) } catch (e) { captureException(e); return getAccountIndexerBalances(...) }. If fail-closed is deliberate (to surface outages), state that in the PR description instead. Either way, decide it symmetrically with mobile — a fallback on only one platform is itself a divergence.


4. A missing account in a fan-out 200 is rendered — and cached — as "unfunded" — HIGH

HIGH — 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 {isFunded: false, balances: {}} and then caches that — so a funded user sees the unfunded UI and it sticks.


Detailed explanation (for agents)

Root cause: parsedResponse.data.find(...) can be undefined; mapAccountBalancesV2(undefined) returns unfunded, and useGetBalances then persists it via saveBalancesForAccount. A missing entry is a malformed response, not an unfunded account — the backend signals unfunded with an explicit is_funded:false entry.

Mobile's contract (the correct one): freighter-mobile fetchBalancesV2 throws "response is missing the requested account".

Suggested fix: throw/captureException when the requested address is absent from a 200 (adopt mobile's behavior); never cache a synthesized-unfunded result derived from an absent entry. Update the unit test that currently pins "missing → unfunded".


5. Native XLM max-send is overstated for accounts with open sell offers — HIGH

HIGH — 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 minimumBalance field; v2 keeps minimum_balance as the pure base reserve. The send screen still computes available = total − minimumBalance − fee and no longer subtracts selling liabilities, so it offers a max-send larger than actually spendable → the transaction fails.

Steps to reproduce:

  1. On an account with an open offer selling XLM (non-zero selling liabilities), enable v2.
  2. Open Send XLM, tap max.
  3. The offered amount exceeds spendable; submitting fails with tx_insufficient_balance / op_underfunded.

Detailed explanation (for agents)

Root cause:

Suggested fixes:

  1. In the adapter (preferred — its job is v1 parity): minimumBalance: new BigNumber(b.minimum_balance).plus(b.selling_liabilities). One line, no consumer change.
  2. Alternatively: point getAvailableBalance at the already-correct available field the mapper receives (b.available) — larger blast radius.

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 contract_ids and the backend resolved a balance for each on demand, so freshly added tokens always rendered. v2 sends only addresses and relies on indexer ingestion — a token the user just added (or one the indexer hasn't ingested for that holder) vanishes from the list with no error.


Detailed explanation (for agents)

Root cause: the v1 path passed contract_ids from local token storage; the v2 POST body carries only addresses. SEP-41 visibility now depends entirely on wallet-backend ingestion, and v2 additionally suppresses Soroban token balances for unfunded accounts (freighter-backend-v2/internal/services/wallet_backend.go).

Suggested fix: either keep a client-side merge (local tokenIds → RPC lookup) for custom tokens on the v2 path, or explicitly accept + document the regression and adjust the Add-Token UX. Verify indexer coverage for the add-token flow before enabling the flag.


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 C… holder address and the v2 handler only accepts G… — but the code and tests pin the wrong behavior for the day that changes.


Detailed explanation (for agents)

Root cause: wallet-backend/internal/data/sac_balances.go stores Balance as a raw i128 string ("handles i128 values") and buildSACBalanceFromDB passes it through unformatted — unlike native/trustline which go through amount.String*. The client mapSac L120-137 treats it as pre-formatted classic.

Suggested fix: resolve alongside stellar/freighter-backend#1 with a live fixture; either scale by decimals (Soroban shape) or captureException on an unexpected SAC arrival so first-occurrence is observable rather than silently wrong.


MEDIUM / LOW / NIT — additional findings

MEDIUM

  • error: {horizon, soroban} dropped on v2 — the horizon-error banner (Account/index.tsx) becomes dead code and payload.error is always undefined. v2 has no partial-failure concept so arguably fine, but the mapper docstring claims blockaidData is the only deliberately-dropped field — update the doc or map systemic degradation onto it.
  • e2e bulk-scan stub gapstubScanAssetMalicious/stubScanAssetSuspicious match **/scan-asset** (catching scan-asset-bulk) and return the single-scan shape, so addBlockaidScanResults reads {} and held assets stay Benign; combined with toV2WireBalance dropping fixture blockaidData, held-asset badge assertions on the v2 path are effectively vacuous. Give those stubs the same bulk branch stubScanAssetSafe has.
  • Benign Blockaid default differs from v1 — client default uses metadata: {} + a METADATA feature entry; v1 server default is metadata: {type: ""} + features: []. Mostly latent, but breaks byte-parity — use a v1-identical default in addBlockaidScanResults.
  • sendIntegration skip-scan check is vacuous — it verifies the destination lookup happened, not that scanning was skipped; a regression that scanned destinations would stay green. Consider asserting the absence of a scan-asset-bulk request correlated with the lookup.

LOW / NIT

  • addBlockaidScanResults: !key.includes(":lp") should be endsWith(LP_IDENTIFIER); assetId.replace("-", ":") replaces only the first dash (a SEP-41 symbol with - round-trips wrong, verdict silently discarded — fail-benign, so safe direction).
  • New client-side dependency on the v1 indexer's /scan-asset-bulk inside the v2 migration cuts against the Blockaid→v2-behind-JWT plan (SE-12017) and ignores the v1 server-side scanning kill-switch — fine as a bridge, leave a tracking ticket reference.
  • mapAccountBalancesV2.ts references docs/balances-api-fields.md twice — that file doesn't exist at this revision.
  • The status !== 200 || !parsed?.data → stringify/captureException/throw block is now the ~5th copy across v2 fetchers — extract a shared assertBackendV2Data<T>(result, label) next to fetchBackendV2 (Copilot/Codex will flag this).
  • callBackendV2.ts / test hunks look prettier-only — split out or note in the description.
  • Confirm Playwright emits extension-SW network events for the new context.waitForEvent("response") in login.ts (no PLAYWRIGHT_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS in config) — if not, it'll hang to a 15s timeout / flake.

Verified solid (coverage)
  • Secrets: POST body carries only public keys ({addresses:[publicKey]}); JWT derivation stays in the background chokepoint; no key/mnemonic/token material persisted, logged, or sent; per-request auth keypair not cached at rest.
  • v1 path untouched (getAccountIndexerBalances has zero diff); useV2 is a required param so no caller was silently missed.
  • Routing matrix: custom → standalone (checked first), Futurenet → v1 unconditionally, PUBLIC/TESTNET → flag; unit tests pin all three.
  • Fan-out correlation is sound (single address per call; backend echoes the address; find by address); muxed→base-G and contract destinations excluded before fetch.
  • Awaits/races: call-args captured per invocation; cache write keyed by the same args, so a stale in-flight result can't pollute a new network's slot.
  • Cache duck output shape unchanged; SEP-41 key format and alphanum4/12 selection match v1; Blockaid helper is PUBLIC-only with observable failure handling.

🤖 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 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 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.
Enable use_balances_v2 and load balances on pubnet/testnet.
Observe the displayed balance is off by a factor of 1e7 vs. the v1 path.

@piyalbasu This is incorrect afaict. The value is not already scaled for sep-41 tokens. Here is an example for pub key GCBDC5AVPZEOSO3IAASQZSVRJMHX3UCCZH5O7S53FPZ636LQ5RHEW65H -
Response from v2 -

{
    "balance": "1000",
    "available": "1000",
    "token_id": "CDWDLFKUUUUXICSABRAR3Q5VANEJER3IG67WGTF564QMV75DYH34P37D",
    "token_type": "SEP41",
    "symbol": "E2E",
    "name": "E2E Token",
    "decimals": 3,
    "last_modified_ledger": 61635676
}

You can match with the storage value for the balance here -
https://stellar.expert/explorer/public/contract/CDWDLFKUUUUXICSABRAR3Q5VANEJER3IG67WGTF564QMV75DYH34P37D/storage

The UI correctly display the value as "1" by scaling 1000 by 3 decimal places, and also matches v1
Screenshot 2026-07-16 at 9 11 35 AM

@aristidesstaffieri

aristidesstaffieri commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author
  1. Flag defaults ON + prod backend not configured + no fallback = balances outage — CRITICAL
    CRITICAL — 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:

With v2 backend unconfigured/500ing (current prod state), install the extension.
Open the popup on mainnet. First paint uses the ON default before flags resolve → v2 request → 500 → error state, zero balances.
Block the Amplitude config request → every subsequent fetch also stays on v2 (rejected config keeps the ON default).

This is already noted as a blocker in the description, we are in the process of standing up the deployments.

@aristidesstaffieri

aristidesstaffieri commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author
  1. 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.

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
@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. Native XLM max-send is overstated for accounts with open sell offers — HIGH

This is fixed in 880e519

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. 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.

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

@aristidesstaffieri

aristidesstaffieri commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author
  1. SAC balances would be 1e7-inflated — MEDIUM (latent; shared with mobile)
    MEDIUM — latent, not currently reachable. Mirror image of init repo structure, build steps, docker steps, etc 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 C… holder address and the v2 handler only accepts G… — but the code and tests pin the wrong behavior for the day that changes.

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
    string, boldly assuming 7-decimal precision."
  3. Checkpoint/backfill path formats identically: internal/services/checkpoint.go:751 — same amount.String128.
  4. DB stores the formatted decimal (numeric column); resolver buildSACBalanceFromDB (account_balances_utils.go:60-71) passes it through — passthrough of a formatted value.
  5. 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.
  6. 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
    carry."

… 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.
@piyalbasu

Copy link
Copy Markdown
Contributor
  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 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. Enable use_balances_v2 and load balances on pubnet/testnet. Observe the displayed balance is off by a factor of 1e7 vs. the v1 path.

@piyalbasu This is incorrect afaict. The value is not already scaled for sep-41 tokens. Here is an example for pub key GCBDC5AVPZEOSO3IAASQZSVRJMHX3UCCZH5O7S53FPZ636LQ5RHEW65H - Response from v2 -

{
    "balance": "1000",
    "available": "1000",
    "token_id": "CDWDLFKUUUUXICSABRAR3Q5VANEJER3IG67WGTF564QMV75DYH34P37D",
    "token_type": "SEP41",
    "symbol": "E2E",
    "name": "E2E Token",
    "decimals": 3,
    "last_modified_ledger": 61635676
}

You can match with the storage value for the balance here - https://stellar.expert/explorer/public/contract/CDWDLFKUUUUXICSABRAR3Q5VANEJER3IG67WGTF564QMV75DYH34P37D/storage

The UI correctly display the value as "1" by scaling 1000 by 3 decimal places, and also matches v1 Screenshot 2026-07-16 at 9 11 35 AM

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

@piyalbasu

Copy link
Copy Markdown
Contributor
  1. 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.

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.

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?

@piyalbasu

Copy link
Copy Markdown
Contributor
  1. 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.

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

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

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. 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.

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.

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?

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.

@aristidesstaffieri

Copy link
Copy Markdown
Contributor Author
  1. 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.

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

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

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`
aristidesstaffieri added a commit to stellar/freighter-backend-v2 that referenced this pull request Jul 17, 2026
…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.
@aristidesstaffieri
aristidesstaffieri requested review from a team and piyalbasu July 17, 2026 16:45

@piyalbasu piyalbasu 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.

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 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants