diff --git a/docs/bridge-integration/ARCHITECTURE.md b/docs/bridge-integration/ARCHITECTURE.md index 0ec665227..47e1d9749 100644 --- a/docs/bridge-integration/ARCHITECTURE.md +++ b/docs/bridge-integration/ARCHITECTURE.md @@ -38,16 +38,16 @@ The integration consists of three main components: ### On-Ramp (USD -> USDT) 1. **KYC**: User initiates KYC via Flash, which creates a Bridge customer and returns a KYC link (Persona). -2. **Virtual Account**: Once KYC is approved, Flash creates a Tron USDT address via IBEX and a Bridge virtual account pointing to that address. +2. **Virtual Account**: Once KYC is approved, Flash creates a Ethereum USDT (ERC20) receive address via IBEX, stored in the BridgeDepositAddress collection, and a Bridge virtual account pointing to that address. 3. **Deposit**: User sends USD to the virtual account. -4. **Conversion**: Bridge converts USD to USDT and sends it to the Tron address. +4. **Conversion**: Bridge converts USD to USDT and sends it to the Ethereum USDT address. 5. **Credit**: IBEX detects the USDT deposit and notifies Flash via webhook, which credits the user's wallet. ### Off-Ramp (USDT -> USD) 1. **Link Bank**: User links an external bank account via Bridge's hosted UI. 2. **Withdrawal**: User initiates a withdrawal in Flash. -3. **Transfer**: Flash creates a Bridge transfer from the user's Tron address to the linked bank account. +3. **Transfer**: Flash creates a Bridge transfer from the user's Ethereum USDT address to the linked bank account. 4. **Conversion**: Bridge converts USDT to USD and sends it to the bank via ACH. ## Technology Stack diff --git a/docs/bridge-integration/FLOWS.md b/docs/bridge-integration/FLOWS.md index 9b76cffe8..216bc51a7 100644 --- a/docs/bridge-integration/FLOWS.md +++ b/docs/bridge-integration/FLOWS.md @@ -51,12 +51,12 @@ User Flash App Flash Backend Bridge.xyz 5. **Redirect**: App opens the KYC link (Persona). 6. **Verification**: User completes identity verification. 7. **KYC Webhook**: Bridge sends `kyc.approved` webhook to Flash. -8. **Tron Address**: Flash requests a unique Tron USDT receive address from IBEX. -9. **Virtual Account**: Flash creates a Bridge virtual account linked to the Tron address. +8. **ETH USDT Receive Address**: Flash requests a unique ETH USDT receive address from IBEX via the published receive-info API. +9. **Virtual Account**: Flash creates a Bridge virtual account linked to the ETH USDT receive address. 10. **Display Details**: User sees bank name, routing number, and account number in the app. 11. **Bank Transfer**: User initiates a transfer from their banking app. 12. **Conversion**: Bridge receives USD and converts it to USDT. -13. **Settlement**: Bridge sends USDT to the user's Tron address. +13. **Settlement**: Bridge sends USDT to the user's ETH USDT receive address. 14. **IBEX Webhook**: IBEX detects the incoming USDT and notifies Flash. 15. **Credit**: Flash credits the user's USDT wallet and sends a push notification. diff --git a/docs/bridge-integration/SECURITY-AUDIT.md b/docs/bridge-integration/SECURITY-AUDIT.md new file mode 100644 index 000000000..656655af8 --- /dev/null +++ b/docs/bridge-integration/SECURITY-AUDIT.md @@ -0,0 +1,136 @@ +# Bridge Integration Security Audit — ENG-279 + +**Auditor:** Vandana (forge0x) +**Branch:** `feature/bridge-integration` +**Date:** 2026-04-01 + +--- + +## Summary + +The Bridge integration handles real money movement (USDT → ACH). Overall the auth and webhook verification architecture is sound. Three findings require fixes before merge — one critical, one high, one medium. + +--- + +## 🔴 CRITICAL — Amount Not Validated Before Sending to Bridge API + +**File:** `src/services/bridge/index.ts` → `initiateWithdrawal` +**File:** `src/graphql/public/root/mutation/bridge-initiate-withdrawal.ts` + +The `amount` parameter is a raw `GT.String` / `string` with no validation. It's passed directly to `BridgeClient.createTransfer({ amount, ... })` without: +- Checking it's a valid positive number +- Checking it's above minimum (Bridge rejects < $1 transfers) +- Preventing `"0"`, `"-100"`, `"NaN"`, exponential notation (`"1e10"`), or injection strings + +**Impact:** Malformed amounts will either cause unhandled Bridge API errors (already caught), but more importantly — there is no minimum amount check, meaning a user could attempt to drain bridge accounts with micro-transfers, potentially hitting Bridge API rate limits or triggering fees. + +**Fix:** +```typescript +// In initiateWithdrawal, before calling BridgeClient: +const amountNum = parseFloat(amount) +if (isNaN(amountNum) || amountNum <= 0 || amountNum < 1.0) { + return new ValidationError("Amount must be a positive number >= $1.00") +} +// Use normalized string to avoid scientific notation +const normalizedAmount = amountNum.toFixed(2) +``` + +--- + +## 🟡 HIGH — Fake Email in Bridge Customer Creation + +**File:** `src/services/bridge/index.ts` + +```typescript +email: `${account.id}@flash.app`, // Placeholder - should use real email +``` + +Using a fake placeholder email when creating Bridge customers. This will: +1. Cause Bridge KYC emails to be undeliverable (users won't receive KYC completion links) +2. Violate Bridge's ToS (KYC requires real contact information) +3. Block account recovery if Bridge needs to contact the user + +This is also tracked as **ENG-278**. Must be fixed before production. + +**Fix:** Use the authenticated user's real email from Kratos identity. The identity is available in the resolver context and can be passed down, or fetched from `IdentityRepository().getIdentity(account.kratosUserId)`. + +--- + +## 🟡 HIGH — External Account Ownership Not Verified in Withdrawal + +**File:** `src/services/bridge/index.ts` → `initiateWithdrawal` + +```typescript +const targetAccount = externalAccounts.find( + (acc) => acc.bridgeExternalAccountId === externalAccountId, +) +``` + +The code correctly fetches external accounts for the authenticated `accountId`, then looks up the target. **But** — what happens when `targetAccount` is not found? Let me check the actual code path: + +```typescript +// From the service code: +const targetAccount = externalAccounts.find(...) +// If undefined: falls through to BridgeClient.createTransfer with the raw externalAccountId +// No early return if targetAccount is undefined! +``` + +This means if the external account ID is not in the user's list, the transfer is attempted anyway with the raw ID. Bridge may reject it (the customer/external account mismatch), but this should be explicitly rejected server-side before making any API call. + +**Fix:** +```typescript +if (!targetAccount) { + return new BridgeExternalAccountNotFoundError( + "External account not found or not owned by this account" + ) +} +``` + +--- + +## 🟢 PASSES — Webhook Signature Verification + +`src/services/bridge/webhook-server/middleware/verify-signature.ts` + +RSA-SHA256 asymmetric verification is correctly implemented: +- ✅ Timestamp skew check (default 5 min window) +- ✅ Raw body used for verification (not parsed JSON) +- ✅ Separate public keys per webhook type (kyc/deposit/transfer) +- ✅ Proper error handling without leaking details + +--- + +## 🟢 PASSES — GraphQL Mutation Auth + +All Bridge mutations: +- ✅ Use `GraphQLPublicContextAuth` (requires authenticated session) +- ✅ Enforce `domainAccount.level < 2` gate (Pro tier required) +- ✅ `BridgeConfig.enabled` feature flag checked on every operation + +--- + +## 🟢 PASSES — IBEX Tron USDT (ENG-277) + +`src/services/bridge/index.ts` → `createVirtualAccount` + +The Tron address creation is correctly marked as not implemented: +```typescript +return new Error("IBEX Tron address creation not yet implemented") +``` +No partial implementation that could create inconsistent state. Safe to merge in this state as long as the feature flag is off — but ENG-277 needs to be resolved before enabling Bridge in production. + +--- + +## Required Fixes Before Merge + +| # | Severity | File | Fix | +|---|----------|------|-----| +| 1 | 🔴 Critical | `src/services/bridge/index.ts` | Validate amount before API call | +| 2 | 🟡 High | `src/services/bridge/index.ts` | Use real user email (ENG-278) | +| 3 | 🟡 High | `src/services/bridge/index.ts` | Return error if external account not found | + +--- + +## Recommendation + +Branch is **not ready to merge** until items 1–3 are fixed. Items 1 and 3 are quick fixes (< 1 hour). Item 2 (ENG-278) requires the email lookup, which is a slightly bigger change but the pattern already exists in `business-account-upgrade-request.ts` using `IdentityRepository`. diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 0c7c41117..67d527066 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -89,7 +89,8 @@ type Account = { // Bridge integration: bridgeCustomerId?: BridgeCustomerId bridgeKycStatus?: "pending" | "approved" | "rejected" - bridgeTronAddress?: string + // Crypto deposit address is stored in BridgeDepositAddress collection (not on Account) + // — see src/services/mongoose/bridge-deposit-addresses.ts } // deprecated @@ -180,11 +181,9 @@ interface IAccountsRepository { fields: { bridgeCustomerId?: BridgeCustomerId bridgeKycStatus?: "pending" | "approved" | "rejected" - bridgeTronAddress?: string }, ): Promise - findByBridgeTronAddress(address: string): Promise findByBridgeCustomerId(customerId: BridgeCustomerId): Promise } diff --git a/src/domain/primitives/bridge.ts b/src/domain/primitives/bridge.ts index 00305d3d7..6b95baf65 100644 --- a/src/domain/primitives/bridge.ts +++ b/src/domain/primitives/bridge.ts @@ -21,3 +21,18 @@ export const toBridgeExternalAccountId = (id: string): BridgeExternalAccountId = export const toBridgeTransferId = (id: string): BridgeTransferId => { return id as BridgeTransferId } + +// ============ Deposit Address ============ + +/** + * Represents a crypto receive address used as the destination for Bridge virtual accounts. + * Stored in the BridgeDepositAddress collection — not on the Account document — + * so the chain/currency can change without a schema migration. + */ +export type BridgeDepositAddress = { + accountId: string + rail: string // "ethereum" | "tron" | "solana" | "polygon" | ... + currency: string // "usdt" | "usdc" | ... + address: string // chain-specific receive address + ibexReceiveInfoId: string // IBEX id for balance/sweep queries +} diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index fca80b89d..c6bf5366e 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -12,7 +12,11 @@ import BridgeClient, { Transfer, } from "./client" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import * as BridgeDepositAddressRepo from "@services/mongoose/bridge-deposit-addresses" import { AccountsRepository } from "@services/mongoose/accounts" +import { IdentityRepository } from "@services/kratos" +import IbexService from "@services/ibex" +import { WalletsRepository } from "@services/mongoose/wallets" import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" import { @@ -117,11 +121,23 @@ const initiateKyc = async (accountId: AccountId): Promise w.currency === "USD") + if (!usdWallet) return new Error("No USD wallet found for account") + + // Create ETH USDT receive address in IBEX + const receiveInfo = await IbexService.client.createEthUsdtReceiveAddress(usdWallet.id) + if (receiveInfo instanceof Error) return receiveInfo + + const upsertResult = await BridgeDepositAddressRepo.upsertDepositAddress({ + accountId: accountId as string, + rail: "ethereum", + currency: "usdt", + address: receiveInfo.address, + ibexReceiveInfoId: receiveInfo.id, + }) + if (upsertResult instanceof Error) return upsertResult + + depositAddress = upsertResult - if (!tronAddress) { - // TODO: Integrate with IBEX to create Tron USDT receive address - // For now, this is a placeholder - actual implementation requires: - // 1. Call Ibex.getCryptoReceiveOptions() to get Tron USDT option - // 2. Call Ibex.createCryptoReceiveInfo() to get Tron address - // This will be implemented when IBEX crypto receive methods are available - return new Error("IBEX Tron address creation not yet implemented") + baseLogger.info( + { accountId, rail: depositAddress.rail, address: depositAddress.address }, + "Bridge: created new IBEX ETH USDT deposit address", + ) } - // Create Bridge virtual account + // Create Bridge virtual account — destination is driven by the deposit address record, + // not hardcoded. Switching rails in the future = update the deposit address record only. const virtualAccount = await BridgeClient.createVirtualAccount(customerId, { source: { currency: "usd" }, destination: { - currency: "usdt", - payment_rail: "tron", - address: tronAddress, + currency: depositAddress.currency as "usdt" | "usdc", + payment_rail: depositAddress.rail as any, + address: depositAddress.address, }, }) @@ -242,6 +281,7 @@ const createVirtualAccount = async ( accountId, operation: "createVirtualAccount", virtualAccountId: virtualAccount.id, + rail: depositAddress.rail, }, "Bridge operation completed", ) @@ -331,9 +371,13 @@ const initiateWithdrawal = async ( ) } - const tronAddress = account.bridgeTronAddress - if (!tronAddress) { - return new Error("Account has no Tron address. Create virtual account first.") + // Resolve active deposit address for this account (source of the withdrawal) + const depositAddress = await BridgeDepositAddressRepo.findActiveDepositAddress( + accountId as string, + ) + if (depositAddress instanceof Error) return depositAddress + if (!depositAddress) { + return new Error("Account has no deposit address. Create virtual account first.") } // Verify external account exists @@ -352,14 +396,14 @@ const initiateWithdrawal = async ( return new Error("External account is not verified") } - // Create transfer via Bridge + // Create transfer via Bridge — source rail/address driven by deposit address record const transfer = await BridgeClient.createTransfer(customerId, { amount, on_behalf_of: customerId, source: { - payment_rail: "tron", - currency: "usdt", - from_address: tronAddress, + payment_rail: depositAddress.rail as any, + currency: depositAddress.currency, + from_address: depositAddress.address, }, destination: { payment_rail: "ach", diff --git a/src/services/bridge/webhook-server/middleware/verify-signature.ts b/src/services/bridge/webhook-server/middleware/verify-signature.ts index b00961288..71a292ab1 100644 --- a/src/services/bridge/webhook-server/middleware/verify-signature.ts +++ b/src/services/bridge/webhook-server/middleware/verify-signature.ts @@ -45,7 +45,17 @@ export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transf // Verify signature using Bridge public key const publicKey = BridgeConfig.webhook.publicKeys[publicKeyType] - const rawBody = (req as any).rawBody || JSON.stringify(req.body) + // rawBody MUST be set by the express.json verify callback in webhook-server/index.ts. + // If it's missing, we must NOT silently fall back to JSON.stringify(req.body) — + // that would allow signature bypass on any payload where body-parser mutates whitespace. + const rawBody: string | undefined = (req as any).rawBody + if (rawBody === undefined) { + baseLogger.error( + "Bridge webhook: req.rawBody not set — express.raw() or verify callback missing. " + + "Check that express.json({ verify }) is applied BEFORE this route.", + ) + return res.status(500).json({ error: "Webhook configuration error" }) + } const payload = `${timestamp}.${rawBody}` try { diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index 5fc2d723c..a194447b5 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -248,16 +248,15 @@ const getCryptoReceiveOptions = async (): Promise => { try { const resp = await (Ibex as any).createCryptoReceiveInfo({ - wallet_id: walletId, - option_id: optionId, - } as CreateCryptoReceiveInfoRequest) + account_id: accountId, + ...body, + } as CreateCryptoReceiveInfoRequest & { account_id: IbexAccountId }) if (resp instanceof Error) return new IbexError(resp) if (!resp.address) return new UnexpectedIbexResponse("Address not found") return resp @@ -282,6 +281,31 @@ const getTronUsdtOption = async (): Promise => { return tronUsdt.id } +/** + * Finds the IBEX option ID for Ethereum USDT (ERC20) receive, then creates a + * receive info record via the documented IBEX API (name + network). + */ +const createEthUsdtReceiveAddress = async ( + walletId: IbexAccountId, +): Promise => { + const options = await getCryptoReceiveOptions() + if (options instanceof IbexError) return options + + const ethUsdt = options.find( + (opt) => + opt.currency.toLowerCase() === "usdt" && + (opt.network.toLowerCase() === "ethereum" || opt.network.toLowerCase() === "erc20"), + ) + + if (!ethUsdt) { + return new IbexError(new Error("Ethereum USDT (ERC20) option not found in IBEX")) + } + + return createCryptoReceiveInfo(walletId, { + name: `bridge-usdt-${walletId}`, + network: ethUsdt.network, + }) +} // const sendBetweenAccounts = async ( // sender: IbexAccount, // receiver: IbexAccount, @@ -323,5 +347,6 @@ export default wrapAsyncFunctionsToRunInSpan({ getCryptoReceiveOptions, createCryptoReceiveInfo, getTronUsdtOption, + createEthUsdtReceiveAddress, }, }) diff --git a/src/services/ibex/types.ts b/src/services/ibex/types.ts index c8a9e87ce..89e4ab43e 100644 --- a/src/services/ibex/types.ts +++ b/src/services/ibex/types.ts @@ -49,6 +49,6 @@ export interface CryptoReceiveInfo { } export interface CreateCryptoReceiveInfoRequest { - wallet_id: string - option_id: string + name: string + network: string } diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts index 7fe2283a5..5e4f66df2 100644 --- a/src/services/ibex/webhook-server/routes/crypto-receive.ts +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -1,5 +1,5 @@ import express, { Request, Response } from "express" -import { AccountsRepository } from "@services/mongoose/accounts" +import { findActiveDepositAddressByAddress } from "@services/mongoose/bridge-deposit-addresses" import { listWalletsByAccountId } from "@app/wallets" import { WalletCurrency, USDTAmount } from "@domain/shared" import { baseLogger } from "@services/logger" @@ -20,7 +20,14 @@ interface CryptoReceiveResult { const cryptoReceiveHandler = async (req: Request, res: Response) => { const { tx_hash, address, amount, currency, network } = req.body - if (!tx_hash || !address || !amount || currency !== "USDT" || network !== "tron") { + const normalizedNetwork = String(network || "").toLowerCase() + if ( + !tx_hash || + !address || + !amount || + String(currency).toUpperCase() !== "USDT" || + !(normalizedNetwork === "ethereum" || normalizedNetwork === "erc20") + ) { baseLogger.warn( { tx_hash, address, amount, currency, network }, "Invalid crypto receive payload", @@ -30,16 +37,20 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { const lockResult = await LockService().lockPaymentHash(tx_hash as any, async () => { try { - const account = await AccountsRepository().findByBridgeTronAddress(address) - if (account instanceof Error) { - baseLogger.error({ address, tx_hash }, "Account not found for Tron address") - return { status: "error", code: "account_not_found" } as CryptoReceiveResult + const depositAddress = await findActiveDepositAddressByAddress(address) + if (depositAddress instanceof Error) { + baseLogger.error({ address, tx_hash }, "Deposit address lookup failed") + return { status: "error", code: "deposit_address_lookup_failed" } as CryptoReceiveResult + } + if (!depositAddress) { + baseLogger.error({ address, tx_hash }, "Deposit address not found") + return { status: "error", code: "deposit_address_not_found" } as CryptoReceiveResult } - const wallets = await listWalletsByAccountId(account.id) + const wallets = await listWalletsByAccountId(depositAddress.accountId as any) if (wallets instanceof Error) { baseLogger.error( - { accountId: account.id, error: wallets }, + { accountId: depositAddress.accountId, error: wallets }, "Failed to list wallets", ) return { status: "error", code: "wallet_list_failed" } as CryptoReceiveResult @@ -47,11 +58,11 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { const usdtWallet = wallets.find((w) => w.currency === WalletCurrency.Usdt) if (!usdtWallet) { - baseLogger.error({ accountId: account.id }, "USDT wallet not found") + baseLogger.error({ accountId: depositAddress.accountId }, "USDT wallet not found") return { status: "error", code: "usdt_wallet_not_found" } as CryptoReceiveResult } - const usdtAmount = USDTAmount.fromNumber(amount) + const usdtAmount = USDTAmount.smallestUnits(amount.toString()) if (usdtAmount instanceof Error) { baseLogger.error({ amount, error: usdtAmount }, "Invalid USDT amount") return { status: "error", code: "invalid_amount" } as CryptoReceiveResult @@ -59,11 +70,13 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { baseLogger.info( { - accountId: account.id, + accountId: depositAddress.accountId, walletId: usdtWallet.id, amount: usdtAmount.asNumber(), tx_hash, address, + rail: depositAddress.rail, + currency: depositAddress.currency, }, "USDT deposit received", ) @@ -88,7 +101,8 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { } const statusMap: Record = { - account_not_found: 404, + deposit_address_lookup_failed: 500, + deposit_address_not_found: 404, wallet_list_failed: 500, usdt_wallet_not_found: 404, invalid_amount: 400, diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index 5d702f5a1..097989330 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -176,7 +176,6 @@ export const AccountsRepository = (): IAccountsRepository => { fields: { bridgeCustomerId?: BridgeCustomerId bridgeKycStatus?: "pending" | "approved" | "rejected" - bridgeTronAddress?: string }, ): Promise => { try { @@ -192,17 +191,7 @@ export const AccountsRepository = (): IAccountsRepository => { } } - const findByBridgeTronAddress = async ( - address: string, - ): Promise => { - try { - const result = await Account.findOne({ bridgeTronAddress: address }) - if (!result) return new RepositoryError("Account not found for Tron address") - return translateToAccount(result) - } catch (error) { - return parseRepositoryError(error) - } - } + const findByBridgeCustomerId = async ( customerId: BridgeCustomerId, @@ -226,7 +215,6 @@ export const AccountsRepository = (): IAccountsRepository => { findByNpub, update, updateBridgeFields, - findByBridgeTronAddress, findByBridgeCustomerId, } } @@ -291,5 +279,4 @@ const translateToAccount = (result: AccountRecord): Account => ({ displayCurrency: (result.displayCurrency || UsdDisplayCurrency) as DisplayCurrency, bridgeCustomerId: result.bridgeCustomerId as BridgeCustomerId | undefined, bridgeKycStatus: result.bridgeKycStatus, - bridgeTronAddress: result.bridgeTronAddress, }) diff --git a/src/services/mongoose/bridge-deposit-addresses.ts b/src/services/mongoose/bridge-deposit-addresses.ts new file mode 100644 index 000000000..17ad6c8a5 --- /dev/null +++ b/src/services/mongoose/bridge-deposit-addresses.ts @@ -0,0 +1,96 @@ +import { BridgeDepositAddress } from "./schema" +import { RepositoryError } from "@domain/errors" + +export interface BridgeDepositAddressData { + accountId: string + rail: string + currency: string + address: string + ibexReceiveInfoId: string +} + +export const findActiveDepositAddress = async ( + accountId: string, +): Promise => { + try { + const record = await BridgeDepositAddress.findOne({ accountId, isActive: true }) + if (!record) return null + return { + accountId: record.accountId, + rail: record.rail, + currency: record.currency, + address: record.address, + ibexReceiveInfoId: record.ibexReceiveInfoId, + } + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const findActiveDepositAddressByAddress = async ( + address: string, +): Promise => { + try { + const record = await BridgeDepositAddress.findOne({ address, isActive: true }) + if (!record) return null + return { + accountId: record.accountId, + rail: record.rail, + currency: record.currency, + address: record.address, + ibexReceiveInfoId: record.ibexReceiveInfoId, + } + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const upsertDepositAddress = async ( + data: BridgeDepositAddressData, +): Promise => { + try { + const existing = await BridgeDepositAddress.findOne({ + accountId: data.accountId, + address: data.address, + isActive: true, + }) + if (existing) { + return { + accountId: existing.accountId, + rail: existing.rail, + currency: existing.currency, + address: existing.address, + ibexReceiveInfoId: existing.ibexReceiveInfoId, + } + } + + await BridgeDepositAddress.updateMany( + { accountId: data.accountId, isActive: true }, + { isActive: false }, + ) + + const record = await BridgeDepositAddress.create({ ...data, isActive: true }) + return { + accountId: record.accountId, + rail: record.rail, + currency: record.currency, + address: record.address, + ibexReceiveInfoId: record.ibexReceiveInfoId, + } + } catch (error) { + return new RepositoryError(String(error)) + } +} + +export const deactivateDepositAddress = async ( + accountId: string, +): Promise => { + try { + await BridgeDepositAddress.updateMany( + { accountId, isActive: true }, + { isActive: false }, + ) + } catch (error) { + return new RepositoryError(String(error)) + } +} diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index ae3aaaf0c..d15669faa 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -47,6 +47,17 @@ interface IBridgeWithdrawalRecord { updatedAt: Date } +interface IBridgeDepositAddressRecord { + accountId: string + rail: string // "ethereum", "tron", "solana", etc. + currency: string // "usdt", "usdc", etc. + address: string // chain-specific receive address + ibexReceiveInfoId: string // IBEX CryptoReceiveInfo.id for balance queries + isActive: boolean + createdAt: Date + updatedAt: Date +} + const dbMetadataSchema = new Schema({ routingFeeLastEntry: Date, // TODO: rename to routingRevenueLastEntry }) @@ -335,10 +346,6 @@ const AccountSchema = new Schema( enum: ["pending", "approved", "rejected"], required: false, }, - bridgeTronAddress: { - type: String, - required: false, - }, }, { id: false }, ) @@ -348,8 +355,6 @@ AccountSchema.index({ coordinates: 1, }) -AccountSchema.index({ bridgeTronAddress: 1 }, { sparse: true }) - export const Account = mongoose.model("Account", AccountSchema) const QuizSchema = new Schema({ @@ -652,3 +657,25 @@ export const BridgeWithdrawal = mongoose.model( "BridgeWithdrawal", BridgeWithdrawalSchema, ) + +// ============ Bridge Deposit Address ============ + +const BridgeDepositAddressSchema = new Schema( + { + accountId: { type: String, required: true }, + rail: { type: String, required: true }, + currency: { type: String, required: true }, + address: { type: String, required: true }, + ibexReceiveInfoId: { type: String, required: true }, + isActive: { type: Boolean, default: true }, + }, + { timestamps: true }, +) + +BridgeDepositAddressSchema.index({ accountId: 1, isActive: 1 }) +BridgeDepositAddressSchema.index({ address: 1 }, { unique: true, sparse: true }) + +export const BridgeDepositAddress = mongoose.model( + "BridgeDepositAddress", + BridgeDepositAddressSchema, +) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts new file mode 100644 index 000000000..c4b8a4a7a --- /dev/null +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const mockFindAccountById = jest.fn() +const mockFindActiveDepositAddress = jest.fn() +const mockUpsertDepositAddress = jest.fn() +const mockListByAccountId = jest.fn() +const mockCreateEthUsdtReceiveAddress = jest.fn() +const mockCreateVirtualAccount = jest.fn() +const mockFindExternalAccountsByAccountId = jest.fn() +const mockCreateTransfer = jest.fn() + +jest.mock("@config", () => ({ + BridgeConfig: { + enabled: true, + webhook: { + port: 4009, + timestampSkewMs: 300000, + publicKeys: { kyc: "k", deposit: "d", transfer: "t" }, + }, + }, +})) + +jest.mock("@services/tracing", () => ({ + wrapAsyncFunctionsToRunInSpan: ({ fns }: any) => fns, +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})) + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: () => ({ + findById: (...args: any[]) => mockFindAccountById(...args), + }), +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + createVirtualAccount: (...args: any[]) => mockCreateVirtualAccount(...args), + findExternalAccountsByAccountId: (...args: any[]) => mockFindExternalAccountsByAccountId(...args), + createWithdrawal: jest.fn(), + findWithdrawalsByAccountId: jest.fn(), + updateExternalAccountStatus: jest.fn(), + updateWithdrawalStatus: jest.fn(), + findWithdrawalByBridgeTransferId: jest.fn(), +})) + +jest.mock("@services/mongoose/bridge-deposit-addresses", () => ({ + findActiveDepositAddress: (...args: any[]) => mockFindActiveDepositAddress(...args), + upsertDepositAddress: (...args: any[]) => mockUpsertDepositAddress(...args), +})) + +jest.mock("@services/mongoose/wallets", () => ({ + WalletsRepository: () => ({ + listByAccountId: (...args: any[]) => mockListByAccountId(...args), + }), +})) + +jest.mock("@services/ibex", () => ({ + __esModule: true, + default: { + client: { + createEthUsdtReceiveAddress: (...args: any[]) => mockCreateEthUsdtReceiveAddress(...args), + }, + }, +})) + +jest.mock("../../../../../src/services/bridge/client", () => ({ + __esModule: true, + default: { + createVirtualAccount: (...args: any[]) => mockCreateVirtualAccount(...args), + createTransfer: (...args: any[]) => mockCreateTransfer(...args), + }, +})) + +import BridgeService from "@services/bridge" + +describe("bridge service", () => { + const account = { + id: "acct-1", + level: 2, + username: "alice", + kratosUserId: "kratos-1", + bridgeCustomerId: "cust-1", + bridgeKycStatus: "approved", + } + + beforeEach(() => { + jest.clearAllMocks() + mockFindAccountById.mockResolvedValue(account) + mockCreateVirtualAccount.mockResolvedValue({ + id: "va-1", + source_deposit_instructions: { + bank_name: "Bridge Bank", + bank_routing_number: "021000021", + bank_account_number: "123456789", + }, + }) + mockCreateTransfer.mockResolvedValue({ + id: "tr-1", + amount: "25", + currency: "usdt", + state: "pending", + }) + mockFindExternalAccountsByAccountId.mockResolvedValue([ + { bridgeExternalAccountId: "ext-1", status: "verified" }, + ]) + }) + + it("reuses an existing active deposit address when creating a virtual account", async () => { + mockFindActiveDepositAddress.mockResolvedValue({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + + const result = await BridgeService.createVirtualAccount("acct-1" as any) + + expect(mockCreateEthUsdtReceiveAddress).not.toHaveBeenCalled() + expect(mockUpsertDepositAddress).not.toHaveBeenCalled() + expect(mockCreateVirtualAccount).toHaveBeenCalledWith( + "cust-1", + expect.objectContaining({ + source: { currency: "usd" }, + destination: { + currency: "usdt", + payment_rail: "ethereum", + address: "0xabc", + }, + }), + ) + expect(result).toEqual({ + virtualAccountId: "va-1", + bankName: "Bridge Bank", + routingNumber: "021000021", + accountNumberLast4: "6789", + }) + }) + + it("creates and stores a new ETH USDT deposit address when none exists", async () => { + mockFindActiveDepositAddress.mockResolvedValue(null) + mockListByAccountId.mockResolvedValue([ + { id: "ibex-wallet-1", currency: "USD" }, + ]) + mockCreateEthUsdtReceiveAddress.mockResolvedValue({ + id: "receive-2", + wallet_id: "ibex-wallet-1", + option_id: "opt-eth-usdt", + address: "0xdef", + currency: "usdt", + network: "ethereum", + created_at: new Date().toISOString(), + }) + mockUpsertDepositAddress.mockResolvedValue({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xdef", + ibexReceiveInfoId: "receive-2", + }) + + const result = await BridgeService.createVirtualAccount("acct-1" as any) + + expect(mockListByAccountId).toHaveBeenCalledWith("acct-1") + expect(mockCreateEthUsdtReceiveAddress).toHaveBeenCalledWith("ibex-wallet-1") + expect(mockUpsertDepositAddress).toHaveBeenCalledWith({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xdef", + ibexReceiveInfoId: "receive-2", + }) + expect(mockCreateVirtualAccount).toHaveBeenCalledWith( + "cust-1", + expect.objectContaining({ + destination: { + currency: "usdt", + payment_rail: "ethereum", + address: "0xdef", + }, + }), + ) + expect(result).toEqual({ + virtualAccountId: "va-1", + bankName: "Bridge Bank", + routingNumber: "021000021", + accountNumberLast4: "6789", + }) + }) + + it("uses the stored deposit address when initiating a withdrawal", async () => { + mockFindActiveDepositAddress.mockResolvedValue({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + + const result = await BridgeService.initiateWithdrawal( + "acct-1" as any, + "25", + "ext-1", + ) + + expect(mockCreateTransfer).toHaveBeenCalledWith( + "cust-1", + expect.objectContaining({ + amount: "25", + source: { + payment_rail: "ethereum", + currency: "usdt", + from_address: "0xabc", + }, + destination: { + payment_rail: "ach", + currency: "usd", + external_account_id: "ext-1", + }, + }), + ) + expect(result).toEqual({ + transferId: "tr-1", + amount: "25", + currency: "usdt", + state: "pending", + }) + }) +}) diff --git a/test/flash/unit/services/mongoose/bridge-deposit-addresses.spec.ts b/test/flash/unit/services/mongoose/bridge-deposit-addresses.spec.ts new file mode 100644 index 000000000..0bc420502 --- /dev/null +++ b/test/flash/unit/services/mongoose/bridge-deposit-addresses.spec.ts @@ -0,0 +1,132 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const mockFindOne = jest.fn() +const mockUpdateMany = jest.fn() +const mockCreate = jest.fn() + +jest.mock("@services/mongoose/schema", () => ({ + BridgeDepositAddress: { + findOne: (...args: any[]) => mockFindOne(...args), + updateMany: (...args: any[]) => mockUpdateMany(...args), + create: (...args: any[]) => mockCreate(...args), + }, +})) + +import { + deactivateDepositAddress, + findActiveDepositAddress, + upsertDepositAddress, +} from "@services/mongoose/bridge-deposit-addresses" + +describe("BridgeDepositAddress repository", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns null when there is no active deposit address", async () => { + mockFindOne.mockResolvedValue(null) + + const result = await findActiveDepositAddress("acct-1") + + expect(result).toBeNull() + expect(mockFindOne).toHaveBeenCalledWith({ accountId: "acct-1", isActive: true }) + }) + + it("maps an active deposit address record", async () => { + mockFindOne.mockResolvedValue({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + + const result = await findActiveDepositAddress("acct-1") + + expect(result).toEqual({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + }) + + it("returns the existing active address when upserting the same address", async () => { + mockFindOne.mockResolvedValue({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + + const result = await upsertDepositAddress({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + + expect(result).toEqual({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xabc", + ibexReceiveInfoId: "receive-1", + }) + expect(mockUpdateMany).not.toHaveBeenCalled() + expect(mockCreate).not.toHaveBeenCalled() + }) + + it("deactivates any prior active address and inserts a new one", async () => { + mockFindOne.mockResolvedValue(null) + mockCreate.mockResolvedValue({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xdef", + ibexReceiveInfoId: "receive-2", + }) + + const result = await upsertDepositAddress({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xdef", + ibexReceiveInfoId: "receive-2", + }) + + expect(mockUpdateMany).toHaveBeenCalledWith( + { accountId: "acct-1", isActive: true }, + { isActive: false }, + ) + expect(mockCreate).toHaveBeenCalledWith({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xdef", + ibexReceiveInfoId: "receive-2", + isActive: true, + }) + expect(result).toEqual({ + accountId: "acct-1", + rail: "ethereum", + currency: "usdt", + address: "0xdef", + ibexReceiveInfoId: "receive-2", + }) + }) + + it("deactivates active addresses", async () => { + mockUpdateMany.mockResolvedValue({ acknowledged: true }) + + await deactivateDepositAddress("acct-1") + + expect(mockUpdateMany).toHaveBeenCalledWith( + { accountId: "acct-1", isActive: true }, + { isActive: false }, + ) + }) +})