diff --git a/src/app/wallets/get-balance-for-wallet.ts b/src/app/wallets/get-balance-for-wallet.ts index 7a2a0b945..36bda6a79 100644 --- a/src/app/wallets/get-balance-for-wallet.ts +++ b/src/app/wallets/get-balance-for-wallet.ts @@ -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 => { - 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 => { + // 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 => { + 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)) +} diff --git a/src/app/wallets/get-transactions-for-wallet.ts b/src/app/wallets/get-transactions-for-wallet.ts index f4dfdb186..493d738b5 100644 --- a/src/app/wallets/get-transactions-for-wallet.ts +++ b/src/app/wallets/get-transactions-for-wallet.ts @@ -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" @@ -15,6 +20,13 @@ export const getTransactionsForWallets = async ({ }): Promise>> => { 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({ @@ -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) @@ -35,6 +64,31 @@ export const getTransactionsForWallets = async ({ }) } +const ledgerHistoryForWallets = async ({ + walletIds, + paginationArgs, +}: { + walletIds: WalletId[] + paginationArgs?: PaginationArgs +}): Promise>> => { + 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 => { diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 51e1f189c..211ab289e 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -1,1248 +1,1259 @@ -import { ValidationError } from "@domain/shared" -import { - TransactionRestrictedError, - LightningPaymentError, - NotFoundError, - SelfPaymentError, - InsufficientBalanceError, - DbError, - UnknownClientError, - InvoiceDecodeError, - ValidationInternalError, - TooManyRequestError, - RouteFindingError, - InvalidCoordinatesError, - InvalidBusinessTitleLengthError, - VerificationCodeError, - UsernameError, - DealerOfflineError, - LndOfflineError, - PhoneProviderError, - UnexpectedClientError, - DealerError, - PhoneAccountAlreadyExistsError, - PhoneAccountAlreadyExistsNeedToSweepFundsError, - PhoneAccountAlreadyExistsCannotUpgradeError, - EmailUnverifiedError, - AccountAlreadyHasEmailError, - PhoneAlreadyExistsError, - EmailAlreadyExistsError, - SessionRefreshRequiredError, - CodeExpiredError, - UnauthorizedIPForOnboardingError, - InvalidPhoneMetadataForOnboardingError, - InvalidPhoneForOnboardingError, - UnauthorizedIPError, - UnauthorizedIPMetadataProxyError, - UnauthorizedIPMetadataCountryError, - IbexError, - InvalidLnurlError, - CustomApolloError, -} from "@graphql/error" -import { baseLogger } from "@services/logger" -import { isBankAccountValidationReason } from "@services/frappe/errors" - -const assertUnreachable = (x: unknown): never => { - throw new Error(`This should never compile with ${x}`) -} - -const bridgeGqlError = ({ - code, - message, -}: { - code: string - message: string -}): CustomApolloError => - new CustomApolloError({ - code, - message, - forwardToClient: true, - logger: baseLogger, - }) - -// Same shape as the Bridge errors: a stable machine-readable code the app can -// branch on, plus a message that is safe to show as-is. -const bankAccountGqlError = bridgeGqlError - -export const mapError = (error: ApplicationError): CustomApolloError => { - const errorName = error.name as ApplicationErrorKey - let message = "" - switch (errorName) { - case "WithdrawalLimitsExceededError": - message = error.message - return new TransactionRestrictedError({ message, logger: baseLogger }) - - case "IntraledgerLimitsExceededError": - message = error.message - return new TransactionRestrictedError({ message, logger: baseLogger }) - - case "TradeIntraAccountLimitsExceededError": - message = error.message - return new TransactionRestrictedError({ message, logger: baseLogger }) - - case "InvalidSendAmountError": - message = error.message || "Amount must be greater than zero" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "SendLimitsUnavailableError": - message = "Sending is temporarily unavailable, please try again later." - return new TransactionRestrictedError({ message, logger: baseLogger }) - - case "CompletedInvoice": - case "AlreadyPaidError": - message = "Invoice is already paid" - return new LightningPaymentError({ message, logger: baseLogger }) - - case "CouldNotFindWalletInvoiceError": - message = `User tried to pay invoice with hash ${error.message}, but it does not exist` - return new LightningPaymentError({ message, logger: baseLogger }) - - case "NoValidNodeForPubkeyError": - message = `Temporary failure when trying to pay, please retry payment` - return new LightningPaymentError({ message, logger: baseLogger }) - - case "PaymentAttemptsTimedOutError": - message = `Temporary failure when trying to pay, please try again later` - return new LightningPaymentError({ message, logger: baseLogger }) - - case "PaymentRejectedByDestinationError": - message = `Payment was rejected by destination as invalid` - return new LightningPaymentError({ message, logger: baseLogger }) - - case "InvoiceExpiredOrBadPaymentHashError": - message = `Invoice already expired, or has bad payment hash` - return new LightningPaymentError({ message, logger: baseLogger }) - - case "CouldNotFindUserFromIdError": - message = `User does not exist for id ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindAccountFromIdError": - message = `Account does not exist for id ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - // Same shape as the id/uuid/username siblings: a not-found for whichever - // user the caller looked up. Admin lookups (accountDetailsByUserPhone, - // accountDetailsByUserEmail) resolve OTHER users through findByUserId, so - // this must never read as "your session is unauthenticated". The one site - // that knows the missing account is the caller's own is the session - // middleware, which raises AuthenticationError itself. - case "CouldNotFindAccountFromKratosIdError": - message = `Account does not exist for user id ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindAccountFromUuidError": - message = `Account does not exist for uuid ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindAccountFromUsernameError": - message = `Account does not exist for username ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindAccountFromNpubError": - message = `Account does not exist for npub ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "NoNpubToReleaseError": - message = `Account ${error.message} has no npub linked` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindMerchantFromUsernameError": - message = `Merchant does not exist for username ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindMerchantFromIdError": - message = `Merchant does not exist for ID ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindTransactionsForAccountError": - message = "No transactions found for your account." - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindUserFromPhoneError": - message = `User does not exist for phone ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindUserFromWalletIdError": - message = `User does not exist for wallet-id ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "CouldNotFindUserFromEmailError": - message = `User does not exist for email` - return new NotFoundError({ message, logger: baseLogger }) - - case "ContactNotExistentError": - message = `Contact does not exist for account ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "IdentifierNotFoundError": - message = `Identifier does not exist ${error.message}` - return new NotFoundError({ message, logger: baseLogger }) - - case "SelfPaymentError": - message = "User tried to pay themselves" - return new SelfPaymentError({ message, logger: baseLogger }) - - case "LnInvoiceMissingPaymentSecretError": - message = "Invoice is missing its 'payment secret' value" - return new InvoiceDecodeError({ message, logger: baseLogger }) - - case "InvalidChecksumForLnInvoiceError": - message = "Invoice has an invalid checksum, please check again" - return new InvoiceDecodeError({ message, logger: baseLogger }) - - case "LnPaymentRequestInTransitError": - message = "There is a pending payment for this invoice" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidApiKeyNameError": - case "InvalidApiKeyScopeError": - case "InvalidApiKeyIpConstraintError": - case "InvalidApiKeyRateLimitError": - case "InvalidApiKeyFormatError": - case "MaxApiKeysPerAccountError": - case "InsufficientApiKeyScopeError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "ApiKeyExpiredError": - message = "This API key has expired" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "ApiKeySecretMismatchError": - message = "Invalid API key" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "ApiKeyIpNotAllowedError": - message = "API key not allowed from this IP" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "ApiKeyCannotManageApiKeysError": - message = "API keys cannot be managed while authenticated with an API key" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "SatoshiAmountRequiredError": - message = "An amount is required to complete payment" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidSatoshiAmountError": - message = "A valid satoshi amount is required" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidBtcPaymentAmountError": - message = "A valid satoshi amount is required" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidUsdPaymentAmountError": - message = "A valid usd amount is required" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "BtcAmountTooLargeError": - message = "Sats amount passed is too large" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "UsdAmountTooLargeError": - message = "Usd cents amount passed is too large" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "LnPaymentRequestNonZeroAmountRequiredError": - message = "Invoice does not have a valid amount to pay" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "LnPaymentRequestZeroAmountRequiredError": - message = "Invoice must be a zero-amount invoice" - return new ValidationInternalError({ message, logger: baseLogger }) - - // The domain-side twin of the provider error above. Left in the catch-all - // it rendered a malformed number as "unexpected error, contact support", - // and echoed the submitted number back inside that message. - case "InvalidPhoneNumberPhoneProviderError": - case "InvalidPhoneNumber": - message = "Phone number is not a valid phone number" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "RestrictedRegionPhoneProviderError": - message = "Phone number is not from a valid region" - return new ValidationInternalError({ message, logger: baseLogger }) - - // A deliberate policy rejection, not a bug: it must not fall into the - // catch-all below, which would tell the user to retry and open a ticket. - case "PhoneCountryNotAllowedError": - message = "Phone number is not from a valid region" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "PhoneProviderConnectionError": - case "PhoneProviderUnavailableError": - message = "Phone provider temporarily unreachable" - return new PhoneProviderError({ message, logger: baseLogger }) - - case "PhoneProviderRateLimitExceededError": - message = "Too many phone number attempts, please try again in a few minutes." - return new PhoneProviderError({ message, logger: baseLogger }) - - case "RestrictedRecipientPhoneNumberError": - message = - "Issue detected with phone number. Please try again later or contact support." - return new PhoneProviderError({ message, logger: baseLogger }) - - case "InvalidOrApprovedVerificationError": - message = - "Issue detected with verification. Please try again later or contact support." - return new PhoneProviderError({ message, logger: baseLogger }) - - case "UnsubscribedRecipientPhoneProviderError": - message = "Phone number has opted out of receiving messages" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidContactAlias": - message = "ContactAlias has incorrect characters or length" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "PaymentSendRateLimiterExceededError": - message = "Too many payment attempts, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "InvoiceCreateRateLimiterExceededError": - case "InvoiceCreateForRecipientRateLimiterExceededError": - message = "Too many invoices creation, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "OnChainAddressCreateRateLimiterExceededError": - message = - "Too many onchain addresses creation, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "BankAccountManageRateLimiterExceededError": - message = "Too many bank account changes, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "FygaroCheckoutCreateRateLimiterExceededError": - message = "Too many card top-up attempts, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "UserCodeAttemptIdentifierRateLimiterExceededError": - message = "Too many request code attempts, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "UserCodeAttemptIpRateLimiterExceededError": - message = - "Too many phone code attempts on same network, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "CreateDeviceAccountIpRateLimiterExceededError": - message = - "Too many account creation attempts on same network, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "PhoneCodeInvalidError": - message = "Invalid or incorrect code entered." - return new VerificationCodeError({ message, logger: baseLogger }) - - case "EmailCodeInvalidError": - message = "Invalid or incorrect code entered." - return new VerificationCodeError({ message, logger: baseLogger }) - - case "ExpiredOrNonExistentPhoneNumberError": - message = "Invalid or incorrect phone entered." - return new VerificationCodeError({ message, logger: baseLogger }) - - case "CouldNotFindAccountFromPhoneError": - message = "Invalid or incorrect phone entered." - return new VerificationCodeError({ message, logger: baseLogger }) - - case "UserLoginIdentifierRateLimiterExceededError": - message = "Too many login attempts, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "EmailValidationSubmittedTooOftenError": - message = "Too many attempts, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "UserLoginIpRateLimiterExceededError": - message = - "Too many login attempts on same network, please wait for a while and try again." - return new TooManyRequestError({ message, logger: baseLogger }) - - case "InvalidQuizQuestionIdError": - message = "Invalid quiz question id was passed." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidPushNotificationSettingError": - message = "Invalid push notification setting was passed." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "RewardAlreadyPresentError": - message = "Reward for quiz question was already claimed." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "SubOneCentSatAmountForUsdSelfSendError": - message = "Amount sent was too low for recipient's usd wallet." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidPhoneForRewardError": - message = "Unsupported phone carrier for rewards." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidIpMetadataError": - message = "Unsupported IP." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "NoDealerPriceDataAvailableError": - message = "No price data from dealer to perform USD operation." - return new DealerError({ message, logger: baseLogger }) - - case "NoConnectionToDealerError": - message = "No connection to dealer to perform USD operation." - return new DealerOfflineError({ message, logger: baseLogger }) - - case "DealerStalePriceError": - message = "Stale dealer price, can't perform USD operation." - return new DealerOfflineError({ message, logger: baseLogger }) - - case "RouteNotFoundError": - case "MaxFeeTooLargeForRoutelessPaymentError": - message = "Unable to find a route for payment." - return new RouteFindingError({ message, logger: baseLogger }) - - case "DestinationMissingDependentFeatureError": - message = "Unable to pay incompatible recipient." - return new RouteFindingError({ message, logger: baseLogger }) - - case "UnknownNextPeerError": - message = "Unable to find a route for payment." - return new RouteFindingError({ message, logger: baseLogger }) - - case "ProbeForRouteTimedOutError": - case "ProbeForRouteTimedOutFromApplicationError": - message = "Unable to find a route for payment." - return new RouteFindingError({ message, logger: baseLogger }) - - case "PaymentInTransitionError": - message = "Payment was sent and is still in transition." - return new LightningPaymentError({ message, logger: baseLogger }) - - case "TemporaryChannelFailureError": - case "TemporaryNodeFailureError": - message = "Issue with lightning payment, please try again." - return new LightningPaymentError({ message, logger: baseLogger }) - - case "UsernameNotAvailableError": - message = "username not available" - return new UsernameError({ message, logger: baseLogger }) - - case "UsernameIsImmutableError": - message = "username is immutable" - return new UsernameError({ message, logger: baseLogger }) - - case "NpubNotAvailableError": - message = "npub is already linked to another account" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "AccountAlreadyHasNpubError": - message = "the receiving account already has an npub linked" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidAccountIdError": - message = `Invalid account id ${error.message}` - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidWalletId": - message = "Invalid walletId for account." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidDisplayCurrencyError": - message = "Invalid currency." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "MismatchedCurrencyForWalletError": - message = "Unsupported operation for wallet's currency." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "UnsupportedCurrencyError": - return new ValidationInternalError({ logger: baseLogger }) - - case "InsufficientBalanceError": - case "InsufficientIbexBalance": - message = error.message - return new InsufficientBalanceError({ message, logger: baseLogger }) - - case "CaptchaUserFailToPassError": - message = "Captcha validation failed." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "AmountLessThanFeeError": - case "LessThanDustThresholdError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "OffChainServiceUnavailableError": - case "OffChainServiceBusyError": - case "OnChainServiceUnavailableError": - /* eslint-disable-next-line no-case-declarations */ - const serviceType = errorName.includes("OffChainService") ? "Offchain" : "Onchain" - message = `${serviceType} action failed, please try again in a few minutes. If the problem persists, please contact support. If you are on regtest, it might because blocks need to be generated so that lnd can be considered active.` - return new LndOfflineError({ message, logger: baseLogger }) - - case "InactiveAccountError": - message = "Account is inactive." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidPriceCurrencyError": - message = "Invalid currency." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidCoordinatesError": - return new InvalidCoordinatesError({ logger: baseLogger }) - - case "InvalidBusinessTitleLengthError": - return new InvalidBusinessTitleLengthError({ logger: baseLogger }) - - case "CannotConnectToDbError": - case "DbConnectionClosedError": - message = - "Service offline, please try again in a few minutes. If the problem persists, please contact support." - return new DbError({ message, logger: baseLogger }) - - case "InvalidPaginationArgumentsError": - message = "Invalid pagination arguments" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "AccountHasPositiveBalanceError": - message = - "The phone is associated with an existing wallet that has a non zero balance. Sweep the funds and try again." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "PhoneAccountAlreadyExistsError": - message = - "Phone Account already exists. Please logout and log back in with your phone account." - return new PhoneAccountAlreadyExistsError({ message, logger: baseLogger }) - - case "PhoneAccountAlreadyExistsNeedToSweepFundsError": - message = - "Error phone account already exists. You need to manually sweep funds to your phone account." - return new PhoneAccountAlreadyExistsNeedToSweepFundsError({ - message, - logger: baseLogger, - }) - - case "PhoneAccountAlreadyExistsCannotUpgradeError": - message = - "Phone number is already registered to another user. Please log out and log in with that phone account." - return new PhoneAccountAlreadyExistsCannotUpgradeError({ - message, - logger: baseLogger, - }) - - case "InvalidIdempotencyKeyError": - message = - "Invalid format for the provided ImpotencyKey. You must use a UUID-v4 format" - return new ValidationInternalError({ - message, - logger: baseLogger, - }) - - case "IdempotencyKeyReuseError": - message = - "This idempotency key was already used for a different payment. Use a new key for a new payment." - return new ValidationInternalError({ - message, - logger: baseLogger, - }) - - case "EmailUnverifiedError": - return new EmailUnverifiedError({ logger: baseLogger }) - - case "AccountAlreadyHasEmailError": - return new AccountAlreadyHasEmailError({ logger: baseLogger }) - - case "PhoneAlreadyExistsError": - return new PhoneAlreadyExistsError({ logger: baseLogger }) - - // The pre-persist registration hook refused the number (unparsable, or - // carrier metadata failed validation). A policy answer, not a bug: it - // must not fall into the catch-all that tells the user to retry. - case "PhoneNotAllowedForRegistrationError": - message = "This phone number can't be used to sign up" - return new ValidationInternalError({ message, logger: baseLogger }) - - // Same hook, phone already bound to a users document. The caller is on the - // sign-up path with no account and no session, so this must not reuse - // PhoneAlreadyExistsError's "one phone per account" text. - case "PhoneAlreadyRegisteredError": - message = - "This phone number is already registered. Contact support if you can't sign in" - return new ValidationInternalError({ message, logger: baseLogger }) - - case "EmailAlreadyExistsError": - return new EmailAlreadyExistsError({ logger: baseLogger }) - - case "CodeExpiredKratosError": - return new CodeExpiredError({ logger: baseLogger }) - - case "SessionRefreshRequiredError": - return new SessionRefreshRequiredError({ logger: baseLogger }) - - case "UnauthorizedIPForOnboardingError": - return new UnauthorizedIPForOnboardingError({ logger: baseLogger }) - - case "InvalidPhoneMetadataForOnboardingError": - return new InvalidPhoneMetadataForOnboardingError({ logger: baseLogger }) - - case "InvalidPhoneForOnboardingError": - return new InvalidPhoneForOnboardingError({ logger: baseLogger }) - - case "UnauthorizedIPError": - return new UnauthorizedIPError({ logger: baseLogger }) - - case "UnauthorizedIPMetadataProxyError": - return new UnauthorizedIPMetadataProxyError({ logger: baseLogger }) - - case "UnauthorizedIPMetadataCountryError": - return new UnauthorizedIPMetadataCountryError({ logger: baseLogger }) - - case "IbexError": - case "UnexpectedIbexResponse": - case "FailedIbexPayment": - return new IbexError(baseLogger) - case "OfferNotFound": - return new NotFoundError({ - message: "Offer not available. Try again.", - logger: baseLogger, - }) - - case "BridgeInvalidAmountError": - message = - error.message || "Amount must be strictly positive with at most 6 decimal places" - return bridgeGqlError({ - code: "BRIDGE_INVALID_AMOUNT", - message, - }) - - case "BridgeBelowMinimumWithdrawalError": - message = error.message || "Withdrawal amount is below the minimum" - return bridgeGqlError({ - code: "BRIDGE_BELOW_MINIMUM_WITHDRAWAL", - message, - }) - - case "BridgeDisabledError": - message = "Bridge integration is currently disabled" - return bridgeGqlError({ - code: "BRIDGE_DISABLED", - message, - }) - - case "BridgeAccountLevelError": - message = error.message || "Bridge requires at least a Personal account (Level 1+)" - return bridgeGqlError({ - code: "BRIDGE_ACCOUNT_LEVEL_ERROR", - message, - }) - - case "BridgeKycEmailNotVerifiedError": - message = - error.message || - "Verify your email address before starting identity verification." - return bridgeGqlError({ - code: "BRIDGE_KYC_EMAIL_NOT_VERIFIED", - message, - }) - - case "BridgeKycCountryNotSupportedError": - message = - error.message || "US virtual accounts aren't available in your country yet." - return bridgeGqlError({ - code: "BRIDGE_KYC_COUNTRY_NOT_SUPPORTED", - message, - }) - - case "BridgeKycPhoneRequiredError": - message = - error.message || - "Add and verify a phone number before starting identity verification." - return bridgeGqlError({ - code: "BRIDGE_KYC_PHONE_REQUIRED", - message, - }) - - case "BridgeKycPendingError": - message = "KYC verification is pending" - return bridgeGqlError({ - code: "BRIDGE_KYC_PENDING", - message, - }) - - case "BridgeKycRejectedError": - message = "KYC verification was rejected" - return bridgeGqlError({ - code: "BRIDGE_KYC_REJECTED", - message, - }) - - case "BridgeKycOffboardedError": - message = "Your account has been offboarded from Bridge. Please contact support." - return bridgeGqlError({ - code: "BRIDGE_KYC_OFFBOARDED", - message, - }) - - case "BridgeKycTierCeilingExceededError": - message = error.message || "Withdrawal amount exceeds the KYC tier ceiling" - return bridgeGqlError({ - code: "BRIDGE_KYC_TIER_CEILING_EXCEEDED", - message, - }) - - case "BridgeCustomerNotFoundError": - message = "Bridge customer not found" - return bridgeGqlError({ - code: "BRIDGE_CUSTOMER_NOT_FOUND", - message, - }) - - case "BridgeInsufficientFundsError": - message = "Insufficient funds for withdrawal" - return bridgeGqlError({ - code: "BRIDGE_INSUFFICIENT_FUNDS", - message, - }) - - case "BridgeWithdrawalNetAmountTooLowError": - message = error.message || "Withdrawal amount must exceed estimated customer fees" - return bridgeGqlError({ - code: "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW", - message, - }) - - case "BridgeWithdrawalNotFoundError": - message = error.message || "Withdrawal request not found" - return bridgeGqlError({ - code: "BRIDGE_WITHDRAWAL_NOT_FOUND", - message, - }) - - case "BridgeWithdrawalAlreadyInitiatedError": - message = - error.message || - "Withdrawal has already been submitted to Bridge and cannot be cancelled" - return bridgeGqlError({ - code: "BRIDGE_WITHDRAWAL_ALREADY_INITIATED", - message, - }) - - case "BridgeRateLimitError": - message = "Rate limit exceeded, please try again later" - return bridgeGqlError({ - code: "BRIDGE_RATE_LIMIT", - message, - }) - - case "BridgeTimeoutError": - message = "Request timed out" - return bridgeGqlError({ - code: "BRIDGE_TIMEOUT", - message, - }) - - case "BridgeTransferFailedError": - message = error.message || "Transfer failed" - return bridgeGqlError({ - code: "BRIDGE_TRANSFER_FAILED", - message, - }) - - case "BridgeDepositInstructionsMissingError": - message = - error.message || - "Bridge did not return crypto deposit instructions for this withdrawal" - return bridgeGqlError({ - code: "BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING", - message, - }) - - case "BridgeWebhookValidationError": - message = "Invalid webhook signature" - return bridgeGqlError({ - code: "BRIDGE_WEBHOOK_VALIDATION", - message, - }) - - case "BridgeApiError": - message = error.message || "Bridge API error" - return bridgeGqlError({ - code: "BRIDGE_API_ERROR", - message, - }) - - case "BridgePlaidNotAvailableError": - message = error.message || "Plaid bank account linking is not available" - return bridgeGqlError({ - code: "BRIDGE_PLAID_NOT_AVAILABLE", - message, - }) - - case "FygaroCheckoutDisabledError": - message = error.message || "Card top-up is currently unavailable" - return bridgeGqlError({ - code: "FYGARO_CHECKOUT_DISABLED", - message, - }) - - case "FygaroBelowMinimumError": - message = error.message || "Amount is below the minimum top-up" - return bridgeGqlError({ - code: "FYGARO_BELOW_MINIMUM", - message, - }) - - case "FygaroAboveSinglePaymentLimitError": - message = error.message || "Amount is above the single top-up limit" - return bridgeGqlError({ - code: "FYGARO_ABOVE_SINGLE_PAYMENT_LIMIT", - message, - }) - - case "FygaroDailyAllowanceExceededError": - message = error.message || "Amount is above your remaining daily top-up allowance" - return bridgeGqlError({ - code: "FYGARO_DAILY_ALLOWANCE_EXCEEDED", - message, - }) - - case "FygaroCheckoutAlreadyOpenError": - message = error.message || "You already have a card top-up link open" - return bridgeGqlError({ - code: "FYGARO_CHECKOUT_ALREADY_OPEN", - message, - }) - - case "FygaroAllowanceUnavailableError": - message = error.message || "Could not check your top-up allowance right now" - return bridgeGqlError({ - code: "FYGARO_ALLOWANCE_UNAVAILABLE", - message, - }) - - case "FygaroLevelNotEligibleError": - message = error.message || "Card top-up isn't available on your account level yet" - return bridgeGqlError({ - code: "FYGARO_LEVEL_NOT_ELIGIBLE", - message, - }) - - case "BridgeInvalidPlaidTokenError": - message = error.message || "linkToken and publicToken are required" - return bridgeGqlError({ - code: "BRIDGE_INVALID_PLAID_TOKEN", - message, - }) - - case "BridgeError": - message = error.message || "Bridge API error" - return bridgeGqlError({ code: "BRIDGE_ERROR", message }) - - // ---------- - // Unhandled below here - // ---------- - case "RateLimiterExceededError": - case "RateLimitError": - case "RateLimitServiceError": - case "CouldNotFindUserError": - case "LedgerError": - case "LedgerServiceError": - case "LedgerFacadeError": - case "LightningError": - case "BadPaymentDataError": - case "LnInvoiceDecodeError": - case "LightningServiceError": - case "CouldNotDecodeReturnedPaymentRequest": - case "InvoiceNotFoundError": - case "LnPaymentPendingError": - case "LnAlreadyPaidError": - case "PaymentNotFoundError": - case "OperationInterruptedError": - case "InconsistentDataError": - case "AuthorizationError": - case "RepositoryError": - case "PersistError": - case "DuplicateError": - case "CouldNotFindError": - case "CouldNotUpdateError": - case "ValidationError": - case "LnRouteValidationError": - case "BadAmountForRouteError": - case "InvalidUsername": - case "InvalidNpubError": - case "InvalidDeviceId": - case "InvalidIdentityPassword": - case "InvalidIdentityUsername": - case "InvalidTotpCode": - case "InvalidEmailAddress": - case "NoContactForUsernameError": - case "NoWalletExistsForUserError": - case "NoBtcWalletExistsForAccountError": - case "LimitsExceededError": - case "CouldNotFindLightningPaymentFlowError": - case "NoExpiredLightningPaymentFlowsError": - case "CouldNotUpdateLightningPaymentFlowError": - case "CouldNotFindWalletFromIdError": - case "CouldNotListWalletsFromAccountIdError": - case "CouldNotFindWalletFromUsernameError": - case "CouldNotFindWalletFromUsernameAndCurrencyError": - case "CouldNotFindWalletFromAccountIdAndCurrencyError": - case "CouldNotFindWalletFromOnChainAddressError": - case "CouldNotFindWalletFromOnChainAddressesError": - case "CouldNotFindBtcWalletForAccountError": - case "CouldNotListWalletsFromWalletCurrencyError": - case "CouldNotFindWalletOnChainPendingReceiveError": - case "CouldNotFindLnPaymentFromHashError": - case "LockError": - case "LockServiceError": - case "ResourceAttemptsLockServiceError": - case "ResourceExpiredLockServiceError": - case "PriceError": - case "PriceServiceError": - case "PriceNotAvailableError": - case "DealerPriceNotAvailableError": - case "PriceHistoryNotAvailableError": - case "PriceCurrenciesNotAvailableError": - case "OnChainError": - case "TransactionDecodeError": - case "OnChainServiceError": - case "OnChainAddressAlreadyCreatedForRequestIdError": - case "OnChainAddressNotFoundError": - case "PayoutNotFoundError": - case "PayoutDestinationBlocked": - case "NotificationsError": - case "NotificationsServiceError": - case "InvalidDeviceNotificationsServiceError": - case "DeviceTokensNotRegisteredNotificationsServiceError": - case "AllDeviceTokensStaleNotificationsServiceError": - case "NoDeviceAcceptedPushNotificationsServiceError": - case "RecipientDisabledNotificationsServiceError": - case "AccountError": - case "IpFetcherError": - case "IpFetcherServiceError": - case "CouldNotFindTransactionError": - case "CouldNotFindTransactionMetadataError": - case "CouldNotFindExpectedTransactionMetadataError": - case "InvalidDocumentIdForDbError": - case "MismatchedResultForTransactionMetadataQuery": - case "InvalidLedgerTransactionId": - case "MultiplePendingPaymentsForHashError": - case "MissingExpectedDisplayAmountsForTransactionError": - case "CacheError": - case "CacheNotAvailableError": - case "CacheServiceError": - case "CacheUndefinedError": - case "PhoneProviderServiceError": - case "ExpectedPhoneMetadataMissingError": - case "PhoneCarrierTypeNotAllowedError": - case "MissingIPMetadataError": - case "UnauthorizedIPMetadataASNError": - case "InvalidAccountStatusError": - case "InvalidOnChainAddress": - case "InvalidScanDepthAmount": - case "InsufficientBalanceForRoutingError": - case "InsufficientBalanceForLnPaymentError": - case "InvalidLanguageError": - case "InvalidAccountLevelError": - case "InvalidAccountLimitTypeError": - case "InvalidWithdrawFeeError": - case "InvalidUsdCents": - case "NonIntegerError": - case "FeeDifferenceError": - case "NoTransactionToSettleError": - case "CorruptLndDbError": - case "NotImplementedError": - case "NotReachableError": - case "DealerPriceError": - case "DealerPriceServiceError": - case "InvalidNegativeAmountError": - case "DomainError": - case "ErrorLevel": - case "RankedErrorLevel": - case "InvalidCurrencyBaseAmountError": - case "NoTransactionToUpdateError": - case "BalanceLessThanZeroError": - case "MissingPhoneError": - case "InvalidUserId": - case "InvalidLightningPaymentFlowBuilderStateError": - case "InvalidOnChainPaymentFlowBuilderStateError": - case "NonLnPaymentTransactionForPaymentFlowError": - case "MissingPropsInTransactionForPaymentFlowError": - case "InvalidZeroAmountPriceRatioInputError": - case "InvalidCurrencyForWalletError": - case "InvalidLightningPaymentFlowStateError": - case "InvalidLedgerTransactionStateError": - case "BadInputsForFindError": - case "LnHashPresentInIntraLedgerFlowError": - case "IntraLedgerHashPresentInLnFlowError": - case "PubSubError": - case "PubSubServiceError": - case "BigIntConversionError": - case "BigIntFloatConversionError": - case "SafeWrapperError": - case "InvalidFeeProbeStateError": - case "LookupPaymentTimedOutError": - case "InvalidPubKeyError": - case "SkipProbeForPubkeyError": - case "SecretDoesNotMatchAnyExistingHodlInvoiceError": - case "CaptchaError": - case "InvalidNonHodlInvoiceError": - case "InvalidAccountError": - case "InvalidMinutesError": - case "InvalidWalletForAccountError": - case "AuthenticationError": - case "LikelyNoUserWithThisPhoneExistError": - case "LikelyUserAlreadyExistError": - case "CouldNotUnsetPhoneFromUserError": - case "NotificationsServiceUnreachableServerError": - case "InvalidDeviceTokenError": - case "EventAugmentationMissingError": - case "NoPayloadFoundError": - case "ExpectedUtxoDetectedPayloadNotFoundError": - case "ExpectedUtxoDroppedPayloadNotFoundError": - case "ExpectedUtxoSettledPayloadNotFoundError": - case "ExpectedPayoutSubmittedPayloadNotFoundError": - case "ExpectedPayoutCommittedPayloadNotFoundError": - case "ExpectedPayoutBroadcastPayloadNotFoundError": - case "ExpectedPayoutSettledPayloadNotFoundError": - case "UnknownPayloadTypeReceivedError": - case "ExpectedAddressInfoMissingInEventError": - case "MissingCreatedAtKratosError": - case "MissingExpiredAtKratosError": - case "InvalidIdentitySessionKratosError": - case "MissingTotpKratosError": - case "IncompatibleSchemaUpgradeError": - case "UnknownKratosError": - case "RegistrationHookFailedError": - case "BriaEventError": - case "BriaPayloadError": - case "KratosError": - case "AuthenticationKratosError": - case "ExtendSessionKratosError": - case "MultipleCurrenciesForSingleCurrencyOperationError": - case "MattermostError": - case "CouldNotFindAccountIpError": - case "InvalidFlowId": - case "CallbackError": - case "InvalidUrlError": - case "SvixEventError": - case "UnknownSvixError": - case "MissingSecretForAuthNCallbackError": - case "InvalidSecretForAuthNCallbackError": - case "MissingRegistrationPayloadPropertiesError": - case "UnsupportedSchemaTypeError": - case "SecretForAuthNCallbackError": - case "RegistrationPayloadValidationError": - case "UnauthorizedPhoneError": - case "PhoneMetadataValidationError": - case "InvalidCarrierForPhoneMetadataError": - case "InvalidCarrierTypeForPhoneMetadataError": - case "InvalidCountryCodeForPhoneMetadataError": - message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ - error.name - }${error.message ? ": " + error.message : ""})` - return new UnexpectedClientError({ message, logger: baseLogger }) - - // parseRepositoryError keeps the Mongo driver text on this error for logs - // and spans. It names internal collections, indexes and the colliding - // value, so unlike the catch-all above the message is not interpolated. - case "DuplicateKeyForPersistError": - message = `Unexpected error occurred, please try again or contact support if it persists (code: ${error.name})` - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "MissingSessionIdError": - case "AuthTokenUserIdMismatchError": - return new UnexpectedClientError({ - message, - logger: baseLogger, - forwardToClient: false, - }) - - // ---------- - // Unknown below here - // ---------- - case "UnknownRouteNotFoundError": - message = "Unknown error occurred when trying to find a route for payment." - return new RouteFindingError({ message, logger: baseLogger }) - - case "UnknownLnInvoiceDecodeError": - message = "Invalid lightning request, couldn't decode." - return new InvoiceDecodeError({ message, logger: baseLogger }) - - case "UnknownRepositoryError": - case "UnknownLedgerError": - message = `Unknown error occurred (code: ${error.name})` - return new DbError({ message, logger: baseLogger, level: "fatal" }) - - case "UnknownLightningServiceError": - message = `Unknown error occurred (code: ${error.name})` - return new LightningPaymentError({ message, logger: baseLogger }) - - case "UnknownRateLimitServiceError": - case "UnknownLockServiceError": - case "UnknownPriceServiceError": - case "UnknownOnChainServiceError": - case "UnknownNotificationsServiceError": - case "UnknownIpFetcherServiceError": - case "UnknownCacheServiceError": - case "UnknownPhoneProviderServiceError": - case "UnknownDealerPriceServiceError": - case "UnknownPubSubError": - case "UnknownBigIntConversionError": - case "UnknownDomainError": - case "UnknownBriaEventError": - case "CouldNotFindAccountError": - case "UpgradeRequestCreateError": - case "IdVerificationQueryError": - case "DecisionReasonQueryError": - message = `Unknown error occurred (code: ${error.name})` - return new UnknownClientError({ message, logger: baseLogger }) - - case "UpgradeRequestQueryError": - message = "No upgrade request found for this account" - return new NotFoundError({ message, logger: baseLogger }) - - case "BankAccountUpgradeRequiredError": - return bankAccountGqlError({ - code: "BANK_ACCOUNT_UPGRADE_REQUIRED", - message: "Complete your account upgrade before managing bank accounts.", - }) - - case "BankAccountNotOwnedError": - return bankAccountGqlError({ - code: "BANK_ACCOUNT_NOT_FOUND", - message: "Bank account not found.", - }) - - case "BankAccountDuplicateNumberError": - return bankAccountGqlError({ - code: "BANK_ACCOUNT_DUPLICATE_NUMBER", - message: "A bank account with this account number already exists.", - }) - - case "BankAccountValidationError": - return bankAccountGqlError({ - code: "BANK_ACCOUNT_INVALID", - // Allowlisted text only — never whatever ERPNext happened to throw. - message: isBankAccountValidationReason(error.message) - ? error.message - : "The bank account details are not valid.", - }) - - case "BankAccountCreateError": - message = "We could not add this bank account. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "BankAccountUpdateError": - message = "We could not update this bank account. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "BankAccountDeleteError": - message = "We could not delete this bank account. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "BankAccountSetDefaultError": - message = "We could not change your default bank account. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "BankAccountQueryError": - case "BanksQueryError": - message = "We could not load your bank details. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "BankAccountUpdateRequestCreateError": - message = "We could not submit your bank account update. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "BankAccountUpdateRequestQueryError": - message = "We could not load your bank account update request. Please try again." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "ExchangeRateQueryError": - message = - "Cashout is temporarily unavailable while we refresh the exchange rate. Please try again shortly." - return new UnexpectedClientError({ message, logger: baseLogger }) - - case "InvalidLnurlError": - return new InvalidLnurlError({ message: error.message, logger: baseLogger }) - - case "InvalidLnurlAmountError": - return new InvalidLnurlError({ message: error.message, logger: baseLogger }) - - case "CashWalletCutoverInProgressError": - message = "Cash Wallet cutover is in progress. Please try again shortly." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "CashWalletMigrationFailedError": - message = "Cash Wallet migration needs support review." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "CashWalletCutoverPreflightError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "CashWalletCutoverTreasuryInsufficientBalanceError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "CashWalletMissingLegacyUsdWalletError": - message = "Legacy USD Cash Wallet is missing for this account." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "CashWalletMissingUsdtWalletError": - message = "USDT Cash Wallet is missing for this account." - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidCashWalletCutoverAmountError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidCashWalletMigrationTransitionError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "InvalidCashWalletCutoverStateTransitionError": - message = error.message - return new ValidationInternalError({ message, logger: baseLogger }) - - case "UnknownCaptchaError": - message = `Unknown error occurred (code: ${error.name}${ - error.message ? ": " + error.message : "" - })` - return new UnknownClientError({ message, logger: baseLogger }) - - default: - return assertUnreachable(errorName) - } -} - -// Move to CustomApolloError class? -export const apolloErrorResponse = (e: CustomApolloError): { errors: IError[] } => { - return { - errors: [ - { - message: e.message, - path: e.path, - code: e.extensions.code, - }, - ], - } -} - -export const mapAndParseErrorForGqlResponse = (err: ApplicationError): IError => { - const mappedError = mapError(err) - return { - message: mappedError.message, - path: mappedError.path, - code: mappedError.extensions.code, - } -} - -export const mapToGqlErrorList = (err: ApplicationError): IError[] => { - if (err instanceof ValidationError && err.errors) - return err.errors.map(mapAndParseErrorForGqlResponse) - else return [mapAndParseErrorForGqlResponse(err)] -} +import { ValidationError } from "@domain/shared" +import { + TransactionRestrictedError, + LightningPaymentError, + NotFoundError, + SelfPaymentError, + InsufficientBalanceError, + DbError, + UnknownClientError, + InvoiceDecodeError, + ValidationInternalError, + TooManyRequestError, + RouteFindingError, + InvalidCoordinatesError, + InvalidBusinessTitleLengthError, + VerificationCodeError, + UsernameError, + DealerOfflineError, + LndOfflineError, + PhoneProviderError, + UnexpectedClientError, + DealerError, + PhoneAccountAlreadyExistsError, + PhoneAccountAlreadyExistsNeedToSweepFundsError, + PhoneAccountAlreadyExistsCannotUpgradeError, + EmailUnverifiedError, + AccountAlreadyHasEmailError, + PhoneAlreadyExistsError, + EmailAlreadyExistsError, + SessionRefreshRequiredError, + CodeExpiredError, + UnauthorizedIPForOnboardingError, + InvalidPhoneMetadataForOnboardingError, + InvalidPhoneForOnboardingError, + UnauthorizedIPError, + UnauthorizedIPMetadataProxyError, + UnauthorizedIPMetadataCountryError, + IbexError, + InvalidLnurlError, + CustomApolloError, +} from "@graphql/error" +import { baseLogger } from "@services/logger" +import { isBankAccountValidationReason } from "@services/frappe/errors" + +const assertUnreachable = (x: unknown): never => { + throw new Error(`This should never compile with ${x}`) +} + +const bridgeGqlError = ({ + code, + message, +}: { + code: string + message: string +}): CustomApolloError => + new CustomApolloError({ + code, + message, + forwardToClient: true, + logger: baseLogger, + }) + +// Same shape as the Bridge errors: a stable machine-readable code the app can +// branch on, plus a message that is safe to show as-is. +const bankAccountGqlError = bridgeGqlError + +export const mapError = (error: ApplicationError): CustomApolloError => { + const errorName = error.name as ApplicationErrorKey + let message = "" + switch (errorName) { + case "WithdrawalLimitsExceededError": + message = error.message + return new TransactionRestrictedError({ message, logger: baseLogger }) + + case "IntraledgerLimitsExceededError": + message = error.message + return new TransactionRestrictedError({ message, logger: baseLogger }) + + case "TradeIntraAccountLimitsExceededError": + message = error.message + return new TransactionRestrictedError({ message, logger: baseLogger }) + + case "InvalidSendAmountError": + message = error.message || "Amount must be greater than zero" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "SendLimitsUnavailableError": + message = "Sending is temporarily unavailable, please try again later." + return new TransactionRestrictedError({ message, logger: baseLogger }) + + case "CompletedInvoice": + case "AlreadyPaidError": + message = "Invoice is already paid" + return new LightningPaymentError({ message, logger: baseLogger }) + + case "CouldNotFindWalletInvoiceError": + message = `User tried to pay invoice with hash ${error.message}, but it does not exist` + return new LightningPaymentError({ message, logger: baseLogger }) + + case "NoValidNodeForPubkeyError": + message = `Temporary failure when trying to pay, please retry payment` + return new LightningPaymentError({ message, logger: baseLogger }) + + case "PaymentAttemptsTimedOutError": + message = `Temporary failure when trying to pay, please try again later` + return new LightningPaymentError({ message, logger: baseLogger }) + + case "PaymentRejectedByDestinationError": + message = `Payment was rejected by destination as invalid` + return new LightningPaymentError({ message, logger: baseLogger }) + + case "InvoiceExpiredOrBadPaymentHashError": + message = `Invoice already expired, or has bad payment hash` + return new LightningPaymentError({ message, logger: baseLogger }) + + case "CouldNotFindUserFromIdError": + message = `User does not exist for id ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindAccountFromIdError": + message = `Account does not exist for id ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + // Same shape as the id/uuid/username siblings: a not-found for whichever + // user the caller looked up. Admin lookups (accountDetailsByUserPhone, + // accountDetailsByUserEmail) resolve OTHER users through findByUserId, so + // this must never read as "your session is unauthenticated". The one site + // that knows the missing account is the caller's own is the session + // middleware, which raises AuthenticationError itself. + case "CouldNotFindAccountFromKratosIdError": + message = `Account does not exist for user id ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindAccountFromUuidError": + message = `Account does not exist for uuid ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindAccountFromUsernameError": + message = `Account does not exist for username ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindAccountFromNpubError": + message = `Account does not exist for npub ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "NoNpubToReleaseError": + message = `Account ${error.message} has no npub linked` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindMerchantFromUsernameError": + message = `Merchant does not exist for username ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindMerchantFromIdError": + message = `Merchant does not exist for ID ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindTransactionsForAccountError": + message = "No transactions found for your account." + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindUserFromPhoneError": + message = `User does not exist for phone ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindUserFromWalletIdError": + message = `User does not exist for wallet-id ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "CouldNotFindUserFromEmailError": + message = `User does not exist for email` + return new NotFoundError({ message, logger: baseLogger }) + + case "ContactNotExistentError": + message = `Contact does not exist for account ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "IdentifierNotFoundError": + message = `Identifier does not exist ${error.message}` + return new NotFoundError({ message, logger: baseLogger }) + + case "SelfPaymentError": + message = "User tried to pay themselves" + return new SelfPaymentError({ message, logger: baseLogger }) + + case "LnInvoiceMissingPaymentSecretError": + message = "Invoice is missing its 'payment secret' value" + return new InvoiceDecodeError({ message, logger: baseLogger }) + + case "InvalidChecksumForLnInvoiceError": + message = "Invoice has an invalid checksum, please check again" + return new InvoiceDecodeError({ message, logger: baseLogger }) + + case "LnPaymentRequestInTransitError": + message = "There is a pending payment for this invoice" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidApiKeyNameError": + case "InvalidApiKeyScopeError": + case "InvalidApiKeyIpConstraintError": + case "InvalidApiKeyRateLimitError": + case "InvalidApiKeyFormatError": + case "MaxApiKeysPerAccountError": + case "InsufficientApiKeyScopeError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "ApiKeyExpiredError": + message = "This API key has expired" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "ApiKeySecretMismatchError": + message = "Invalid API key" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "ApiKeyIpNotAllowedError": + message = "API key not allowed from this IP" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "ApiKeyCannotManageApiKeysError": + message = "API keys cannot be managed while authenticated with an API key" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "SatoshiAmountRequiredError": + message = "An amount is required to complete payment" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidSatoshiAmountError": + message = "A valid satoshi amount is required" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidBtcPaymentAmountError": + message = "A valid satoshi amount is required" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidUsdPaymentAmountError": + message = "A valid usd amount is required" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "BtcAmountTooLargeError": + message = "Sats amount passed is too large" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "UsdAmountTooLargeError": + message = "Usd cents amount passed is too large" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "LnPaymentRequestNonZeroAmountRequiredError": + message = "Invoice does not have a valid amount to pay" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "LnPaymentRequestZeroAmountRequiredError": + message = "Invoice must be a zero-amount invoice" + return new ValidationInternalError({ message, logger: baseLogger }) + + // The domain-side twin of the provider error above. Left in the catch-all + // it rendered a malformed number as "unexpected error, contact support", + // and echoed the submitted number back inside that message. + case "InvalidPhoneNumberPhoneProviderError": + case "InvalidPhoneNumber": + message = "Phone number is not a valid phone number" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "RestrictedRegionPhoneProviderError": + message = "Phone number is not from a valid region" + return new ValidationInternalError({ message, logger: baseLogger }) + + // A deliberate policy rejection, not a bug: it must not fall into the + // catch-all below, which would tell the user to retry and open a ticket. + case "PhoneCountryNotAllowedError": + message = "Phone number is not from a valid region" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "PhoneProviderConnectionError": + case "PhoneProviderUnavailableError": + message = "Phone provider temporarily unreachable" + return new PhoneProviderError({ message, logger: baseLogger }) + + case "PhoneProviderRateLimitExceededError": + message = "Too many phone number attempts, please try again in a few minutes." + return new PhoneProviderError({ message, logger: baseLogger }) + + case "RestrictedRecipientPhoneNumberError": + message = + "Issue detected with phone number. Please try again later or contact support." + return new PhoneProviderError({ message, logger: baseLogger }) + + case "InvalidOrApprovedVerificationError": + message = + "Issue detected with verification. Please try again later or contact support." + return new PhoneProviderError({ message, logger: baseLogger }) + + case "UnsubscribedRecipientPhoneProviderError": + message = "Phone number has opted out of receiving messages" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidContactAlias": + message = "ContactAlias has incorrect characters or length" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "PaymentSendRateLimiterExceededError": + message = "Too many payment attempts, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "InvoiceCreateRateLimiterExceededError": + case "InvoiceCreateForRecipientRateLimiterExceededError": + message = "Too many invoices creation, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "OnChainAddressCreateRateLimiterExceededError": + message = + "Too many onchain addresses creation, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "BankAccountManageRateLimiterExceededError": + message = "Too many bank account changes, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "FygaroCheckoutCreateRateLimiterExceededError": + message = "Too many card top-up attempts, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "UserCodeAttemptIdentifierRateLimiterExceededError": + message = "Too many request code attempts, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "UserCodeAttemptIpRateLimiterExceededError": + message = + "Too many phone code attempts on same network, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "CreateDeviceAccountIpRateLimiterExceededError": + message = + "Too many account creation attempts on same network, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "PhoneCodeInvalidError": + message = "Invalid or incorrect code entered." + return new VerificationCodeError({ message, logger: baseLogger }) + + case "EmailCodeInvalidError": + message = "Invalid or incorrect code entered." + return new VerificationCodeError({ message, logger: baseLogger }) + + case "ExpiredOrNonExistentPhoneNumberError": + message = "Invalid or incorrect phone entered." + return new VerificationCodeError({ message, logger: baseLogger }) + + case "CouldNotFindAccountFromPhoneError": + message = "Invalid or incorrect phone entered." + return new VerificationCodeError({ message, logger: baseLogger }) + + case "UserLoginIdentifierRateLimiterExceededError": + message = "Too many login attempts, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "EmailValidationSubmittedTooOftenError": + message = "Too many attempts, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "UserLoginIpRateLimiterExceededError": + message = + "Too many login attempts on same network, please wait for a while and try again." + return new TooManyRequestError({ message, logger: baseLogger }) + + case "InvalidQuizQuestionIdError": + message = "Invalid quiz question id was passed." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidPushNotificationSettingError": + message = "Invalid push notification setting was passed." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "RewardAlreadyPresentError": + message = "Reward for quiz question was already claimed." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "SubOneCentSatAmountForUsdSelfSendError": + message = "Amount sent was too low for recipient's usd wallet." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidPhoneForRewardError": + message = "Unsupported phone carrier for rewards." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidIpMetadataError": + message = "Unsupported IP." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "NoDealerPriceDataAvailableError": + message = "No price data from dealer to perform USD operation." + return new DealerError({ message, logger: baseLogger }) + + case "NoConnectionToDealerError": + message = "No connection to dealer to perform USD operation." + return new DealerOfflineError({ message, logger: baseLogger }) + + case "DealerStalePriceError": + message = "Stale dealer price, can't perform USD operation." + return new DealerOfflineError({ message, logger: baseLogger }) + + case "RouteNotFoundError": + case "MaxFeeTooLargeForRoutelessPaymentError": + message = "Unable to find a route for payment." + return new RouteFindingError({ message, logger: baseLogger }) + + case "DestinationMissingDependentFeatureError": + message = "Unable to pay incompatible recipient." + return new RouteFindingError({ message, logger: baseLogger }) + + case "UnknownNextPeerError": + message = "Unable to find a route for payment." + return new RouteFindingError({ message, logger: baseLogger }) + + case "ProbeForRouteTimedOutError": + case "ProbeForRouteTimedOutFromApplicationError": + message = "Unable to find a route for payment." + return new RouteFindingError({ message, logger: baseLogger }) + + case "PaymentInTransitionError": + message = "Payment was sent and is still in transition." + return new LightningPaymentError({ message, logger: baseLogger }) + + case "TemporaryChannelFailureError": + case "TemporaryNodeFailureError": + message = "Issue with lightning payment, please try again." + return new LightningPaymentError({ message, logger: baseLogger }) + + case "UsernameNotAvailableError": + message = "username not available" + return new UsernameError({ message, logger: baseLogger }) + + case "UsernameIsImmutableError": + message = "username is immutable" + return new UsernameError({ message, logger: baseLogger }) + + case "NpubNotAvailableError": + message = "npub is already linked to another account" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "AccountAlreadyHasNpubError": + message = "the receiving account already has an npub linked" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidAccountIdError": + message = `Invalid account id ${error.message}` + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidWalletId": + message = "Invalid walletId for account." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidDisplayCurrencyError": + message = "Invalid currency." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "MismatchedCurrencyForWalletError": + message = "Unsupported operation for wallet's currency." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "UnsupportedCurrencyError": + return new ValidationInternalError({ logger: baseLogger }) + + case "InsufficientBalanceError": + case "InsufficientIbexBalance": + message = error.message + return new InsufficientBalanceError({ message, logger: baseLogger }) + + case "CaptchaUserFailToPassError": + message = "Captcha validation failed." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "AmountLessThanFeeError": + case "LessThanDustThresholdError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "OffChainServiceUnavailableError": + case "OffChainServiceBusyError": + case "OnChainServiceUnavailableError": + /* eslint-disable-next-line no-case-declarations */ + const serviceType = errorName.includes("OffChainService") ? "Offchain" : "Onchain" + message = `${serviceType} action failed, please try again in a few minutes. If the problem persists, please contact support. If you are on regtest, it might because blocks need to be generated so that lnd can be considered active.` + return new LndOfflineError({ message, logger: baseLogger }) + + case "InactiveAccountError": + message = "Account is inactive." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidPriceCurrencyError": + message = "Invalid currency." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCoordinatesError": + return new InvalidCoordinatesError({ logger: baseLogger }) + + case "InvalidBusinessTitleLengthError": + return new InvalidBusinessTitleLengthError({ logger: baseLogger }) + + case "CannotConnectToDbError": + case "DbConnectionClosedError": + message = + "Service offline, please try again in a few minutes. If the problem persists, please contact support." + return new DbError({ message, logger: baseLogger }) + + case "InvalidPaginationArgumentsError": + message = "Invalid pagination arguments" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "AccountHasPositiveBalanceError": + message = + "The phone is associated with an existing wallet that has a non zero balance. Sweep the funds and try again." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "PhoneAccountAlreadyExistsError": + message = + "Phone Account already exists. Please logout and log back in with your phone account." + return new PhoneAccountAlreadyExistsError({ message, logger: baseLogger }) + + case "PhoneAccountAlreadyExistsNeedToSweepFundsError": + message = + "Error phone account already exists. You need to manually sweep funds to your phone account." + return new PhoneAccountAlreadyExistsNeedToSweepFundsError({ + message, + logger: baseLogger, + }) + + case "PhoneAccountAlreadyExistsCannotUpgradeError": + message = + "Phone number is already registered to another user. Please log out and log in with that phone account." + return new PhoneAccountAlreadyExistsCannotUpgradeError({ + message, + logger: baseLogger, + }) + + case "InvalidIdempotencyKeyError": + message = + "Invalid format for the provided ImpotencyKey. You must use a UUID-v4 format" + return new ValidationInternalError({ + message, + logger: baseLogger, + }) + + case "IdempotencyKeyReuseError": + message = + "This idempotency key was already used for a different payment. Use a new key for a new payment." + return new ValidationInternalError({ + message, + logger: baseLogger, + }) + + case "EmailUnverifiedError": + return new EmailUnverifiedError({ logger: baseLogger }) + + case "AccountAlreadyHasEmailError": + return new AccountAlreadyHasEmailError({ logger: baseLogger }) + + case "PhoneAlreadyExistsError": + return new PhoneAlreadyExistsError({ logger: baseLogger }) + + // The pre-persist registration hook refused the number (unparsable, or + // carrier metadata failed validation). A policy answer, not a bug: it + // must not fall into the catch-all that tells the user to retry. + case "PhoneNotAllowedForRegistrationError": + message = "This phone number can't be used to sign up" + return new ValidationInternalError({ message, logger: baseLogger }) + + // Same hook, phone already bound to a users document. The caller is on the + // sign-up path with no account and no session, so this must not reuse + // PhoneAlreadyExistsError's "one phone per account" text. + case "PhoneAlreadyRegisteredError": + message = + "This phone number is already registered. Contact support if you can't sign in" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "EmailAlreadyExistsError": + return new EmailAlreadyExistsError({ logger: baseLogger }) + + case "CodeExpiredKratosError": + return new CodeExpiredError({ logger: baseLogger }) + + case "SessionRefreshRequiredError": + return new SessionRefreshRequiredError({ logger: baseLogger }) + + case "UnauthorizedIPForOnboardingError": + return new UnauthorizedIPForOnboardingError({ logger: baseLogger }) + + case "InvalidPhoneMetadataForOnboardingError": + return new InvalidPhoneMetadataForOnboardingError({ logger: baseLogger }) + + case "InvalidPhoneForOnboardingError": + return new InvalidPhoneForOnboardingError({ logger: baseLogger }) + + case "UnauthorizedIPError": + return new UnauthorizedIPError({ logger: baseLogger }) + + case "UnauthorizedIPMetadataProxyError": + return new UnauthorizedIPMetadataProxyError({ logger: baseLogger }) + + case "UnauthorizedIPMetadataCountryError": + return new UnauthorizedIPMetadataCountryError({ logger: baseLogger }) + + case "IbexError": + case "UnexpectedIbexResponse": + case "FailedIbexPayment": + return new IbexError(baseLogger) + // The rail never answered (as opposed to answering "no"), so the honest + // client-facing state is "temporarily unavailable" with a retry hint — + // not the generic contact-support wording, which tells a user with a + // funded BTC wallet to open a ticket for a blip. Must stay listed: the + // switch ends in assertUnreachable, so an unlisted name throws. + case "IbexUnavailableError": + message = error.message || "Cash Wallet is temporarily unavailable." + return bridgeGqlError({ + code: "IBEX_UNAVAILABLE", + message, + }) + case "OfferNotFound": + return new NotFoundError({ + message: "Offer not available. Try again.", + logger: baseLogger, + }) + + case "BridgeInvalidAmountError": + message = + error.message || "Amount must be strictly positive with at most 6 decimal places" + return bridgeGqlError({ + code: "BRIDGE_INVALID_AMOUNT", + message, + }) + + case "BridgeBelowMinimumWithdrawalError": + message = error.message || "Withdrawal amount is below the minimum" + return bridgeGqlError({ + code: "BRIDGE_BELOW_MINIMUM_WITHDRAWAL", + message, + }) + + case "BridgeDisabledError": + message = "Bridge integration is currently disabled" + return bridgeGqlError({ + code: "BRIDGE_DISABLED", + message, + }) + + case "BridgeAccountLevelError": + message = error.message || "Bridge requires at least a Personal account (Level 1+)" + return bridgeGqlError({ + code: "BRIDGE_ACCOUNT_LEVEL_ERROR", + message, + }) + + case "BridgeKycEmailNotVerifiedError": + message = + error.message || + "Verify your email address before starting identity verification." + return bridgeGqlError({ + code: "BRIDGE_KYC_EMAIL_NOT_VERIFIED", + message, + }) + + case "BridgeKycCountryNotSupportedError": + message = + error.message || "US virtual accounts aren't available in your country yet." + return bridgeGqlError({ + code: "BRIDGE_KYC_COUNTRY_NOT_SUPPORTED", + message, + }) + + case "BridgeKycPhoneRequiredError": + message = + error.message || + "Add and verify a phone number before starting identity verification." + return bridgeGqlError({ + code: "BRIDGE_KYC_PHONE_REQUIRED", + message, + }) + + case "BridgeKycPendingError": + message = "KYC verification is pending" + return bridgeGqlError({ + code: "BRIDGE_KYC_PENDING", + message, + }) + + case "BridgeKycRejectedError": + message = "KYC verification was rejected" + return bridgeGqlError({ + code: "BRIDGE_KYC_REJECTED", + message, + }) + + case "BridgeKycOffboardedError": + message = "Your account has been offboarded from Bridge. Please contact support." + return bridgeGqlError({ + code: "BRIDGE_KYC_OFFBOARDED", + message, + }) + + case "BridgeKycTierCeilingExceededError": + message = error.message || "Withdrawal amount exceeds the KYC tier ceiling" + return bridgeGqlError({ + code: "BRIDGE_KYC_TIER_CEILING_EXCEEDED", + message, + }) + + case "BridgeCustomerNotFoundError": + message = "Bridge customer not found" + return bridgeGqlError({ + code: "BRIDGE_CUSTOMER_NOT_FOUND", + message, + }) + + case "BridgeInsufficientFundsError": + message = "Insufficient funds for withdrawal" + return bridgeGqlError({ + code: "BRIDGE_INSUFFICIENT_FUNDS", + message, + }) + + case "BridgeWithdrawalNetAmountTooLowError": + message = error.message || "Withdrawal amount must exceed estimated customer fees" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_NET_AMOUNT_TOO_LOW", + message, + }) + + case "BridgeWithdrawalNotFoundError": + message = error.message || "Withdrawal request not found" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_NOT_FOUND", + message, + }) + + case "BridgeWithdrawalAlreadyInitiatedError": + message = + error.message || + "Withdrawal has already been submitted to Bridge and cannot be cancelled" + return bridgeGqlError({ + code: "BRIDGE_WITHDRAWAL_ALREADY_INITIATED", + message, + }) + + case "BridgeRateLimitError": + message = "Rate limit exceeded, please try again later" + return bridgeGqlError({ + code: "BRIDGE_RATE_LIMIT", + message, + }) + + case "BridgeTimeoutError": + message = "Request timed out" + return bridgeGqlError({ + code: "BRIDGE_TIMEOUT", + message, + }) + + case "BridgeTransferFailedError": + message = error.message || "Transfer failed" + return bridgeGqlError({ + code: "BRIDGE_TRANSFER_FAILED", + message, + }) + + case "BridgeDepositInstructionsMissingError": + message = + error.message || + "Bridge did not return crypto deposit instructions for this withdrawal" + return bridgeGqlError({ + code: "BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING", + message, + }) + + case "BridgeWebhookValidationError": + message = "Invalid webhook signature" + return bridgeGqlError({ + code: "BRIDGE_WEBHOOK_VALIDATION", + message, + }) + + case "BridgeApiError": + message = error.message || "Bridge API error" + return bridgeGqlError({ + code: "BRIDGE_API_ERROR", + message, + }) + + case "BridgePlaidNotAvailableError": + message = error.message || "Plaid bank account linking is not available" + return bridgeGqlError({ + code: "BRIDGE_PLAID_NOT_AVAILABLE", + message, + }) + + case "FygaroCheckoutDisabledError": + message = error.message || "Card top-up is currently unavailable" + return bridgeGqlError({ + code: "FYGARO_CHECKOUT_DISABLED", + message, + }) + + case "FygaroBelowMinimumError": + message = error.message || "Amount is below the minimum top-up" + return bridgeGqlError({ + code: "FYGARO_BELOW_MINIMUM", + message, + }) + + case "FygaroAboveSinglePaymentLimitError": + message = error.message || "Amount is above the single top-up limit" + return bridgeGqlError({ + code: "FYGARO_ABOVE_SINGLE_PAYMENT_LIMIT", + message, + }) + + case "FygaroDailyAllowanceExceededError": + message = error.message || "Amount is above your remaining daily top-up allowance" + return bridgeGqlError({ + code: "FYGARO_DAILY_ALLOWANCE_EXCEEDED", + message, + }) + + case "FygaroCheckoutAlreadyOpenError": + message = error.message || "You already have a card top-up link open" + return bridgeGqlError({ + code: "FYGARO_CHECKOUT_ALREADY_OPEN", + message, + }) + + case "FygaroAllowanceUnavailableError": + message = error.message || "Could not check your top-up allowance right now" + return bridgeGqlError({ + code: "FYGARO_ALLOWANCE_UNAVAILABLE", + message, + }) + + case "FygaroLevelNotEligibleError": + message = error.message || "Card top-up isn't available on your account level yet" + return bridgeGqlError({ + code: "FYGARO_LEVEL_NOT_ELIGIBLE", + message, + }) + + case "BridgeInvalidPlaidTokenError": + message = error.message || "linkToken and publicToken are required" + return bridgeGqlError({ + code: "BRIDGE_INVALID_PLAID_TOKEN", + message, + }) + + case "BridgeError": + message = error.message || "Bridge API error" + return bridgeGqlError({ code: "BRIDGE_ERROR", message }) + + // ---------- + // Unhandled below here + // ---------- + case "RateLimiterExceededError": + case "RateLimitError": + case "RateLimitServiceError": + case "CouldNotFindUserError": + case "LedgerError": + case "LedgerServiceError": + case "LedgerFacadeError": + case "LightningError": + case "BadPaymentDataError": + case "LnInvoiceDecodeError": + case "LightningServiceError": + case "CouldNotDecodeReturnedPaymentRequest": + case "InvoiceNotFoundError": + case "LnPaymentPendingError": + case "LnAlreadyPaidError": + case "PaymentNotFoundError": + case "OperationInterruptedError": + case "InconsistentDataError": + case "AuthorizationError": + case "RepositoryError": + case "PersistError": + case "DuplicateError": + case "CouldNotFindError": + case "CouldNotUpdateError": + case "ValidationError": + case "LnRouteValidationError": + case "BadAmountForRouteError": + case "InvalidUsername": + case "InvalidNpubError": + case "InvalidDeviceId": + case "InvalidIdentityPassword": + case "InvalidIdentityUsername": + case "InvalidTotpCode": + case "InvalidEmailAddress": + case "NoContactForUsernameError": + case "NoWalletExistsForUserError": + case "NoBtcWalletExistsForAccountError": + case "LimitsExceededError": + case "CouldNotFindLightningPaymentFlowError": + case "NoExpiredLightningPaymentFlowsError": + case "CouldNotUpdateLightningPaymentFlowError": + case "CouldNotFindWalletFromIdError": + case "CouldNotListWalletsFromAccountIdError": + case "CouldNotFindWalletFromUsernameError": + case "CouldNotFindWalletFromUsernameAndCurrencyError": + case "CouldNotFindWalletFromAccountIdAndCurrencyError": + case "CouldNotFindWalletFromOnChainAddressError": + case "CouldNotFindWalletFromOnChainAddressesError": + case "CouldNotFindBtcWalletForAccountError": + case "CouldNotListWalletsFromWalletCurrencyError": + case "CouldNotFindWalletOnChainPendingReceiveError": + case "CouldNotFindLnPaymentFromHashError": + case "LockError": + case "LockServiceError": + case "ResourceAttemptsLockServiceError": + case "ResourceExpiredLockServiceError": + case "PriceError": + case "PriceServiceError": + case "PriceNotAvailableError": + case "DealerPriceNotAvailableError": + case "PriceHistoryNotAvailableError": + case "PriceCurrenciesNotAvailableError": + case "OnChainError": + case "TransactionDecodeError": + case "OnChainServiceError": + case "OnChainAddressAlreadyCreatedForRequestIdError": + case "OnChainAddressNotFoundError": + case "PayoutNotFoundError": + case "PayoutDestinationBlocked": + case "NotificationsError": + case "NotificationsServiceError": + case "InvalidDeviceNotificationsServiceError": + case "DeviceTokensNotRegisteredNotificationsServiceError": + case "AllDeviceTokensStaleNotificationsServiceError": + case "NoDeviceAcceptedPushNotificationsServiceError": + case "RecipientDisabledNotificationsServiceError": + case "AccountError": + case "IpFetcherError": + case "IpFetcherServiceError": + case "CouldNotFindTransactionError": + case "CouldNotFindTransactionMetadataError": + case "CouldNotFindExpectedTransactionMetadataError": + case "InvalidDocumentIdForDbError": + case "MismatchedResultForTransactionMetadataQuery": + case "InvalidLedgerTransactionId": + case "MultiplePendingPaymentsForHashError": + case "MissingExpectedDisplayAmountsForTransactionError": + case "CacheError": + case "CacheNotAvailableError": + case "CacheServiceError": + case "CacheUndefinedError": + case "PhoneProviderServiceError": + case "ExpectedPhoneMetadataMissingError": + case "PhoneCarrierTypeNotAllowedError": + case "MissingIPMetadataError": + case "UnauthorizedIPMetadataASNError": + case "InvalidAccountStatusError": + case "InvalidOnChainAddress": + case "InvalidScanDepthAmount": + case "InsufficientBalanceForRoutingError": + case "InsufficientBalanceForLnPaymentError": + case "InvalidLanguageError": + case "InvalidAccountLevelError": + case "InvalidAccountLimitTypeError": + case "InvalidWithdrawFeeError": + case "InvalidUsdCents": + case "NonIntegerError": + case "FeeDifferenceError": + case "NoTransactionToSettleError": + case "CorruptLndDbError": + case "NotImplementedError": + case "NotReachableError": + case "DealerPriceError": + case "DealerPriceServiceError": + case "InvalidNegativeAmountError": + case "DomainError": + case "ErrorLevel": + case "RankedErrorLevel": + case "InvalidCurrencyBaseAmountError": + case "NoTransactionToUpdateError": + case "BalanceLessThanZeroError": + case "MissingPhoneError": + case "InvalidUserId": + case "InvalidLightningPaymentFlowBuilderStateError": + case "InvalidOnChainPaymentFlowBuilderStateError": + case "NonLnPaymentTransactionForPaymentFlowError": + case "MissingPropsInTransactionForPaymentFlowError": + case "InvalidZeroAmountPriceRatioInputError": + case "InvalidCurrencyForWalletError": + case "InvalidLightningPaymentFlowStateError": + case "InvalidLedgerTransactionStateError": + case "BadInputsForFindError": + case "LnHashPresentInIntraLedgerFlowError": + case "IntraLedgerHashPresentInLnFlowError": + case "PubSubError": + case "PubSubServiceError": + case "BigIntConversionError": + case "BigIntFloatConversionError": + case "SafeWrapperError": + case "InvalidFeeProbeStateError": + case "LookupPaymentTimedOutError": + case "InvalidPubKeyError": + case "SkipProbeForPubkeyError": + case "SecretDoesNotMatchAnyExistingHodlInvoiceError": + case "CaptchaError": + case "InvalidNonHodlInvoiceError": + case "InvalidAccountError": + case "InvalidMinutesError": + case "InvalidWalletForAccountError": + case "AuthenticationError": + case "LikelyNoUserWithThisPhoneExistError": + case "LikelyUserAlreadyExistError": + case "CouldNotUnsetPhoneFromUserError": + case "NotificationsServiceUnreachableServerError": + case "InvalidDeviceTokenError": + case "EventAugmentationMissingError": + case "NoPayloadFoundError": + case "ExpectedUtxoDetectedPayloadNotFoundError": + case "ExpectedUtxoDroppedPayloadNotFoundError": + case "ExpectedUtxoSettledPayloadNotFoundError": + case "ExpectedPayoutSubmittedPayloadNotFoundError": + case "ExpectedPayoutCommittedPayloadNotFoundError": + case "ExpectedPayoutBroadcastPayloadNotFoundError": + case "ExpectedPayoutSettledPayloadNotFoundError": + case "UnknownPayloadTypeReceivedError": + case "ExpectedAddressInfoMissingInEventError": + case "MissingCreatedAtKratosError": + case "MissingExpiredAtKratosError": + case "InvalidIdentitySessionKratosError": + case "MissingTotpKratosError": + case "IncompatibleSchemaUpgradeError": + case "UnknownKratosError": + case "RegistrationHookFailedError": + case "BriaEventError": + case "BriaPayloadError": + case "KratosError": + case "AuthenticationKratosError": + case "ExtendSessionKratosError": + case "MultipleCurrenciesForSingleCurrencyOperationError": + case "MattermostError": + case "CouldNotFindAccountIpError": + case "InvalidFlowId": + case "CallbackError": + case "InvalidUrlError": + case "SvixEventError": + case "UnknownSvixError": + case "MissingSecretForAuthNCallbackError": + case "InvalidSecretForAuthNCallbackError": + case "MissingRegistrationPayloadPropertiesError": + case "UnsupportedSchemaTypeError": + case "SecretForAuthNCallbackError": + case "RegistrationPayloadValidationError": + case "UnauthorizedPhoneError": + case "PhoneMetadataValidationError": + case "InvalidCarrierForPhoneMetadataError": + case "InvalidCarrierTypeForPhoneMetadataError": + case "InvalidCountryCodeForPhoneMetadataError": + message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ + error.name + }${error.message ? ": " + error.message : ""})` + return new UnexpectedClientError({ message, logger: baseLogger }) + + // parseRepositoryError keeps the Mongo driver text on this error for logs + // and spans. It names internal collections, indexes and the colliding + // value, so unlike the catch-all above the message is not interpolated. + case "DuplicateKeyForPersistError": + message = `Unexpected error occurred, please try again or contact support if it persists (code: ${error.name})` + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "MissingSessionIdError": + case "AuthTokenUserIdMismatchError": + return new UnexpectedClientError({ + message, + logger: baseLogger, + forwardToClient: false, + }) + + // ---------- + // Unknown below here + // ---------- + case "UnknownRouteNotFoundError": + message = "Unknown error occurred when trying to find a route for payment." + return new RouteFindingError({ message, logger: baseLogger }) + + case "UnknownLnInvoiceDecodeError": + message = "Invalid lightning request, couldn't decode." + return new InvoiceDecodeError({ message, logger: baseLogger }) + + case "UnknownRepositoryError": + case "UnknownLedgerError": + message = `Unknown error occurred (code: ${error.name})` + return new DbError({ message, logger: baseLogger, level: "fatal" }) + + case "UnknownLightningServiceError": + message = `Unknown error occurred (code: ${error.name})` + return new LightningPaymentError({ message, logger: baseLogger }) + + case "UnknownRateLimitServiceError": + case "UnknownLockServiceError": + case "UnknownPriceServiceError": + case "UnknownOnChainServiceError": + case "UnknownNotificationsServiceError": + case "UnknownIpFetcherServiceError": + case "UnknownCacheServiceError": + case "UnknownPhoneProviderServiceError": + case "UnknownDealerPriceServiceError": + case "UnknownPubSubError": + case "UnknownBigIntConversionError": + case "UnknownDomainError": + case "UnknownBriaEventError": + case "CouldNotFindAccountError": + case "UpgradeRequestCreateError": + case "IdVerificationQueryError": + case "DecisionReasonQueryError": + message = `Unknown error occurred (code: ${error.name})` + return new UnknownClientError({ message, logger: baseLogger }) + + case "UpgradeRequestQueryError": + message = "No upgrade request found for this account" + return new NotFoundError({ message, logger: baseLogger }) + + case "BankAccountUpgradeRequiredError": + return bankAccountGqlError({ + code: "BANK_ACCOUNT_UPGRADE_REQUIRED", + message: "Complete your account upgrade before managing bank accounts.", + }) + + case "BankAccountNotOwnedError": + return bankAccountGqlError({ + code: "BANK_ACCOUNT_NOT_FOUND", + message: "Bank account not found.", + }) + + case "BankAccountDuplicateNumberError": + return bankAccountGqlError({ + code: "BANK_ACCOUNT_DUPLICATE_NUMBER", + message: "A bank account with this account number already exists.", + }) + + case "BankAccountValidationError": + return bankAccountGqlError({ + code: "BANK_ACCOUNT_INVALID", + // Allowlisted text only — never whatever ERPNext happened to throw. + message: isBankAccountValidationReason(error.message) + ? error.message + : "The bank account details are not valid.", + }) + + case "BankAccountCreateError": + message = "We could not add this bank account. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "BankAccountUpdateError": + message = "We could not update this bank account. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "BankAccountDeleteError": + message = "We could not delete this bank account. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "BankAccountSetDefaultError": + message = "We could not change your default bank account. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "BankAccountQueryError": + case "BanksQueryError": + message = "We could not load your bank details. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "BankAccountUpdateRequestCreateError": + message = "We could not submit your bank account update. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "BankAccountUpdateRequestQueryError": + message = "We could not load your bank account update request. Please try again." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "ExchangeRateQueryError": + message = + "Cashout is temporarily unavailable while we refresh the exchange rate. Please try again shortly." + return new UnexpectedClientError({ message, logger: baseLogger }) + + case "InvalidLnurlError": + return new InvalidLnurlError({ message: error.message, logger: baseLogger }) + + case "InvalidLnurlAmountError": + return new InvalidLnurlError({ message: error.message, logger: baseLogger }) + + case "CashWalletCutoverInProgressError": + message = "Cash Wallet cutover is in progress. Please try again shortly." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMigrationFailedError": + message = "Cash Wallet migration needs support review." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletCutoverPreflightError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletCutoverTreasuryInsufficientBalanceError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMissingLegacyUsdWalletError": + message = "Legacy USD Cash Wallet is missing for this account." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMissingUsdtWalletError": + message = "USDT Cash Wallet is missing for this account." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletCutoverAmountError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletMigrationTransitionError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletCutoverStateTransitionError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "UnknownCaptchaError": + message = `Unknown error occurred (code: ${error.name}${ + error.message ? ": " + error.message : "" + })` + return new UnknownClientError({ message, logger: baseLogger }) + + default: + return assertUnreachable(errorName) + } +} + +// Move to CustomApolloError class? +export const apolloErrorResponse = (e: CustomApolloError): { errors: IError[] } => { + return { + errors: [ + { + message: e.message, + path: e.path, + code: e.extensions.code, + }, + ], + } +} + +export const mapAndParseErrorForGqlResponse = (err: ApplicationError): IError => { + const mappedError = mapError(err) + return { + message: mappedError.message, + path: mappedError.path, + code: mappedError.extensions.code, + } +} + +export const mapToGqlErrorList = (err: ApplicationError): IError[] => { + if (err instanceof ValidationError && err.errors) + return err.errors.map(mapAndParseErrorForGqlResponse) + else return [mapAndParseErrorForGqlResponse(err)] +} diff --git a/src/graphql/shared/types/object/usd-wallet.ts b/src/graphql/shared/types/object/usd-wallet.ts index 137bf3f0b..fdb8dedaa 100644 --- a/src/graphql/shared/types/object/usd-wallet.ts +++ b/src/graphql/shared/types/object/usd-wallet.ts @@ -16,6 +16,7 @@ import { import { WalletCurrency as WalletCurrencyDomain, USDTAmount } from "@domain/shared" import { WalletType } from "@domain/wallets" +import { IbexUnavailableError } from "@services/ibex/errors" import IWallet from "../abstract/wallet" @@ -79,6 +80,11 @@ const UsdWallet = GT.Object({ walletId: balanceWallet.id, currency: balanceWallet.currency, }) + // IBEX not answering is not the caller's problem to interpret: the + // nullable balance field IS the unavailable state, so the wallet + // screen still renders (and the BTC wallet on the same account keeps + // its numbers) instead of the whole query coming back as errors. + if (balance instanceof IbexUnavailableError) return null if (balance instanceof Error) { throw mapError(balance) } diff --git a/src/graphql/shared/types/object/usdt-wallet.ts b/src/graphql/shared/types/object/usdt-wallet.ts index 3845ae696..6f135f941 100644 --- a/src/graphql/shared/types/object/usdt-wallet.ts +++ b/src/graphql/shared/types/object/usdt-wallet.ts @@ -17,6 +17,8 @@ import { } from "@domain/shared" import { WalletType } from "@domain/wallets" +import { IbexUnavailableError } from "@services/ibex/errors" + import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import IWallet from "../abstract/wallet" @@ -66,6 +68,9 @@ const UsdtWallet = GT.Object({ walletId: source.id, currency: source.currency, }) + // See the USD wallet: a nullable balance IS the unavailable state, so + // the rest of the account still renders while IBEX is not answering. + if (balance instanceof IbexUnavailableError) return null if (balance instanceof Error) { throw mapError(balance) } diff --git a/src/services/ibex/availability.ts b/src/services/ibex/availability.ts new file mode 100644 index 000000000..d16edf693 --- /dev/null +++ b/src/services/ibex/availability.ts @@ -0,0 +1,37 @@ +/** + * Availability state for the IBEX rail. + * + * Every cash-wallet read in this app goes through IBEX, so a vendor blip used + * to mean: each GraphQL request waits on its own fetch timeout and then + * surfaces its own error — the wallet screen fails once per field. With a + * breaker, the FIRST unanswered call opens the circuit and every later call in + * the window takes the degraded path immediately (BTC reads from the mongo + * ledger, cash reports an explicit unavailable state) instead of each paying + * the timeout again. After the window the circuit is half-open: the next call + * probes the rail and either closes the circuit or reopens it. + * + * The clock is injectable so the window is testable without timers. + */ +const IBEX_OPEN_WINDOW_MS = 3_000 + +let openedAt: number | undefined + +export const isIbexAvailable = (now: () => number = Date.now): boolean => { + if (openedAt === undefined) return true + + if (now() - openedAt >= IBEX_OPEN_WINDOW_MS) { + // Half-open: forget the trip so the next call actually probes the rail. + openedAt = undefined + return true + } + + return false +} + +export const markIbexUnavailable = (now: () => number = Date.now): void => { + openedAt = now() +} + +export const resetIbexAvailability = (): void => { + openedAt = undefined +} diff --git a/src/services/ibex/errors.ts b/src/services/ibex/errors.ts index 4677479e5..1f3c8ae10 100644 --- a/src/services/ibex/errors.ts +++ b/src/services/ibex/errors.ts @@ -1,172 +1,199 @@ -import { DomainError, ErrorLevel } from "@domain/shared" -import { - ApiError, - AuthenticationError, - IbexClientError, - UnexpectedResponseError, -} from "ibex-client" - -export class IbexError extends DomainError { - readonly type: string - readonly level: ErrorLevel - readonly httpCode?: number - - constructor(err: Error, level: ErrorLevel = ErrorLevel.Critical) { - super(err) - this.type = err.name - this.level = level - this.httpCode = err instanceof ApiError ? err.httpCode : undefined - } -} - -export class UnexpectedIbexResponse extends IbexError { - constructor(message: string, level?: ErrorLevel) { - super(new UnexpectedResponseError(message), level) - } -} - -export class ParseError extends IbexError {} - -export class InsufficientIbexBalance extends IbexError { - // Full vendor detail, preserved verbatim for logs/spans (pino's error - // serializer picks up own enumerable properties). - readonly detail?: string - - constructor(err: Error, level: ErrorLevel = ErrorLevel.Info, detail?: string) { - super(err, level) - this.detail = detail - // error-map forwards `message` to the GraphQL client verbatim (the - // INSUFFICIENT_BALANCE case sets message = error.message). Keep it the - // human-readable IBEX detail — never the wrapped error's stack trace — - // but strip the trailing internal IBEX account UUID ("... account: "): - // end users must never see vendor-internal identifiers on the failure - // screen. The unstripped detail stays on `this.detail`. - this.message = - detail?.replace(/[.,]?\s*account:\s*\S+\s*$/i, "") ?? "insufficient balance" - } -} -export class CompletedInvoice extends IbexError {} - -/** - * IBEX accepted the request but the response carries no recognisable payment - * status, so we cannot say whether funds moved. Distinct from the generic - * UnexpectedIbexResponse so this condition — money may or may not have left a - * user's wallet — is greppable and alertable on its own. Never returned to - * clients: the send resolvers record it and report the payment as still in - * flight, the only honest reading of "we don't know yet". - * - * SEVERITY: the default is **Warn**, and Warn is the honest level until a real - * response from either send endpoint has been captured. Neither `payInvoiceV2` - * nor `payToLnurl` has an observed 200/201 in this repo — the committed - * fixtures under test/flash/mocks/ibex/ are the vendor's openapi examples — so - * an unreadable response may well be a rail's ordinary shape rather than an - * incident, and paging on every send of that rail would be an outage, not a - * severity. Raise the default (or pass Critical at a specific call site) once a - * capture proves the payment-level fields are populated. The level is honoured - * by the recorder in ./payment-status, which passes `level: error.level` - * straight through to the span. - * - * `uncorroboratedOutcome` names a terminal outcome IBEX *claimed* in the - * top-level `status` field and this codebase refused to honour uncorroborated. - * It is what tells that same recorder how loud to be: a response that reported - * nothing at all is an ordinary in-flight payment and gets a span event, while - * one carrying an unhonoured SUCCEEDED/FAILED is a genuine field-level - * disagreement and gets a recorded exception (which sets the span status to - * ERROR). See the doc block on `recordUnconfirmed` in ./payment-status. - */ -export class UnconfirmedIbexPayment extends IbexError { - readonly uncorroboratedOutcome?: "SUCCEEDED" | "FAILED" - - constructor( - message: string, - level: ErrorLevel = ErrorLevel.Warn, - uncorroboratedOutcome?: "SUCCEEDED" | "FAILED", - ) { - super(new UnexpectedResponseError(message), level) - this.uncorroboratedOutcome = uncorroboratedOutcome - } -} - -/** - * IBEX answered 200/201 and the response says the payment FAILED — a definite, - * corroborated negative, not an unreadable one. Distinct from - * `UnconfirmedIbexPayment` ("we cannot tell") and from the generic `IbexError` - * ("the call itself errored"): this is the shape that sails through any caller - * which only checks `resp instanceof IbexError`, letting downstream side - * effects fire behind a lightning payment that never settled. - */ -export class FailedIbexPayment extends IbexError { - constructor(message: string, level: ErrorLevel = ErrorLevel.Warn) { - super(new IbexClientError(message), level) - } -} - -/** - * Best-effort extraction of the IBEX error text from a failed call. - * Shapes handled: - * - ibex-client > 3.2.0 (lnflash/ibex-client#6): ApiError carries the parsed - * body on `ibexResponse` and the extracted text on `ibexMessage` - * - the generated api SDK's FetchError: parsed JSON body on `.data`, - * typically `{ "error": "..." }`, sometimes `{ "message": "..." }` or text. - * No live caller passes this shape anymore — the payInvoice raw-fetch seam - * was collapsed onto the SDK path (lnflash/flash#478) — but the fallback is - * a few lines and keeps this helper safe for any future raw caller. - */ -export const ibexErrorDetail = (e: unknown): string | undefined => { - if (typeof e !== "object" || e === null) return undefined - const { ibexMessage, data } = e as { ibexMessage?: unknown; data?: unknown } - if (typeof ibexMessage === "string" && ibexMessage !== "") return ibexMessage - if (typeof data === "string" && data !== "") return data - if (typeof data === "object" && data !== null) { - const record = data as Record - if (typeof record.error === "string" && record.error !== "") return record.error - if (typeof record.message === "string" && record.message !== "") return record.message - } - return undefined -} - -// The single needle list mapping IBEX error prose to a typed error class. -// Matching is case-insensitive (the haystack is lowercased once here) so a -// vendor rewording like "Insufficient Balance" cannot silently revert -// classification to the generic path. errorHandler classifies through this -// helper — never add a needle anywhere else. -const classifyIbexErrorText = ( - text: string, -): typeof InsufficientIbexBalance | typeof CompletedInvoice | undefined => { - const haystack = text.toLowerCase() - if (haystack.includes("insufficient balance")) return InsufficientIbexBalance - if (haystack.includes("payment already prepared")) return CompletedInvoice - return undefined -} - -export const errorHandler = ( - e: T | IbexClientError | AuthenticationError | ApiError, -): T | IbexError => { - if (e instanceof AuthenticationError) return new IbexError(e, ErrorLevel.Critical) - if (e instanceof ApiError) { - // Classify against the structured body detail when the error carries one - // (ibex-client >= 3.3.0's ApiError extracts it onto `ibexMessage`), and - // against `message` otherwise — a defensive fallback for shapes that only - // embed the body text in the message. Body-carrying shapes are checked - // first so a stack that happens to contain a needle can't misclassify. - const detail = ibexErrorDetail(e) - const classified = classifyIbexErrorText(detail ?? e.message) - if (classified === InsufficientIbexBalance) - return new InsufficientIbexBalance(e, ErrorLevel.Info, detail) - if (classified === CompletedInvoice) return new CompletedInvoice(e, ErrorLevel.Info) - // Unclassified path: an unrecognized IBEX 400 must still log what IBEX - // actually said. ibex-client >= 3.3.0's ApiError already appends - // "IBEX response (): " to its own message, so only carry - // the detail when the message doesn't already contain it (older or raw - // shapes with a stack-only message). Build a new IbexError rather than - // mutating `e`, which the caller may still hold. - if (detail !== undefined) { - const generic = new IbexError(e, ErrorLevel.Warn) - if (!generic.message.includes(detail)) - generic.message = `${detail}\n${generic.message}` - return generic - } - } - if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn) - return e -} +import { DomainError, ErrorLevel } from "@domain/shared" +import { + ApiError, + AuthenticationError, + IbexClientError, + UnexpectedResponseError, +} from "ibex-client" + +import { markIbexUnavailable } from "./availability" + +export class IbexError extends DomainError { + readonly type: string + readonly level: ErrorLevel + readonly httpCode?: number + + constructor(err: Error, level: ErrorLevel = ErrorLevel.Critical) { + super(err) + this.type = err.name + this.level = level + this.httpCode = err instanceof ApiError ? err.httpCode : undefined + } +} + +export class UnexpectedIbexResponse extends IbexError { + constructor(message: string, level?: ErrorLevel) { + super(new UnexpectedResponseError(message), level) + } +} + +export class ParseError extends IbexError {} + +/** + * IBEX never answered: no HTTP status, no response body — a socket-level + * failure rather than a refusal. Distinct from the generic `IbexError` ("the + * rail said no") because the correct response differs: the rail is treated as + * unavailable for a short window and callers degrade to their IBEX-independent + * reads instead of every request paying its own timeout. + * + * Warn by default, like `UnconfirmedIbexPayment`: an unreachable rail is + * already visible as failed wallet reads, and a Critical per request is noise. + */ +export class IbexUnavailableError extends IbexError { + constructor(err: Error, level: ErrorLevel = ErrorLevel.Warn) { + super(err, level) + } +} + +export class InsufficientIbexBalance extends IbexError { + // Full vendor detail, preserved verbatim for logs/spans (pino's error + // serializer picks up own enumerable properties). + readonly detail?: string + + constructor(err: Error, level: ErrorLevel = ErrorLevel.Info, detail?: string) { + super(err, level) + this.detail = detail + // error-map forwards `message` to the GraphQL client verbatim (the + // INSUFFICIENT_BALANCE case sets message = error.message). Keep it the + // human-readable IBEX detail — never the wrapped error's stack trace — + // but strip the trailing internal IBEX account UUID ("... account: "): + // end users must never see vendor-internal identifiers on the failure + // screen. The unstripped detail stays on `this.detail`. + this.message = + detail?.replace(/[.,]?\s*account:\s*\S+\s*$/i, "") ?? "insufficient balance" + } +} +export class CompletedInvoice extends IbexError {} + +/** + * IBEX accepted the request but the response carries no recognisable payment + * status, so we cannot say whether funds moved. Distinct from the generic + * UnexpectedIbexResponse so this condition — money may or may not have left a + * user's wallet — is greppable and alertable on its own. Never returned to + * clients: the send resolvers record it and report the payment as still in + * flight, the only honest reading of "we don't know yet". + * + * SEVERITY: the default is **Warn**, and Warn is the honest level until a real + * response from either send endpoint has been captured. Neither `payInvoiceV2` + * nor `payToLnurl` has an observed 200/201 in this repo — the committed + * fixtures under test/flash/mocks/ibex/ are the vendor's openapi examples — so + * an unreadable response may well be a rail's ordinary shape rather than an + * incident, and paging on every send of that rail would be an outage, not a + * severity. Raise the default (or pass Critical at a specific call site) once a + * capture proves the payment-level fields are populated. The level is honoured + * by the recorder in ./payment-status, which passes `level: error.level` + * straight through to the span. + * + * `uncorroboratedOutcome` names a terminal outcome IBEX *claimed* in the + * top-level `status` field and this codebase refused to honour uncorroborated. + * It is what tells that same recorder how loud to be: a response that reported + * nothing at all is an ordinary in-flight payment and gets a span event, while + * one carrying an unhonoured SUCCEEDED/FAILED is a genuine field-level + * disagreement and gets a recorded exception (which sets the span status to + * ERROR). See the doc block on `recordUnconfirmed` in ./payment-status. + */ +export class UnconfirmedIbexPayment extends IbexError { + readonly uncorroboratedOutcome?: "SUCCEEDED" | "FAILED" + + constructor( + message: string, + level: ErrorLevel = ErrorLevel.Warn, + uncorroboratedOutcome?: "SUCCEEDED" | "FAILED", + ) { + super(new UnexpectedResponseError(message), level) + this.uncorroboratedOutcome = uncorroboratedOutcome + } +} + +/** + * IBEX answered 200/201 and the response says the payment FAILED — a definite, + * corroborated negative, not an unreadable one. Distinct from + * `UnconfirmedIbexPayment` ("we cannot tell") and from the generic `IbexError` + * ("the call itself errored"): this is the shape that sails through any caller + * which only checks `resp instanceof IbexError`, letting downstream side + * effects fire behind a lightning payment that never settled. + */ +export class FailedIbexPayment extends IbexError { + constructor(message: string, level: ErrorLevel = ErrorLevel.Warn) { + super(new IbexClientError(message), level) + } +} + +/** + * Best-effort extraction of the IBEX error text from a failed call. + * Shapes handled: + * - ibex-client > 3.2.0 (lnflash/ibex-client#6): ApiError carries the parsed + * body on `ibexResponse` and the extracted text on `ibexMessage` + * - the generated api SDK's FetchError: parsed JSON body on `.data`, + * typically `{ "error": "..." }`, sometimes `{ "message": "..." }` or text. + * No live caller passes this shape anymore — the payInvoice raw-fetch seam + * was collapsed onto the SDK path (lnflash/flash#478) — but the fallback is + * a few lines and keeps this helper safe for any future raw caller. + */ +export const ibexErrorDetail = (e: unknown): string | undefined => { + if (typeof e !== "object" || e === null) return undefined + const { ibexMessage, data } = e as { ibexMessage?: unknown; data?: unknown } + if (typeof ibexMessage === "string" && ibexMessage !== "") return ibexMessage + if (typeof data === "string" && data !== "") return data + if (typeof data === "object" && data !== null) { + const record = data as Record + if (typeof record.error === "string" && record.error !== "") return record.error + if (typeof record.message === "string" && record.message !== "") return record.message + } + return undefined +} + +// The single needle list mapping IBEX error prose to a typed error class. +// Matching is case-insensitive (the haystack is lowercased once here) so a +// vendor rewording like "Insufficient Balance" cannot silently revert +// classification to the generic path. errorHandler classifies through this +// helper — never add a needle anywhere else. +const classifyIbexErrorText = ( + text: string, +): typeof InsufficientIbexBalance | typeof CompletedInvoice | undefined => { + const haystack = text.toLowerCase() + if (haystack.includes("insufficient balance")) return InsufficientIbexBalance + if (haystack.includes("payment already prepared")) return CompletedInvoice + return undefined +} + +export const errorHandler = ( + e: T | IbexClientError | AuthenticationError | ApiError, +): T | IbexError => { + if (e instanceof AuthenticationError) return new IbexError(e, ErrorLevel.Critical) + if (e instanceof ApiError) { + // Classify against the structured body detail when the error carries one + // (ibex-client >= 3.3.0's ApiError extracts it onto `ibexMessage`), and + // against `message` otherwise — a defensive fallback for shapes that only + // embed the body text in the message. Body-carrying shapes are checked + // first so a stack that happens to contain a needle can't misclassify. + const detail = ibexErrorDetail(e) + const classified = classifyIbexErrorText(detail ?? e.message) + if (classified === InsufficientIbexBalance) + return new InsufficientIbexBalance(e, ErrorLevel.Info, detail) + if (classified === CompletedInvoice) return new CompletedInvoice(e, ErrorLevel.Info) + // Unclassified path: an unrecognized IBEX 400 must still log what IBEX + // actually said. ibex-client >= 3.3.0's ApiError already appends + // "IBEX response (): " to its own message, so only carry + // the detail when the message doesn't already contain it (older or raw + // shapes with a stack-only message). Build a new IbexError rather than + // mutating `e`, which the caller may still hold. + if (detail !== undefined) { + const generic = new IbexError(e, ErrorLevel.Warn) + if (!generic.message.includes(detail)) + generic.message = `${detail}\n${generic.message}` + return generic + } + // No body and no HTTP status at all: nothing in the stack reached an + // application layer, so this is a socket-level failure — the rail did not + // answer, it did not refuse. Trip the breaker (see ./availability) so the + // rest of this request's reads degrade immediately instead of each paying + // its own fetch timeout, and return the typed unavailable error. + if (e.httpCode === undefined) { + markIbexUnavailable() + return new IbexUnavailableError(e) + } + } + if (e instanceof IbexClientError) return new IbexError(e, ErrorLevel.Warn) + return e +} 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 92738907d..a27922a87 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,69 +1,149 @@ -import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" -import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" -import Ibex from "@services/ibex/client" - -jest.mock("@services/ibex/client", () => ({ - __esModule: true, - default: { - getAccountDetails: jest.fn(), - getCryptoReceiveBalance: jest.fn(), - }, -})) - -describe("getBalanceForWallet", () => { - beforeEach(() => { - jest.mocked(Ibex.getAccountDetails).mockReset() - jest.mocked(Ibex.getCryptoReceiveBalance).mockReset() - }) - - it("loads USDT balances from the IBEX account id, not a crypto receive-info id", async () => { - const balance = USDTAmount.ZERO - jest.mocked(Ibex.getAccountDetails).mockResolvedValue({ - id: "ibex-account-id", - balance, - } as never) - - const result = await getBalanceForWallet({ - walletId: "ibex-account-id" as WalletId, - currency: WalletCurrency.Usdt, - }) - - expect(Ibex.getAccountDetails).toHaveBeenCalledWith( - "ibex-account-id", - WalletCurrency.Usdt, - ) - 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) - }) -}) +import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import Ibex from "@services/ibex/client" + +const mockGetWalletBalance = jest.fn() +const mockUsdFromBtcMidPriceFn = jest.fn() + +jest.mock("@services/ledger", () => ({ + LedgerService: () => ({ + getWalletBalance: (...args: Parameters) => + mockGetWalletBalance(...args), + }), +})) + +// The mid-price helper opens its own span and calls the dealer service; the +// unit harness only needs the conversion it performs on the ledger figure. +jest.mock("@app/prices/mid-price", () => ({ + usdFromBtcMidPriceFn: (...args: Parameters) => + mockUsdFromBtcMidPriceFn(...args), +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getAccountDetails: jest.fn(), + getCryptoReceiveBalance: jest.fn(), + }, +})) + +import { + markIbexUnavailable, + resetIbexAvailability, +} from "@services/ibex/availability" + +describe("getBalanceForWallet", () => { + beforeEach(() => { + jest.mocked(Ibex.getAccountDetails).mockReset() + jest.mocked(Ibex.getCryptoReceiveBalance).mockReset() + jest.mocked(mockGetWalletBalance).mockReset() + jest.mocked(mockUsdFromBtcMidPriceFn).mockReset() + resetIbexAvailability() + }) + + it("loads USDT balances from the IBEX account id, not a crypto receive-info id", async () => { + const balance = USDTAmount.ZERO + jest.mocked(Ibex.getAccountDetails).mockResolvedValue({ + id: "ibex-account-id", + balance, + } as never) + + const result = await getBalanceForWallet({ + walletId: "ibex-account-id" as WalletId, + currency: WalletCurrency.Usdt, + }) + + expect(Ibex.getAccountDetails).toHaveBeenCalledWith( + "ibex-account-id", + WalletCurrency.Usdt, + ) + 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) + }) + + // Circuit open (an unanswered IBEX call, see @services/ibex/availability): + // a BTC wallet is still fully readable from the mongo ledger, so the wallet + // screen keeps its numbers instead of failing on the cash rail. + it("reads a BTC balance from the ledger while IBEX is unavailable", async () => { + markIbexUnavailable() + mockGetWalletBalance.mockResolvedValue(1000) + mockUsdFromBtcMidPriceFn.mockResolvedValue({ + currency: WalletCurrency.Usd, + amount: 42n, + }) + + const result = await getBalanceForWallet({ + walletId: "btc-wallet-id" as WalletId, + currency: WalletCurrency.Btc, + }) + + expect(Ibex.getAccountDetails).not.toHaveBeenCalled() + expect(mockGetWalletBalance).toHaveBeenCalledWith("btc-wallet-id") + expect(mockUsdFromBtcMidPriceFn).toHaveBeenCalledWith({ + currency: WalletCurrency.Btc, + amount: 1000n, + }) + expect(result).toBeInstanceOf(USDAmount) + expect((result as USDAmount).asCents()).toBe("42") + }) + + it("propagates a ledger failure on the degraded BTC path", async () => { + markIbexUnavailable() + mockGetWalletBalance.mockResolvedValue(new Error("mongo down")) + + const result = await getBalanceForWallet({ + walletId: "btc-wallet-id" as WalletId, + currency: WalletCurrency.Btc, + }) + + expect(result).toBeInstanceOf(Error) + }) + + // Cash balances are only authoritative at IBEX: a stale ledger figure would + // be worse than an honest "unavailable", which the wallet resolvers render + // as a null balance rather than an error. + it("reports a cash balance as unavailable instead of reading the ledger", async () => { + markIbexUnavailable() + + const result = await getBalanceForWallet({ + walletId: "usd-wallet-id" as WalletId, + currency: WalletCurrency.Usd, + }) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).name).toBe("IbexUnavailableError") + expect(mockGetWalletBalance).not.toHaveBeenCalled() + expect(Ibex.getAccountDetails).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts index deabaa9c7..31363fe74 100644 --- a/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts +++ b/test/flash/unit/app/wallets/get-transactions-for-wallet.spec.ts @@ -11,10 +11,56 @@ import { } from "@app/wallets/get-transactions-for-wallet" import { WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" +import { LedgerTransactionType } from "@domain/ledger" import Ibex from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" import { baseLogger } from "@services/logger" import { GResponse200 } from "ibex-client" +const mockGetTransactionsByWalletIds = jest.fn() + +// The degraded path reads confirmed rows straight from the mongo ledger, so +// the harness needs the two pieces `ledgerHistoryForWallets` touches. +jest.mock("@services/ledger", () => ({ + LedgerService: () => ({ + getTransactionsByWalletIds: ( + ...args: Parameters + ) => mockGetTransactionsByWalletIds(...args), + }), + getNonEndUserWalletIds: async () => ({ dealerBtc: "dealer-btc" }), +})) + +import { + markIbexUnavailable, + resetIbexAvailability, +} from "@services/ibex/availability" + +// A confirmed ledger row, already in the shape LedgerService returns (the real +// service maps its mongo documents through translateToLedgerTx). +const ledgerTxn = (id: string): LedgerTransaction => + ({ + id, + walletId: "33333333-3333-4333-8333-333333333333", + type: LedgerTransactionType.Payment, + credit: 1000, + debit: 0, + currency: WalletCurrency.Btc, + timestamp: new Date("2026-06-01T00:00:00.000Z"), + pendingConfirmation: false, + journalId: "journal-id", + lnMemo: "memo", + feeKnownInAdvance: true, + paymentHash: "payment-hash", + pubkey: "pubkey", + satsAmount: 1000, + centsAmount: 20, + satsFee: 0, + centsFee: 0, + displayAmount: 20, + displayFee: 0, + displayCurrency: "USD", + }) as unknown as LedgerTransaction + const accountId = "account-id" as AccountId const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => @@ -316,6 +362,8 @@ describe("toWalletTransactions", () => { describe("getTransactionsForWallets", () => { beforeEach(() => { jest.mocked(Ibex.getAccountTransactions).mockReset() + jest.mocked(mockGetTransactionsByWalletIds).mockReset() + resetIbexAvailability() }) it("concatenates active wallet history before the legacy archive wallet history", async () => { @@ -378,4 +426,102 @@ describe("getTransactionsForWallets", () => { "legacy-archive-older", ]) }) + + // Circuit already open (see @services/ibex/availability): the whole page + // comes from the ledger in one read, so a BTC history still renders while + // the cash rail is down — and nothing pays the IBEX timeout at all. + it("serves confirmed ledger rows while IBEX is unavailable", async () => { + markIbexUnavailable() + mockGetTransactionsByWalletIds.mockResolvedValue({ + slice: [ledgerTxn("ledger-txn-1")], + total: 7, + }) + + const btcWallet = wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Btc, + }) + const result = await getTransactionsForWallets({ + wallets: [btcWallet], + paginationArgs: { first: 20 }, + }) + + expect(Ibex.getAccountTransactions).not.toHaveBeenCalled() + expect(mockGetTransactionsByWalletIds).toHaveBeenCalledWith({ + walletIds: [btcWallet.id], + paginationArgs: { first: 20 }, + }) + expect(result.error).toBeUndefined() + expect(result.result?.total).toBe(7) + expect(result.result?.slice.map((transaction) => transaction.id)).toEqual([ + "ledger-txn-1", + ]) + // Rows are translated into wallet transactions, not handed over raw. + expect(result.result?.slice[0].settlementAmount).toBe(1000) + expect(result.result?.slice[0].settlementCurrency).toBe(WalletCurrency.Btc) + }) + + it("reports a failure when neither IBEX nor the ledger has rows", async () => { + markIbexUnavailable() + mockGetTransactionsByWalletIds.mockResolvedValue(new Error("mongo down")) + + const result = await getTransactionsForWallets({ + wallets: [ + wallet({ + id: "33333333-3333-4333-8333-333333333333", + currency: WalletCurrency.Btc, + }), + ], + }) + + expect(result.result).toBeNull() + expect(result.error).toBeInstanceOf(Error) + }) + + // One cash wallet refusing to answer must not hide the rows that did come + // back: the page is rebuilt from the ledger (one consistent source rather + // than a half-filled mix) and still renders. + it("keeps a page intact when one wallet's IBEX call fails", async () => { + const btcWallet = wallet({ + id: "11111111-1111-4111-8111-111111111111", + currency: WalletCurrency.Btc, + }) + const cashWallet = wallet({ + id: "22222222-2222-4222-8222-222222222222", + currency: WalletCurrency.Usd, + }) + jest + .mocked(Ibex.getAccountTransactions) + .mockImplementation(async ({ account_id }) => { + if (account_id === cashWallet.id) return new IbexError(new Error("boom")) + return [ + { + id: "ibex-row", + accountId: btcWallet.id, + amount: 1, + currencyId: 3, + transactionTypeId: 1, + createdAt: "2026-06-01T00:00:00.000Z", + }, + ] as GResponse200 + }) + mockGetTransactionsByWalletIds.mockResolvedValue({ + slice: [ledgerTxn("ledger-txn-1")], + total: 1, + }) + + const result = await getTransactionsForWallets({ + wallets: [btcWallet, cashWallet], + paginationArgs: { first: 20 }, + }) + + expect(result.error).toBeUndefined() + expect(result.result?.slice.map((transaction) => transaction.id)).toEqual([ + "ledger-txn-1", + ]) + expect(mockGetTransactionsByWalletIds).toHaveBeenCalledWith({ + walletIds: [btcWallet.id, cashWallet.id], + paginationArgs: { first: 20 }, + }) + }) }) diff --git a/test/flash/unit/services/ibex/availability.spec.ts b/test/flash/unit/services/ibex/availability.spec.ts new file mode 100644 index 000000000..04f21775e --- /dev/null +++ b/test/flash/unit/services/ibex/availability.spec.ts @@ -0,0 +1,121 @@ +import { + isIbexAvailable, + markIbexUnavailable, + resetIbexAvailability, +} from "@services/ibex/availability" +import { ApiError } from "ibex-client" + +import { errorHandler, IbexUnavailableError } from "@services/ibex/errors" + +// A hand-rolled clock keeps the half-open window assertable without timers. +const clock = (initial: number) => { + let now = initial + return { + now: () => now, + advance: (ms: number) => { + now += ms + }, + } +} + +describe("isIbexAvailable", () => { + beforeEach(() => { + resetIbexAvailability() + }) + + it("is available until something reports the rail as unreachable", () => { + const now = clock(0) + + expect(isIbexAvailable(now.now)).toBe(true) + }) + + it("degrades for the whole open window after a trip", () => { + const now = clock(1_000) + markIbexUnavailable(now.now) + + expect(isIbexAvailable(now.now)).toBe(false) + + now.advance(2_999) + expect(isIbexAvailable(now.now)).toBe(false) + }) + + it("half-opens after the window: one probe is allowed through", () => { + const now = clock(0) + markIbexUnavailable(now.now) + + now.advance(3_000) + // First call past the window forgets the trip and returns true so the + // caller actually hits the rail... + expect(isIbexAvailable(now.now)).toBe(true) + // ... and the circuit stays closed for later calls in the same tick. + expect(isIbexAvailable(now.now)).toBe(true) + }) + + it("re-opens on the next trip after half-opening", () => { + const now = clock(0) + markIbexUnavailable(now.now) + + now.advance(3_000) + expect(isIbexAvailable(now.now)).toBe(true) + + // Still not answering: the trip is recorded at the current time, so the + // following calls degrade again rather than all probing at once. + markIbexUnavailable(now.now) + expect(isIbexAvailable(now.now)).toBe(false) + }) + + it("a fresh trip replaces an older one", () => { + const now = clock(0) + markIbexUnavailable(now.now) + + now.advance(2_000) + markIbexUnavailable(now.now) + + // 2_000 ms after the first trip — but only 0 ms after the second. + expect(isIbexAvailable(now.now)).toBe(false) + now.advance(1_000) + expect(isIbexAvailable(now.now)).toBe(false) + now.advance(2_000) + expect(isIbexAvailable(now.now)).toBe(true) + }) + + it("resetIbexAvailability clears the state immediately", () => { + const now = clock(0) + markIbexUnavailable(now.now) + expect(isIbexAvailable(now.now)).toBe(false) + + resetIbexAvailability() + expect(isIbexAvailable(now.now)).toBe(true) + }) +}) + +describe("errorHandler trips the breaker", () => { + beforeEach(() => { + resetIbexAvailability() + }) + + it("treats a status-less ApiError as an unavailable rail", () => { + const now = clock(5_000) + + const result = errorHandler(new ApiError(new TypeError("fetch failed"))) + + expect(result).toBeInstanceOf(IbexUnavailableError) + expect((result as IbexUnavailableError).httpCode).toBeUndefined() + expect(isIbexAvailable(now.now)).toBe(false) + }) + + it("leaves the circuit alone when the rail answered with a status", () => { + const now = clock(5_000) + const raw = new Error("Bad Request") as Error & { status: number; data: unknown } + raw.name = "FetchError" + raw.status = 400 + raw.data = { error: "invalid invoice" } + + const result = errorHandler(new ApiError(raw)) + + expect(result).toBeInstanceOf(Error) + expect(result).not.toBeInstanceOf(IbexUnavailableError) + // A refusal carries information; the next call should still try the rail. + expect(isIbexAvailable(now.now)).toBe(true) + }) +})