diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index dbe9b27db..28ad62e5d 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -284,6 +284,28 @@ type BridgeCancelWithdrawalRequestPayload withdrawal: BridgeWithdrawal } +input BridgeCreateExternalAccountInput + @join__type(graph: PUBLIC) +{ + accountNumber: String! + accountOwnerName: String! + bankName: String! + checkingOrSavings: String = "checking" + city: String! + country: String! + postalCode: String! + routingNumber: String! + state: String! + streetLine1: String! +} + +type BridgeCreateExternalAccountPayload + @join__type(graph: PUBLIC) +{ + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + type BridgeCreateVirtualAccountPayload @join__type(graph: PUBLIC) { @@ -379,13 +401,13 @@ type BridgeWithdrawal bridgeTransferId: String createdAt: String! currency: String! - externalAccountId: String - failureReason: String - finalAmount: String estimatedBridgeFee: String estimatedBridgeFeePercent: String estimatedCustomerFee: String estimatedGasBuffer: String + externalAccountId: String + failureReason: String + finalAmount: String flashFee: String flashFeeIsEstimate: Boolean! flashFeeNotice: String @@ -1273,6 +1295,7 @@ type Mutation accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! + bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! diff --git a/package.json b/package.json index 30ba998c0..fff66e9bf 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "graphql-ws": "^5.13.1", "gt3-server-node-express-sdk": "https://github.com/GaloyMoney/gt3-server-node-express-bypass#master", "i18n": "^0.15.1", - "ibex-client": "^3.0.0", + "ibex-client": "github:lnflash/ibex-client#28f4a784cb59e033f49257f22a437b68c95fd94b", "invoices": "^3.0.0", "ioredis": "^5.3.2", "ioredis-cache": "^2.0.0", diff --git a/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts b/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts index a50bded31..48234d1be 100644 --- a/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts +++ b/src/graphql/admin/root/query/bridge-reconciliation-orphans.ts @@ -22,6 +22,8 @@ const BridgeReconciliationOrphansQuery = GT.Field({ orphanType: orphanType as | "bridge_without_ibex" | "ibex_without_bridge" + | "bridge_transfer_without_ibex_send" + | "ibex_send_without_bridge_settlement" | undefined, limit: limit ?? 50, }) diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 379319ba0..3fbcc6d6d 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -606,6 +606,15 @@ export const mapError = (error: ApplicationError): CustomApolloError => { 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({ @@ -620,6 +629,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "BridgePlaidNotAvailableError": + message = error.message || "Plaid bank account linking is not available" + return bridgeGqlError({ + code: "BRIDGE_PLAID_NOT_AVAILABLE", + message, + }) + case "BridgeError": message = error.message || "Bridge API error" return bridgeGqlError({ code: "BRIDGE_ERROR", message }) diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 79f9d3df7..a1f91bd05 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -65,6 +65,7 @@ import UpdateExternalWalletMutation from "./root/mutation/update-external-wallet import BridgeInitiateKycMutation from "./root/mutation/bridge-initiate-kyc" import BridgeCreateVirtualAccountMutation from "./root/mutation/bridge-create-virtual-account" import BridgeAddExternalAccountMutation from "./root/mutation/bridge-add-external-account" +import BridgeCreateExternalAccountMutation from "./root/mutation/bridge-create-external-account" import BridgeRequestWithdrawalMutation from "./root/mutation/bridge-request-withdrawal" import BridgeInitiateWithdrawalMutation from "./root/mutation/bridge-initiate-withdrawal" import BridgeCancelWithdrawalRequestMutation from "./root/mutation/bridge-cancel-withdrawal-request" @@ -125,6 +126,7 @@ export const mutationFields = { bridgeInitiateKyc: BridgeInitiateKycMutation, bridgeCreateVirtualAccount: BridgeCreateVirtualAccountMutation, bridgeAddExternalAccount: BridgeAddExternalAccountMutation, + bridgeCreateExternalAccount: BridgeCreateExternalAccountMutation, bridgeRequestWithdrawal: BridgeRequestWithdrawalMutation, bridgeInitiateWithdrawal: BridgeInitiateWithdrawalMutation, bridgeCancelWithdrawalRequest: BridgeCancelWithdrawalRequestMutation, diff --git a/src/graphql/public/root/mutation/bridge-create-external-account.ts b/src/graphql/public/root/mutation/bridge-create-external-account.ts new file mode 100644 index 000000000..0b56798a0 --- /dev/null +++ b/src/graphql/public/root/mutation/bridge-create-external-account.ts @@ -0,0 +1,79 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import IError from "@graphql/shared/types/abstract/error" +import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import BridgeCreateExternalAccountInput from "@graphql/public/types/input/bridge-create-external-account-input" +import { BridgeConfig } from "@config" +import BridgeService from "@services/bridge" +import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" + +const BridgeCreateExternalAccountPayload = GT.Object({ + name: "BridgeCreateExternalAccountPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + externalAccount: { type: BridgeExternalAccount }, + }), +}) + +const bridgeCreateExternalAccount = GT.Field({ + type: GT.NonNull(BridgeCreateExternalAccountPayload), + args: { + input: { type: GT.NonNull(BridgeCreateExternalAccountInput) }, + }, + resolve: async ( + _, + { + input, + }: { + input: { + bankName: string + accountNumber: string + routingNumber: string + accountOwnerName: string + checkingOrSavings?: string + streetLine1: string + city: string + state: string + postalCode: string + country: string + } + }, + { domainAccount }: GraphQLPublicContextAuth, + ) => { + if (!BridgeConfig.enabled) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeDisabledError())] } + } + + if (!domainAccount || domainAccount.level <= 0) { + return { errors: [mapAndParseErrorForGqlResponse(new BridgeAccountLevelError())] } + } + + const result = await BridgeService.createExternalAccount(domainAccount.id, { + account_owner_name: input.accountOwnerName, + bank_name: input.bankName, + currency: "usd", + account_type: "us", + account: { + account_number: input.accountNumber, + routing_number: input.routingNumber, + checking_or_savings: + (input.checkingOrSavings as "checking" | "savings") ?? "checking", + }, + address: { + street_line_1: input.streetLine1, + city: input.city, + state: input.state, + postal_code: input.postalCode, + country: input.country, + }, + }) + + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { externalAccount: result, errors: [] } + }, +}) + +export default bridgeCreateExternalAccount diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index a84a220a2..19d14781a 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -255,6 +255,24 @@ type BridgeCancelWithdrawalRequestPayload { withdrawal: BridgeWithdrawal } +input BridgeCreateExternalAccountInput { + accountNumber: String! + accountOwnerName: String! + bankName: String! + checkingOrSavings: String = "checking" + city: String! + country: String! + postalCode: String! + routingNumber: String! + state: String! + streetLine1: String! +} + +type BridgeCreateExternalAccountPayload { + errors: [Error!]! + externalAccount: BridgeExternalAccount +} + type BridgeCreateVirtualAccountPayload { errors: [Error!]! virtualAccount: BridgeVirtualAccount @@ -326,13 +344,13 @@ type BridgeWithdrawal { bridgeTransferId: String createdAt: String! currency: String! - externalAccountId: String - failureReason: String - finalAmount: String estimatedBridgeFee: String estimatedBridgeFeePercent: String estimatedCustomerFee: String estimatedGasBuffer: String + externalAccountId: String + failureReason: String + finalAmount: String flashFee: String flashFeeIsEstimate: Boolean! flashFeeNotice: String @@ -989,6 +1007,7 @@ type Mutation { accountUpdateDisplayCurrency(input: AccountUpdateDisplayCurrencyInput!): AccountUpdateDisplayCurrencyPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! + bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! diff --git a/src/graphql/public/types/input/bridge-create-external-account-input.ts b/src/graphql/public/types/input/bridge-create-external-account-input.ts new file mode 100644 index 000000000..cf477ce56 --- /dev/null +++ b/src/graphql/public/types/input/bridge-create-external-account-input.ts @@ -0,0 +1,19 @@ +import { GT } from "@graphql/index" + +const BridgeCreateExternalAccountInput = GT.Input({ + name: "BridgeCreateExternalAccountInput", + fields: () => ({ + bankName: { type: GT.NonNull(GT.String) }, + accountNumber: { type: GT.NonNull(GT.String) }, + routingNumber: { type: GT.NonNull(GT.String) }, + accountOwnerName: { type: GT.NonNull(GT.String) }, + checkingOrSavings: { type: GT.String, defaultValue: "checking" }, + streetLine1: { type: GT.NonNull(GT.String) }, + city: { type: GT.NonNull(GT.String) }, + state: { type: GT.NonNull(GT.String) }, + postalCode: { type: GT.NonNull(GT.String) }, + country: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeCreateExternalAccountInput diff --git a/src/servers/cron.ts b/src/servers/cron.ts index 469b59062..fe773cf42 100644 --- a/src/servers/cron.ts +++ b/src/servers/cron.ts @@ -20,7 +20,10 @@ import { import { baseLogger } from "@services/logger" import { setupMongoConnection } from "@services/mongodb" import { activateLndHealthCheck, checkAllLndHealth } from "@services/lnd/health" -import { reconcileBridgeAndIbexDeposits } from "@services/bridge/reconciliation" +import { + reconcileBridgeAndIbexDeposits, + reconcileBridgeAndIbexWithdrawals, +} from "@services/bridge/reconciliation" import { elapsedSinceTimestamp, sleep } from "@utils" import { rebalancingInternalChannels } from "@services/lnd/rebalancing" @@ -76,6 +79,15 @@ const reconcileBridgeDepositsJob = async () => { if (result instanceof Error) throw result } +const reconcileBridgeWithdrawalsJob = async () => { + if (!BridgeConfig.enabled) return + + const result = await reconcileBridgeAndIbexWithdrawals({ + windowMs: RECONCILE_WINDOW_MS, + }) + if (result instanceof Error) throw result +} + const main = async () => { console.log("cronjob started") const start = new Date() @@ -97,6 +109,7 @@ const main = async () => { ...(cronConfig.rebalanceEnabled ? [rebalance] : []), ...(cronConfig.swapEnabled ? [swapOutJob] : []), reconcileBridgeDepositsJob, + reconcileBridgeWithdrawalsJob, deleteExpiredPaymentFlows, deleteExpiredInvoices, deleteLndPaymentsBefore2Months, diff --git a/src/services/alerts/ibex-bridge-movement.ts b/src/services/alerts/ibex-bridge-movement.ts index a8bfd8ab8..85fee30c7 100644 --- a/src/services/alerts/ibex-bridge-movement.ts +++ b/src/services/alerts/ibex-bridge-movement.ts @@ -44,7 +44,11 @@ export const alertIbexReconciliationOrphan = ({ reason, context, }: { - orphanType: "bridge_without_ibex" | "ibex_without_bridge" + orphanType: + | "bridge_without_ibex" + | "ibex_without_bridge" + | "bridge_transfer_without_ibex_send" + | "ibex_send_without_bridge_settlement" txHash?: string transferId?: string reason: string @@ -60,7 +64,11 @@ export const alertIbexReconciliationOrphan = ({ const title = orphanType === "ibex_without_bridge" ? "IBEX crypto receive without matching Bridge deposit" - : "Bridge deposit without matching IBEX crypto receive" + : orphanType === "bridge_without_ibex" + ? "Bridge deposit without matching IBEX crypto receive" + : orphanType === "bridge_transfer_without_ibex_send" + ? "Bridge withdrawal transfer without matching IBEX send" + : "IBEX withdrawal send without Bridge settlement" alertIbexMovement(dedupKey, { title, diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index 3e4bbbc2e..3449b7181 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -160,6 +160,8 @@ export interface CreateExternalAccountRequest { street_line_1: string city: string country: string + state?: string + postal_code?: string } account_type: string | "us" | "iban" | "unknown" | "clabe" | "pix" | "gb" currency: "usd" | "gbp" | "brl" | "eur" | string @@ -203,8 +205,10 @@ export interface ExternalAccount { currency: string bank_name?: string account_number_last_4?: string + last_4?: string routing_number?: string iban?: string + active?: boolean created_at: string } @@ -373,8 +377,8 @@ export class BridgeClient { "Content-Type": "application/json", } - // Bridge rejects Idempotency-Key on some GET endpoints (e.g. /webhook_events). - if (method.toUpperCase() !== "GET") { + // Bridge rejects Idempotency-Key on GET and DELETE endpoints. + if (!["GET", "DELETE"].includes(method.toUpperCase())) { if (idempotencyKey) { headers["Idempotency-Key"] = idempotencyKey } else { @@ -556,14 +560,15 @@ export class BridgeClient { return this.request("POST", "/transfers", bodyWithCustomer, idempotencyKey) } - async getTransfer( - customerId: BridgeCustomerId, - transferId: BridgeTransferId, - ): Promise { + async getTransfer(transferId: BridgeTransferId): Promise { // Note: Bridge API uses /transfers/{id} not /customers/{id}/transfers/{id} return this.request("GET", `/transfers/${transferId}`) } + async deleteTransfer(transferId: BridgeTransferId): Promise { + return this.request("DELETE", `/transfers/${transferId}`) + } + // ============ List Events ============ async listEvents(params?: ListEventsParams): Promise> { diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 10c84c434..eb9c29ef0 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -101,6 +101,14 @@ export class BridgeTransferFailedError extends BridgeError { } } +export class BridgeDepositInstructionsMissingError extends BridgeError { + constructor( + message: string = "Bridge did not return crypto deposit instructions for this withdrawal", + ) { + super(message) + } +} + export class BridgeWebhookValidationError extends BridgeError { constructor(message: string = "Invalid webhook signature") { super(message) @@ -127,6 +135,14 @@ export class BridgeWithdrawalAlreadyInitiatedError extends BridgeError { } } +export class BridgePlaidNotAvailableError extends BridgeError { + constructor( + message: string = "Bank account linking via Plaid is not available. Please enter your bank details manually.", + ) { + super(message) + } +} + /** * Maps HTTP status codes from Bridge API to domain error types * diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index b6bf80710..7c8e0ed38 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -24,9 +24,11 @@ import { WalletsRepository } from "@services/mongoose/wallets" import { IdentityRepository } from "@services/kratos" import IbexClient from "@services/ibex/client" +import { writeBridgeCashoutPending } from "@services/frappe/BridgeTransferRequestWriter" import { IbexError } from "@services/ibex/errors" import { + BridgeApiError, BridgeInsufficientFundsError, BridgeError, BridgeDisabledError, @@ -37,9 +39,14 @@ import { BridgeCustomerNotFoundError, BridgeWithdrawalNotFoundError, BridgeWithdrawalAlreadyInitiatedError, + BridgePlaidNotAvailableError, + BridgeDepositInstructionsMissingError, BridgeWithdrawalNetAmountTooLowError, } from "./errors" -import BridgeApiClient from "./client" +import BridgeApiClient, { + type CreateExternalAccountRequest, + type ExternalAccount, +} from "./client" import { presentBridgeWithdrawal, receiptFeesFromTransfer, @@ -129,6 +136,100 @@ type ExternalAccountResult = { export const deriveWithdrawalIdempotencyKey = (rowId: string): string => crypto.createHash("sha256").update(`withdrawal:${rowId}`).digest("hex") +const bridgeDepositAddressFromTransfer = (transfer: { + source_deposit_instructions?: { to_address?: string } +}) => transfer.source_deposit_instructions?.to_address + +const ibexPayoutIdFromSendResponse = (response: unknown): string | undefined => { + if (typeof response !== "object" || response === null) return undefined + const typed = response as { + transaction?: { id?: string } + transactionHub?: { id?: string } + transactionId?: string + } + return typed.transaction?.id ?? typed.transactionHub?.id ?? typed.transactionId +} + +const ibexTxHashFromSendResponse = (response: unknown): string | undefined => { + if (typeof response !== "object" || response === null) return undefined + const typed = response as { + txHash?: string + transactionHash?: string + networkTxId?: string + transactionHub?: { txHash?: string; transactionHash?: string; hash?: string } + cryptoTransaction?: { txHash?: string; networkTxId?: string } + } + return ( + typed.txHash ?? + typed.transactionHash ?? + typed.networkTxId ?? + typed.transactionHub?.txHash ?? + typed.transactionHub?.transactionHash ?? + typed.transactionHub?.hash ?? + typed.cryptoTransaction?.txHash ?? + typed.cryptoTransaction?.networkTxId + ) +} + +const bridgeExternalAccountLast4 = (externalAccount: ExternalAccount): string => + externalAccount.account_number_last_4 ?? externalAccount.last_4 ?? "" + +const bridgeExternalAccountIsActive = (externalAccount: ExternalAccount): boolean => + externalAccount.active !== false + +const externalAccountResultFromRecord = (acc: { + bridgeExternalAccountId: string + bankName: string + accountNumberLast4: string + status: string +}): ExternalAccountResult => ({ + bridgeExternalAccountId: acc.bridgeExternalAccountId, + bankName: acc.bankName, + accountNumberLast4: acc.accountNumberLast4, + status: acc.status as "pending" | "verified" | "failed", +}) + +const syncExternalAccountsFromBridge = async ( + accountId: string, + customerId: string, +): Promise => { + const bridgeAccounts = await BridgeApiClient.listExternalAccounts( + toBridgeCustomerId(customerId), + ) + const activeBridgeAccounts = bridgeAccounts.data.filter(bridgeExternalAccountIsActive) + const activeBridgeAccountIds = activeBridgeAccounts.map((acc) => acc.id) + + for (const externalAccount of activeBridgeAccounts) { + const persisted = await BridgeAccountsRepo.createExternalAccount({ + accountId, + bridgeExternalAccountId: externalAccount.id, + bankName: externalAccount.bank_name ?? "", + accountNumberLast4: bridgeExternalAccountLast4(externalAccount), + status: "verified", + }) + if (persisted instanceof Error) return persisted + } + + const staleMarkResult = await BridgeAccountsRepo.markExternalAccountsMissingFromBridge( + accountId, + activeBridgeAccountIds, + ) + if (staleMarkResult instanceof Error) return staleMarkResult + + const localAccounts = + await BridgeAccountsRepo.findExternalAccountsByAccountId(accountId) + if (localAccounts instanceof Error) return localAccounts + + const activeBridgeAccountIdsSet = new Set(activeBridgeAccountIds) + return localAccounts + .filter( + (acc) => + activeBridgeAccountIdsSet.has(acc.bridgeExternalAccountId) && + acc.status === "verified", + ) + .map(externalAccountResultFromRecord) +} + const ensureEthUsdtCashWallet = async ( account: Account, ): Promise => { @@ -496,6 +597,85 @@ const addExternalAccount = async ( { accountId, operation: "addExternalAccount", error }, "Bridge operation failed", ) + + if ( + error instanceof BridgeApiError && + (error.statusCode === 401 || error.statusCode === 403) + ) { + return new BridgePlaidNotAvailableError() + } + + return error instanceof Error ? error : new Error(String(error)) + } +} + +/** + * Creates an external account directly via Bridge API (bypassing Plaid Link). + * Used as a fallback when Plaid Link is unavailable. + */ +const createExternalAccount = async ( + accountId: AccountId, + data: CreateExternalAccountRequest, +): Promise => { + baseLogger.info( + { accountId, operation: "createExternalAccount" }, + "Bridge operation started", + ) + + const enabledCheck = checkBridgeEnabled() + if (enabledCheck instanceof Error) return enabledCheck + + const account = await checkAccountLevel(accountId) + if (account instanceof Error) return account + + try { + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const externalAccount = await BridgeApiClient.createExternalAccount( + customerId, + data, + crypto.randomUUID(), + ) + + const result: ExternalAccountResult = { + bridgeExternalAccountId: externalAccount.id, + bankName: externalAccount.bank_name ?? "", + accountNumberLast4: bridgeExternalAccountLast4(externalAccount), + status: "verified", + } + + // Persist the external account reference in the local repository + const persistResult = await BridgeAccountsRepo.createExternalAccount({ + accountId, + bridgeExternalAccountId: result.bridgeExternalAccountId, + bankName: result.bankName, + accountNumberLast4: result.accountNumberLast4, + status: "verified", + }) + if (persistResult instanceof Error) { + baseLogger.error( + { accountId, operation: "createExternalAccount", error: persistResult }, + "Failed to persist external account locally", + ) + return persistResult + } + + baseLogger.info( + { accountId, operation: "createExternalAccount", result }, + "Bridge operation completed", + ) + + return result + } catch (error) { + baseLogger.error( + { accountId, operation: "createExternalAccount", error }, + "Bridge operation failed", + ) return error instanceof Error ? error : new Error(String(error)) } } @@ -573,9 +753,11 @@ const requestWithdrawal = async ( ) } - // CRIT-2 (ENG-281): Verify caller owns this external account - const externalAccounts = await BridgeAccountsRepo.findExternalAccountsByAccountId( + // CRIT-2 (ENG-281): Bridge is the source of truth. Sync first so + // Dashboard-deleted external accounts cannot remain locally selectable. + const externalAccounts = await syncExternalAccountsFromBridge( accountId as string, + customerId, ) if (externalAccounts instanceof Error) return externalAccounts @@ -684,11 +866,6 @@ const initiateWithdrawal = async ( ) } - const ethereumAddress = account.bridgeEthereumAddress - if (!ethereumAddress) { - return new Error("Account has no Ethereum address. Create virtual account first.") - } - const pendingWithdrawal = await BridgeAccountsRepo.findWithdrawalById(withdrawalId) if (pendingWithdrawal instanceof Error) { return new BridgeWithdrawalNotFoundError() @@ -696,12 +873,28 @@ const initiateWithdrawal = async ( if (pendingWithdrawal.accountId !== (accountId as string)) { return new BridgeWithdrawalNotFoundError() } - if (pendingWithdrawal.status !== "pending" || pendingWithdrawal.bridgeTransferId) { + if ( + pendingWithdrawal.status !== "pending" || + pendingWithdrawal.bridgeTransferId || + pendingWithdrawal.ibexPayoutId + ) { return new BridgeWithdrawalAlreadyInitiatedError() } const { amount, externalAccountId } = pendingWithdrawal + const externalAccounts = await syncExternalAccountsFromBridge( + accountId as string, + customerId, + ) + if (externalAccounts instanceof Error) return externalAccounts + const targetAccount = externalAccounts.find( + (acc) => acc.bridgeExternalAccountId === externalAccountId, + ) + if (!targetAccount) { + return new Error("External account not found") + } + // Re-check balance at execution time — funds may have changed since the request const wallets = await WalletsRepository().listByAccountId(accountId) if (wallets instanceof Error) return wallets @@ -726,6 +919,9 @@ const initiateWithdrawal = async ( ) } + const sendAmount = USDTAmount.fromNumber(amount) + if (sendAmount instanceof Error) return sendAmount + const idempotencyKey = deriveWithdrawalIdempotencyKey(pendingWithdrawal.id) const transfer = await BridgeApiClient.createTransfer( @@ -736,7 +932,6 @@ const initiateWithdrawal = async ( source: { payment_rail: "ethereum", currency: "usdt", - from_address: ethereumAddress, }, developer_fee_percent: String(BridgeConfig.developerFeePercent), destination: { @@ -744,21 +939,138 @@ const initiateWithdrawal = async ( currency: "usd", external_account_id: externalAccountId, }, + features: { + allow_any_from_address: true, + }, }, idempotencyKey, ) - const updated = await BridgeAccountsRepo.updateWithdrawalTransferId( + const bridgeDepositAddress = bridgeDepositAddressFromTransfer(transfer) + if (!bridgeDepositAddress) { + return new BridgeDepositInstructionsMissingError() + } + + const submitted = await BridgeAccountsRepo.updateWithdrawalTransferId( pendingWithdrawal.id, transfer.id, transfer.amount, transfer.currency, + bridgeDepositAddress, receiptFeesFromTransfer(transfer.receipt), ) + if (submitted instanceof Error) return submitted + + const sendRequirements = await IbexClient.getCryptoSendRequirements({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }) + if (sendRequirements instanceof Error) { + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + sendRequirements.message, + ) + return sendRequirements + } + + const cryptoSendInfo = await IbexClient.createCryptoSendInfo({ + name: `bridge-withdrawal-${pendingWithdrawal.id}`, + requirementsId: sendRequirements.requirementsId, + data: { address: bridgeDepositAddress }, + }) + if (cryptoSendInfo instanceof Error) { + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + cryptoSendInfo.message, + ) + return cryptoSendInfo + } + if (!cryptoSendInfo.id) { + const error = new Error("IBEX crypto send info did not return id") + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + error.message, + ) + return error + } + + const sendResult = await IbexClient.sendCrypto({ + accountId: usdtWallet.id as IbexAccountId, + cryptoSendInfosId: cryptoSendInfo.id, + amount: sendAmount.toIbex(), + }) + if (sendResult instanceof Error) { + await BridgeAccountsRepo.updateWithdrawalSendFailed( + pendingWithdrawal.id, + transfer.id, + transfer.amount, + transfer.currency, + bridgeDepositAddress, + sendResult.message, + ) + return sendResult + } + + const ibexPayoutId = ibexPayoutIdFromSendResponse(sendResult) + if (!ibexPayoutId) { + baseLogger.error( + { + accountId, + withdrawalId: pendingWithdrawal.id, + transferId: transfer.id, + sendResult, + }, + "IBEX crypto send succeeded without transaction id; manual payout linking required", + ) + } + + const updated = await BridgeAccountsRepo.updateWithdrawalOnchainSend( + pendingWithdrawal.id, + ibexPayoutId, + ibexTxHashFromSendResponse(sendResult), + ) if (updated instanceof Error) return updated + const auditResult = await writeBridgeCashoutPending({ + transferId: transfer.id, + amount: transfer.amount, + currency: transfer.currency, + accountId: accountId as string, + sourceEventId: updated.id, + sourceEventType: "bridge.withdrawal.usdt_sent", + rawPayload: { + withdrawalId: updated.id, + bridgeTransferId: transfer.id, + ibexPayoutId, + ibexTxHash: updated.ibexTxHash, + }, + }) + if (auditResult instanceof Error) { + baseLogger.warn( + { accountId, withdrawalId, transferId: transfer.id, error: auditResult }, + "Failed to write pending Bridge cashout transfer request", + ) + } + baseLogger.info( - { accountId, operation: "initiateWithdrawal", transferId: transfer.id }, + { + accountId, + operation: "initiateWithdrawal", + transferId: transfer.id, + ibexPayoutId, + }, "Bridge operation completed", ) @@ -1072,17 +1384,15 @@ const getExternalAccounts = async ( if (account instanceof Error) return account try { - const externalAccounts = await BridgeAccountsRepo.findExternalAccountsByAccountId( - accountId as string, - ) - if (externalAccounts instanceof Error) return externalAccounts + const customerId = account.bridgeCustomerId + if (!customerId) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } - const result: ExternalAccountResult[] = externalAccounts.map((acc) => ({ - bridgeExternalAccountId: acc.bridgeExternalAccountId, - bankName: acc.bankName, - accountNumberLast4: acc.accountNumberLast4, - status: acc.status as "pending" | "verified" | "failed", - })) + const result = await syncExternalAccountsFromBridge(accountId as string, customerId) + if (result instanceof Error) return result baseLogger.info( { accountId, operation: "getExternalAccounts", count: result.length }, @@ -1146,6 +1456,7 @@ export default wrapAsyncFunctionsToRunInSpan({ initiateKyc, createVirtualAccount, addExternalAccount, + createExternalAccount, requestWithdrawal, initiateWithdrawal, cancelWithdrawalRequest, diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts index 26ef10ada..a0d0f9035 100644 --- a/src/services/bridge/reconciliation.ts +++ b/src/services/bridge/reconciliation.ts @@ -5,12 +5,30 @@ import { upsertBridgeReconciliationOrphan, resolveOrphansByTxHash, } from "@services/mongoose/bridge-reconciliation-orphan" -import { BridgeDeposits, IbexCryptoReceive } from "@services/mongoose/schema" +import { + BridgeDeposits, + BridgeWithdrawal, + IbexCryptoReceive, +} from "@services/mongoose/schema" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { PubSubService } from "@services/pubsub" import { PubSubDefaultTriggers } from "@domain/pubsub" +import { toBridgeTransferId } from "@domain/primitives/bridge" + +import BridgeApiClient from "./client" const FIFTEEN_MIN_MS = 15 * 60 * 1000 +const WITHDRAWAL_TERMINAL_FAILURE_STATES = new Set([ + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + type BridgeDepositLike = { eventId: string transferId: string @@ -32,6 +50,21 @@ type IbexReceiveLike = { receivedAt: Date } +type BridgeWithdrawalLike = { + id?: string + _id?: { toString(): string } | string + accountId: string + bridgeTransferId?: string + bridgeDepositAddress?: string + ibexPayoutId?: string + amount: string + currency: string + status: "usdt_sent" | "send_failed" + failureReason?: string + updatedAt: Date + createdAt: Date +} + const toOrphanKey = (prefix: string, value: string) => `${prefix}:${value.toLowerCase()}` export const reconcileBridgeAndIbexDeposits = async ({ @@ -198,6 +231,138 @@ export const reconcileBridgeAndIbexDeposits = async ({ } } +export const reconcileBridgeAndIbexWithdrawals = async ({ + windowMs = FIFTEEN_MIN_MS, +}: { + windowMs?: number +} = {}): Promise< + | { + scannedWithdrawals: number + cancelledSendFailedTransfers: number + finalizedCompletedTransfers: number + ibexSendWithoutBridgeSettlement: number + bridgeTransferWithoutIbexSend: number + } + | Error +> => { + try { + const now = new Date() + const since = new Date(now.getTime() - windowMs) + + const withdrawals = (await BridgeWithdrawal.find({ + updatedAt: { $gte: since, $lte: now }, + status: { $in: ["usdt_sent", "send_failed"] }, + bridgeTransferId: { $exists: true }, + }) + .lean() + .exec()) as BridgeWithdrawalLike[] + + let cancelledSendFailedTransfers = 0 + let finalizedCompletedTransfers = 0 + let ibexSendWithoutBridgeSettlement = 0 + let bridgeTransferWithoutIbexSend = 0 + + for (const withdrawal of withdrawals) { + const transferId = withdrawal.bridgeTransferId + if (!transferId) continue + const bridgeTransferId = toBridgeTransferId(transferId) + + if (withdrawal.status === "send_failed") { + try { + await BridgeApiClient.deleteTransfer(bridgeTransferId) + cancelledSendFailedTransfers++ + } catch (error) { + bridgeTransferWithoutIbexSend++ + const reason = "Bridge transfer exists but IBEX crypto send failed" + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("withdrawal-send-failed", transferId), + orphanType: "bridge_transfer_without_ibex_send", + transferId, + amount: withdrawal.amount, + currency: withdrawal.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + accountId: withdrawal.accountId, + bridgeDepositAddress: withdrawal.bridgeDepositAddress, + failureReason: withdrawal.failureReason, + deleteTransferError: error instanceof Error ? error.message : String(error), + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "bridge_transfer_without_ibex_send", + transferId, + reason, + context: { + account_id: withdrawal.accountId, + amount: withdrawal.amount, + currency: withdrawal.currency, + }, + }) + } + continue + } + + const transfer = await BridgeApiClient.getTransfer(bridgeTransferId) + if (transfer.state === "payment_processed") { + const finalized = await BridgeAccountsRepo.updateWithdrawalStatus( + bridgeTransferId, + "completed", + ) + if (!(finalized instanceof Error)) finalizedCompletedTransfers++ + continue + } + + if (!WITHDRAWAL_TERMINAL_FAILURE_STATES.has(transfer.state)) continue + + ibexSendWithoutBridgeSettlement++ + const reason = `IBEX crypto send succeeded but Bridge transfer is ${transfer.state}` + await upsertBridgeReconciliationOrphan({ + orphanKey: toOrphanKey("withdrawal-ibex-sent", transferId), + orphanType: "ibex_send_without_bridge_settlement", + transferId, + customerId: transfer.on_behalf_of, + amount: withdrawal.amount, + currency: withdrawal.currency, + triageContext: { + reason, + windowStart: since.toISOString(), + windowEnd: now.toISOString(), + accountId: withdrawal.accountId, + ibexPayoutId: withdrawal.ibexPayoutId, + bridgeState: transfer.state, + }, + }) + alertIbexReconciliationOrphan({ + orphanType: "ibex_send_without_bridge_settlement", + transferId, + reason, + context: { + account_id: withdrawal.accountId, + ibex_payout_id: withdrawal.ibexPayoutId, + bridge_state: transfer.state, + amount: withdrawal.amount, + currency: withdrawal.currency, + }, + }) + } + + const summary = { + scannedWithdrawals: withdrawals.length, + cancelledSendFailedTransfers, + finalizedCompletedTransfers, + ibexSendWithoutBridgeSettlement, + bridgeTransferWithoutIbexSend, + } + + baseLogger.info(summary, "Bridge withdrawal reconciliation completed") + return summary + } catch (error) { + return error instanceof Error ? error : new Error(String(error)) + } +} + type ReconcileByTxHashResult = { txHash: string status: "matched" | "unmatched" diff --git a/src/services/frappe/BridgeTransferRequestWriter.ts b/src/services/frappe/BridgeTransferRequestWriter.ts index 3185974a9..abe2e1dfe 100644 --- a/src/services/frappe/BridgeTransferRequestWriter.ts +++ b/src/services/frappe/BridgeTransferRequestWriter.ts @@ -161,6 +161,32 @@ type BridgeCashoutWriteInput = { rawPayload: unknown } +export const writeBridgeCashoutPending = async ({ + transferId, + amount, + currency, + accountId, + sourceEventId, + sourceEventType, + rawPayload, +}: BridgeCashoutWriteInput): Promise => { + return upsert( + new BridgeTransferRequest({ + requestId: transferId, + transactionType: BridgeTransferRequestTransactionType.Cashout, + status: BridgeTransferRequestStatus.Pending, + amount: String(amount), + currency: String(currency), + accountId, + bridgeTransferId: transferId, + sourceEventId, + sourceEventType, + sourceSystemsSeen: ["bridge_transfer"], + rawPayload, + }), + ) +} + export const writeBridgeCashoutCompleted = async ({ transferId, amount, diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index b72e571fc..4b9685f09 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -4,6 +4,11 @@ import IbexClient, { CreateAccountResponse201, CreateLnurlPayBodyParam, CreateLnurlPayResponse201, + CreateCryptoSendInfoBodyParam, + CryptoSendInfo, + CryptoSendBodyParam, + CryptoSendRequirements, + CryptoSendResponse200, DecodeLnurlMetadataParam, DecodeLnurlResponse200, EstimateFeeCopyResponse200, @@ -220,6 +225,28 @@ const sendOnchain = async ( return Ibex.sendToAddressV2(bodyWithHooks).then(errorHandler) } +const sendCrypto = async ( + body: CryptoSendBodyParam, +): Promise => { + addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) + return Ibex.sendCrypto(body).then(errorHandler) +} + +const getCryptoSendRequirements = async (args: { + network: string + currencyId: IbexCurrencyId +}): Promise => { + addAttributesToCurrentSpan({ "request.params": JSON.stringify(args) }) + return Ibex.getCryptoSendRequirements(args).then(errorHandler) +} + +const createCryptoSendInfo = async ( + body: CreateCryptoSendInfoBodyParam, +): Promise => { + addAttributesToCurrentSpan({ "request.params": JSON.stringify(body) }) + return Ibex.createCryptoSendInfo(body).then(errorHandler) +} + const estimateOnchainFee = async ( send: UsdWalletAmount, address: OnChainAddress, @@ -481,6 +508,9 @@ export default wrapAsyncFunctionsToRunInSpan({ getLnFeeEstimation, payInvoice, sendOnchain, + sendCrypto, + getCryptoSendRequirements, + createCryptoSendInfo, estimateOnchainFee, createLnurlPay, decodeLnurl, diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 67bab6264..c69e892fc 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -61,10 +61,13 @@ export const createExternalAccount = async (data: { status?: "pending" | "verified" | "failed" }) => { try { - const { bridgeExternalAccountId, accountId, status, ...immutable } = data + const { bridgeExternalAccountId, accountId, status, ...metadata } = data const record = await BridgeExternalAccount.findOneAndUpdate( { bridgeExternalAccountId, accountId }, - { $setOnInsert: { bridgeExternalAccountId, accountId, ...immutable }, $set: { status: status ?? "pending" } }, + { + $setOnInsert: { bridgeExternalAccountId, accountId }, + $set: { ...metadata, status: status ?? "pending", updatedAt: new Date() }, + }, { upsert: true, new: true, setDefaultsOnInsert: true }, ) return record @@ -82,6 +85,28 @@ export const findExternalAccountsByAccountId = async (accountId: string) => { } } +export const markExternalAccountsMissingFromBridge = async ( + accountId: string, + bridgeExternalAccountIds: string[], +) => { + try { + const filter: Record = { + accountId, + status: { $ne: "failed" }, + } + if (bridgeExternalAccountIds.length > 0) { + filter.bridgeExternalAccountId = { $nin: bridgeExternalAccountIds } + } + + return await BridgeExternalAccount.updateMany(filter, { + status: "failed", + updatedAt: new Date(), + }) + } catch (error) { + return new RepositoryError(String(error)) + } +} + export const updateExternalAccountStatus = async ( bridgeId: BridgeExternalAccountId, status: "pending" | "verified" | "failed", @@ -209,6 +234,7 @@ export const updateWithdrawalTransferId = async ( bridgeTransferId: string, amount: string, currency: string, + bridgeDepositAddress?: string, receiptFees?: { bridgeDeveloperFee?: string bridgeExchangeFee?: string @@ -217,22 +243,77 @@ export const updateWithdrawalTransferId = async ( }, ) => { try { - const record = await BridgeWithdrawal.findByIdAndUpdate( - id, + const update: Record = { + bridgeTransferId, + amount, + currency, + bridgeDeveloperFee: receiptFees?.bridgeDeveloperFee, + bridgeExchangeFee: receiptFees?.bridgeExchangeFee, + subtotalAmount: receiptFees?.subtotalAmount, + finalAmount: receiptFees?.finalAmount, + status: "submitted", + updatedAt: new Date(), + } + if (bridgeDepositAddress) update.bridgeDepositAddress = bridgeDepositAddress + + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: id, status: "pending", bridgeTransferId: { $exists: false } }, + update, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or already submitted") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalOnchainSend = async ( + id: string, + ibexPayoutId: string | undefined, + ibexTxHash?: string, +) => { + try { + const update: Record = { + status: "usdt_sent", + updatedAt: new Date(), + } + if (ibexPayoutId) update.ibexPayoutId = ibexPayoutId + if (ibexTxHash) update.ibexTxHash = ibexTxHash + + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: id, status: "submitted", ibexPayoutId: { $exists: false } }, + update, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found or already sent") + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const updateWithdrawalSendFailed = async ( + id: string, + bridgeTransferId: string, + amount: string, + currency: string, + bridgeDepositAddress: string, + failureReason: string, +) => { + try { + const record = await BridgeWithdrawal.findOneAndUpdate( + { _id: id, status: "submitted", ibexPayoutId: { $exists: false } }, { bridgeTransferId, amount, currency, - bridgeDeveloperFee: receiptFees?.bridgeDeveloperFee, - bridgeExchangeFee: receiptFees?.bridgeExchangeFee, - subtotalAmount: receiptFees?.subtotalAmount, - finalAmount: receiptFees?.finalAmount, - status: "submitted", + bridgeDepositAddress, + status: "send_failed", + failureReason: truncateBridgeFailureReason(failureReason), updatedAt: new Date(), }, { new: true }, ) - return record || new RepositoryError("Withdrawal not found") + return record || new RepositoryError("Withdrawal not found or already sent") } catch (error) { return new RepositoryError(String(error)) } @@ -258,7 +339,7 @@ export const updateWithdrawalStatus = async ( if (truncatedReason !== undefined) update.failureReason = truncatedReason const record = await BridgeWithdrawal.findOneAndUpdate( - { bridgeTransferId, status: "submitted" }, + { bridgeTransferId, status: { $in: ["submitted", "usdt_sent"] } }, update, { new: true }, ) @@ -299,7 +380,12 @@ export const findWithdrawalById = async (id: string) => { export const cancelWithdrawal = async (accountId: string, withdrawalId: string) => { try { const record = await BridgeWithdrawal.findOneAndUpdate( - { _id: withdrawalId, accountId, status: "pending", bridgeTransferId: { $exists: false } }, + { + _id: withdrawalId, + accountId, + status: "pending", + bridgeTransferId: { $exists: false }, + }, { status: "cancelled", updatedAt: new Date() }, { new: true }, ) diff --git a/src/services/mongoose/bridge-reconciliation-orphan.ts b/src/services/mongoose/bridge-reconciliation-orphan.ts index 3e3b6807f..4c63a649f 100644 --- a/src/services/mongoose/bridge-reconciliation-orphan.ts +++ b/src/services/mongoose/bridge-reconciliation-orphan.ts @@ -1,6 +1,10 @@ import { BridgeReconciliationOrphan } from "./schema" -type OrphanType = "bridge_without_ibex" | "ibex_without_bridge" +type OrphanType = + | "bridge_without_ibex" + | "ibex_without_bridge" + | "bridge_transfer_without_ibex_send" + | "ibex_send_without_bridge_settlement" type OrphanStatus = "unmatched" | "resolved" export const upsertBridgeReconciliationOrphan = async (data: { diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 8ee8c0c77..43d619335 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -40,14 +40,25 @@ interface IBridgeExternalAccountRecord { accountNumberLast4: string status: "pending" | "verified" | "failed" createdAt: Date + updatedAt: Date } interface IBridgeWithdrawalRecord { accountId: string bridgeTransferId?: string + bridgeDepositAddress?: string + ibexPayoutId?: string + ibexTxHash?: string amount: string currency: string - status: "pending" | "submitted" | "completed" | "failed" | "cancelled" + status: + | "pending" + | "submitted" + | "usdt_sent" + | "send_failed" + | "completed" + | "failed" + | "cancelled" flashFeePercent: string flashFee: string estimatedBridgeFeePercent: string @@ -349,7 +360,18 @@ const AccountSchema = new Schema( }, bridgeKycStatus: { type: String, - enum: ["open", "not_started", "incomplete", "awaiting_questionnaire", "awaiting_ubo", "under_review", "paused", "approved", "rejected", "offboarded"], + enum: [ + "open", + "not_started", + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + "approved", + "rejected", + "offboarded", + ], required: false, }, bridgeEthereumAddress: { @@ -710,6 +732,7 @@ const BridgeExternalAccountSchema = new Schema({ accountNumberLast4: { type: String, required: true }, status: { type: String, enum: ["pending", "verified", "failed"], default: "pending" }, createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, }) // CRIT-2 (ENG-281): Compound index enforces that a given bridgeExternalAccountId @@ -723,9 +746,24 @@ BridgeExternalAccountSchema.index( const BridgeWithdrawalSchema = new Schema({ accountId: { type: String, required: true, index: true }, bridgeTransferId: { type: String, unique: true, sparse: true }, + bridgeDepositAddress: { type: String }, + ibexPayoutId: { type: String, unique: true, sparse: true }, + ibexTxHash: { type: String }, amount: { type: String, required: true }, currency: { type: String, required: true }, - status: { type: String, enum: ["pending", "submitted", "completed", "failed", "cancelled"], default: "pending" }, + status: { + type: String, + enum: [ + "pending", + "submitted", + "usdt_sent", + "send_failed", + "completed", + "failed", + "cancelled", + ], + default: "pending", + }, flashFeePercent: { type: String, required: true }, flashFee: { type: String, required: true }, estimatedBridgeFeePercent: { type: String, required: true }, @@ -796,7 +834,12 @@ const BridgeReconciliationOrphanSchema = new Schema({ orphanKey: { type: String, required: true, unique: true }, orphanType: { type: String, - enum: ["bridge_without_ibex", "ibex_without_bridge"], + enum: [ + "bridge_without_ibex", + "ibex_without_bridge", + "bridge_transfer_without_ibex_send", + "ibex_send_without_bridge_settlement", + ], required: true, }, status: { diff --git a/test/flash/unit/graphql/error-map.spec.ts b/test/flash/unit/graphql/error-map.spec.ts index 0fd27d505..4cdb7c3ff 100644 --- a/test/flash/unit/graphql/error-map.spec.ts +++ b/test/flash/unit/graphql/error-map.spec.ts @@ -3,6 +3,7 @@ import { PhoneAccountAlreadyExistsCannotUpgradeError } from "@services/kratos" import { BridgeWithdrawalNotFoundError, BridgeWithdrawalAlreadyInitiatedError, + BridgeDepositInstructionsMissingError, } from "@services/bridge/errors" describe("error-map", () => { @@ -20,6 +21,13 @@ describe("error-map", () => { expect(result.message).toContain("already been submitted") }) + it("maps BridgeDepositInstructionsMissingError to BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING", () => { + const result = mapError(new BridgeDepositInstructionsMissingError()) + + expect(result.extensions.code).toBe("BRIDGE_DEPOSIT_INSTRUCTIONS_MISSING") + expect(result.message).toContain("deposit instructions") + }) + it("maps PhoneAccountAlreadyExistsCannotUpgradeError to correct GQL error", () => { const input = new PhoneAccountAlreadyExistsCannotUpgradeError() const result = mapError(input) diff --git a/test/flash/unit/services/bridge/client.spec.ts b/test/flash/unit/services/bridge/client.spec.ts index fc437762a..3809ec41e 100644 --- a/test/flash/unit/services/bridge/client.spec.ts +++ b/test/flash/unit/services/bridge/client.spec.ts @@ -178,3 +178,37 @@ describe("listAllEvents", () => { expect(listEventsSpy).toHaveBeenCalledTimes(1) }) }) + +describe("BridgeClient transfer deletion", () => { + const originalFetch = global.fetch + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "tr_123", + amount: "2.5", + currency: "usd", + state: "canceled", + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { payment_rail: "ach", currency: "usd" }, + created_at: "2026-06-17T00:00:00Z", + updated_at: "2026-06-17T00:00:00Z", + }), + } as Response) + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it("does not send an Idempotency-Key on DELETE transfer requests", async () => { + const client = new BridgeClient() + + await client.deleteTransfer("tr_123" as never) + + const [, init] = (global.fetch as jest.Mock).mock.calls[0] + expect(init.method).toBe("DELETE") + expect(init.headers["Idempotency-Key"]).toBeUndefined() + }) +}) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 3c538e7ed..754917ca3 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -12,8 +12,15 @@ jest.mock("@services/tracing", () => ({ jest.mock("@config", () => ({ BridgeConfig: { enabled: true, minWithdrawalAmount: 10, developerFeePercent: 2 }, // Minimal stubs so schema.ts can run its module-level initialisation - getFeesConfig: jest.fn().mockReturnValue({ depositFeeVariable: 0, depositFeeFixed: 0, withdrawFeeVariable: 0, withdrawFeeFixed: 0 }), - getDefaultAccountsConfig: jest.fn().mockReturnValue({ initialStatus: "active", initialLevel: 0, maxCurrencies: 5 }), + getFeesConfig: jest.fn().mockReturnValue({ + depositFeeVariable: 0, + depositFeeFixed: 0, + withdrawFeeVariable: 0, + withdrawFeeFixed: 0, + }), + getDefaultAccountsConfig: jest + .fn() + .mockReturnValue({ initialStatus: "active", initialLevel: 0, maxCurrencies: 5 }), getDefaultFCMTopics: jest.fn().mockReturnValue([]), Levels: [0, 1, 2, 3], getI18nInstance: jest.fn().mockReturnValue({ __: jest.fn() }), @@ -43,9 +50,11 @@ jest.mock("@services/bridge/withdrawal-fees", () => { jest.mock("@services/mongoose/bridge-accounts", () => ({ createVirtualAccount: jest.fn(), findVirtualAccountByAccountId: jest.fn(), + createExternalAccount: jest.fn(), createWithdrawal: jest.fn(), findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), + markExternalAccountsMissingFromBridge: jest.fn(), updateWithdrawalFeeEstimates: jest.fn(), bridgeWithdrawalRecordId: jest.requireActual("@services/mongoose/bridge-accounts") .bridgeWithdrawalRecordId, @@ -53,13 +62,17 @@ jest.mock("@services/mongoose/bridge-accounts", () => ({ findWithdrawalById: jest.fn(), findWithdrawalsByAccountId: jest.fn(), cancelWithdrawal: jest.fn(), + updateWithdrawalOnchainSend: jest.fn(), + updateWithdrawalSendFailed: jest.fn(), })) jest.mock("@services/bridge/client", () => ({ __esModule: true, default: { createVirtualAccount: jest.fn(), + createExternalAccount: jest.fn(), createTransfer: jest.fn(), + listExternalAccounts: jest.fn(), getCustomer: jest.fn().mockResolvedValue({ status: "active" }), }, })) @@ -69,6 +82,10 @@ jest.mock("@services/ibex/client", () => ({ default: { getEthereumUsdtOption: jest.fn(), createCryptoReceiveInfo: jest.fn(), + getCryptoSendRequirements: jest.fn(), + createCryptoSendInfo: jest.fn(), + sendOnchain: jest.fn(), + sendCrypto: jest.fn(), }, })) @@ -98,6 +115,8 @@ jest.mock("@domain/primitives/bridge", () => ({ // guards in the service are satisfied during tests. jest.mock("@domain/shared", () => { class USDTAmount { + static currencyId = 29 + ibexValue: number constructor(ibexValue: number) { @@ -107,6 +126,10 @@ jest.mock("@domain/shared", () => { toIbex() { return this.ibexValue } + + static fromNumber(value: number | string) { + return new USDTAmount(Number(value)) + } } return { ...jest.requireActual("@domain/shared"), USDTAmount } }) @@ -135,9 +158,14 @@ const ETHEREUM_ADDRESS = "ETH_ADDR_001" const TRANSFER_ID = "transfer-bridge-001" const WITHDRAWAL_ID = "withdrawal-mongo-001" const USDT_WALLET_ID = "ibex-eth-usdt-wallet-001" +const BRIDGE_DEPOSIT_ADDRESS = "0xbridgeDepositAddress" +const IBEX_PAYOUT_ID = "ibex-payout-001" +const IBEX_CRYPTO_SEND_REQUIREMENTS_ID = "send-requirements-001" +const IBEX_CRYPTO_SEND_INFO_ID = "send-info-001" const RECEIVE_INFO_ID = "receive-info-001" const VIRTUAL_ACCOUNT_ID = "virtual-account-001" const CREATED_AT = new Date("2026-01-01T00:00:00Z") +const STALE_EXTERNAL_ACCOUNT_ID = "stale-ext-account-001" const mockAccount = { id: ACCOUNT_ID, @@ -172,6 +200,11 @@ const mockTransfer = { amount: AMOUNT, currency: "usd", state: "pending", + source_deposit_instructions: { + payment_rail: "ethereum", + currency: "usdt", + to_address: BRIDGE_DEPOSIT_ADDRESS, + }, receipt: { initial_amount: AMOUNT, developer_fee: "1.00", @@ -190,6 +223,18 @@ const mockVirtualAccount = { }, } +const mockBridgeExternalAccount = { + id: EXTERNAL_ACCOUNT_ID, + customer_id: CUSTOMER_ID, + account_owner_name: "Dread", + account_type: "us", + currency: "usd", + bank_name: "Test Bank", + account_number_last_4: "1111", + active: true, + created_at: "2026-01-01T00:00:00Z", +} + const makeWallet = (id: string, currency: string) => ({ id, accountId: ACCOUNT_ID, @@ -220,25 +265,71 @@ const setupGuards = () => { updateBridgeFields: jest.fn(), }) ;(WalletsRepository as jest.Mock).mockReturnValue({ - listByAccountId: jest.fn().mockResolvedValue([ - { id: USDT_WALLET_ID, currency: "USDT", type: "checking" }, - ]), + listByAccountId: jest + .fn() + .mockResolvedValue([{ id: USDT_WALLET_ID, currency: "USDT", type: "checking" }]), persistNew: jest.fn(), }) ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(1000)) ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ - { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, status: "verified" }, + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, ]) + ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }) + ;( + BridgeAccountsRepo.markExternalAccountsMissingFromBridge as jest.Mock + ).mockResolvedValue({ modifiedCount: 0 }) + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [mockBridgeExternalAccount], + has_more: false, + }) ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ ...makeRow(WITHDRAWAL_ID), bridgeTransferId: TRANSFER_ID, status: "submitted" as const, + bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }) + ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, bridgeDeveloperFee: "1.00", bridgeExchangeFee: "0.10", subtotalAmount: "48.90", finalAmount: "48.90", + ibexPayoutId: IBEX_PAYOUT_ID, + status: "usdt_sent" as const, }) ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) + ;(IbexClient.sendOnchain as jest.Mock).mockResolvedValue({ + status: "PENDING", + transactionHub: { id: IBEX_PAYOUT_ID }, + }) + ;(IbexClient.getCryptoSendRequirements as jest.Mock).mockResolvedValue({ + requirementsId: IBEX_CRYPTO_SEND_REQUIREMENTS_ID, + data: { address: { required: true } }, + }) + ;(IbexClient.createCryptoSendInfo as jest.Mock).mockResolvedValue({ + id: IBEX_CRYPTO_SEND_INFO_ID, + name: `bridge-withdrawal-${WITHDRAWAL_ID}`, + data: { address: BRIDGE_DEPOSIT_ADDRESS }, + }) + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + transaction: { id: IBEX_PAYOUT_ID, status: "PENDING" }, + }) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -303,7 +394,9 @@ describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", } ;(AccountsRepository as jest.Mock).mockReturnValue(accountsRepo) ;(WalletsRepository as jest.Mock).mockReturnValue({ - listByAccountId: jest.fn().mockResolvedValue([makeWallet("legacy-usd-wallet-id", "USD")]), + listByAccountId: jest + .fn() + .mockResolvedValue([makeWallet("legacy-usd-wallet-id", "USD")]), persistNew: jest.fn().mockResolvedValue(usdtWallet), }) ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( @@ -324,7 +417,9 @@ describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", network: "ethereum", created_at: "2026-05-09T00:00:00Z", }) - ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue(mockVirtualAccount) + ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue( + mockVirtualAccount, + ) ;(BridgeAccountsRepo.createVirtualAccount as jest.Mock).mockResolvedValue({ bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, }) @@ -380,7 +475,9 @@ describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", ;(BridgeAccountsRepo.findVirtualAccountByAccountId as jest.Mock).mockResolvedValue( new RepositoryError("not found"), ) - ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue(mockVirtualAccount) + ;(BridgeClient.createVirtualAccount as jest.Mock).mockResolvedValue( + mockVirtualAccount, + ) ;(BridgeAccountsRepo.createVirtualAccount as jest.Mock).mockResolvedValue({ bridgeVirtualAccountId: VIRTUAL_ACCOUNT_ID, }) @@ -441,6 +538,57 @@ describe("createVirtualAccount — ETH-USDT Cash Wallet provisioning (ENG-296)", }) }) +describe("createExternalAccount", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + ;(BridgeClient.createExternalAccount as jest.Mock).mockResolvedValue({ + ...mockBridgeExternalAccount, + account_number_last_4: "4321", + }) + }) + + const createExternalAccountInput = { + account_owner_name: "Dread", + address: { + street_line_1: "1 Test St", + city: "San Francisco", + country: "US", + state: "CA", + postal_code: "94105", + }, + account_type: "us", + currency: "usd", + account: { + account_number: "123456789012", + routing_number: "021000021", + checking_or_savings: "checking" as const, + }, + bank_name: "Test Bank", + } + + it("returns a local persistence error instead of reporting a linked account", async () => { + const persistError = new RepositoryError("mongo unavailable") + ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue( + persistError, + ) + + const result = await BridgeService.createExternalAccount( + ACCOUNT_ID, + createExternalAccountInput, + ) + + expect(result).toBe(persistError) + expect(BridgeAccountsRepo.createExternalAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "4321", + status: "verified", + }) + }) +}) + // ───────────────────────────────────────────────────────────────────────────── // requestWithdrawal // Step 1 of the split flow: validates everything and writes a pending MongoDB @@ -502,7 +650,55 @@ describe("requestWithdrawal", () => { }) }) - it("never calls the Bridge API", async () => { + it("syncs Bridge external accounts before accepting a withdrawal request", async () => { + await BridgeService.requestWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) + + expect(BridgeClient.listExternalAccounts).toHaveBeenCalledWith(CUSTOMER_ID) + expect(BridgeAccountsRepo.createExternalAccount).toHaveBeenCalledWith({ + accountId: ACCOUNT_ID as string, + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }) + expect(BridgeAccountsRepo.markExternalAccountsMissingFromBridge).toHaveBeenCalledWith( + ACCOUNT_ID as string, + [EXTERNAL_ACCOUNT_ID], + ) + }) + + it("rejects a withdrawal when Bridge no longer lists the selected external account", async () => { + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [{ ...mockBridgeExternalAccount, id: "bridge-current-account-002" }], + has_more: false, + }) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Deleted Bank", + accountNumberLast4: "1111", + status: "verified", + }, + { + bridgeExternalAccountId: "bridge-current-account-002", + bankName: "Current Bank", + accountNumberLast4: "2222", + status: "verified", + }, + ]) + + const result = await BridgeService.requestWithdrawal( + ACCOUNT_ID, + AMOUNT, + EXTERNAL_ACCOUNT_ID, + ) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("External account not found for this account") + expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + }) + + it("never creates a Bridge transfer", async () => { await BridgeService.requestWithdrawal(ACCOUNT_ID, AMOUNT, EXTERNAL_ACCOUNT_ID) expect(BridgeClient.createTransfer).not.toHaveBeenCalled() }) @@ -617,7 +813,9 @@ describe("requestWithdrawal", () => { it("returns BridgeCustomerNotFoundError when account has no Bridge customer ID", async () => { ;(AccountsRepository as jest.Mock).mockReturnValue({ - findById: jest.fn().mockResolvedValue({ ...mockAccount, bridgeCustomerId: undefined }), + findById: jest + .fn() + .mockResolvedValue({ ...mockAccount, bridgeCustomerId: undefined }), }) const result = await BridgeService.requestWithdrawal( @@ -648,6 +846,51 @@ describe("requestWithdrawal", () => { }) }) +// ───────────────────────────────────────────────────────────────────────────── +// getExternalAccounts +// Bridge is the source of truth so Dashboard-deleted banks cannot remain selectable. +// ───────────────────────────────────────────────────────────────────────────── + +describe("getExternalAccounts", () => { + beforeEach(() => { + jest.clearAllMocks() + setupGuards() + }) + + it("syncs from Bridge and returns only external accounts that Bridge still lists", async () => { + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: STALE_EXTERNAL_ACCOUNT_ID, + bankName: "Deleted Bank", + accountNumberLast4: "9999", + status: "verified", + }, + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, + ]) + + const result = await BridgeService.getExternalAccounts(ACCOUNT_ID) + + expect(BridgeClient.listExternalAccounts).toHaveBeenCalledWith(CUSTOMER_ID) + expect(BridgeAccountsRepo.markExternalAccountsMissingFromBridge).toHaveBeenCalledWith( + ACCOUNT_ID as string, + [EXTERNAL_ACCOUNT_ID], + ) + expect(expectSuccess(result)).toEqual([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, + ]) + }) +}) + // ───────────────────────────────────────────────────────────────────────────── // initiateWithdrawal (refactored) // Step 2A: fetches the pending record by ID, re-checks balance, calls Bridge. @@ -685,16 +928,117 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { ) }) - it("calling twice with the same withdrawalId passes the same idempotency key to Bridge", async () => { + it("does not send crypto again when the withdrawal already has an IBEX payout", async () => { + ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( + makeRow(WITHDRAWAL_ID, { + status: "usdt_sent", + bridgeTransferId: TRANSFER_ID, + ibexPayoutId: IBEX_PAYOUT_ID, + }), + ) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeWithdrawalAlreadyInitiatedError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeWithdrawalAlreadyInitiatedError) + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("creates a Bridge transfer that asks Bridge for source deposit instructions", async () => { await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeClient.createTransfer).toHaveBeenCalledWith( + CUSTOMER_ID, + expect.objectContaining({ + amount: AMOUNT, + source: { + payment_rail: "ethereum", + currency: "usdt", + }, + features: expect.objectContaining({ + allow_any_from_address: true, + }), + }), + deriveWithdrawalIdempotencyKey(WITHDRAWAL_ID), + ) + }) + + it("revalidates the pending withdrawal external account against Bridge before creating a transfer", async () => { + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [{ ...mockBridgeExternalAccount, id: "bridge-current-account-002" }], + has_more: false, + }) + ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Deleted Bank", + accountNumberLast4: "1111", + status: "verified", + }, + { + bridgeExternalAccountId: "bridge-current-account-002", + bankName: "Current Bank", + accountNumberLast4: "2222", + status: "verified", + }, + ]) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe("External account not found") + expect(BridgeClient.createTransfer).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("does not send USDT when Bridge omits source deposit instructions", async () => { + ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue({ + ...mockTransfer, + source_deposit_instructions: undefined, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + const { BridgeDepositInstructionsMissingError } = jest.requireActual( + "@services/bridge/errors", + ) + expect(result).toBeInstanceOf(BridgeDepositInstructionsMissingError) + expect(BridgeAccountsRepo.updateWithdrawalTransferId).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("creates an IBEX crypto send info for Bridge's deposit address before sending", async () => { await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) - const calls = (BridgeClient.createTransfer as jest.Mock).mock.calls - expect(calls[0][2]).toBe(calls[1][2]) - expect(calls[0][2]).toBe(deriveWithdrawalIdempotencyKey(WITHDRAWAL_ID)) + expect(IbexClient.getCryptoSendRequirements).toHaveBeenCalledWith({ + network: "ethereum", + currencyId: 29, + }) + expect(IbexClient.createCryptoSendInfo).toHaveBeenCalledWith({ + name: `bridge-withdrawal-${WITHDRAWAL_ID}`, + requirementsId: IBEX_CRYPTO_SEND_REQUIREMENTS_ID, + data: { address: BRIDGE_DEPOSIT_ADDRESS }, + }) }) - it("updates the withdrawal record with the Bridge transfer ID and transitions status to submitted", async () => { + it("sends the user's USDT wallet through IBEX crypto send info", async () => { + await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(IbexClient.sendCrypto).toHaveBeenCalledWith({ + accountId: USDT_WALLET_ID, + cryptoSendInfosId: IBEX_CRYPTO_SEND_INFO_ID, + amount: Number(AMOUNT), + }) + expect(IbexClient.sendOnchain).not.toHaveBeenCalled() + }) + + it("updates the withdrawal record with Bridge and IBEX identifiers after the send", async () => { const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) expect(BridgeAccountsRepo.updateWithdrawalTransferId).toHaveBeenCalledWith( @@ -702,6 +1046,7 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { TRANSFER_ID, AMOUNT, "usd", + BRIDGE_DEPOSIT_ADDRESS, { bridgeDeveloperFee: "1.00", bridgeExchangeFee: "0.10", @@ -709,8 +1054,13 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { finalAmount: "48.90", }, ) + expect(BridgeAccountsRepo.updateWithdrawalOnchainSend).toHaveBeenCalledWith( + WITHDRAWAL_ID, + IBEX_PAYOUT_ID, + undefined, + ) expect(expectSuccess(result)).toMatchObject({ - status: "submitted", + status: "usdt_sent", bridgeTransferId: TRANSFER_ID, flashFeeIsEstimate: false, bridgeDeveloperFee: "1.00", @@ -718,6 +1068,81 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { }) }) + it("preserves successful IBEX sends that do not expose a transaction id", async () => { + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + status: "PENDING", + accepted: true, + }) + ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + bridgeDepositAddress: BRIDGE_DEPOSIT_ADDRESS, + ibexPayoutId: undefined, + status: "usdt_sent" as const, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalOnchainSend).toHaveBeenCalledWith( + WITHDRAWAL_ID, + undefined, + undefined, + ) + expect(expectSuccess(result)).toMatchObject({ + status: "usdt_sent", + bridgeTransferId: TRANSFER_ID, + }) + }) + + it("marks the withdrawal send_failed when IBEX send fails after Bridge transfer creation", async () => { + const ibexError = new Error("ibex unavailable") + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue(ibexError) + ;(BridgeAccountsRepo.updateWithdrawalSendFailed as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "send_failed" as const, + failureReason: ibexError.message, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBe(ibexError) + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + ibexError.message, + ) + }) + + it("marks the withdrawal send_failed when IBEX send info creation fails", async () => { + const ibexError = new Error("requirements unavailable") + ;(IbexClient.getCryptoSendRequirements as jest.Mock).mockResolvedValue(ibexError) + ;(BridgeAccountsRepo.updateWithdrawalSendFailed as jest.Mock).mockResolvedValue({ + ...makeRow(WITHDRAWAL_ID), + bridgeTransferId: TRANSFER_ID, + status: "send_failed" as const, + failureReason: ibexError.message, + }) + + const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) + + expect(result).toBe(ibexError) + expect(IbexClient.createCryptoSendInfo).not.toHaveBeenCalled() + expect(IbexClient.sendCrypto).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalSendFailed).toHaveBeenCalledWith( + WITHDRAWAL_ID, + TRANSFER_ID, + AMOUNT, + "usd", + BRIDGE_DEPOSIT_ADDRESS, + ibexError.message, + ) + }) + it("returns BridgeWithdrawalNotFoundError when the withdrawal ID does not exist", async () => { ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( new RepositoryError("Withdrawal not found"), @@ -725,7 +1150,9 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) expect(BridgeClient.createTransfer).not.toHaveBeenCalled() }) @@ -737,7 +1164,9 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { const result = await BridgeService.initiateWithdrawal(ACCOUNT_ID, WITHDRAWAL_ID) - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) expect(BridgeClient.createTransfer).not.toHaveBeenCalled() }) @@ -844,7 +1273,9 @@ describe("cancelWithdrawalRequest", () => { const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() }) @@ -856,7 +1287,9 @@ describe("cancelWithdrawalRequest", () => { const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() }) @@ -883,7 +1316,9 @@ describe("cancelWithdrawalRequest", () => { const result = await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, WITHDRAWAL_ID) - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) expect(result).toBeInstanceOf(BridgeWithdrawalNotFoundError) expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() }) @@ -938,7 +1373,7 @@ describe("withdrawal request → confirm/cancel flow", () => { await BridgeService.initiateWithdrawal(ACCOUNT_ID, pending.id), ) expect(initiated).toMatchObject({ - status: "submitted", + status: "usdt_sent", bridgeTransferId: TRANSFER_ID, }) expect(BridgeClient.createTransfer).toHaveBeenCalledTimes(1) @@ -947,6 +1382,7 @@ describe("withdrawal request → confirm/cancel flow", () => { TRANSFER_ID, AMOUNT, "usd", + BRIDGE_DEPOSIT_ADDRESS, { bridgeDeveloperFee: "1.00", bridgeExchangeFee: "0.10", @@ -999,6 +1435,7 @@ describe("withdrawal request → confirm/cancel flow", () => { TRANSFER_ID, AMOUNT, "usd", + BRIDGE_DEPOSIT_ADDRESS, { bridgeDeveloperFee: "1.00", bridgeExchangeFee: "0.10", @@ -1034,7 +1471,9 @@ describe("withdrawal request → confirm/cancel flow", () => { }) it("initiate returns BridgeWithdrawalNotFoundError for missing or wrong-owner withdrawalId", async () => { - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( new RepositoryError("Withdrawal not found"), @@ -1042,7 +1481,6 @@ describe("withdrawal request → confirm/cancel flow", () => { expect( await BridgeService.initiateWithdrawal(ACCOUNT_ID, "missing-withdrawal"), ).toBeInstanceOf(BridgeWithdrawalNotFoundError) - ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( makeRow(WITHDRAWAL_ID, { accountId: "other-account" }), ) @@ -1067,7 +1505,9 @@ describe("withdrawal request → confirm/cancel flow", () => { }) it("cancel returns BridgeWithdrawalNotFoundError for missing or wrong-owner withdrawalId", async () => { - const { BridgeWithdrawalNotFoundError } = jest.requireActual("@services/bridge/errors") + const { BridgeWithdrawalNotFoundError } = jest.requireActual( + "@services/bridge/errors", + ) ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( new RepositoryError("Withdrawal not found"), @@ -1076,7 +1516,6 @@ describe("withdrawal request → confirm/cancel flow", () => { await BridgeService.cancelWithdrawalRequest(ACCOUNT_ID, "missing-withdrawal"), ).toBeInstanceOf(BridgeWithdrawalNotFoundError) expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() - ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue( makeRow(WITHDRAWAL_ID, { accountId: "other-account" }), ) @@ -1154,11 +1593,11 @@ describe("getWithdrawals", () => { it("excludes cancelled rows without a bridgeTransferId, includes submitted/completed/failed", async () => { ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ - makeRow("w-1", { status: "pending" }), // excluded - makeRow("w-2", { status: "cancelled" }), // excluded (no transferId) - makeRow("w-3", { status: "submitted", bridgeTransferId: TRANSFER_ID }), - makeRow("w-4", { status: "completed", bridgeTransferId: "t-completed" }), - makeRow("w-5", { status: "failed", bridgeTransferId: "t-failed" }), + makeRow("w-1", { status: "pending" }), // excluded + makeRow("w-2", { status: "cancelled" }), // excluded (no transferId) + makeRow("w-3", { status: "submitted", bridgeTransferId: TRANSFER_ID }), + makeRow("w-4", { status: "completed", bridgeTransferId: "t-completed" }), + makeRow("w-5", { status: "failed", bridgeTransferId: "t-failed" }), ]) const result = expectSuccess(await BridgeService.getWithdrawals(ACCOUNT_ID)) diff --git a/test/flash/unit/services/bridge/reconciliation.spec.ts b/test/flash/unit/services/bridge/reconciliation.spec.ts index 9e8a91092..530c96a38 100644 --- a/test/flash/unit/services/bridge/reconciliation.spec.ts +++ b/test/flash/unit/services/bridge/reconciliation.spec.ts @@ -11,9 +11,22 @@ jest.mock("@services/logger", () => ({ jest.mock("@services/mongoose/schema", () => ({ BridgeDeposits: { findOne: jest.fn(), find: jest.fn() }, + BridgeWithdrawal: { find: jest.fn() }, IbexCryptoReceive: { findOne: jest.fn() }, })) +jest.mock("@services/mongoose/bridge-accounts", () => ({ + updateWithdrawalStatus: jest.fn(), +})) + +jest.mock("@services/bridge/client", () => ({ + __esModule: true, + default: { + deleteTransfer: jest.fn(), + getTransfer: jest.fn(), + }, +})) + jest.mock("@services/mongoose/ibex-crypto-receive-log", () => ({ findIbexCryptoReceivesSince: jest.fn(), })) @@ -38,8 +51,14 @@ jest.mock("@services/alerts/ibex-bridge-movement", () => ({ alertIbexReconciliationFailed: jest.fn(), })) -import { BridgeDeposits, IbexCryptoReceive } from "@services/mongoose/schema" +import { + BridgeDeposits, + BridgeWithdrawal, + IbexCryptoReceive, +} from "@services/mongoose/schema" import { findIbexCryptoReceivesSince } from "@services/mongoose/ibex-crypto-receive-log" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import BridgeApiClient from "@services/bridge/client" import { upsertBridgeReconciliationOrphan, resolveOrphansByTxHash, @@ -49,6 +68,7 @@ import { alertIbexReconciliationOrphan } from "@services/alerts/ibex-bridge-move import { reconcileByTxHash, reconcileBridgeAndIbexDeposits, + reconcileBridgeAndIbexWithdrawals, } from "@services/bridge/reconciliation" // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -77,6 +97,28 @@ const IBEX_RECEIVE = { receivedAt: new Date("2026-01-01T12:00:02Z"), } +const BRIDGE_WITHDRAWAL_USDT_SENT = { + _id: "withdrawal_001", + accountId: "acct_001", + bridgeTransferId: "tr_withdrawal_001", + bridgeDepositAddress: "0xbridge", + ibexPayoutId: "ibex_payout_001", + amount: "25.00", + currency: "usdt", + status: "usdt_sent", + createdAt: new Date("2026-01-01T12:00:00Z"), + updatedAt: new Date("2026-01-01T12:00:01Z"), +} + +const BRIDGE_WITHDRAWAL_SEND_FAILED = { + ...BRIDGE_WITHDRAWAL_USDT_SENT, + _id: "withdrawal_002", + bridgeTransferId: "tr_withdrawal_002", + ibexPayoutId: undefined, + status: "send_failed", + failureReason: "ibex unavailable", +} + // ── Helpers ─────────────────────────────────────────────────────────────────── const mockPublish = jest.fn() @@ -90,6 +132,15 @@ beforeEach(() => { ;(PubSubService as jest.Mock).mockReturnValue({ publish: mockPublish }) ;(resolveOrphansByTxHash as jest.Mock).mockResolvedValue({ resolvedCount: 0 }) ;(upsertBridgeReconciliationOrphan as jest.Mock).mockResolvedValue({ id: "orphan_001" }) + ;(BridgeApiClient.deleteTransfer as jest.Mock).mockResolvedValue({ id: "tr_deleted" }) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_001", + state: "awaiting_funds", + }) + ;(BridgeAccountsRepo.updateWithdrawalStatus as jest.Mock).mockResolvedValue({ + ...BRIDGE_WITHDRAWAL_USDT_SENT, + status: "completed", + }) }) // ── reconcileByTxHash ───────────────────────────────────────────────────────── @@ -425,3 +476,73 @@ describe("reconcileBridgeAndIbexDeposits", () => { }) }) }) + +describe("reconcileBridgeAndIbexWithdrawals", () => { + const makeWithdrawalFind = (withdrawals: unknown[]) => ({ + lean: () => ({ exec: () => Promise.resolve(withdrawals) }), + }) + + it("cancels Bridge transfers when IBEX crypto send failed", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([BRIDGE_WITHDRAWAL_SEND_FAILED]), + ) + + const result = await reconcileBridgeAndIbexWithdrawals() + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(BridgeApiClient.deleteTransfer).toHaveBeenCalledWith("tr_withdrawal_002") + expect(result.cancelledSendFailedTransfers).toBe(1) + expect(upsertBridgeReconciliationOrphan).not.toHaveBeenCalled() + }) + + it("self-heals a sent withdrawal when Bridge already reports payment_processed", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([BRIDGE_WITHDRAWAL_USDT_SENT]), + ) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_001", + state: "payment_processed", + }) + + const result = await reconcileBridgeAndIbexWithdrawals() + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(BridgeAccountsRepo.updateWithdrawalStatus).toHaveBeenCalledWith( + "tr_withdrawal_001", + "completed", + ) + expect(result.finalizedCompletedTransfers).toBe(1) + }) + + it("alerts when IBEX sent funds but Bridge is terminally failed", async () => { + ;(BridgeWithdrawal.find as jest.Mock).mockReturnValue( + makeWithdrawalFind([BRIDGE_WITHDRAWAL_USDT_SENT]), + ) + ;(BridgeApiClient.getTransfer as jest.Mock).mockResolvedValue({ + id: "tr_withdrawal_001", + state: "error", + on_behalf_of: "cust_001", + }) + + const result = await reconcileBridgeAndIbexWithdrawals() + + expect(result).not.toBeInstanceOf(Error) + if (result instanceof Error) return + expect(result.ibexSendWithoutBridgeSettlement).toBe(1) + expect(upsertBridgeReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanKey: "withdrawal-ibex-sent:tr_withdrawal_001", + orphanType: "ibex_send_without_bridge_settlement", + transferId: "tr_withdrawal_001", + }), + ) + expect(alertIbexReconciliationOrphan).toHaveBeenCalledWith( + expect.objectContaining({ + orphanType: "ibex_send_without_bridge_settlement", + transferId: "tr_withdrawal_001", + }), + ) + }) +}) diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index 2eb852c17..372f6840a 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -17,7 +17,7 @@ jest.mock("@services/tracing", () => ({ jest.mock("@config", () => ({ ...jest.requireActual("@config"), - BridgeConfig: { enabled: true, minWithdrawalAmount: 10 }, + BridgeConfig: { enabled: true, minWithdrawalAmount: 10, developerFeePercent: 2 }, })) jest.mock("@services/logger", () => { @@ -36,26 +36,37 @@ jest.mock("@app/bridge/send-withdrawal-notification", () => ({ sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), })) +jest.mock("@services/frappe/BridgeTransferRequestWriter", () => ({ + writeBridgeCashoutPending: jest.fn().mockResolvedValue(true), +})) + jest.mock("@services/ibex/client", () => ({ __esModule: true, default: { getEthereumUsdtOption: jest.fn(), createCryptoReceiveInfo: jest.fn(), + getCryptoSendRequirements: jest.fn(), + createCryptoSendInfo: jest.fn(), + sendOnchain: jest.fn(), + sendCrypto: jest.fn(), }, })) jest.mock("@services/mongoose/bridge-accounts", () => ({ + createExternalAccount: jest.fn(), createWithdrawal: jest.fn(), findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), + markExternalAccountsMissingFromBridge: jest.fn(), findWithdrawalsByAccountId: jest.fn(), findWithdrawalById: jest.fn(), updateWithdrawalTransferId: jest.fn(), + updateWithdrawalOnchainSend: jest.fn(), })) jest.mock("@services/bridge/client", () => ({ __esModule: true, - default: { createTransfer: jest.fn() }, + default: { createTransfer: jest.fn(), listExternalAccounts: jest.fn() }, })) jest.mock("@services/mongoose/accounts", () => ({ @@ -81,6 +92,8 @@ jest.mock("@domain/primitives/bridge", () => ({ jest.mock("@domain/shared", () => { class USDTAmount { + static currencyId = 29 + private readonly ibexValue: number constructor(ibexValue: number) { @@ -90,6 +103,10 @@ jest.mock("@domain/shared", () => { toIbex() { return this.ibexValue } + + static fromNumber(value: number | string) { + return new USDTAmount(Number(value)) + } } return { ...jest.requireActual("@domain/shared"), USDTAmount } }) @@ -108,6 +125,9 @@ const CUSTOMER_ID = "cust-001" const ETHEREUM_ADDRESS = "ETH_ADDR_001" const TRANSFER_ID = "transfer-bridge-001" const WITHDRAWAL_ID = "withdrawal-mongo-001" +const BRIDGE_DEPOSIT_ADDRESS = "0xbridgeDepositAddress" +const IBEX_PAYOUT_ID = "ibex-payout-001" +const IBEX_CRYPTO_SEND_INFO_ID = "send-info-001" const CREATED_AT = new Date("2026-06-05T00:00:00.000Z") const mockAccount = { @@ -124,6 +144,18 @@ const mockTransfer = { amount: AMOUNT, currency: "usd", state: "pending", + source_deposit_instructions: { + payment_rail: "ethereum", + currency: "usdt", + to_address: BRIDGE_DEPOSIT_ADDRESS, + }, + receipt: { + initial_amount: AMOUNT, + developer_fee: "1.00", + exchange_fee: "0.10", + subtotal_amount: "48.90", + final_amount: "48.90", + }, } const makeRow = (overrides: Record = {}) => ({ @@ -154,14 +186,74 @@ const setupGuards = () => { }) ;(getBalanceForWallet as jest.Mock).mockResolvedValue(new USDTAmount(1000)) ;(BridgeAccountsRepo.findExternalAccountsByAccountId as jest.Mock).mockResolvedValue([ - { bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, status: "verified" }, + { + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }, ]) + ;(BridgeAccountsRepo.createExternalAccount as jest.Mock).mockResolvedValue({ + bridgeExternalAccountId: EXTERNAL_ACCOUNT_ID, + bankName: "Test Bank", + accountNumberLast4: "1111", + status: "verified", + }) + ;( + BridgeAccountsRepo.markExternalAccountsMissingFromBridge as jest.Mock + ).mockResolvedValue({ modifiedCount: 0 }) + ;(BridgeClient.listExternalAccounts as jest.Mock).mockResolvedValue({ + data: [ + { + id: EXTERNAL_ACCOUNT_ID, + customer_id: CUSTOMER_ID, + account_owner_name: "Dread", + account_type: "us", + currency: "usd", + bank_name: "Test Bank", + account_number_last_4: "1111", + active: true, + created_at: "2026-06-05T00:00:00.000Z", + }, + ], + has_more: false, + }) ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(makeRow()) ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ ...makeRow(), bridgeTransferId: TRANSFER_ID, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", status: "submitted" as const, }) + ;(BridgeAccountsRepo.updateWithdrawalOnchainSend as jest.Mock).mockResolvedValue({ + ...makeRow(), + bridgeTransferId: TRANSFER_ID, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + ibexPayoutId: IBEX_PAYOUT_ID, + status: "usdt_sent" as const, + }) + const IbexClient = jest.requireMock("@services/ibex/client").default + ;(IbexClient.getCryptoSendRequirements as jest.Mock).mockResolvedValue({ + requirementsId: "send-requirements-001", + data: { address: { required: true } }, + }) + ;(IbexClient.createCryptoSendInfo as jest.Mock).mockResolvedValue({ + id: IBEX_CRYPTO_SEND_INFO_ID, + data: { address: BRIDGE_DEPOSIT_ADDRESS }, + }) + ;(IbexClient.sendCrypto as jest.Mock).mockResolvedValue({ + transaction: { id: IBEX_PAYOUT_ID, status: "PENDING" }, + }) + ;(IbexClient.sendOnchain as jest.Mock).mockResolvedValue({ + status: "PENDING", + transactionHub: { id: IBEX_PAYOUT_ID }, + }) ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) } @@ -180,7 +272,7 @@ describe("initiateWithdrawal — BridgeWithdrawal GraphQL contract shape", () => expect(result.id).toBe(WITHDRAWAL_ID) expect(result.amount).toBe(AMOUNT) expect(result.currency).toBe("usdt") - expect(result.status).toBe("submitted") + expect(result.status).toBe("usdt_sent") expect(result.createdAt).toBe(CREATED_AT.toISOString()) expect(result.bridgeTransferId).toBe(TRANSFER_ID) expect((result as Record).transferId).toBeUndefined() @@ -198,7 +290,11 @@ describe("getWithdrawals — BridgeWithdrawal GraphQL contract shape", () => { it("maps Mongo rows to id/status (not legacy transferId/state)", async () => { ;(BridgeAccountsRepo.findWithdrawalsByAccountId as jest.Mock).mockResolvedValue([ - makeRow({ bridgeTransferId: TRANSFER_ID, status: "submitted", failureReason: "ACH return" }), + makeRow({ + bridgeTransferId: TRANSFER_ID, + status: "submitted", + failureReason: "ACH return", + }), ]) const result = await BridgeService.getWithdrawals(ACCOUNT_ID) diff --git a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts index 736631830..71fd7d1de 100644 --- a/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts +++ b/test/flash/unit/services/frappe/BridgeTransferRequestWriter.spec.ts @@ -11,6 +11,7 @@ import { baseLogger } from "@services/logger" import { writeBridgeCashoutCompleted, writeBridgeCashoutFailed, + writeBridgeCashoutPending, writeBridgeDepositRequest, writeIbexCryptoReceiveRequest, } from "@services/frappe/BridgeTransferRequestWriter" @@ -172,6 +173,28 @@ describe("BridgeTransferRequestWriter", () => { ) }) + it("writes pending Bridge transfers as pending cashout audit requests", async () => { + await writeBridgeCashoutPending({ + transferId: "tr_cashout", + amount: "5.00", + currency: "usdt", + accountId: "acct_123" as AccountId, + sourceEventId: "withdrawal_123", + sourceEventType: "bridge.withdrawal.usdt_sent", + rawPayload: { bridgeTransferId: "tr_cashout" }, + }) + + expect(lastRequestInput()).toEqual( + expect.objectContaining({ + requestId: "tr_cashout", + status: BridgeTransferRequestStatus.Pending, + accountId: "acct_123", + sourceEventId: "withdrawal_123", + sourceEventType: "bridge.withdrawal.usdt_sent", + }), + ) + }) + it("writes failed Bridge transfers as failed cashout audit requests", async () => { await writeBridgeCashoutFailed({ transferId: "tr_cashout", diff --git a/test/flash/unit/services/ibex/client-usd-wallet.spec.ts b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts index f61c7831e..b3a844d0e 100644 --- a/test/flash/unit/services/ibex/client-usd-wallet.spec.ts +++ b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts @@ -1,6 +1,9 @@ const mockAddInvoice = jest.fn() const mockGetFeeEstimation = jest.fn() const mockEstimateFeeV2 = jest.fn() +const mockGetCryptoSendRequirements = jest.fn() +const mockCreateCryptoSendInfo = jest.fn() +const mockSendCrypto = jest.fn() jest.mock("@services/ibex/cache", () => ({ Redis: { @@ -10,6 +13,25 @@ jest.mock("@services/ibex/cache", () => ({ }, })) +jest.mock("@services/ibex/webhook-server", () => ({ + __esModule: true, + default: { + endpoints: { + onReceive: { + invoice: "https://flash.test/ibex/receive/invoice", + lnurl: "https://flash.test/ibex/receive/lnurl", + onchain: "https://flash.test/ibex/receive/onchain", + }, + onPay: { + invoice: "https://flash.test/ibex/pay/invoice", + lnurl: "https://flash.test/ibex/pay/lnurl", + onchain: "https://flash.test/ibex/pay/onchain", + }, + }, + secret: "test-secret", + }, +})) + jest.mock("ibex-client", () => { class AuthenticationError extends Error {} class ApiError extends Error {} @@ -30,11 +52,22 @@ jest.mock("ibex-client", () => { addInvoice: (...args: unknown[]) => mockAddInvoice(...args), getFeeEstimation: (...args: unknown[]) => mockGetFeeEstimation(...args), estimateFeeV2: (...args: unknown[]) => mockEstimateFeeV2(...args), + getCryptoSendRequirements: (...args: unknown[]) => + mockGetCryptoSendRequirements(...args), + createCryptoSendInfo: (...args: unknown[]) => mockCreateCryptoSendInfo(...args), + sendCrypto: (...args: unknown[]) => mockSendCrypto(...args), })), AuthenticationError, ApiError, UnexpectedResponseError, IbexClientError, + IbexUrls: { + sandbox: { + authDomain: "https://auth.sandbox.example", + audience: "https://api.sandbox.example", + hubUrl: "https://api.sandbox.example", + }, + }, } }) @@ -133,4 +166,60 @@ describe("IBEX USD wallet amount handling", () => { "address": "0xabc", }) }) + + it("forwards crypto sends to the IBEX crypto send endpoint", async () => { + mockSendCrypto.mockResolvedValue({ + transaction: { id: "ibex-payout-001", status: "PENDING" }, + cryptoTransaction: { networkTxId: "0xtx" }, + }) + + await Ibex.sendCrypto({ + accountId: "ibex-usdt-account", + cryptoSendInfosId: "send-info-001", + amount: 2.5, + }) + + expect(mockSendCrypto).toHaveBeenCalledWith({ + accountId: "ibex-usdt-account", + cryptoSendInfosId: "send-info-001", + amount: 2.5, + }) + }) + + it("fetches crypto send requirements", async () => { + mockGetCryptoSendRequirements.mockResolvedValue({ + requirementsId: "requirements-001", + data: { address: { required: true } }, + }) + + await Ibex.getCryptoSendRequirements({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }) + + expect(mockGetCryptoSendRequirements).toHaveBeenCalledWith({ + network: "ethereum", + currencyId: USDTAmount.currencyId, + }) + }) + + it("creates crypto send info", async () => { + mockCreateCryptoSendInfo.mockResolvedValue({ + id: "send-info-001", + name: "bridge-withdrawal-001", + data: { address: "0xbridge" }, + }) + + await Ibex.createCryptoSendInfo({ + name: "bridge-withdrawal-001", + requirementsId: "requirements-001", + data: { address: "0xbridge" }, + }) + + expect(mockCreateCryptoSendInfo).toHaveBeenCalledWith({ + name: "bridge-withdrawal-001", + requirementsId: "requirements-001", + data: { address: "0xbridge" }, + }) + }) }) diff --git a/yarn.lock b/yarn.lock index d7e6ae9d5..e0319e597 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8982,10 +8982,9 @@ i18n@^0.15.1: math-interval-parser "^2.0.1" mustache "^4.2.0" -ibex-client@^3.0.0: +"ibex-client@github:lnflash/ibex-client#28f4a784cb59e033f49257f22a437b68c95fd94b": version "3.0.0" - resolved "https://registry.yarnpkg.com/ibex-client/-/ibex-client-3.0.0.tgz#963ced9561b1e2cea0cec876ebf282f62ef69540" - integrity sha512-ATfB1qptJLv2C7K0bzzZCjTSYgJO/ihx059FCT+DjngfSkijJLOdFdFepQTcy7Cadh1ij5TdFa5+jYbr3ZiViw== + resolved "git+ssh://git@github.com/lnflash/ibex-client.git#28f4a784cb59e033f49257f22a437b68c95fd94b" dependencies: api "^6.1.2" node-cache "^5.1.2"