From ffd9abf8eaf340c014cf90bb5c1767eb80bf7844 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 10 Jul 2026 15:04:19 -0600 Subject: [PATCH] fix(wallets): resolve drained IBEX accounts as zero balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IBEX omits the balance field for drained / never-funded accounts (absent means zero — verified in prod during the USDT cutover). getBalanceForWallet treated that as UnexpectedIbexResponse('Balance not found'), which throws in the wallet balance resolvers. Post-cutover this broke the ADMIN GraphQL API for every migrated account: wallets[].balance on the legacy (drained) USD wallet errors with IBEX_ERROR, failing the whole accountDetails* response. The cash-wallet compat redirect does not apply in admin ctx (no client capabilities / domainAccount), so the admin path always does the raw read. Public app API was unaffected. Evidence (prod, patoo): wallets -> USD balance null + IBEX_ERROR, USDT 2000 reads fine. Same signature on TEST for any migrated account. Fix: missing balance field on an otherwise-valid response resolves to the currency's ZERO, matching the existing 404 arm and cutover-verifier semantics. Unit tests for both currencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/wallets/get-balance-for-wallet.ts | 12 +++++-- .../wallets/get-balance-for-wallet.spec.ts | 33 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/app/wallets/get-balance-for-wallet.ts b/src/app/wallets/get-balance-for-wallet.ts index 46ddf6cad..7a2a0b945 100644 --- a/src/app/wallets/get-balance-for-wallet.ts +++ b/src/app/wallets/get-balance-for-wallet.ts @@ -1,7 +1,7 @@ import { UnknownLedgerError } from "@domain/ledger" import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" -import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" +import { IbexError } from "@services/ibex/errors" export const getBalanceForWallet = async ({ walletId, @@ -18,7 +18,15 @@ export const getBalanceForWallet = async ({ } return resp } - if (resp.balance === undefined) return new UnexpectedIbexResponse("Balance not found") + if (resp.balance === undefined) { + // IBEX omits `balance` for drained / never-funded accounts (per-account and + // bulk endpoints alike: absent means zero — verified in prod during the USDT + // cutover). Post-cutover, every migrated account's legacy USD wallet reads + // this way; returning an error here broke the admin API's wallets[].balance + // for all migrated accounts (the cash-wallet compat redirect only runs when + // client capabilities are in ctx, i.e. for app users — never for admin). + return currency === WalletCurrency.Usdt ? USDTAmount.ZERO : USDAmount.ZERO + } return resp.balance } catch (err) { return new UnknownLedgerError(err) diff --git a/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts index 4c7fe2680..92738907d 100644 --- a/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts +++ b/test/flash/unit/app/wallets/get-balance-for-wallet.spec.ts @@ -1,5 +1,5 @@ import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" -import { USDTAmount, WalletCurrency } from "@domain/shared" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import Ibex from "@services/ibex/client" jest.mock("@services/ibex/client", () => ({ @@ -35,4 +35,35 @@ describe("getBalanceForWallet", () => { expect(Ibex.getCryptoReceiveBalance).not.toHaveBeenCalled() expect(result).toBe(balance) }) + + // IBEX omits `balance` on drained / never-funded accounts (absent means zero — + // verified in prod during the USDT cutover). Post-cutover every migrated + // account's legacy USD wallet reads this way; it must resolve to zero, not an + // error (an error here broke admin-API wallets[].balance for all migrated + // accounts, since the compat redirect never runs in admin ctx). + it("treats a missing balance field as zero for USD (drained legacy wallet)", async () => { + jest.mocked(Ibex.getAccountDetails).mockResolvedValue({ + id: "drained-usd-ibex-id", + } as never) + + const result = await getBalanceForWallet({ + walletId: "drained-usd-ibex-id" as WalletId, + currency: WalletCurrency.Usd, + }) + + expect(result).toBe(USDAmount.ZERO) + }) + + it("treats a missing balance field as zero for USDT", async () => { + jest.mocked(Ibex.getAccountDetails).mockResolvedValue({ + id: "drained-usdt-ibex-id", + } as never) + + const result = await getBalanceForWallet({ + walletId: "drained-usdt-ibex-id" as WalletId, + currency: WalletCurrency.Usdt, + }) + + expect(result).toBe(USDTAmount.ZERO) + }) })