diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index f05edb152..53aeb4cb9 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1646,7 +1646,6 @@ type Query @join__type(graph: PUBLIC) { accountDefaultWallet(username: Username!, walletCurrency: WalletCurrency): PublicWallet! - latestAccountUpgradeRequest: AccountUpgradeRequestPayload! bridgeExternalAccounts: [BridgeExternalAccount] bridgeKycStatus: String bridgeVirtualAccount: BridgeVirtualAccount @@ -1657,6 +1656,7 @@ type Query currencyList: [Currency!]! globals: Globals isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload + latestAccountUpgradeRequest: AccountUpgradeRequestPayload! lnInvoicePaymentStatus(input: LnInvoicePaymentStatusInput!): LnInvoicePaymentStatusPayload! me: User mobileVersions: [MobileVersions] diff --git a/dev/bruno/Flash GraphQL API/environments/local.bru b/dev/bruno/Flash GraphQL API/environments/local.bru index 2c8d6c6bd..1cda70a56 100644 --- a/dev/bruno/Flash GraphQL API/environments/local.bru +++ b/dev/bruno/Flash GraphQL API/environments/local.bru @@ -8,6 +8,6 @@ vars { token: walletId: walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1 - userEmail: maurienteso@gmail.com - userFullName: maurienteso + userEmail: mauriente@gmail.com + userFullName: maurientes } diff --git a/src/app/authentication/login.ts b/src/app/authentication/login.ts index 736eb90ed..c87b3705c 100644 --- a/src/app/authentication/login.ts +++ b/src/app/authentication/login.ts @@ -309,7 +309,10 @@ export const loginDeviceUpgradeWithPhone = async ({ if (deviceWallets instanceof Error) return deviceWallets let deviceAccountHasBalance = false for (const wallet of deviceWallets) { - const balance = await getBalanceForWallet({ walletId: wallet.id }) + const balance = await getBalanceForWallet({ + walletId: wallet.id, + currency: wallet.currency, + }) if (balance instanceof Error) return balance if (!balance.isZero()) { deviceAccountHasBalance = true diff --git a/src/domain/pubsub/index.ts b/src/domain/pubsub/index.ts index a64a52b7b..d27047df1 100644 --- a/src/domain/pubsub/index.ts +++ b/src/domain/pubsub/index.ts @@ -5,6 +5,7 @@ export const PubSubDefaultTriggers = { UserPriceUpdate: "USER_PRICE_UPDATE", AccountUpdate: "ACCOUNT_UPDATE", LnPaymentStatus: "LN_PAYMENT_STATUS", + BridgeReconciliationUpdate: "BRIDGE_RECONCILIATION_UPDATE", } as const export const customPubSubTrigger = ({ diff --git a/src/graphql/admin/queries.ts b/src/graphql/admin/queries.ts index f18b8836a..a069657df 100644 --- a/src/graphql/admin/queries.ts +++ b/src/graphql/admin/queries.ts @@ -15,6 +15,7 @@ import AccountDetailsByAccountId from "./root/query/account-details-by-account-i import MerchantsPendingApprovalQuery from "./root/query/merchants-pending-approval-listing" import IdDocumentReadUrlQuery from "./root/query/id-document-read-url" import NotificationTopicsQuery from "./root/query/notification-topics" +import BridgeReconciliationOrphansQuery from "./root/query/bridge-reconciliation-orphans" export const queryFields = { unauthed: {}, @@ -34,6 +35,7 @@ export const queryFields = { merchantsPendingApproval: MerchantsPendingApprovalQuery, idDocumentReadUrl: IdDocumentReadUrlQuery, notificationTopics: NotificationTopicsQuery, + bridgeReconciliationOrphans: BridgeReconciliationOrphansQuery, }, } diff --git a/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts b/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts new file mode 100644 index 000000000..a50bded31 --- /dev/null +++ b/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts @@ -0,0 +1,40 @@ +import { GT } from "@graphql/index" +import BridgeReconciliationOrphanObject from "@graphql/admin/types/object/bridge-reconciliation-orphan" +import { findOrphans } from "@services/mongoose/bridge-reconciliation-orphan" + +const BridgeReconciliationOrphansQuery = GT.Field({ + type: GT.NonNullList(BridgeReconciliationOrphanObject), + args: { + status: { type: GT.String, defaultValue: null }, + orphanType: { type: GT.String, defaultValue: null }, + limit: { type: GT.Int, defaultValue: 50 }, + }, + resolve: async ( + _: unknown, + { + status, + orphanType, + limit, + }: { status?: string; orphanType?: string; limit?: number }, + ) => { + const result = await findOrphans({ + status: status as "unmatched" | "resolved" | undefined, + orphanType: orphanType as + | "bridge_without_ibex" + | "ibex_without_bridge" + | undefined, + limit: limit ?? 50, + }) + + if (result instanceof Error) throw result + + return result.map((o) => ({ + ...o, + detectedAt: o.detectedAt.toISOString(), + resolvedAt: o.resolvedAt?.toISOString() ?? null, + triageContext: JSON.stringify(o.triageContext), + })) + }, +}) + +export default BridgeReconciliationOrphansQuery diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index d858eaf79..1eb75c263 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -110,6 +110,21 @@ type BTCWallet implements Wallet { walletCurrency: WalletCurrency! } +type BridgeReconciliationOrphan { + amount: String + currency: String + customerId: String + detectedAt: String! + id: ID! + orphanKey: String! + orphanType: String! + resolvedAt: String + status: String! + transferId: String + triageContext: String! + txHash: String +} + input BusinessDeleteMapInfoInput { username: Username! } @@ -319,6 +334,7 @@ type Query { accountDetailsByUserPhone(phone: Phone!): AuditedAccount! accountDetailsByUsername(username: Username!): AuditedAccount! allLevels: [AccountLevel!]! + bridgeReconciliationOrphans(limit: Int = 50, orphanType: String = null, status: String = null): [BridgeReconciliationOrphan!]! idDocumentReadUrl( """Storage key of the ID document file""" fileKey: String! @@ -551,6 +567,50 @@ type UsdWallet implements Wallet { walletCurrency: WalletCurrency! } +""" +A wallet belonging to an account which contains a USDT balance and a list of transactions. +""" +type UsdtWallet implements Wallet { + accountId: ID! + balance: FractionalCentAmount! + id: ID! + isExternal: Boolean! + lnurlp: Lnurl + + """An unconfirmed incoming onchain balance.""" + pendingIncomingBalance: SignedAmount! + transactions( + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + transactionsByAddress( + """Returns the items that include this address.""" + address: OnChainAddress! + + """Returns the items in the list that come after the specified cursor.""" + after: String + + """Returns the items in the list that come before the specified cursor.""" + before: String + + """Returns the first n items from the list.""" + first: Int + + """Returns the last n items from the list.""" + last: Int + ): TransactionConnection + walletCurrency: WalletCurrency! +} + input UserUpdatePhoneInput { accountUuid: ID! phone: Phone! diff --git a/src/graphql/admin/types/index.ts b/src/graphql/admin/types/index.ts index 566bf6c92..95ebebcf9 100644 --- a/src/graphql/admin/types/index.ts +++ b/src/graphql/admin/types/index.ts @@ -1,5 +1,6 @@ import BtcWallet from "@graphql/shared/types/object/btc-wallet" import GraphQLApplicationError from "@graphql/shared/types/object/graphql-application-error" import UsdWallet from "@graphql/shared/types/object/usd-wallet" +import UsdtWallet from "@graphql/shared/types/object/usdt-wallet" -export const ALL_INTERFACE_TYPES = [GraphQLApplicationError, BtcWallet, UsdWallet] +export const ALL_INTERFACE_TYPES = [GraphQLApplicationError, BtcWallet, UsdWallet, UsdtWallet] diff --git a/src/graphql/admin/types/object/bridge-reconciliation-orphan.ts b/src/graphql/admin/types/object/bridge-reconciliation-orphan.ts new file mode 100644 index 000000000..7ce3bc480 --- /dev/null +++ b/src/graphql/admin/types/object/bridge-reconciliation-orphan.ts @@ -0,0 +1,21 @@ +import { GT } from "@graphql/index" + +const BridgeReconciliationOrphanObject = GT.Object({ + name: "BridgeReconciliationOrphan", + fields: () => ({ + id: { type: GT.NonNullID }, + orphanKey: { type: GT.NonNull(GT.String) }, + orphanType: { type: GT.NonNull(GT.String) }, + status: { type: GT.NonNull(GT.String) }, + txHash: { type: GT.String }, + transferId: { type: GT.String }, + customerId: { type: GT.String }, + amount: { type: GT.String }, + currency: { type: GT.String }, + detectedAt: { type: GT.NonNull(GT.String) }, + resolvedAt: { type: GT.String }, + triageContext: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeReconciliationOrphanObject diff --git a/src/graphql/public/root/mutation/onchain-payment-send-all.ts b/src/graphql/public/root/mutation/onchain-payment-send-all.ts index 2f90ff8c9..c40b2c50a 100644 --- a/src/graphql/public/root/mutation/onchain-payment-send-all.ts +++ b/src/graphql/public/root/mutation/onchain-payment-send-all.ts @@ -10,7 +10,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import { Wallets } from "@app" import { getBalanceForWallet } from "@app/wallets" -import { USDAmount } from "@domain/shared" +import { USDAmount, WalletCurrency } from "@domain/shared" const OnChainPaymentSendAllInput = GT.Input({ name: "OnChainPaymentSendAllInput", @@ -63,7 +63,10 @@ const OnChainPaymentSendAllMutation = GT.Field< return { errors: [{ message: speed.message }] } } - const amount = await getBalanceForWallet({ walletId }) + const amount = await getBalanceForWallet({ + walletId, + currency: WalletCurrency.Usd, + }) if (amount instanceof Error) return amount if (!(amount instanceof USDAmount)) { return { errors: [{ message: "Onchain payments require a USD wallet" }] } diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 3f7ee3552..2bf332f00 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1290,7 +1290,6 @@ type PublicWallet { type Query { accountDefaultWallet(username: Username!, walletCurrency: WalletCurrency): PublicWallet! - latestAccountUpgradeRequest: AccountUpgradeRequestPayload! bridgeExternalAccounts: [BridgeExternalAccount] bridgeKycStatus: String bridgeVirtualAccount: BridgeVirtualAccount @@ -1301,6 +1300,7 @@ type Query { currencyList: [Currency!]! globals: Globals isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload + latestAccountUpgradeRequest: AccountUpgradeRequestPayload! lnInvoicePaymentStatus(input: LnInvoicePaymentStatusInput!): LnInvoicePaymentStatusPayload! me: User mobileVersions: [MobileVersions] diff --git a/src/graphql/shared/types/object/btc-wallet.ts b/src/graphql/shared/types/object/btc-wallet.ts index 3a29738e2..9991092a0 100644 --- a/src/graphql/shared/types/object/btc-wallet.ts +++ b/src/graphql/shared/types/object/btc-wallet.ts @@ -52,7 +52,10 @@ const BtcWallet = GT.Object({ description: "A balance stored in BTC.", resolve: async (source) => { if (source.type === WalletType.External) return null - const balanceSats = await Wallets.getBalanceForWallet({ walletId: source.id }) + const balanceSats = await Wallets.getBalanceForWallet({ + walletId: source.id, + currency: source.currency, + }) if (balanceSats instanceof Error) { throw mapError(balanceSats) } diff --git a/src/scripts/reconcile-bridge-ibex-deposits.ts b/src/scripts/reconcile-bridge-ibex-deposits.ts index 7e643bc0f..747ac99ca 100644 --- a/src/scripts/reconcile-bridge-ibex-deposits.ts +++ b/src/scripts/reconcile-bridge-ibex-deposits.ts @@ -9,14 +9,14 @@ import { reconcileBridgeAndIbexDeposits } from "@services/bridge/reconciliation" const args = yargs(hideBin(process.argv)) .option("window-hours", { type: "number", - default: 24, - describe: "Reconciliation window in hours", + default: 0.25, + describe: "Reconciliation window in hours (default: 15 minutes)", }) .option("configPath", { type: "string", demandOption: true }) .parseSync() const main = async () => { - const windowMs = Math.max(1, Math.floor(args["window-hours"])) * 60 * 60 * 1000 + const windowMs = args["window-hours"] * 60 * 60 * 1000 const result = await reconcileBridgeAndIbexDeposits({ windowMs }) if (result instanceof Error) throw result baseLogger.info(result, "Bridge↔IBEX reconciliation finished") diff --git a/src/servers/cron.ts b/src/servers/cron.ts index 19511890e..469b59062 100644 --- a/src/servers/cron.ts +++ b/src/servers/cron.ts @@ -65,10 +65,14 @@ const swapOutJob = async () => { if (swapResult instanceof Error) throw swapResult } +// Window covers 15 min of events — real-time webhook reconciliation handles everything +// else immediately. This batch pass is only a safety net for missed/delayed webhooks. +const RECONCILE_WINDOW_MS = 15 * 60 * 1000 + const reconcileBridgeDepositsJob = async () => { if (!BridgeConfig.enabled) return - const result = await reconcileBridgeAndIbexDeposits() + const result = await reconcileBridgeAndIbexDeposits({ windowMs: RECONCILE_WINDOW_MS }) if (result instanceof Error) throw result } diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index ecd4891c2..f5f51e2da 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -17,6 +17,7 @@ import { RepositoryError } from "@domain/errors" import { toBridgeCustomerId } from "@domain/primitives/bridge" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" import { USDTAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" import { WalletsRepository } from "@services/mongoose/wallets" import { IdentityRepository } from "@services/kratos" @@ -91,6 +92,38 @@ type ExternalAccountResult = { export const deriveWithdrawalIdempotencyKey = (rowId: string): string => crypto.createHash("sha256").update(`withdrawal:${rowId}`).digest("hex") +const ensureEthUsdtCashWallet = async ( + account: Account, +): Promise => { + const wallets = await WalletsRepository().listByAccountId(account.id) + if (wallets instanceof Error) return wallets + + let usdtWallet = wallets.find( + (wallet) => + wallet.currency === WalletCurrency.Usdt && wallet.type === WalletType.Checking, + ) + + if (!usdtWallet) { + const createdWallet = await WalletsRepository().persistNew({ + accountId: account.id, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + if (createdWallet instanceof Error) return createdWallet + usdtWallet = createdWallet + } + + if (account.defaultWalletId !== usdtWallet.id) { + const updatedAccount = await AccountsRepository().update({ + ...account, + defaultWalletId: usdtWallet.id, + }) + if (updatedAccount instanceof Error) return updatedAccount + } + + return usdtWallet +} + // ============ Guards ============ const checkBridgeEnabled = (): true | BridgeDisabledError => { @@ -180,6 +213,16 @@ const initiateKyc = async ({ return result } catch (error) { + const bridgeError = error as { statusCode?: number; response?: { existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } } } + + if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) { + return { + kycLink: bridgeError.response.existing_kyc_link.kyc_link, + customerId: bridgeError.response.existing_kyc_link.customer_id, + tosLink: bridgeError.response.existing_kyc_link.tos_link, + } + } + baseLogger.error( { accountId, operation: "initiateKyc", error }, "Bridge operation failed", @@ -191,8 +234,9 @@ const initiateKyc = async ({ /** * Creates a virtual account for receiving USD deposits * - Requires approved KYC - * - Creates IBEX Tron USDT receive address - * - Creates Bridge virtual account pointing to Tron address + * - Ensures an IBEX ETH-USDT Cash Wallet exists and is the account default + * - Creates IBEX Ethereum USDT receive address + * - Creates Bridge virtual account pointing to Ethereum address */ const createVirtualAccount = async ( accountId: AccountId, @@ -227,7 +271,7 @@ const createVirtualAccount = async ( return new BridgeKycPendingError("KYC not yet completed") } - // Idempotency guard: return the existing VA immediately without touching Bridge + // Idempotency guard first: do not mutate wallets/default when a VA already exists const existingVa = await BridgeAccountsRepo.findVirtualAccountByAccountId( accountId as string, ) @@ -241,26 +285,29 @@ const createVirtualAccount = async ( } } - // Get or create Ethereum address - let ethereumAddress = - account.bridgeEthereumAddress || "0xaF095D35bfDd462165eA7eCF8AC75351a93d72bD" + const usdtCashWallet = await ensureEthUsdtCashWallet(account) + if (usdtCashWallet instanceof Error) return usdtCashWallet + + // Get or create Ethereum USDT receive address for the ETH-USDT Cash Wallet + let ethereumAddress = account.bridgeEthereumAddress if (!ethereumAddress) { - const option = await IbexClient.getEthereumUsdtOption() + let option = await IbexClient.getEthereumUsdtOption() if (option instanceof Error) return new BridgeError(option.message) + option.name = `USDT-ETH ${account.username}-${crypto.randomBytes(4).toString("hex")}` const receiveInfo = await IbexClient.createCryptoReceiveInfo( - account.defaultWalletId as IbexAccountId, + usdtCashWallet.id as IbexAccountId, option, ) if (receiveInfo instanceof Error) return new BridgeError(receiveInfo.message) const updateResult = await AccountsRepository().updateBridgeFields(accountId, { - bridgeEthereumAddress: receiveInfo.address, + bridgeEthereumAddress: receiveInfo.data.address, }) if (updateResult instanceof Error) return updateResult - ethereumAddress = receiveInfo.address + ethereumAddress = receiveInfo.data.address } // Deterministic key so Bridge deduplicates on their side if two calls race past @@ -407,7 +454,9 @@ const initiateWithdrawal = async ( const wallets = await WalletsRepository().listByAccountId(accountId) if (wallets instanceof Error) return wallets - const usdtWallet = wallets.find((w) => w.currency === WalletCurrency.Usdt) + const usdtWallet = wallets.find( + (w) => w.currency === WalletCurrency.Usdt && w.type === WalletType.Checking, + ) if (!usdtWallet) { return new BridgeInsufficientFundsError("No USDT wallet found on account") } diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts index ec964ba00..891229e10 100644 --- a/src/services/bridge/reconciliation.ts +++ b/src/services/bridge/reconciliation.ts @@ -1,9 +1,14 @@ import { baseLogger } from "@services/logger" import { findIbexCryptoReceiveLogsSince } from "@services/mongoose/ibex-crypto-receive-log" -import { upsertBridgeReconciliationOrphan } from "@services/mongoose/bridge-reconciliation-orphan" -import { BridgeDepositLog } from "@services/mongoose/schema" +import { + upsertBridgeReconciliationOrphan, + resolveOrphansByTxHash, +} from "@services/mongoose/bridge-reconciliation-orphan" +import { BridgeDepositLog, IbexCryptoReceiveLog } from "@services/mongoose/schema" +import { PubSubService } from "@services/pubsub" +import { PubSubDefaultTriggers } from "@domain/pubsub" -const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const FIFTEEN_MIN_MS = 15 * 60 * 1000 type BridgeDepositLike = { eventId: string @@ -29,7 +34,7 @@ type IbexReceiveLike = { const toOrphanKey = (prefix: string, value: string) => `${prefix}:${value.toLowerCase()}` export const reconcileBridgeAndIbexDeposits = async ({ - windowMs = ONE_DAY_MS, + windowMs = FIFTEEN_MIN_MS, }: { windowMs?: number } = {}): Promise< @@ -47,7 +52,7 @@ export const reconcileBridgeAndIbexDeposits = async ({ const bridgeDeposits = (await BridgeDepositLog.find({ createdAt: { $gte: since, $lte: now }, - state: "funds_received", + state: "payment_processed", }) .lean() .exec()) as BridgeDepositLike[] @@ -82,7 +87,7 @@ export const reconcileBridgeAndIbexDeposits = async ({ amount: deposit.amount, currency: deposit.currency, triageContext: { - reason: "Bridge funds_received has no destinationTxHash", + reason: "Bridge payment_processed has no destinationTxHash", windowStart: since.toISOString(), windowEnd: now.toISOString(), depositState: deposit.state, @@ -107,7 +112,7 @@ export const reconcileBridgeAndIbexDeposits = async ({ currency: deposit.currency, triageContext: { reason: - "No IBEX crypto.receive found for Bridge destinationTxHash within 24h window", + "No IBEX crypto.receive found for Bridge destinationTxHash within window", windowStart: since.toISOString(), windowEnd: now.toISOString(), depositState: deposit.state, @@ -129,7 +134,7 @@ export const reconcileBridgeAndIbexDeposits = async ({ currency: receive.currency, triageContext: { reason: - "No Bridge deposit funds_received found for IBEX tx hash within 24h window", + "No Bridge deposit payment_processed found for IBEX tx hash within window", windowStart: since.toISOString(), windowEnd: now.toISOString(), address: receive.address, @@ -153,3 +158,129 @@ export const reconcileBridgeAndIbexDeposits = async ({ return error instanceof Error ? error : new Error(String(error)) } } + +type ReconcileByTxHashResult = { + txHash: string + status: "matched" | "unmatched" + orphanType?: "bridge_without_ibex" | "ibex_without_bridge" + transferId?: string + customerId?: string + amount?: string + currency?: string + detectedAt: Date +} + +export const reconcileByTxHash = async ({ + txHash, +}: { + txHash: string +}): Promise => { + const normalizedHash = txHash.toLowerCase() + const now = new Date() + + try { + const [bridgeDeposit, ibexReceive] = await Promise.all([ + BridgeDepositLog.findOne({ + destinationTxHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, + state: "payment_processed", + }) + .lean() + .exec(), + IbexCryptoReceiveLog.findOne({ + txHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, + }) + .lean() + .exec(), + ]) + + const pubsub = PubSubService() + + if (bridgeDeposit && ibexReceive) { + await resolveOrphansByTxHash(normalizedHash) + + const event: ReconcileByTxHashResult = { + txHash: normalizedHash, + status: "matched", + transferId: (bridgeDeposit as BridgeDepositLike).transferId, + customerId: (bridgeDeposit as BridgeDepositLike).customerId, + amount: (bridgeDeposit as BridgeDepositLike).amount, + currency: (bridgeDeposit as BridgeDepositLike).currency, + detectedAt: now, + } + + baseLogger.info(event, "Bridge↔IBEX real-time reconciliation: matched") + pubsub.publish({ + trigger: PubSubDefaultTriggers.BridgeReconciliationUpdate, + payload: event, + }) + return event + } + + let orphanType: "bridge_without_ibex" | "ibex_without_bridge" + let orphanKey: string + let triageContext: Record + let transferId: string | undefined + let customerId: string | undefined + let amount: string | undefined + let currency: string | undefined + + if (bridgeDeposit && !ibexReceive) { + orphanType = "bridge_without_ibex" + orphanKey = toOrphanKey("bridge", normalizedHash) + transferId = (bridgeDeposit as BridgeDepositLike).transferId + customerId = (bridgeDeposit as BridgeDepositLike).customerId + amount = (bridgeDeposit as BridgeDepositLike).amount + currency = (bridgeDeposit as BridgeDepositLike).currency + triageContext = { + reason: "Bridge payment_processed has no matching IBEX crypto.receive yet", + txHash: normalizedHash, + depositState: (bridgeDeposit as BridgeDepositLike).state, + createdAt: (bridgeDeposit as BridgeDepositLike).createdAt.toISOString(), + detectedAt: now.toISOString(), + } + } else { + orphanType = "ibex_without_bridge" + orphanKey = toOrphanKey("ibex", normalizedHash) + amount = ibexReceive ? (ibexReceive as IbexReceiveLike).amount : undefined + currency = ibexReceive ? (ibexReceive as IbexReceiveLike).currency : undefined + triageContext = { + reason: "IBEX crypto.receive has no matching Bridge funds_received yet", + txHash: normalizedHash, + address: ibexReceive ? (ibexReceive as IbexReceiveLike).address : undefined, + network: ibexReceive ? (ibexReceive as IbexReceiveLike).network : undefined, + detectedAt: now.toISOString(), + } + } + + await upsertBridgeReconciliationOrphan({ + orphanKey, + orphanType, + txHash: normalizedHash, + transferId, + customerId, + amount, + currency, + triageContext, + }) + + const event: ReconcileByTxHashResult = { + txHash: normalizedHash, + status: "unmatched", + orphanType, + transferId, + customerId, + amount, + currency, + detectedAt: now, + } + + baseLogger.info(event, "Bridge↔IBEX real-time reconciliation: unmatched") + pubsub.publish({ + trigger: PubSubDefaultTriggers.BridgeReconciliationUpdate, + payload: event, + }) + return event + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts index e2217c7b7..a87352b1a 100644 --- a/src/services/bridge/webhook-server/routes/deposit.ts +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -10,6 +10,7 @@ import { Request, Response } from "express" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" import { createBridgeDepositLog } from "@services/mongoose/bridge-deposit-log" +import { reconcileByTxHash } from "@services/bridge/reconciliation" export const depositHandler = async (req: Request, res: Response) => { const { event_id, event_object } = req.body @@ -77,6 +78,12 @@ export const depositHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to persist deposit log" }) } + if (state === "payment_processed" && receipt?.destination_tx_hash) { + reconcileByTxHash({ txHash: receipt.destination_tx_hash }).catch((err) => + baseLogger.error({ err, event_id, id }, "Real-time reconciliation failed"), + ) + } + return res.status(200).json({ status: "success" }) } catch (error) { baseLogger.error({ error, id, event_id }, "Error processing Bridge deposit webhook") diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index 2eb0a65fa..da0b4b1f4 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -39,6 +39,7 @@ import { CryptoReceiveOption, CryptoReceiveInfo, CreateCryptoReceiveInfoRequest, + IbexCurrency, } from "./types" import { errorHandler, IbexError, ParseError, UnexpectedIbexResponse } from "./errors" @@ -231,10 +232,10 @@ const payToLnurl = async ( const getIbexToken = async (): Promise => { const cached = await Ibex.authentication.storage.getAccessToken() - if (typeof cached === "string") return `Bearer ${cached}` + if (typeof cached === "string") return `${cached}` // The SDK uses a single base URL for all calls, but the sandbox auth domain is separate - const resp = await fetch(`${IbexConfig.authUrl}/auth/signin`, { + const resp = await fetch(`${IbexConfig.url}/auth/signin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: IbexConfig.email, password: IbexConfig.password }), @@ -268,7 +269,7 @@ const getIbexToken = async (): Promise => { ) } - return `Bearer ${data.accessToken}` + return data.accessToken as string } const ibexFetch = async ( @@ -299,6 +300,24 @@ const ibexGet = (token: string, path: string) => const ibexPost = (token: string, path: string, body: unknown) => ibexFetch(token, path, { method: "POST", body: JSON.stringify(body) }) + +const createIbexAccount = async ( + name: string, + currencyId: IbexCurrencyId, +): Promise => { + try { + const token = await getIbexToken() + if (token instanceof IbexError) return token + const data = await ibexPost( + token, + "/account/create" + , { name, currencyId }) + if (data instanceof IbexError) return data + return data + } catch (err) { + return new IbexError(err instanceof Error ? err : new Error(String(err))) + } +} const getCryptoReceiveBalance = async ( receiveInfoId: string, ): Promise => { @@ -322,12 +341,13 @@ const getCryptoReceiveOptions = async (): Promise( + const data = await ibexGet( token, "/crypto/receive-infos/options", ) + if (data instanceof IbexError) return data - return data.options || [] + return data } catch (err) { return new IbexError(err instanceof Error ? err : new Error(String(err))) } @@ -346,36 +366,39 @@ const createCryptoReceiveInfo = async ( { name: option.name, network: option.network } as CreateCryptoReceiveInfoRequest, ) if (data instanceof IbexError) return data - if (!data.address) return new UnexpectedIbexResponse("Address not found") + if (!data.data.address) return new UnexpectedIbexResponse("Address not found") return data } catch (err) { return new IbexError(err instanceof Error ? err : new Error(String(err))) } } -const getTronUsdtOption = async (): Promise => { +const getTronUsdtOption = async (): Promise => { const options = await getCryptoReceiveOptions() if (options instanceof IbexError) return options const tronUsdt = options.find( (opt) => - opt.currency.toLowerCase() === "usdt" && opt.network.toLowerCase() === "tron", + opt.currencyId === USDTAmount.currencyId && opt.network.toLowerCase() === "tron", ) if (!tronUsdt) { return new IbexError(new Error("Tron USDT option not found")) } - return tronUsdt.id + return tronUsdt } const getEthereumUsdtOption = async (): Promise => { const options = await getCryptoReceiveOptions() if (options instanceof IbexError) return options + const UsdtCurrencyId = await getIbexCurrencyId(WalletCurrency.Usdt) + if (UsdtCurrencyId instanceof IbexError) return UsdtCurrencyId as IbexError + const ethereumUsdt = options.find( (opt) => - opt.currency.toLowerCase() === "usdt" && opt.network.toLowerCase() === "ethereum", + opt.currencyId === UsdtCurrencyId && opt.network.toLowerCase() === "ethereum", ) if (!ethereumUsdt) { @@ -385,6 +408,16 @@ const getEthereumUsdtOption = async (): Promise return ethereumUsdt } +const getIbexCurrencyId = async ( + currency: WalletCurrency, +): Promise => { + const data = await ibexGet<{ currencies: IbexCurrency[] }>("", "/currency/all") + if (data instanceof IbexError) return data + const currencyId = data.currencies.find((c) => c.name === currency)?.id + if (!currencyId) return new IbexError(new Error(`Currency ${currency} not found`)) + return currencyId +} + // const sendBetweenAccounts = async ( // sender: IbexAccount, // receiver: IbexAccount, @@ -422,10 +455,12 @@ export default wrapAsyncFunctionsToRunInSpan({ createLnurlPay, decodeLnurl, payToLnurl, + createIbexAccount, getCryptoReceiveBalance, getCryptoReceiveOptions, createCryptoReceiveInfo, getTronUsdtOption, getEthereumUsdtOption, + getIbexCurrencyId, }, }) diff --git a/src/services/ibex/types.ts b/src/services/ibex/types.ts index ec3d55015..16764d137 100644 --- a/src/services/ibex/types.ts +++ b/src/services/ibex/types.ts @@ -38,17 +38,27 @@ export type IbexInvoiceArgs = { } export interface CryptoReceiveOption { - id: string - currency: string + id?: string + currencyId: number network: string + name?: string +} + +export interface IbexCurrency { + id: IbexCurrencyId name: string + isFiat: boolean + symbol: string + accountEnabled: boolean } export interface CryptoReceiveInfo { id: string wallet_id: string option_id: string - address: string + data: { + address: string + } currency: string network: string created_at: string diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts index e104a52df..83282f9a5 100644 --- a/src/services/ibex/webhook-server/routes/crypto-receive.ts +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -5,6 +5,7 @@ import { listWalletsByAccountId } from "@app/wallets" import { WalletCurrency, USDTAmount } from "@domain/shared" import { baseLogger } from "@services/logger" import { LockService } from "@services/lock" +import { reconcileByTxHash } from "@services/bridge/reconciliation" import { authenticate, logRequest } from "../middleware" @@ -21,8 +22,16 @@ interface CryptoReceiveResult { const cryptoReceiveHandler = async (req: Request, res: Response) => { const { tx_hash, address, amount, currency, network } = req.body - - if (!tx_hash || !address || !amount || currency !== "USDT" || network !== "tron") { + const normalizedCurrency = String(currency || "").toUpperCase() + const normalizedNetwork = String(network || "").toLowerCase() + + if ( + !tx_hash || + !address || + !amount || + normalizedCurrency !== "USDT" || + normalizedNetwork !== "ethereum" + ) { baseLogger.warn( { tx_hash, address, amount, currency, network }, "Invalid crypto receive payload", @@ -30,8 +39,8 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid payload" }) } - const lockResult = await LockService().lockPaymentHash( - tx_hash as PaymentHash, + const lockResult = await LockService().lockOnChainTxHash( + tx_hash as OnChainTxHash, async () => { try { const account = await AccountsRepository().findByBridgeEthereumAddress(address) @@ -44,8 +53,8 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { txHash: String(tx_hash), address: String(address), amount: String(amount), - currency: String(currency), - network: String(network), + currency: normalizedCurrency, + network: normalizedNetwork, accountId: account.id, }) if (ibexLog instanceof Error) { @@ -56,6 +65,10 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { return { status: "error", code: "internal_error" } as CryptoReceiveResult } + reconcileByTxHash({ txHash: String(tx_hash) }).catch((err) => + baseLogger.error({ err, tx_hash }, "Real-time reconciliation failed"), + ) + const wallets = await listWalletsByAccountId(account.id) if (wallets instanceof Error) { baseLogger.error( @@ -122,4 +135,4 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { router.post(paths.cryptoReceive, authenticate, logRequest, cryptoReceiveHandler) -export { paths, router } +export { cryptoReceiveHandler, paths, router } diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 1828e3650..234304c2c 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -106,7 +106,18 @@ export const createWithdrawal = async (data: { try { const record = await BridgeWithdrawal.create(data) return record - } catch (error) { + } catch (error: unknown) { + const mongoErr = error as { code?: number } + if (mongoErr.code === 11000) { + const record = await BridgeWithdrawal.findOne({ + accountId: data.accountId, + externalAccountId: data.externalAccountId, + amount: data.amount, + currency: data.currency, + status: "pending", + }) + if (record) return record + } return new RepositoryError(String(error)) } } diff --git a/src/services/mongoose/bridge-reconciliation-orphan.ts b/src/services/mongoose/bridge-reconciliation-orphan.ts index 0150333d2..3e3b6807f 100644 --- a/src/services/mongoose/bridge-reconciliation-orphan.ts +++ b/src/services/mongoose/bridge-reconciliation-orphan.ts @@ -1,6 +1,7 @@ import { BridgeReconciliationOrphan } from "./schema" type OrphanType = "bridge_without_ibex" | "ibex_without_bridge" +type OrphanStatus = "unmatched" | "resolved" export const upsertBridgeReconciliationOrphan = async (data: { orphanKey: string @@ -16,7 +17,7 @@ export const upsertBridgeReconciliationOrphan = async (data: { try { const orphan = await BridgeReconciliationOrphan.findOneAndUpdate( { orphanKey: data.orphanKey }, - { ...data, detectedAt: new Date() }, + { ...data, status: "unmatched", detectedAt: new Date() }, { upsert: true, new: true, setDefaultsOnInsert: true }, ) @@ -25,3 +26,76 @@ export const upsertBridgeReconciliationOrphan = async (data: { return error instanceof Error ? error : new Error(String(error)) } } + +export const resolveOrphansByTxHash = async ( + txHash: string, +): Promise<{ resolvedCount: number } | Error> => { + try { + const now = new Date() + const result = await BridgeReconciliationOrphan.updateMany( + { + txHash: txHash.toLowerCase(), + status: "unmatched", + }, + { $set: { status: "resolved", resolvedAt: now } }, + ) + return { resolvedCount: result.modifiedCount } + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + +export const findOrphans = async ({ + status, + orphanType, + limit = 50, +}: { + status?: OrphanStatus + orphanType?: OrphanType + limit?: number +} = {}): Promise< + | { + id: string + orphanKey: string + orphanType: OrphanType + status: OrphanStatus + transferId?: string + txHash?: string + customerId?: string + amount?: string + currency?: string + triageContext: Record + detectedAt: Date + resolvedAt?: Date + }[] + | Error +> => { + try { + const filter: Record = {} + if (status) filter.status = status + if (orphanType) filter.orphanType = orphanType + + const docs = await BridgeReconciliationOrphan.find(filter) + .sort({ detectedAt: -1 }) + .limit(limit) + .lean() + .exec() + + return docs.map((d) => ({ + id: (d._id as { toString(): string }).toString(), + orphanKey: d.orphanKey as string, + orphanType: d.orphanType as OrphanType, + status: (d.status ?? "unmatched") as OrphanStatus, + transferId: d.transferId as string | undefined, + txHash: d.txHash as string | undefined, + customerId: d.customerId as string | undefined, + amount: d.amount as string | undefined, + currency: d.currency as string | undefined, + triageContext: d.triageContext as Record, + detectedAt: d.detectedAt as Date, + resolvedAt: d.resolvedAt as Date | undefined, + })) + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 2feb91d87..15a1fd2f5 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -658,6 +658,18 @@ const BridgeWithdrawalSchema = new Schema({ updatedAt: { type: Date, default: Date.now }, }) +// At most one pending row per (account, destination, amount, currency). Partial filter must +// not use $exists:false — MongoDB rejects it for partial indexes ("$not ... $exists"). +// "pending" alone is enough: completed/failed rows are excluded so the same tuple can repeat +// after a terminal status. +BridgeWithdrawalSchema.index( + { accountId: 1, externalAccountId: 1, amount: 1, currency: 1 }, + { + unique: true, + partialFilterExpression: { status: "pending" }, + }, +) + const BridgeDepositLogSchema = new Schema({ eventId: { type: String, required: true, unique: true }, transferId: { type: String, required: true }, @@ -703,6 +715,12 @@ const BridgeReconciliationOrphanSchema = new Schema({ enum: ["bridge_without_ibex", "ibex_without_bridge"], required: true, }, + status: { + type: String, + enum: ["unmatched", "resolved"], + default: "unmatched", + required: true, + }, transferId: { type: String }, txHash: { type: String }, bridgeEventId: { type: String }, @@ -711,10 +729,13 @@ const BridgeReconciliationOrphanSchema = new Schema({ currency: { type: String }, triageContext: { type: Schema.Types.Mixed, required: true }, detectedAt: { type: Date, default: Date.now }, + resolvedAt: { type: Date }, }) BridgeReconciliationOrphanSchema.index({ orphanType: 1, detectedAt: -1 }) BridgeReconciliationOrphanSchema.index({ detectedAt: -1 }) +BridgeReconciliationOrphanSchema.index({ status: 1, detectedAt: -1 }) +BridgeReconciliationOrphanSchema.index({ txHash: 1 }) export const BridgeReconciliationOrphan = mongoose.model( "BridgeReconciliationOrphan", diff --git a/src/services/mongoose/wallets.ts b/src/services/mongoose/wallets.ts index 0a2da5d61..80da0dcce 100644 --- a/src/services/mongoose/wallets.ts +++ b/src/services/mongoose/wallets.ts @@ -4,7 +4,6 @@ import { CouldNotFindWalletFromIdError, CouldNotFindWalletFromOnChainAddressError, CouldNotFindWalletFromOnChainAddressesError, - CouldNotListWalletsFromAccountIdError, CouldNotListWalletsFromWalletCurrencyError, InvalidLnurlError, RepositoryError, @@ -23,6 +22,7 @@ import { ErrorLevel, USDAmount, USDTAmount, WalletCurrency } from "@domain/share import { WalletType } from "@domain/wallets" + import { toObjectId, fromObjectId, parseRepositoryError } from "./utils" import { Wallet } from "./schema" import { AccountsRepository } from "./accounts" @@ -66,6 +66,7 @@ export const WalletsRepository = (): IWalletsRepository => { if (resp instanceof IbexError) return resp const ibexAccountId = resp.id + let lnurlp: string | undefined if (ibexAccountId !== undefined) { const lnurlResp = await Ibex.createLnurlPay({ @@ -118,7 +119,7 @@ export const WalletsRepository = (): IWalletsRepository => { _accountId: toObjectId(accountId), }) if (!result || result.length === 0) { - return new CouldNotListWalletsFromAccountIdError(`accountId: ${accountId}}`) + return [] } return result.map(resultToWallet) } catch (err) { diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 635200766..67d6b50c1 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -18,6 +18,8 @@ jest.mock("@services/logger", () => ({ })) jest.mock("@services/mongoose/bridge-accounts", () => ({ + createVirtualAccount: jest.fn(), + findVirtualAccountByAccountId: jest.fn(), createWithdrawal: jest.fn(), findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), @@ -26,7 +28,15 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ jest.mock("@services/bridge/client", () => ({ __esModule: true, - default: { createTransfer: jest.fn() }, + default: { createVirtualAccount: jest.fn(), createTransfer: jest.fn() }, +})) + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + getEthereumUsdtOption: jest.fn(), + createCryptoReceiveInfo: jest.fn(), + }, })) jest.mock("@services/ibex/client", () => ({ @@ -83,6 +93,8 @@ import BridgeClient from "@services/bridge/client" import { AccountsRepository } from "@services/mongoose/accounts" import { WalletsRepository } from "@services/mongoose/wallets" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" +import IbexClient from "@services/ibex/client" +import { RepositoryError } from "@domain/errors" // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -92,6 +104,9 @@ const AMOUNT = "50" const CUSTOMER_ID = "cust-001" const ETHEREUM_ADDRESS = "ETH_ADDR_001" const TRANSFER_ID = "transfer-bridge-001" +const USDT_WALLET_ID = "ibex-eth-usdt-wallet-001" +const RECEIVE_INFO_ID = "receive-info-001" +const VIRTUAL_ACCOUNT_ID = "virtual-account-001" const mockAccount = { id: ACCOUNT_ID, @@ -119,6 +134,22 @@ const mockTransfer = { state: "pending", } +const mockVirtualAccount = { + id: VIRTUAL_ACCOUNT_ID, + source_deposit_instructions: { + bank_name: "Test Bank", + bank_routing_number: "123456789", + bank_account_number: "123456789012", + }, +} + +const makeWallet = (id: string, currency: string) => ({ + id, + accountId: ACCOUNT_ID, + type: "checking", + currency, +}) + // ── Helpers ─────────────────────────────────────────────────────────────────── const setupGuards = () => { @@ -131,9 +162,9 @@ const setupGuards = () => { findById: jest.fn().mockResolvedValue(mockAccount), }) ;(WalletsRepository as jest.Mock).mockReturnValue({ - listByAccountId: jest - .fn() - .mockResolvedValue([{ id: "wallet-001", currency: "USDT" }]), + listByAccountId: jest.fn().mockResolvedValue([ + { id: "wallet-001", currency: "USDT", type: "checking" }, + ]), }) ;(getBalanceForWallet as jest.Mock).mockResolvedValue(balance) ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ @@ -178,6 +209,181 @@ describe("deriveWithdrawalIdempotencyKey", () => { }) }) +/** + * Linear ENG-296 — ETH-USDT Cash Wallet + Bridge virtual account + * @see https://linear.app/island-bitcoin/issue/ENG-296 + * + * Acceptance ↔ tests (staging steps: `dev/qa/ENG-296-staging-checklist.md`): + * - AC1 IBEX ETH-USDT as Cash Wallet: first `it` — USDT checking persisted/reused, Ibex + * `createCryptoReceiveInfo` uses USDT wallet id; account `bridgeEthereumAddress` updated. + * - AC2 USD not primary: first `it` — `AccountsRepository.update` sets `defaultWalletId` to USDT wallet. + * - AC3 createVirtualAccount E2E: first & second `it` — Bridge + repo; third `it` — idempotent VA return. + */ +describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("ENG-296 AC1+AC2+AC3: provisions USDT cash wallet, flips default off USD, persists Ibex ETH receive address, creates Bridge VA", async () => { + const usdtWallet = makeWallet(USDT_WALLET_ID, "USDT") + const accountWithoutUsdt = { + ...mockAccount, + defaultWalletId: "legacy-usd-wallet-id", + bridgeEthereumAddress: undefined, + } + + const accountsRepo = { + findById: jest.fn().mockResolvedValue(accountWithoutUsdt), + update: jest.fn().mockResolvedValue({ + ...accountWithoutUsdt, + defaultWalletId: USDT_WALLET_ID, + }), + updateBridgeFields: jest.fn().mockResolvedValue({ + ...accountWithoutUsdt, + defaultWalletId: USDT_WALLET_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + }), + } + ;(AccountsRepository as jest.Mock).mockReturnValue(accountsRepo) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest.fn().mockResolvedValue([makeWallet("legacy-usd-wallet-id", "USD")]), + persistNew: jest.fn().mockResolvedValue(usdtWallet), + }) + ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( + new RepositoryError("not found"), + ) + ;(IbexClient.getEthereumUsdtOption as jest.Mock).mockResolvedValue({ + id: "eth-usdt-option", + currency: "USDT", + network: "ethereum", + name: "Ethereum USDT", + }) + ;(IbexClient.createCryptoReceiveInfo as jest.Mock).mockResolvedValue({ + id: RECEIVE_INFO_ID, + wallet_id: USDT_WALLET_ID, + option_id: "eth-usdt-option", + data: { address: ETHEREUM_ADDRESS }, + currency: "USDT", + network: "ethereum", + created_at: "2026-05-09T00:00:00Z", + }) + ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue(mockVirtualAccount) + ;(BridgeAccountsRepo.createVirtualAccount as jest.Mock).mockResolvedValue({ + bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, + }) + + await BridgeService.createVirtualAccount(ACCOUNT_ID) + + expect(WalletsRepository().persistNew).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID, + type: "checking", + currency: "USDT", + }) + expect(AccountsRepository().update).toHaveBeenCalledWith( + expect.objectContaining({ defaultWalletId: USDT_WALLET_ID }), + ) + expect(IbexClient.createCryptoReceiveInfo).toHaveBeenCalledWith( + USDT_WALLET_ID, + expect.objectContaining({ network: "ethereum", currency: "USDT" }), + ) + expect(accountsRepo.updateBridgeFields).toHaveBeenCalledWith( + ACCOUNT_ID, + expect.objectContaining({ bridgeEthereumAddress: ETHEREUM_ADDRESS }), + ) + expect(BridgeClient.createVirtualAccount).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.objectContaining({ + destination: expect.objectContaining({ + currency: "usdt", + payment_rail: "ethereum", + address: ETHEREUM_ADDRESS, + }), + }), + expect.any(String), + ) + }) + + it("ENG-296 AC1+AC3: reuses existing USDT cash wallet and stored Ethereum address (no extra Ibex receive-info call)", async () => { + const usdtWallet = makeWallet(USDT_WALLET_ID, "USDT") + const accountWithUsdtDefault = { + ...mockAccount, + defaultWalletId: USDT_WALLET_ID, + bridgeEthereumAddress: ETHEREUM_ADDRESS, + } + + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(accountWithUsdtDefault), + update: jest.fn(), + updateBridgeFields: jest.fn(), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest.fn().mockResolvedValue([usdtWallet]), + persistNew: jest.fn(), + }) + ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( + new RepositoryError("not found"), + ) + ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue(mockVirtualAccount) + ;(BridgeAccountsRepo.createVirtualAccount as jest.Mock).mockResolvedValue({ + bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, + }) + + await BridgeService.createVirtualAccount(ACCOUNT_ID) + + expect(WalletsRepository().persistNew).not.toHaveBeenCalled() + expect(AccountsRepository().update).not.toHaveBeenCalled() + expect(IbexClient.createCryptoReceiveInfo).not.toHaveBeenCalled() + expect(BridgeClient.createVirtualAccount).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.objectContaining({ + destination: expect.objectContaining({ address: ETHEREUM_ADDRESS }), + }), + expect.any(String), + ) + }) + + it("ENG-296 AC3 (idempotent): existing VA returns stored bank details without wallet or Ibex side effects", async () => { + const existingVaRecord = { + bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, + bankName: "Existing Bank", + routingNumber: "021000021", + accountNumber: "000111222", + accountNumberLast4: "0222", + } + + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + update: jest.fn(), + updateBridgeFields: jest.fn(), + }) + ;(WalletsRepository as jest.Mock).mockReturnValue({ + listByAccountId: jest.fn(), + persistNew: jest.fn(), + }) + ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( + existingVaRecord, + ) + + const result = await BridgeService.createVirtualAccount(ACCOUNT_ID) + + expect(result).toEqual( + expect.objectContaining({ + virtualAccountId: VIRTUAL_ACCOUNT_ID, + bankName: "Existing Bank", + routingNumber: "021000021", + accountNumber: "000111222", + accountNumberLast4: "0222", + }), + ) + expect(WalletsRepository().listByAccountId).not.toHaveBeenCalled() + expect(WalletsRepository().persistNew).not.toHaveBeenCalled() + expect(AccountsRepository().update).not.toHaveBeenCalled() + expect(IbexClient.getEthereumUsdtOption).not.toHaveBeenCalled() + expect(IbexClient.createCryptoReceiveInfo).not.toHaveBeenCalled() + expect(BridgeClient.createVirtualAccount).not.toHaveBeenCalled() + }) +}) + describe("initiateWithdrawal — idempotency key wiring", () => { beforeEach(() => { jest.clearAllMocks() diff --git a/test/flash/unit/services/bridge/reconciliation.spec.ts b/test/flash/unit/services/bridge/reconciliation.spec.ts new file mode 100644 index 000000000..2c4c3a6d6 --- /dev/null +++ b/test/flash/unit/services/bridge/reconciliation.spec.ts @@ -0,0 +1,378 @@ +/** + * Unit tests for Bridge↔IBEX reconciliation + * Covers reconcileByTxHash (real-time) and reconcileBridgeAndIbexDeposits (batch) + */ + +// ── Mocks (must be before imports) ─────────────────────────────────────────── + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/schema", () => ({ + BridgeDepositLog: { findOne: jest.fn(), find: jest.fn() }, + IbexCryptoReceiveLog: { findOne: jest.fn() }, +})) + +jest.mock("@services/mongoose/ibex-crypto-receive-log", () => ({ + findIbexCryptoReceiveLogsSince: jest.fn(), +})) + +jest.mock("@services/mongoose/bridge-reconciliation-orphan", () => ({ + upsertBridgeReconciliationOrphan: jest.fn(), + resolveOrphansByTxHash: jest.fn(), +})) + +jest.mock("@services/pubsub", () => ({ + PubSubService: jest.fn(), +})) + +jest.mock("@domain/pubsub", () => ({ + PubSubDefaultTriggers: { + BridgeReconciliationUpdate: "BRIDGE_RECONCILIATION_UPDATE", + }, +})) + +import { BridgeDepositLog, IbexCryptoReceiveLog } from "@services/mongoose/schema" +import { findIbexCryptoReceiveLogsSince } from "@services/mongoose/ibex-crypto-receive-log" +import { + upsertBridgeReconciliationOrphan, + resolveOrphansByTxHash, +} from "@services/mongoose/bridge-reconciliation-orphan" +import { PubSubService } from "@services/pubsub" +import { + reconcileByTxHash, + reconcileBridgeAndIbexDeposits, +} from "@services/bridge/reconciliation" + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const TX_HASH = "0xABC123def456" +const NORM_HASH = TX_HASH.toLowerCase() + +const BRIDGE_DEPOSIT = { + eventId: "evt_001", + transferId: "tr_001", + customerId: "cust_001", + amount: "100", + currency: "usdt", + destinationTxHash: NORM_HASH, + state: "payment_processed", + createdAt: new Date("2026-01-01T12:00:00Z"), +} + +const IBEX_RECEIVE = { + txHash: NORM_HASH, + address: "0xdeadbeef", + amount: "100", + currency: "USDT", + network: "tron", + accountId: "acc_001", + receivedAt: new Date("2026-01-01T12:00:02Z"), +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const mockPublish = jest.fn() + +const makeLeanQuery = (result: unknown) => ({ + lean: () => ({ exec: () => Promise.resolve(result) }), +}) + +beforeEach(() => { + jest.clearAllMocks() + ;(PubSubService as jest.Mock).mockReturnValue({ publish: mockPublish }) + ;(resolveOrphansByTxHash as jest.Mock).mockResolvedValue({ resolvedCount: 0 }) + ;(upsertBridgeReconciliationOrphan as jest.Mock).mockResolvedValue({ id: "orphan_001" }) +}) + +// ── reconcileByTxHash ───────────────────────────────────────────────────────── + +describe("reconcileByTxHash", () => { + describe("both sides found → matched", () => { + beforeEach(() => { + ;(BridgeDepositLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(BRIDGE_DEPOSIT)) + ;(IbexCryptoReceiveLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(IBEX_RECEIVE)) + }) + + it("returns status matched", async () => { + const result = await reconcileByTxHash({ txHash: TX_HASH }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("matched") + expect(result.txHash).toBe(NORM_HASH) + }) + + it("calls resolveOrphansByTxHash with normalized hash", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(resolveOrphansByTxHash).toHaveBeenCalledWith(NORM_HASH) + }) + + it("does NOT call upsertBridgeReconciliationOrphan", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + + it("publishes a matched event to PubSub", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(mockPublish).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: "BRIDGE_RECONCILIATION_UPDATE", + payload: expect.objectContaining({ + status: "matched", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + customerId: BRIDGE_DEPOSIT.customerId, + amount: BRIDGE_DEPOSIT.amount, + }), + }), + ) + }) + + it("normalizes txHash to lowercase before querying and returning", async () => { + const result = await reconcileByTxHash({ txHash: "0XABC123DEF456" }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.txHash).toBe(NORM_HASH) + const [bridgeCall] = (BridgeDepositLog.findOne as jest.Mock).mock.calls + expect(bridgeCall[0].destinationTxHash.$regex.flags).toContain("i") + }) + }) + + describe("only Bridge found → bridge_without_ibex", () => { + beforeEach(() => { + ;(BridgeDepositLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(BRIDGE_DEPOSIT)) + ;(IbexCryptoReceiveLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + }) + + it("returns status unmatched with correct orphanType", async () => { + const result = await reconcileByTxHash({ txHash: TX_HASH }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("unmatched") + expect(result.orphanType).toBe("bridge_without_ibex") + }) + + it("does NOT call resolveOrphansByTxHash", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(resolveOrphansByTxHash).not.toHaveBeenCalled() + }) + + it("upserts orphan with key bridge:{hash}", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `bridge:${NORM_HASH}`, + orphanType: "bridge_without_ibex", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + customerId: BRIDGE_DEPOSIT.customerId, + }), + ) + }) + + it("publishes an unmatched event to PubSub", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(mockPublish).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + status: "unmatched", + orphanType: "bridge_without_ibex", + }), + }), + ) + }) + }) + + describe("only IBEX found → ibex_without_bridge", () => { + beforeEach(() => { + ;(BridgeDepositLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + ;(IbexCryptoReceiveLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(IBEX_RECEIVE)) + }) + + it("returns status unmatched with correct orphanType", async () => { + const result = await reconcileByTxHash({ txHash: TX_HASH }) + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("unmatched") + expect(result.orphanType).toBe("ibex_without_bridge") + }) + + it("upserts orphan with key ibex:{hash}", async () => { + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `ibex:${NORM_HASH}`, + orphanType: "ibex_without_bridge", + txHash: NORM_HASH, + }), + ) + }) + }) + + describe("self-healing: second call with both sides resolves orphan", () => { + it("resolves orphan when called again after missing side arrives", async () => { + // First call: only Bridge + ;(BridgeDepositLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(BRIDGE_DEPOSIT)) + ;(IbexCryptoReceiveLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + await reconcileByTxHash({ txHash: TX_HASH }) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledTimes(1) + + jest.clearAllMocks() + ;(PubSubService as jest.Mock).mockReturnValue({ publish: mockPublish }) + ;(resolveOrphansByTxHash as jest.Mock).mockResolvedValue({ resolvedCount: 1 }) + + // Second call: both sides present (IBEX webhook arrived) + ;(BridgeDepositLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(BRIDGE_DEPOSIT)) + ;(IbexCryptoReceiveLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(IBEX_RECEIVE)) + const result = await reconcileByTxHash({ txHash: TX_HASH }) + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.status).toBe("matched") + expect(resolveOrphansByTxHash).toHaveBeenCalledWith(NORM_HASH) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + }) + + describe("Bridge query uses payment_processed state filter", () => { + it("passes state: payment_processed to BridgeDepositLog.findOne", async () => { + ;(BridgeDepositLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + ;(IbexCryptoReceiveLog.findOne as jest.Mock).mockReturnValue(makeLeanQuery(null)) + await reconcileByTxHash({ txHash: TX_HASH }) + expect(BridgeDepositLog.findOne).toHaveBeenCalledWith( + expect.objectContaining({ state: "payment_processed" }), + ) + }) + }) +}) + +// ── reconcileBridgeAndIbexDeposits (batch) ──────────────────────────────────── + +describe("reconcileBridgeAndIbexDeposits", () => { + const makeBridgeFind = (deposits: unknown[]) => ({ + lean: () => ({ exec: () => Promise.resolve(deposits) }), + }) + + describe("all deposits matched", () => { + it("returns zero orphans when every Bridge deposit has a matching IBEX receive", async () => { + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue(makeBridgeFind([BRIDGE_DEPOSIT])) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue([IBEX_RECEIVE]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.scannedBridge).toBe(1) + expect(result.scannedIbex).toBe(1) + expect(result.bridgeWithoutIbex).toBe(0) + expect(result.ibexWithoutBridge).toBe(0) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + }) + + describe("Bridge deposit with no matching IBEX receive", () => { + it("flags as bridge_without_ibex orphan", async () => { + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue(makeBridgeFind([BRIDGE_DEPOSIT])) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue([]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.bridgeWithoutIbex).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `bridge:${NORM_HASH}`, + orphanType: "bridge_without_ibex", + txHash: NORM_HASH, + transferId: BRIDGE_DEPOSIT.transferId, + }), + ) + }) + }) + + describe("Bridge deposit with no destinationTxHash", () => { + it("flags as bridge-no-tx:{transferId} orphan", async () => { + const depositNoHash = { ...BRIDGE_DEPOSIT, destinationTxHash: undefined } + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue(makeBridgeFind([depositNoHash])) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue([]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.bridgeWithoutIbex).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `bridge-no-tx:${BRIDGE_DEPOSIT.transferId}`, + orphanType: "bridge_without_ibex", + }), + ) + }) + }) + + describe("IBEX receive with no matching Bridge deposit", () => { + it("flags as ibex_without_bridge orphan", async () => { + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue(makeBridgeFind([])) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue([IBEX_RECEIVE]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.ibexWithoutBridge).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: `ibex:${NORM_HASH}`, + orphanType: "ibex_without_bridge", + txHash: IBEX_RECEIVE.txHash, + }), + ) + }) + }) + + describe("batch uses payment_processed state filter", () => { + it("passes state: payment_processed to BridgeDepositLog.find", async () => { + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue(makeBridgeFind([])) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue([]) + + await reconcileBridgeAndIbexDeposits() + expect(BridgeDepositLog.find).toHaveBeenCalledWith( + expect.objectContaining({ state: "payment_processed" }), + ) + }) + }) + + describe("mixed scenario", () => { + it("counts matched and unmatched independently", async () => { + const deposit2 = { ...BRIDGE_DEPOSIT, transferId: "tr_002", destinationTxHash: "0xother" } + const ibex2 = { ...IBEX_RECEIVE, txHash: "0xorphan_ibex" } + + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue( + makeBridgeFind([BRIDGE_DEPOSIT, deposit2]), + ) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue([IBEX_RECEIVE, ibex2]) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + // BRIDGE_DEPOSIT ↔ IBEX_RECEIVE match (same hash) + // deposit2 has no ibex → bridge_without_ibex + // ibex2 has no bridge → ibex_without_bridge + expect(result.scannedBridge).toBe(2) + expect(result.scannedIbex).toBe(2) + expect(result.bridgeWithoutIbex).toBe(1) + expect(result.ibexWithoutBridge).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledTimes(2) + }) + }) + + describe("error handling", () => { + it("returns an Error when findIbexCryptoReceiveLogsSince fails", async () => { + ;(BridgeDepositLog.find as jest.Mock).mockReturnValue(makeBridgeFind([])) + ;(findIbexCryptoReceiveLogsSince as jest.Mock).mockResolvedValue( + new Error("mongo connection lost"), + ) + + const result = await reconcileBridgeAndIbexDeposits() + expect(result).toBeInstanceOf(Error) + }) + }) +}) diff --git a/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts new file mode 100644 index 000000000..b3249c702 --- /dev/null +++ b/test/flash/unit/services/ibex/webhook-server/routes/crypto-receive.spec.ts @@ -0,0 +1,117 @@ +jest.mock("@services/ibex/webhook-server/middleware", () => ({ + authenticate: jest.fn((_req, _res, next) => next()), + logRequest: jest.fn((_req, _res, next) => next()), +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/ibex-crypto-receive-log", () => ({ + createIbexCryptoReceiveLog: jest.fn(), +})) + +jest.mock("@app/wallets", () => ({ + listWalletsByAccountId: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/bridge/reconciliation", () => ({ + reconcileByTxHash: jest.fn().mockResolvedValue({ status: "matched" }), +})) + +import { cryptoReceiveHandler } from "@services/ibex/webhook-server/routes/crypto-receive" +import { AccountsRepository } from "@services/mongoose/accounts" +import { createIbexCryptoReceiveLog } from "@services/mongoose/ibex-crypto-receive-log" +import { listWalletsByAccountId } from "@app/wallets" +import { LockService } from "@services/lock" +import { WalletCurrency } from "@domain/shared" + +const ACCOUNT_ID = "account-001" as AccountId +const WALLET_ID = "wallet-usdt-001" as WalletId +const ADDRESS = "0xabc123" +const TX_HASH = "tx-001" + +const makeResponse = () => { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } + return res +} + +describe("cryptoReceiveHandler", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(LockService as jest.Mock).mockReturnValue({ + lockOnChainTxHash: jest.fn((_hash, fn) => fn()), + }) + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findByBridgeEthereumAddress: jest.fn().mockResolvedValue({ id: ACCOUNT_ID }), + }) + ;(createIbexCryptoReceiveLog as jest.Mock).mockResolvedValue({ id: "log-001" }) + ;(listWalletsByAccountId as jest.Mock).mockResolvedValue([ + { id: WALLET_ID, currency: WalletCurrency.Usdt }, + ]) + }) + + it("accepts Ethereum USDT receive webhooks and normalizes persisted currency/network", async () => { + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "usdt", + network: "Ethereum", + }, + } as never, + res as never, + ) + + expect(AccountsRepository().findByBridgeEthereumAddress).toHaveBeenCalledWith(ADDRESS) + expect(createIbexCryptoReceiveLog).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "ethereum", + accountId: ACCOUNT_ID, + }), + ) + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ status: "success" }) + }) + + it("rejects legacy Tron USDT receive webhooks for the ETH-USDT Cash Wallet path", async () => { + const res = makeResponse() + + await cryptoReceiveHandler( + { + body: { + tx_hash: TX_HASH, + address: ADDRESS, + amount: "12.345678", + currency: "USDT", + network: "tron", + }, + } as never, + res as never, + ) + + expect(LockService().lockOnChainTxHash).not.toHaveBeenCalled() + expect(createIbexCryptoReceiveLog).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ error: "Invalid payload" }) + }) +})