Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 70 additions & 34 deletions src/app/wallets/get-balance-for-wallet.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,70 @@
import { UnknownLedgerError } from "@domain/ledger"
import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared"
import Ibex from "@services/ibex/client"
import { IbexError } from "@services/ibex/errors"

export const getBalanceForWallet = async ({
walletId,
currency,
}: {
walletId: WalletId
currency?: WalletCurrency
}): Promise<USDAmount | USDTAmount | ApplicationError> => {
try {
const resp = await Ibex.getAccountDetails(walletId, currency)
if (resp instanceof IbexError) {
if (resp.httpCode === 404) {
return currency === WalletCurrency.Usdt ? USDTAmount.ZERO : USDAmount.ZERO
}
return resp
}
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)
}
}
import { UnknownLedgerError } from "@domain/ledger"
import { BtcPaymentAmount, USDAmount, USDTAmount, WalletCurrency } from "@domain/shared"
import { LedgerService } from "@services/ledger"
import { isIbexAvailable } from "@services/ibex/availability"
import Ibex from "@services/ibex/client"
import { IbexError, IbexUnavailableError } from "@services/ibex/errors"

export const getBalanceForWallet = async ({
walletId,
currency,
}: {
walletId: WalletId
currency?: WalletCurrency
}): Promise<USDAmount | USDTAmount | ApplicationError> => {
// While the IBEX circuit is open (see @services/ibex/availability) a BTC
// wallet is still fully readable from the mongo ledger — the same path
// `get-on-chain-fee` and the by-address history use — so the wallet screen
// keeps showing real numbers through an IBEX blip instead of failing one
// field per request, each paying its own fetch timeout. Cash balances are
// authoritative only at IBEX, so those report unavailable rather than a
// possibly stale ledger figure; the wallet resolvers turn that into a null
// balance rather than an error.
if (!isIbexAvailable()) {
if (currency === WalletCurrency.Btc) return ledgerBalanceForWallet(walletId)
return new IbexUnavailableError(new Error("IBEX is not responding"))
}

try {
const resp = await Ibex.getAccountDetails(walletId, currency)
if (resp instanceof IbexError) {
if (resp.httpCode === 404) {
return currency === WalletCurrency.Usdt ? USDTAmount.ZERO : USDAmount.ZERO
}
return resp
}
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)
}
}

// The ledger holds a BTC wallet's balance in sats, while every caller of this
// function is served cents (the IBEX path above converts IBEX's dollar figure
// with `USDAmount.dollars`). Converting at the mid price keeps the degraded
// read in the same unit as the healthy one, so a wallet screen does not jump
// between sats and cents as the circuit opens and closes.
const ledgerBalanceForWallet = async (
walletId: WalletId,
): Promise<USDAmount | USDTAmount | ApplicationError> => {
const balance = await LedgerService().getWalletBalance(walletId)
if (balance instanceof Error) return balance

// Loaded lazily so importing this module does not pull the price stack
// (which reads dealer config at module scope) into every caller's graph.
const { usdFromBtcMidPriceFn } = await import("@app/prices/mid-price")

const usdBalance = await usdFromBtcMidPriceFn(BtcPaymentAmount(BigInt(balance)))
if (usdBalance instanceof Error) return usdBalance

return USDAmount.cents(String(usdBalance.amount))
}
56 changes: 55 additions & 1 deletion src/app/wallets/get-transactions-for-wallet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { PartialResult } from "@app/partial-result"
import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared"
import { memoSharingConfig } from "@config"
import { ErrorLevel, USDAmount, USDTAmount, WalletCurrency } from "@domain/shared"
import { WalletTransactionHistory } from "@domain/wallets"
import { getNonEndUserWalletIds, LedgerService } from "@services/ledger"
import { recordExceptionInCurrentSpan } from "@services/tracing"
import { isIbexAvailable } from "@services/ibex/availability"
import Ibex from "@services/ibex/client"
import { IbexError } from "@services/ibex/errors"
import { baseLogger } from "@services/logger"
Expand All @@ -15,6 +20,13 @@ export const getTransactionsForWallets = async ({
}): Promise<PartialResult<PaginatedArray<IbexTransaction>>> => {
const walletIds = wallets.map((wallet) => wallet.id)

// Circuit already open: go straight to the mongo ledger, which holds every
// wallet's confirmed rows and needs no IBEX round trip. Same shape as the
// ledger fallback in ./get-transactions-by-addresses.
if (!isIbexAvailable()) {
return ledgerHistoryForWallets({ walletIds, paginationArgs })
}

const ibexCalls = await Promise.all(
walletIds.map((id) =>
Ibex.getAccountTransactions({
Expand All @@ -24,6 +36,23 @@ export const getTransactionsForWallets = async ({
),
)

const ibexFailed = ibexCalls.some((resp) => resp instanceof IbexError)
if (ibexFailed) {
const error = ibexCalls.find((resp) => resp instanceof IbexError) as IbexError
const ledgerHistory = await ledgerHistoryForWallets({ walletIds, paginationArgs })
// Rows that did come back are never discarded for the sake of the ones
// that did not: a caller with both a BTC and a cash wallet still sees the
// BTC history. Only when the ledger has nothing either is the read a
// failure. The IBEX error is recorded on the span rather than returned,
// because the resolvers throw on any `error` in the partial result — and
// throwing here would hide the rows that did render.
if (ledgerHistory.result) {
recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn })
return ledgerHistory
}
return PartialResult.err(error)
}

const transactions = ibexCalls.flatMap((resp) => {
if (resp instanceof IbexError) return []
else return toWalletTransactions(resp)
Expand All @@ -35,6 +64,31 @@ export const getTransactionsForWallets = async ({
})
}

const ledgerHistoryForWallets = async ({
walletIds,
paginationArgs,
}: {
walletIds: WalletId[]
paginationArgs?: PaginationArgs
}): Promise<PartialResult<PaginatedArray<IbexTransaction>>> => {
const confirmed = await LedgerService().getTransactionsByWalletIds({
walletIds,
paginationArgs: paginationArgs || ({} as PaginationArgs),
})
if (confirmed instanceof Error) return PartialResult.err(confirmed)

const history = WalletTransactionHistory.fromLedger({
ledgerTransactions: confirmed.slice,
nonEndUserWalletIds: Object.values(await getNonEndUserWalletIds()),
memoSharingConfig,
})

return PartialResult.ok({
slice: history.transactions as IbexTransaction[],
total: confirmed.total,
})
}

const currencyFromIbexCurrencyId = (
currencyId: number | undefined,
): WalletCurrency | undefined => {
Expand Down
Loading