Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
07a7e8b
feat(bridge): provision ETH-USDT cash wallet
forge0x May 9, 2026
92adf91
ENG-394 create USDT wallet for new accounts
forge0x May 12, 2026
afb41ba
fix(wallets): return empty list when account has no wallets
heyolaniran May 12, 2026
4fc1d6e
fix(graphql): pass wallet currency into Ibex balance reads
heyolaniran May 12, 2026
a095d2b
fix(ibex): authenticate via API base /auth/signin
heyolaniran May 12, 2026
8fb99e8
fix(bridge): VA idempotency before USDT wallet; checking USDT for wit…
heyolaniran May 12, 2026
f3e3929
feat(bridge): unique partial index on pending withdrawals; handle E11000
heyolaniran May 12, 2026
3c4d83a
test(bridge): ENG-296 VA idempotency and bridgeEthereumAddress assert…
heyolaniran May 12, 2026
96dab87
chore(bruno): update local environment variables
heyolaniran May 12, 2026
e63e1b4
fix(bridge): align crypto receive webhook to ethereum usdt
forge0x May 12, 2026
861d401
fix: Ibex USDT address
heyolaniran May 13, 2026
d83f331
fix: remove logs from the process
heyolaniran May 13, 2026
c4cbce9
fix: Ibex Client operations
heyolaniran May 13, 2026
b02cdd1
fix: Ibex types
heyolaniran May 13, 2026
7df4302
fix: Ibex account creation
heyolaniran May 13, 2026
e8397a7
Add orphan status and resolution fields to Bridge reconciliation Mong…
heyolaniran May 11, 2026
92b2191
Add PubSub trigger for Bridge↔IBEX reconciliation updates
heyolaniran May 11, 2026
08b870f
Tighten batch reconciliation and add reconcileByTxHash
heyolaniran May 11, 2026
55bc082
Trigger real-time Bridge↔IBEX reconciliation from webhooks
heyolaniran May 11, 2026
4534aee
Expose Bridge reconciliation orphans and live events over GraphQL
heyolaniran May 11, 2026
d77fee8
Align scheduled and CLI reconciliation with the 15-minute safety net
heyolaniran May 11, 2026
3dfd6d6
Add unit tests for Bridge↔IBEX reconciliation helpers
heyolaniran May 11, 2026
7d36fc9
Document Bridge↔IBEX deposit reconciliation design
heyolaniran May 11, 2026
cd8a3e3
fix(ibex): validate Ethereum USDT on crypto-receive webhook
heyolaniran May 12, 2026
bdfe9e4
fix: return existing link if the account has incomplete hosted kyc st…
heyolaniran May 14, 2026
d9db77a
fix(graphql): register UsdtWallet in admin schema interface types
heyolaniran May 15, 2026
f1da0c0
fix(bridge): address PR review feedback
heyolaniran May 15, 2026
8080684
chore(graphql): remove unused bridge reconciliation public types
heyolaniran May 15, 2026
7073a9d
chore: codegen SDL cleanup and drop stale reconciliation doc
heyolaniran May 15, 2026
6b95936
fix(ibex): return Promise from reconcileByTxHash mock in crypto-recei…
heyolaniran May 15, 2026
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
10 changes: 6 additions & 4 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,12 @@ type BridgeCreateVirtualAccountPayload
type BridgeExternalAccount
@join__type(graph: PUBLIC)
{
accountNumberLast4: String!
bankName: String!
id: ID!
status: String!
accountNumberLast4: String
bankName: String
expiresAt: Timestamp
id: ID
linkUrl: String
status: String
}

input BridgeInitiateKycInput
Expand Down
4 changes: 2 additions & 2 deletions dev/bruno/Flash GraphQL API/environments/local.bru
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ vars {
token:
walletId:
walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1
userEmail: maurienteso@gmail.com
userFullName: maurienteso
userEmail: mauriente@gmail.com
userFullName: maurientes
}
5 changes: 4 additions & 1 deletion src/app/authentication/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ export const loginDeviceUpgradeWithPhone = async ({
if (deviceWallets instanceof Error) return deviceWallets
let deviceAccountHasBalance = false
for (const wallet of deviceWallets) {
const balance = await getBalanceForWallet({ walletId: wallet.id })
const balance = await getBalanceForWallet({
walletId: wallet.id,
currency: wallet.currency,
})
if (balance instanceof Error) return balance
if (!balance.isZero()) {
deviceAccountHasBalance = true
Expand Down
1 change: 1 addition & 0 deletions src/domain/pubsub/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const PubSubDefaultTriggers = {
UserPriceUpdate: "USER_PRICE_UPDATE",
AccountUpdate: "ACCOUNT_UPDATE",
LnPaymentStatus: "LN_PAYMENT_STATUS",
BridgeReconciliationUpdate: "BRIDGE_RECONCILIATION_UPDATE",
} as const

export const customPubSubTrigger = ({
Expand Down
2 changes: 2 additions & 0 deletions src/graphql/admin/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import AccountDetailsByAccountId from "./root/query/account-details-by-account-i
import MerchantsPendingApprovalQuery from "./root/query/merchants-pending-approval-listing"
import IdDocumentReadUrlQuery from "./root/query/id-document-read-url"
import NotificationTopicsQuery from "./root/query/notification-topics"
import BridgeReconciliationOrphansQuery from "./root/query/bridge-reconciliation-orphans"

export const queryFields = {
unauthed: {},
Expand All @@ -34,6 +35,7 @@ export const queryFields = {
merchantsPendingApproval: MerchantsPendingApprovalQuery,
idDocumentReadUrl: IdDocumentReadUrlQuery,
notificationTopics: NotificationTopicsQuery,
bridgeReconciliationOrphans: BridgeReconciliationOrphansQuery,
},
}

Expand Down
40 changes: 40 additions & 0 deletions src/graphql/admin/root/query/bridge-reconciliation-orphans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { GT } from "@graphql/index"
import BridgeReconciliationOrphanObject from "@graphql/admin/types/object/bridge-reconciliation-orphan"
import { findOrphans } from "@services/mongoose/bridge-reconciliation-orphan"

const BridgeReconciliationOrphansQuery = GT.Field({
type: GT.NonNullList(BridgeReconciliationOrphanObject),
args: {
status: { type: GT.String, defaultValue: null },
orphanType: { type: GT.String, defaultValue: null },
limit: { type: GT.Int, defaultValue: 50 },
},
resolve: async (
_: unknown,
{
status,
orphanType,
limit,
}: { status?: string; orphanType?: string; limit?: number },
) => {
const result = await findOrphans({
status: status as "unmatched" | "resolved" | undefined,
orphanType: orphanType as
| "bridge_without_ibex"
| "ibex_without_bridge"
| undefined,
limit: limit ?? 50,
})

if (result instanceof Error) throw result

return result.map((o) => ({
...o,
detectedAt: o.detectedAt.toISOString(),
resolvedAt: o.resolvedAt?.toISOString() ?? null,
triageContext: JSON.stringify(o.triageContext),
}))
},
})

export default BridgeReconciliationOrphansQuery
18 changes: 17 additions & 1 deletion src/graphql/admin/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ type Query {
accountDetailsByUserPhone(phone: Phone!): AuditedAccount!
accountDetailsByUsername(username: Username!): AuditedAccount!
allLevels: [AccountLevel!]!
bridgeReconciliationOrphans(status: String, orphanType: String, limit: Int): [BridgeReconciliationOrphan!]!
idDocumentReadUrl(
"""Storage key of the ID document file"""
fileKey: String!
Expand Down Expand Up @@ -618,4 +619,19 @@ enum WalletCurrency {
}

"""Unique identifier of a wallet"""
scalar WalletId
scalar WalletId

type BridgeReconciliationOrphan {
id: ID!
orphanKey: String!
orphanType: String!
status: String!
txHash: String
transferId: String
customerId: String
amount: String
currency: String
detectedAt: String!
resolvedAt: String
triageContext: String!
}
3 changes: 2 additions & 1 deletion src/graphql/admin/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import BtcWallet from "@graphql/shared/types/object/btc-wallet"
import GraphQLApplicationError from "@graphql/shared/types/object/graphql-application-error"
import UsdWallet from "@graphql/shared/types/object/usd-wallet"
import UsdtWallet from "@graphql/shared/types/object/usdt-wallet"

export const ALL_INTERFACE_TYPES = [GraphQLApplicationError, BtcWallet, UsdWallet]
export const ALL_INTERFACE_TYPES = [GraphQLApplicationError, BtcWallet, UsdWallet, UsdtWallet]
21 changes: 21 additions & 0 deletions src/graphql/admin/types/object/bridge-reconciliation-orphan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { GT } from "@graphql/index"

const BridgeReconciliationOrphanObject = GT.Object({
name: "BridgeReconciliationOrphan",
fields: () => ({
id: { type: GT.NonNullID },
orphanKey: { type: GT.NonNull(GT.String) },
orphanType: { type: GT.NonNull(GT.String) },
status: { type: GT.NonNull(GT.String) },
txHash: { type: GT.String },
transferId: { type: GT.String },
customerId: { type: GT.String },
amount: { type: GT.String },
currency: { type: GT.String },
detectedAt: { type: GT.NonNull(GT.String) },
resolvedAt: { type: GT.String },
triageContext: { type: GT.NonNull(GT.String) },
}),
})

export default BridgeReconciliationOrphanObject
7 changes: 5 additions & 2 deletions src/graphql/public/root/mutation/onchain-payment-send-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id"

import { Wallets } from "@app"
import { getBalanceForWallet } from "@app/wallets"
import { USDAmount } from "@domain/shared"
import { USDAmount, WalletCurrency } from "@domain/shared"

const OnChainPaymentSendAllInput = GT.Input({
name: "OnChainPaymentSendAllInput",
Expand Down Expand Up @@ -63,7 +63,10 @@ const OnChainPaymentSendAllMutation = GT.Field<
return { errors: [{ message: speed.message }] }
}

const amount = await getBalanceForWallet({ walletId })
const amount = await getBalanceForWallet({
walletId,
currency: WalletCurrency.Usd,
})
if (amount instanceof Error) return amount
if (!(amount instanceof USDAmount)) {
return { errors: [{ message: "Onchain payments require a USD wallet" }] }
Expand Down
10 changes: 6 additions & 4 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,12 @@ type BridgeCreateVirtualAccountPayload {
}

type BridgeExternalAccount {
accountNumberLast4: String!
bankName: String!
id: ID!
status: String!
accountNumberLast4: String
bankName: String
expiresAt: Timestamp
id: ID
linkUrl: String
status: String
}

input BridgeInitiateKycInput {
Expand Down
5 changes: 4 additions & 1 deletion src/graphql/shared/types/object/btc-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ const BtcWallet = GT.Object<Wallet>({
description: "A balance stored in BTC.",
resolve: async (source) => {
if (source.type === WalletType.External) return null
const balanceSats = await Wallets.getBalanceForWallet({ walletId: source.id })
const balanceSats = await Wallets.getBalanceForWallet({
walletId: source.id,
currency: source.currency,
})
if (balanceSats instanceof Error) {
throw mapError(balanceSats)
}
Expand Down
6 changes: 3 additions & 3 deletions src/scripts/reconcile-bridge-ibex-deposits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import { reconcileBridgeAndIbexDeposits } from "@services/bridge/reconciliation"
const args = yargs(hideBin(process.argv))
.option("window-hours", {
type: "number",
default: 24,
describe: "Reconciliation window in hours",
default: 0.25,
describe: "Reconciliation window in hours (default: 15 minutes)",
})
.option("configPath", { type: "string", demandOption: true })
.parseSync()

const main = async () => {
const windowMs = Math.max(1, Math.floor(args["window-hours"])) * 60 * 60 * 1000
const windowMs = args["window-hours"] * 60 * 60 * 1000
const result = await reconcileBridgeAndIbexDeposits({ windowMs })
if (result instanceof Error) throw result
baseLogger.info(result, "Bridge↔IBEX reconciliation finished")
Expand Down
6 changes: 5 additions & 1 deletion src/servers/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ const swapOutJob = async () => {
if (swapResult instanceof Error) throw swapResult
}

// Window covers 15 min of events — real-time webhook reconciliation handles everything
// else immediately. This batch pass is only a safety net for missed/delayed webhooks.
const RECONCILE_WINDOW_MS = 15 * 60 * 1000

const reconcileBridgeDepositsJob = async () => {
if (!BridgeConfig.enabled) return

const result = await reconcileBridgeAndIbexDeposits()
const result = await reconcileBridgeAndIbexDeposits({ windowMs: RECONCILE_WINDOW_MS })
if (result instanceof Error) throw result
}

Expand Down
71 changes: 60 additions & 11 deletions src/services/bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RepositoryError } from "@domain/errors"
import { toBridgeCustomerId } from "@domain/primitives/bridge"
import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet"
import { USDTAmount, WalletCurrency } from "@domain/shared"
import { WalletType } from "@domain/wallets"
import { WalletsRepository } from "@services/mongoose/wallets"

import { IdentityRepository } from "@services/kratos"
Expand Down Expand Up @@ -91,6 +92,38 @@ type ExternalAccountResult = {
export const deriveWithdrawalIdempotencyKey = (rowId: string): string =>
crypto.createHash("sha256").update(`withdrawal:${rowId}`).digest("hex")

const ensureEthUsdtCashWallet = async (
account: Account,
): Promise<Wallet | ApplicationError> => {
const wallets = await WalletsRepository().listByAccountId(account.id)
if (wallets instanceof Error) return wallets

let usdtWallet = wallets.find(
(wallet) =>
wallet.currency === WalletCurrency.Usdt && wallet.type === WalletType.Checking,
)

if (!usdtWallet) {
const createdWallet = await WalletsRepository().persistNew({
accountId: account.id,
type: WalletType.Checking,
currency: WalletCurrency.Usdt,
})
if (createdWallet instanceof Error) return createdWallet
usdtWallet = createdWallet
}

if (account.defaultWalletId !== usdtWallet.id) {
const updatedAccount = await AccountsRepository().update({
...account,
defaultWalletId: usdtWallet.id,
})
if (updatedAccount instanceof Error) return updatedAccount
}

return usdtWallet
}

// ============ Guards ============

const checkBridgeEnabled = (): true | BridgeDisabledError => {
Expand Down Expand Up @@ -180,6 +213,16 @@ const initiateKyc = async ({

return result
} catch (error) {
const bridgeError = error as { statusCode?: number; response?: { existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } } }

if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) {
return {
kycLink: bridgeError.response.existing_kyc_link.kyc_link,
customerId: bridgeError.response.existing_kyc_link.customer_id,
tosLink: bridgeError.response.existing_kyc_link.tos_link,
}
}

baseLogger.error(
{ accountId, operation: "initiateKyc", error },
"Bridge operation failed",
Expand All @@ -191,8 +234,9 @@ const initiateKyc = async ({
/**
* Creates a virtual account for receiving USD deposits
* - Requires approved KYC
* - Creates IBEX Tron USDT receive address
* - Creates Bridge virtual account pointing to Tron address
* - Ensures an IBEX ETH-USDT Cash Wallet exists and is the account default
* - Creates IBEX Ethereum USDT receive address
* - Creates Bridge virtual account pointing to Ethereum address
*/
const createVirtualAccount = async (
accountId: AccountId,
Expand Down Expand Up @@ -227,7 +271,7 @@ const createVirtualAccount = async (
return new BridgeKycPendingError("KYC not yet completed")
}

// Idempotency guard: return the existing VA immediately without touching Bridge
// Idempotency guard first: do not mutate wallets/default when a VA already exists
const existingVa = await BridgeAccountsRepo.findVirtualAccountByAccountId(
accountId as string,
)
Expand All @@ -241,26 +285,29 @@ const createVirtualAccount = async (
}
}

// Get or create Ethereum address
let ethereumAddress =
account.bridgeEthereumAddress || "0xaF095D35bfDd462165eA7eCF8AC75351a93d72bD"
const usdtCashWallet = await ensureEthUsdtCashWallet(account)
if (usdtCashWallet instanceof Error) return usdtCashWallet

// Get or create Ethereum USDT receive address for the ETH-USDT Cash Wallet
let ethereumAddress = account.bridgeEthereumAddress

if (!ethereumAddress) {
const option = await IbexClient.getEthereumUsdtOption()
let option = await IbexClient.getEthereumUsdtOption()
if (option instanceof Error) return new BridgeError(option.message)

option.name = `USDT-ETH ${account.username}-${crypto.randomBytes(4).toString("hex")}`
const receiveInfo = await IbexClient.createCryptoReceiveInfo(
account.defaultWalletId as IbexAccountId,
usdtCashWallet.id as IbexAccountId,
option,
)
if (receiveInfo instanceof Error) return new BridgeError(receiveInfo.message)

const updateResult = await AccountsRepository().updateBridgeFields(accountId, {
bridgeEthereumAddress: receiveInfo.address,
bridgeEthereumAddress: receiveInfo.data.address,
})
if (updateResult instanceof Error) return updateResult

ethereumAddress = receiveInfo.address
ethereumAddress = receiveInfo.data.address
}

// Deterministic key so Bridge deduplicates on their side if two calls race past
Expand Down Expand Up @@ -407,7 +454,9 @@ const initiateWithdrawal = async (

const wallets = await WalletsRepository().listByAccountId(accountId)
if (wallets instanceof Error) return wallets
const usdtWallet = wallets.find((w) => w.currency === WalletCurrency.Usdt)
const usdtWallet = wallets.find(
(w) => w.currency === WalletCurrency.Usdt && w.type === WalletType.Checking,
)
if (!usdtWallet) {
return new BridgeInsufficientFundsError("No USDT wallet found on account")
}
Expand Down
Loading