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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/bridge-integration/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/bridge-integration/FLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
136 changes: 136 additions & 0 deletions docs/bridge-integration/SECURITY-AUDIT.md
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 2 additions & 3 deletions src/domain/accounts/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -180,11 +181,9 @@ interface IAccountsRepository {
fields: {
bridgeCustomerId?: BridgeCustomerId
bridgeKycStatus?: "pending" | "approved" | "rejected"
bridgeTronAddress?: string
},
): Promise<Account | RepositoryError>

findByBridgeTronAddress(address: string): Promise<Account | RepositoryError>

findByBridgeCustomerId(customerId: BridgeCustomerId): Promise<Account | RepositoryError>
}
Expand Down
15 changes: 15 additions & 0 deletions src/domain/primitives/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
86 changes: 65 additions & 21 deletions src/services/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -117,11 +121,23 @@ const initiateKyc = async (accountId: AccountId): Promise<InitiateKycResult | Er
// Create customer if not exists
if (!customerId) {
// For now, create with minimal data - in production, gather from account profile
// Fetch real email from Kratos identity
let customerEmail: string = `${account.id}@flash.app` // fallback (no real email risk)
const identity = await IdentityRepository().getIdentity(account.kratosUserId)
if (!(identity instanceof Error) && identity.email) {
customerEmail = identity.email
} else {
baseLogger.warn(
{ accountId, kratosUserId: account.kratosUserId },
"Bridge KYC: could not resolve real email from Kratos β€” using account-id placeholder",
)
}

const customer = await BridgeClient.createCustomer({
type: "individual",
first_name: account.username || "Flash",
last_name: "User",
email: `${account.id}@flash.app`, // Placeholder - should use real email
email: customerEmail,
})

customerId = toBridgeCustomerId(customer.id)
Expand Down Expand Up @@ -196,25 +212,48 @@ const createVirtualAccount = async (
return new BridgeKycPendingError("KYC not yet completed")
}

// Get or create Tron address
let tronAddress = account.bridgeTronAddress
// Get or create crypto deposit address (chain-agnostic β€” stored in BridgeDepositAddress collection)
let depositAddress = await BridgeDepositAddressRepo.findActiveDepositAddress(
accountId as string,
)
if (depositAddress instanceof Error) return depositAddress

if (!depositAddress) {
// Find the account's IBEX wallet ID (wallet.id = IBEX account ID)
const wallets = await WalletsRepository().listByAccountId(accountId)
if (wallets instanceof Error) return wallets
const usdWallet = wallets.find((w) => 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,
},
})

Expand Down Expand Up @@ -242,6 +281,7 @@ const createVirtualAccount = async (
accountId,
operation: "createVirtualAccount",
virtualAccountId: virtualAccount.id,
rail: depositAddress.rail,
},
"Bridge operation completed",
)
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading