From e4b0e43bcff511706cfe84bcdd00a5d5049621d3 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 16:30:20 +0100 Subject: [PATCH 1/8] feat(bridge): add withdrawal fee estimate config Introduce developerFeePercent and withdrawalFeeEstimate settings (bridge fixed fee, gas limit, RPC URL, and fallbacks) for customer fee breakdown on Bridge withdrawals. --- dev/config/base-config.yaml | 10 +++++++++- src/config/schema.ts | 12 ++++++++++++ src/config/schema.types.d.ts | 11 +++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 58cc23919..adeeac309 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -14,9 +14,17 @@ ibex: bridge: enabled: true - apiKey: "sk-test-3bd6463c9cd77c3d8858c60b9997d0c6" + apiKey: "" baseUrl: "https://api.sandbox.bridge.xyz/v0" minWithdrawalAmount: 2 + developerFeePercent: 2.0 + withdrawalFeeEstimate: + bridgeFixedFeePercent: 0.6 + usdtTransferGasLimit: 65000 + gasPriceBufferMultiplier: 1.5 + ethereumGasRpcUrl: "https://cloudflare-eth.com" + fallbackGasPriceGwei: 30 + ethUsdFallback: 3000 timeoutMs: 15000 webhook: port: 4009 diff --git a/src/config/schema.ts b/src/config/schema.ts index 9a62f7163..769bec4dd 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -649,6 +649,18 @@ export const configSchema = { apiKey: { type: "string" }, baseUrl: { type: "string" }, minWithdrawalAmount: { type: "number" }, + developerFeePercent: { type: "number", default: 2.0 }, + withdrawalFeeEstimate: { + type: "object", + properties: { + bridgeFixedFeePercent: { type: "number", default: 0.6 }, + usdtTransferGasLimit: { type: "integer", default: 65000 }, + gasPriceBufferMultiplier: { type: "number", default: 1.5 }, + ethereumGasRpcUrl: { type: "string", default: "https://cloudflare-eth.com" }, + fallbackGasPriceGwei: { type: "number", default: 30 }, + ethUsdFallback: { type: "number", default: 3000 }, + }, + }, timeoutMs: { type: "integer", default: 10000 }, webhook: { type: "object", diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index bc1819d2d..be49681a0 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -43,11 +43,22 @@ type BridgeWebhook = { replaySecret?: string } +type BridgeWithdrawalFeeEstimateConfig = { + bridgeFixedFeePercent?: number + usdtTransferGasLimit?: number + gasPriceBufferMultiplier?: number + ethereumGasRpcUrl?: string + fallbackGasPriceGwei?: number + ethUsdFallback?: number +} + type BridgeConfig = { enabled: boolean apiKey: string baseUrl: string minWithdrawalAmount: number + developerFeePercent: number + withdrawalFeeEstimate?: BridgeWithdrawalFeeEstimateConfig timeoutMs?: number webhook: BridgeWebhook } From caf4e4c815ce64491e5adf38c8fc52f2201f11e1 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 16:31:03 +0100 Subject: [PATCH 2/8] feat(bridge): compute withdrawal customer fee estimates Add flash fee, Bridge rail fee (0.6%), and buffered Ethereum gas estimates, plus presenter helpers for pending vs receipt amounts. --- src/services/bridge/ethereum-gas-estimate.ts | 151 ++++++++++ src/services/bridge/withdrawal-fees.ts | 272 ++++++++++++++++++ .../bridge/ethereum-gas-estimate.spec.ts | 44 +++ .../services/bridge/withdrawal-fees.spec.ts | 179 ++++++++++++ 4 files changed, 646 insertions(+) create mode 100644 src/services/bridge/ethereum-gas-estimate.ts create mode 100644 src/services/bridge/withdrawal-fees.ts create mode 100644 test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts create mode 100644 test/flash/unit/services/bridge/withdrawal-fees.spec.ts diff --git a/src/services/bridge/ethereum-gas-estimate.ts b/src/services/bridge/ethereum-gas-estimate.ts new file mode 100644 index 000000000..712e943b8 --- /dev/null +++ b/src/services/bridge/ethereum-gas-estimate.ts @@ -0,0 +1,151 @@ +import { baseLogger } from "@services/logger" + +export type EthereumGasMarketSnapshot = { + gasPriceGwei: number + ethUsd: number +} + +export const computeEstimatedGasBufferUsd = ({ + gasLimit, + gasPriceGwei, + ethUsd, + bufferMultiplier, +}: { + gasLimit: number + gasPriceGwei: number + ethUsd: number + bufferMultiplier: number +}): string => { + const gasUsd = ((gasLimit * gasPriceGwei * ethUsd) / 1e9) * bufferMultiplier + return gasUsd.toFixed(2) +} + +const parseHexWeiToGwei = (hexWei: string): number | Error => { + const normalized = hexWei.startsWith("0x") ? hexWei.slice(2) : hexWei + if (!/^[0-9a-fA-F]+$/.test(normalized)) { + return new Error(`Invalid gas price response: ${hexWei}`) + } + const wei = BigInt(`0x${normalized}`) + return Number(wei) / 1e9 +} + +export const fetchEthereumGasPriceGwei = async ({ + rpcUrl, + timeoutMs, +}: { + rpcUrl: string + timeoutMs: number +}): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_gasPrice", + params: [], + }), + signal: controller.signal, + }) + + if (!response.ok) { + return new Error(`Ethereum RPC gas price request failed: HTTP ${response.status}`) + } + + const payload = (await response.json()) as { + result?: string + error?: { message?: string } + } + + if (payload.error?.message) { + return new Error(`Ethereum RPC gas price error: ${payload.error.message}`) + } + if (!payload.result) { + return new Error("Ethereum RPC gas price response missing result") + } + + const gasPriceGwei = parseHexWeiToGwei(payload.result) + if (gasPriceGwei instanceof Error) return gasPriceGwei + if (!Number.isFinite(gasPriceGwei) || gasPriceGwei <= 0) { + return new Error(`Invalid gas price gwei value: ${gasPriceGwei}`) + } + + return gasPriceGwei + } catch (error) { + baseLogger.warn({ error, rpcUrl }, "Failed to fetch Ethereum gas price") + return error instanceof Error ? error : new Error(String(error)) + } finally { + clearTimeout(timeout) + } +} + +export const fetchEthUsdPrice = async ({ + timeoutMs, +}: { + timeoutMs: number +}): Promise => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const url = + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd" + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) { + return new Error(`ETH/USD price request failed: HTTP ${response.status}`) + } + + const payload = (await response.json()) as { ethereum?: { usd?: number } } + const ethUsd = payload.ethereum?.usd + if (ethUsd == null || !Number.isFinite(ethUsd) || ethUsd <= 0) { + return new Error("ETH/USD price response missing ethereum.usd") + } + + return ethUsd + } catch (error) { + baseLogger.warn({ error }, "Failed to fetch ETH/USD price") + return error instanceof Error ? error : new Error(String(error)) + } finally { + clearTimeout(timeout) + } +} + +export const fetchEthereumGasMarketSnapshot = async ({ + rpcUrl, + timeoutMs, + fallbackGasPriceGwei, + ethUsdFallback, +}: { + rpcUrl: string + timeoutMs: number + fallbackGasPriceGwei: number + ethUsdFallback: number +}): Promise => { + const [gasPriceResult, ethUsdResult] = await Promise.all([ + fetchEthereumGasPriceGwei({ rpcUrl, timeoutMs }), + fetchEthUsdPrice({ timeoutMs }), + ]) + + const gasPriceGwei = + gasPriceResult instanceof Error ? fallbackGasPriceGwei : gasPriceResult + const ethUsd = ethUsdResult instanceof Error ? ethUsdFallback : ethUsdResult + + if (gasPriceResult instanceof Error) { + baseLogger.warn( + { fallbackGasPriceGwei, error: gasPriceResult.message }, + "Using fallback Ethereum gas price for withdrawal fee estimate", + ) + } + if (ethUsdResult instanceof Error) { + baseLogger.warn( + { ethUsdFallback, error: ethUsdResult.message }, + "Using fallback ETH/USD price for withdrawal fee estimate", + ) + } + + return { gasPriceGwei, ethUsd } +} diff --git a/src/services/bridge/withdrawal-fees.ts b/src/services/bridge/withdrawal-fees.ts new file mode 100644 index 000000000..ed777b3b0 --- /dev/null +++ b/src/services/bridge/withdrawal-fees.ts @@ -0,0 +1,272 @@ +/** + * Bridge withdrawal customer fee estimates. + * + * estimatedCustomerFee = flashFee + estimatedBridgeFee + estimatedGasBuffer + * flashFee = amount * developerFeePercent (Flash fee passed to Bridge) + * estimatedBridgeFee = amount * bridgeFixedFeePercent (0.60% for USDT ETH -> USD ACH) + * estimatedGasBuffer = gasLimit * gasPriceGwei * ethUsd / 1e9 * bufferMultiplier + */ + +import { BridgeConfig } from "@config" + +import { + computeEstimatedGasBufferUsd, + fetchEthereumGasMarketSnapshot, + type EthereumGasMarketSnapshot, +} from "./ethereum-gas-estimate" + +export type BridgeWithdrawalFeeEstimateConfig = { + bridgeFixedFeePercent: number + usdtTransferGasLimit: number + gasPriceBufferMultiplier: number + ethereumGasRpcUrl: string + fallbackGasPriceGwei: number + ethUsdFallback: number +} + +export type CustomerFeeEstimate = { + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + flashFeePercent: string + flashFee: string +} + +export const defaultWithdrawalFeeEstimateConfig = (): BridgeWithdrawalFeeEstimateConfig => ({ + bridgeFixedFeePercent: 0.6, + usdtTransferGasLimit: 65_000, + gasPriceBufferMultiplier: 1.5, + ethereumGasRpcUrl: "https://cloudflare-eth.com", + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, +}) + +export const getWithdrawalFeeEstimateConfig = (): BridgeWithdrawalFeeEstimateConfig => ({ + ...defaultWithdrawalFeeEstimateConfig(), + ...BridgeConfig.withdrawalFeeEstimate, +}) + +export const computeCustomerFeeEstimateFromGasMarket = ({ + amount, + gasMarket, + config = getWithdrawalFeeEstimateConfig(), + developerFeePercent = BridgeConfig.developerFeePercent ?? 2, +}: { + amount: string + gasMarket: EthereumGasMarketSnapshot + config?: BridgeWithdrawalFeeEstimateConfig + developerFeePercent?: number +}): CustomerFeeEstimate => { + const flashFee = ((parseFloat(amount) * developerFeePercent) / 100).toFixed(2) + const estimatedBridgeFee = ( + (parseFloat(amount) * config.bridgeFixedFeePercent) / + 100 + ).toFixed(2) + const estimatedGasBuffer = computeEstimatedGasBufferUsd({ + gasLimit: config.usdtTransferGasLimit, + gasPriceGwei: gasMarket.gasPriceGwei, + ethUsd: gasMarket.ethUsd, + bufferMultiplier: config.gasPriceBufferMultiplier, + }) + const estimatedCustomerFee = ( + parseFloat(flashFee) + + parseFloat(estimatedBridgeFee) + + parseFloat(estimatedGasBuffer) + ).toFixed(2) + + return { + estimatedBridgeFeePercent: String(config.bridgeFixedFeePercent), + estimatedBridgeFee, + estimatedGasBuffer, + estimatedCustomerFee, + flashFeePercent: String(developerFeePercent), + flashFee, + } +} + +export const resolveWithdrawalCustomerFeeEstimate = async ( + amount: string, +): Promise => { + const config = getWithdrawalFeeEstimateConfig() + const gasMarket = await fetchEthereumGasMarketSnapshot({ + rpcUrl: config.ethereumGasRpcUrl, + timeoutMs: BridgeConfig.timeoutMs ?? 10_000, + fallbackGasPriceGwei: config.fallbackGasPriceGwei, + ethUsdFallback: config.ethUsdFallback, + }) + + return computeCustomerFeeEstimateFromGasMarket({ amount, gasMarket, config }) +} + +export const computePendingAmountEstimates = ( + amount: string, + estimatedCustomerFee: string, +): { subtotalAmount: string; finalAmount: string } => { + const subtotal = Math.max(0, parseFloat(amount) - parseFloat(estimatedCustomerFee)) + const formatted = subtotal.toFixed(2) + return { subtotalAmount: formatted, finalAmount: formatted } +} + +export type BridgeWithdrawalReceiptFees = { + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string +} + +export type BridgeWithdrawalLike = { + id?: string + amount: string + currency: string + externalAccountId: string + status: string + flashFeePercent?: string + flashFee?: string + estimatedBridgeFeePercent?: string + estimatedBridgeFee?: string + estimatedGasBuffer?: string + estimatedCustomerFee?: string + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string + bridgeTransferId?: string | null + failureReason?: string + createdAt: Date | string +} + +export const isFlashFeeEstimate = ( + withdrawal: Pick, +): boolean => !withdrawal.bridgeDeveloperFee + +export const receiptFeesFromTransfer = ( + receipt?: { + developer_fee?: string + exchange_fee?: string + subtotal_amount?: string + final_amount?: string + }, +): BridgeWithdrawalReceiptFees => ({ + bridgeDeveloperFee: + receipt?.developer_fee != null ? String(receipt.developer_fee) : undefined, + bridgeExchangeFee: + receipt?.exchange_fee != null ? String(receipt.exchange_fee) : undefined, + subtotalAmount: + receipt?.subtotal_amount != null ? String(receipt.subtotal_amount) : undefined, + finalAmount: receipt?.final_amount != null ? String(receipt.final_amount) : undefined, +}) + +const withdrawalId = (withdrawal: BridgeWithdrawalLike): string => { + if (withdrawal.id) return withdrawal.id + const mongoId = (withdrawal as { _id?: { toString(): string } })._id + return mongoId ? mongoId.toString() : "" +} + +type MaybeMongooseWithdrawal = BridgeWithdrawalLike & { + toObject?: (options?: { virtuals?: boolean }) => Record +} + +/** Mongoose documents do not spread their schema fields; normalize before merging. */ +export const toBridgeWithdrawalLike = ( + withdrawal: MaybeMongooseWithdrawal, +): BridgeWithdrawalLike => { + if (typeof withdrawal.toObject !== "function") return withdrawal + + const plain = withdrawal.toObject({ virtuals: true }) as BridgeWithdrawalLike + return { + ...plain, + id: withdrawalId(plain), + } +} + +const estimatedCustomerFeeFor = (withdrawal: BridgeWithdrawalLike): string | undefined => { + if (withdrawal.estimatedCustomerFee) return withdrawal.estimatedCustomerFee + if ( + withdrawal.flashFee != null && + withdrawal.estimatedBridgeFee != null && + withdrawal.estimatedGasBuffer != null + ) { + return ( + parseFloat(withdrawal.flashFee) + + parseFloat(withdrawal.estimatedBridgeFee) + + parseFloat(withdrawal.estimatedGasBuffer) + ).toFixed(2) + } + return undefined +} + +const resolveAmounts = (withdrawal: BridgeWithdrawalLike, flashFeeIsEstimate: boolean) => { + if (withdrawal.subtotalAmount && withdrawal.finalAmount) { + return { + subtotalAmount: withdrawal.subtotalAmount, + finalAmount: withdrawal.finalAmount, + } + } + + const estimatedCustomerFee = estimatedCustomerFeeFor(withdrawal) + if (flashFeeIsEstimate && estimatedCustomerFee) { + return computePendingAmountEstimates(withdrawal.amount, estimatedCustomerFee) + } + + return { + subtotalAmount: withdrawal.subtotalAmount, + finalAmount: withdrawal.finalAmount, + } +} + +const withFeeEstimate = ( + withdrawal: BridgeWithdrawalLike, + feeEstimate?: CustomerFeeEstimate, +): BridgeWithdrawalLike => { + const plain = toBridgeWithdrawalLike(withdrawal) + if (!feeEstimate) return plain + + return { + ...plain, + flashFeePercent: feeEstimate.flashFeePercent, + flashFee: feeEstimate.flashFee, + estimatedBridgeFeePercent: feeEstimate.estimatedBridgeFeePercent, + estimatedBridgeFee: feeEstimate.estimatedBridgeFee, + estimatedGasBuffer: feeEstimate.estimatedGasBuffer, + estimatedCustomerFee: feeEstimate.estimatedCustomerFee, + } +} + +export const presentBridgeWithdrawal = ( + withdrawal: BridgeWithdrawalLike, + feeEstimate?: CustomerFeeEstimate, +) => { + const source = withFeeEstimate(withdrawal, feeEstimate) + const createdAt = + source.createdAt instanceof Date + ? source.createdAt.toISOString() + : source.createdAt + const flashFeeIsEstimate = isFlashFeeEstimate(source) + const { subtotalAmount, finalAmount } = resolveAmounts(source, flashFeeIsEstimate) + const estimatedCustomerFee = estimatedCustomerFeeFor(source) + + return { + id: withdrawalId(source), + amount: source.amount, + currency: source.currency, + externalAccountId: source.externalAccountId, + status: source.status, + estimatedBridgeFeePercent: source.estimatedBridgeFeePercent, + estimatedBridgeFee: source.estimatedBridgeFee, + estimatedGasBuffer: source.estimatedGasBuffer, + estimatedCustomerFee, + flashFeePercent: source.flashFeePercent, + flashFee: source.flashFee, + flashFeeIsEstimate, + bridgeDeveloperFee: source.bridgeDeveloperFee, + bridgeExchangeFee: source.bridgeExchangeFee, + subtotalAmount, + finalAmount, + bridgeTransferId: source.bridgeTransferId ?? undefined, + failureReason: source.failureReason, + createdAt, + } +} + +export type PresentedBridgeWithdrawal = ReturnType diff --git a/test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts b/test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts new file mode 100644 index 000000000..bc2ae99a2 --- /dev/null +++ b/test/flash/unit/services/bridge/ethereum-gas-estimate.spec.ts @@ -0,0 +1,44 @@ +import { + computeEstimatedGasBufferUsd, + fetchEthereumGasPriceGwei, + fetchEthUsdPrice, +} from "@services/bridge/ethereum-gas-estimate" + +describe("ethereum gas estimate", () => { + it("computes buffered ERC-20 gas cost in USD", () => { + expect( + computeEstimatedGasBufferUsd({ + gasLimit: 65_000, + gasPriceGwei: 20, + ethUsd: 3000, + bufferMultiplier: 1.5, + }), + ).toBe("5.85") + }) + + it("parses eth_gasPrice hex wei into gwei", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ result: "0x4a817c800" }), + } as Response) + + const gasPriceGwei = await fetchEthereumGasPriceGwei({ + rpcUrl: "https://example.invalid", + timeoutMs: 1000, + }) + + expect(gasPriceGwei).toBe(20) + fetchMock.mockRestore() + }) + + it("reads ETH/USD from CoinGecko", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ ethereum: { usd: 2500.5 } }), + } as Response) + + const ethUsd = await fetchEthUsdPrice({ timeoutMs: 1000 }) + expect(ethUsd).toBe(2500.5) + fetchMock.mockRestore() + }) +}) diff --git a/test/flash/unit/services/bridge/withdrawal-fees.spec.ts b/test/flash/unit/services/bridge/withdrawal-fees.spec.ts new file mode 100644 index 000000000..0e7b07436 --- /dev/null +++ b/test/flash/unit/services/bridge/withdrawal-fees.spec.ts @@ -0,0 +1,179 @@ +jest.mock("@config", () => ({ + BridgeConfig: { + developerFeePercent: 2, + timeoutMs: 10_000, + withdrawalFeeEstimate: { + bridgeFixedFeePercent: 0.6, + usdtTransferGasLimit: 65_000, + gasPriceBufferMultiplier: 1.5, + ethereumGasRpcUrl: "https://cloudflare-eth.com", + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, + }, + }, +})) + +import { + computeCustomerFeeEstimateFromGasMarket, + computePendingAmountEstimates, + presentBridgeWithdrawal, + receiptFeesFromTransfer, +} from "@services/bridge/withdrawal-fees" + +const feeConfig = { + bridgeFixedFeePercent: 0.6, + usdtTransferGasLimit: 65_000, + gasPriceBufferMultiplier: 1.5, + ethereumGasRpcUrl: "https://cloudflare-eth.com", + fallbackGasPriceGwei: 30, + ethUsdFallback: 3000, +} + +describe("bridge withdrawal fees", () => { + it("computes customer fee as flash fee + bridge fee + buffered gas", () => { + const estimate = computeCustomerFeeEstimateFromGasMarket({ + amount: "50.00", + gasMarket: { gasPriceGwei: 20, ethUsd: 3000 }, + config: feeConfig, + developerFeePercent: 2, + }) + + expect(estimate.flashFeePercent).toBe("2") + expect(estimate.flashFee).toBe("1.00") + expect(estimate.estimatedBridgeFeePercent).toBe("0.6") + expect(estimate.estimatedBridgeFee).toBe("0.30") + expect(estimate.estimatedGasBuffer).toBe("5.85") + expect(estimate.estimatedCustomerFee).toBe("7.15") + }) + + it("computes pending subtotal and final amount from estimated customer fee", () => { + expect(computePendingAmountEstimates("50.00", "7.15")).toEqual({ + subtotalAmount: "42.85", + finalAmount: "42.85", + }) + }) + + it("merges fee estimates on mongoose documents without losing core fields", () => { + const mongooseDoc = { + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + createdAt: new Date("2026-06-11T00:00:00.000Z"), + toObject: () => ({ + _id: { toString: () => "w-mongo" }, + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + createdAt: new Date("2026-06-11T00:00:00.000Z"), + }), + } + + const pending = presentBridgeWithdrawal(mongooseDoc, { + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + }) + + expect(pending.id).toBe("w-mongo") + expect(pending.amount).toBe("50.00") + expect(pending.currency).toBe("usdt") + expect(pending.estimatedCustomerFee).toBe("7.15") + expect(pending.subtotalAmount).toBe("42.85") + }) + + it("fills missing legacy fee fields when a fresh estimate is provided", () => { + const pending = presentBridgeWithdrawal( + { + id: "w-legacy", + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + createdAt: "2026-06-11T00:00:00.000Z", + }, + { + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + }, + ) + + expect(pending.estimatedBridgeFeePercent).toBe("0.6") + expect(pending.estimatedGasBuffer).toBe("5.85") + expect(pending.estimatedCustomerFee).toBe("7.15") + expect(pending.subtotalAmount).toBe("42.85") + }) + + it("exposes pending amount estimates until Bridge receipt fees are stored", () => { + const pending = presentBridgeWithdrawal({ + id: "w-1", + amount: "50.00", + currency: "usdt", + externalAccountId: "ext-1", + status: "pending", + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + createdAt: "2026-06-11T00:00:00.000Z", + }) + + expect(pending.flashFeeIsEstimate).toBe(true) + expect(pending.flashFee).toBe("1.00") + expect(pending.estimatedCustomerFee).toBe("7.15") + expect(pending.subtotalAmount).toBe("42.85") + expect(pending.finalAmount).toBe("42.85") + }) + + it("uses receipt amounts once Bridge fees are available", () => { + const submitted = presentBridgeWithdrawal({ + id: "w-1", + amount: "49.00", + currency: "usd", + externalAccountId: "ext-1", + status: "submitted", + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "5.85", + estimatedCustomerFee: "7.15", + bridgeDeveloperFee: "0.30", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + bridgeTransferId: "tr-1", + createdAt: "2026-06-11T00:00:00.000Z", + }) + + expect(submitted.flashFeeIsEstimate).toBe(false) + expect(submitted.subtotalAmount).toBe("48.90") + expect(submitted.finalAmount).toBe("48.90") + }) + + it("maps Bridge transfer receipt fields", () => { + expect( + receiptFeesFromTransfer({ + developer_fee: "0.30", + exchange_fee: "0.10", + subtotal_amount: "48.90", + final_amount: "48.90", + }), + ).toEqual({ + bridgeDeveloperFee: "0.30", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }) + }) +}) From 1fd686c5cd9d1a92f8ed0efa1247ae79dca82422 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 16:32:01 +0100 Subject: [PATCH 3/8] feat(bridge): persist withdrawal fee breakdown in MongoDB Store flash, Bridge, gas, and total customer fee estimates on pending withdrawals and refresh them when reusing an existing pending request. --- src/services/mongoose/bridge-accounts.ts | 64 +++++++++++++++++++++++- src/services/mongoose/schema.ts | 20 ++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 41f7fec4c..67bab6264 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -112,12 +112,27 @@ export const truncateBridgeFailureReason = ( return `${reason.slice(0, BRIDGE_FAILURE_REASON_MAX_LENGTH - 3)}...` } +export const bridgeWithdrawalRecordId = (record: { + id?: string + _id?: { toString(): string } +}): string => { + if (record.id) return record.id + if (record._id) return record._id.toString() + return "" +} + export const createWithdrawal = async (data: { accountId: string bridgeTransferId?: string amount: string currency: string externalAccountId: string + flashFeePercent: string + flashFee: string + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string status?: "pending" | "completed" | "failed" }) => { try { @@ -139,6 +154,37 @@ export const createWithdrawal = async (data: { } } +export const updateWithdrawalFeeEstimates = async ( + id: string, + fees: { + flashFeePercent: string + flashFee: string + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + }, +) => { + try { + const record = await BridgeWithdrawal.findByIdAndUpdate( + id, + { + flashFeePercent: fees.flashFeePercent, + flashFee: fees.flashFee, + estimatedBridgeFeePercent: fees.estimatedBridgeFeePercent, + estimatedBridgeFee: fees.estimatedBridgeFee, + estimatedGasBuffer: fees.estimatedGasBuffer, + estimatedCustomerFee: fees.estimatedCustomerFee, + updatedAt: new Date(), + }, + { new: true }, + ) + return record || new RepositoryError("Withdrawal not found") + } catch (error) { + return new RepositoryError(String(error)) + } +} + export const findPendingWithdrawalWithoutTransfer = async ( accountId: string, externalAccountId: string, @@ -163,11 +209,27 @@ export const updateWithdrawalTransferId = async ( bridgeTransferId: string, amount: string, currency: string, + receiptFees?: { + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string + }, ) => { try { const record = await BridgeWithdrawal.findByIdAndUpdate( id, - { bridgeTransferId, amount, currency, status: "submitted", updatedAt: new Date() }, + { + bridgeTransferId, + amount, + currency, + bridgeDeveloperFee: receiptFees?.bridgeDeveloperFee, + bridgeExchangeFee: receiptFees?.bridgeExchangeFee, + subtotalAmount: receiptFees?.subtotalAmount, + finalAmount: receiptFees?.finalAmount, + status: "submitted", + updatedAt: new Date(), + }, { new: true }, ) return record || new RepositoryError("Withdrawal not found") diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 291a8c8b7..8ee8c0c77 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -48,6 +48,16 @@ interface IBridgeWithdrawalRecord { amount: string currency: string status: "pending" | "submitted" | "completed" | "failed" | "cancelled" + flashFeePercent: string + flashFee: string + estimatedBridgeFeePercent: string + estimatedBridgeFee: string + estimatedGasBuffer: string + estimatedCustomerFee: string + bridgeDeveloperFee?: string + bridgeExchangeFee?: string + subtotalAmount?: string + finalAmount?: string failureReason?: string externalAccountId: string createdAt: Date @@ -716,6 +726,16 @@ const BridgeWithdrawalSchema = new Schema({ amount: { type: String, required: true }, currency: { type: String, required: true }, status: { type: String, enum: ["pending", "submitted", "completed", "failed", "cancelled"], default: "pending" }, + flashFeePercent: { type: String, required: true }, + flashFee: { type: String, required: true }, + estimatedBridgeFeePercent: { type: String, required: true }, + estimatedBridgeFee: { type: String, required: true }, + estimatedGasBuffer: { type: String, required: true }, + estimatedCustomerFee: { type: String, required: true }, + bridgeDeveloperFee: { type: String }, + bridgeExchangeFee: { type: String }, + subtotalAmount: { type: String }, + finalAmount: { type: String }, failureReason: { type: String, maxlength: 512 }, externalAccountId: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, From 85da7e4dbcb2166056d90345e078fadd3d47d237 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 16:32:54 +0100 Subject: [PATCH 4/8] feat(bridge): surface fee estimates in withdrawal service flow Resolve and persist fee estimates on request, map Bridge transfer receipt fees on initiate, improve Ibex balance error mapping, and pass developer_fee_percent when creating virtual accounts. --- src/services/bridge/client.ts | 14 +- src/services/bridge/index.ts | 139 ++++++++++-------- test/flash/unit/services/bridge/index.spec.ts | 121 ++++++++++++++- 3 files changed, 204 insertions(+), 70 deletions(-) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index d6a8c9310..3e4bbbc2e 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -293,7 +293,19 @@ export interface Transfer { bank_routing_number?: string to_address?: string } - receipt?: Record + receipt?: { + initial_amount: string + developer_fee: string + exchange_fee: string + subtotal_amount: string + gas_fee?: string + final_amount?: string + exchange_rate?: string + source_tx_hash?: string + destination_tx_hash?: string + remaining_prefunded_balance?: string + url?: string + } created_at: string updated_at: string } diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index 052947ad7..a20584057 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -24,6 +24,7 @@ import { WalletsRepository } from "@services/mongoose/wallets" import { IdentityRepository } from "@services/kratos" import IbexClient from "@services/ibex/client" +import { IbexError } from "@services/ibex/errors" import { BridgeInsufficientFundsError, @@ -38,6 +39,26 @@ import { BridgeWithdrawalAlreadyInitiatedError, } from "./errors" import BridgeApiClient from "./client" +import { + presentBridgeWithdrawal, + receiptFeesFromTransfer, + resolveWithdrawalCustomerFeeEstimate, + type PresentedBridgeWithdrawal, +} from "./withdrawal-fees" + +const asBridgeRequestWithdrawalError = (error: unknown): BridgeError => { + if (error instanceof BridgeError) return error + if (error instanceof IbexError) { + return new BridgeInsufficientFundsError( + "Unable to verify USDT wallet balance. Ensure IBEX is running and the USDT Cash Wallet is funded.", + ) + } + if (error instanceof RepositoryError) { + return new BridgeError(`Failed to persist withdrawal request: ${error.message}`) + } + if (error instanceof Error) return new BridgeError(error.message) + return new BridgeError(String(error)) +} // ============ Types ============ @@ -60,24 +81,9 @@ type AddExternalAccountResult = { expiresAt: string } -type WithdrawalRequestResult = { - id: string - amount: string - currency: string - externalAccountId: string - status: string - failureReason?: string - createdAt: string -} +type WithdrawalRequestResult = PresentedBridgeWithdrawal -type InitiateWithdrawalResult = { - id: string - amount: string - currency: string - status: string - bridgeTransferId?: string - createdAt: string -} +type InitiateWithdrawalResult = PresentedBridgeWithdrawal type CancelWithdrawalResult = { id: string @@ -87,16 +93,7 @@ type CancelWithdrawalResult = { createdAt: string } -type WithdrawalResult = { - id: string - amount: string - currency: string - externalAccountId: string - status: string - bridgeTransferId?: string - failureReason?: string - createdAt: string -} +type WithdrawalResult = PresentedBridgeWithdrawal type KycStatusResult = | "open" @@ -409,6 +406,7 @@ const createVirtualAccount = async ( payment_rail: "ethereum", address: ethereumAddress, }, + developer_fee_percent: String(BridgeConfig.developerFeePercent), }, vaIdempotencyKey, ) @@ -547,7 +545,13 @@ const requestWithdrawal = async ( walletId: usdtWallet.id, currency: WalletCurrency.Usdt, }) - if (balance instanceof Error) return balance + if (balance instanceof Error) { + baseLogger.error( + { accountId, walletId: usdtWallet.id, error: balance, operation: "requestWithdrawal" }, + "Failed to read USDT wallet balance for withdrawal request", + ) + return asBridgeRequestWithdrawalError(balance) + } if (!(balance instanceof USDTAmount)) { return new BridgeInsufficientFundsError("Invalid balance type") @@ -578,12 +582,14 @@ const requestWithdrawal = async ( (acc) => acc.bridgeExternalAccountId === externalAccountId, ) if (!targetAccount) { - return new Error("External account not found") + return new BridgeError("External account not found for this account") } if (targetAccount.status !== "verified") { - return new Error("External account is not verified") + return new BridgeError("External account is not verified") } + const feeEstimate = await resolveWithdrawalCustomerFeeEstimate(amount) + const existingWithdrawal = await BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer( accountId as string, @@ -592,37 +598,56 @@ const requestWithdrawal = async ( ) if (existingWithdrawal instanceof Error) return existingWithdrawal - const pendingWithdrawal = - existingWithdrawal || - (await BridgeAccountsRepo.createWithdrawal({ + let pendingWithdrawal + if (existingWithdrawal) { + pendingWithdrawal = await BridgeAccountsRepo.updateWithdrawalFeeEstimates( + BridgeAccountsRepo.bridgeWithdrawalRecordId(existingWithdrawal), + feeEstimate, + ) + } else { + pendingWithdrawal = await BridgeAccountsRepo.createWithdrawal({ accountId: accountId as string, amount, currency: "usdt", externalAccountId, + flashFeePercent: feeEstimate.flashFeePercent, + flashFee: feeEstimate.flashFee, + estimatedBridgeFeePercent: feeEstimate.estimatedBridgeFeePercent, + estimatedBridgeFee: feeEstimate.estimatedBridgeFee, + estimatedGasBuffer: feeEstimate.estimatedGasBuffer, + estimatedCustomerFee: feeEstimate.estimatedCustomerFee, status: "pending", - })) - if (pendingWithdrawal instanceof Error) return pendingWithdrawal + }) + if ( + !(pendingWithdrawal instanceof Error) && + !pendingWithdrawal.estimatedCustomerFee + ) { + pendingWithdrawal = await BridgeAccountsRepo.updateWithdrawalFeeEstimates( + BridgeAccountsRepo.bridgeWithdrawalRecordId(pendingWithdrawal), + feeEstimate, + ) + } + } + if (pendingWithdrawal instanceof Error) { + return asBridgeRequestWithdrawalError(pendingWithdrawal) + } baseLogger.info( - { accountId, operation: "requestWithdrawal", withdrawalId: pendingWithdrawal.id }, + { + accountId, + operation: "requestWithdrawal", + withdrawalId: BridgeAccountsRepo.bridgeWithdrawalRecordId(pendingWithdrawal), + }, "Bridge operation completed", ) - return { - id: pendingWithdrawal.id, - amount: pendingWithdrawal.amount, - currency: pendingWithdrawal.currency, - externalAccountId: pendingWithdrawal.externalAccountId, - status: pendingWithdrawal.status, - failureReason: pendingWithdrawal.failureReason, - createdAt: pendingWithdrawal.createdAt.toISOString(), - } + return presentBridgeWithdrawal(pendingWithdrawal, feeEstimate) } catch (error) { baseLogger.error( { accountId, operation: "requestWithdrawal", error }, "Bridge operation failed", ) - return error instanceof Error ? error : new Error(String(error)) + return asBridgeRequestWithdrawalError(error) } } @@ -707,6 +732,7 @@ const initiateWithdrawal = async ( currency: "usdt", from_address: ethereumAddress, }, + developer_fee_percent: String(BridgeConfig.developerFeePercent), destination: { payment_rail: "ach", currency: "usd", @@ -721,6 +747,7 @@ const initiateWithdrawal = async ( transfer.id, transfer.amount, transfer.currency, + receiptFeesFromTransfer(transfer.receipt), ) if (updated instanceof Error) return updated @@ -729,14 +756,7 @@ const initiateWithdrawal = async ( "Bridge operation completed", ) - return { - id: updated.id, - amount: updated.amount, - currency: updated.currency, - status: updated.status, - bridgeTransferId: updated.bridgeTransferId, - createdAt: updated.createdAt.toISOString(), - } + return presentBridgeWithdrawal(updated) } catch (error) { baseLogger.error( { accountId, operation: "initiateWithdrawal", error }, @@ -1095,16 +1115,7 @@ const getWithdrawals = async ( const result: WithdrawalResult[] = withdrawals .filter((w) => w.bridgeTransferId !== null && w.bridgeTransferId !== undefined) - .map((w) => ({ - id: w.id, - amount: w.amount, - currency: w.currency, - externalAccountId: w.externalAccountId, - status: w.status, - bridgeTransferId: w.bridgeTransferId, - failureReason: w.failureReason, - createdAt: w.createdAt.toISOString(), - })) + .map((w) => presentBridgeWithdrawal(w)) baseLogger.info( { accountId, operation: "getWithdrawals", count: result.length }, diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 7fd60e2ad..7ff2e219d 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -10,7 +10,7 @@ jest.mock("@services/tracing", () => ({ })) jest.mock("@config", () => ({ - BridgeConfig: { enabled: true, minWithdrawalAmount: 10 }, + 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 }), @@ -23,12 +23,32 @@ jest.mock("@services/logger", () => ({ baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })) +const MOCK_WITHDRAWAL_FEE_ESTIMATE = { + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", +} + +jest.mock("@services/bridge/withdrawal-fees", () => { + const actual = jest.requireActual("@services/bridge/withdrawal-fees") + return { + ...actual, + resolveWithdrawalCustomerFeeEstimate: jest.fn(), + } +}) + jest.mock("@services/mongoose/bridge-accounts", () => ({ createVirtualAccount: jest.fn(), findVirtualAccountByAccountId: jest.fn(), createWithdrawal: jest.fn(), findPendingWithdrawalWithoutTransfer: jest.fn(), findExternalAccountsByAccountId: jest.fn(), + updateWithdrawalFeeEstimates: jest.fn(), + bridgeWithdrawalRecordId: jest.requireActual("@services/mongoose/bridge-accounts") + .bridgeWithdrawalRecordId, updateWithdrawalTransferId: jest.fn(), findWithdrawalById: jest.fn(), findWithdrawalsByAccountId: jest.fn(), @@ -135,6 +155,12 @@ const makeRow = (id: string, overrides: Record = {}) => ({ currency: "usdt", externalAccountId: EXTERNAL_ACCOUNT_ID, status: "pending" as const, + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", bridgeTransferId: undefined, failureReason: undefined, createdAt: CREATED_AT, @@ -146,6 +172,13 @@ const mockTransfer = { amount: AMOUNT, currency: "usd", state: "pending", + receipt: { + initial_amount: AMOUNT, + developer_fee: "1.00", + exchange_fee: "0.10", + subtotal_amount: "48.90", + final_amount: "48.90", + }, } const mockVirtualAccount = { @@ -200,6 +233,10 @@ const setupGuards = () => { ...makeRow(WITHDRAWAL_ID), bridgeTransferId: TRANSFER_ID, status: "submitted" as const, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", }) ;(BridgeClient.createTransfer as jest.Mock).mockResolvedValue(mockTransfer) } @@ -414,6 +451,10 @@ describe("requestWithdrawal", () => { beforeEach(() => { jest.clearAllMocks() setupGuards() + const { resolveWithdrawalCustomerFeeEstimate } = jest.requireMock( + "@services/bridge/withdrawal-fees", + ) + resolveWithdrawalCustomerFeeEstimate.mockResolvedValue(MOCK_WITHDRAWAL_FEE_ESTIMATE) ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( null, ) @@ -434,6 +475,12 @@ describe("requestWithdrawal", () => { amount: AMOUNT, currency: "usdt", externalAccountId: EXTERNAL_ACCOUNT_ID, + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", status: "pending", }) expect(expectSuccess(result)).toMatchObject({ @@ -442,6 +489,15 @@ describe("requestWithdrawal", () => { currency: "usdt", externalAccountId: EXTERNAL_ACCOUNT_ID, status: "pending", + flashFeePercent: "2", + flashFee: "1.00", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", + flashFeeIsEstimate: true, + subtotalAmount: "47.70", + finalAmount: "47.70", createdAt: expect.any(String), }) }) @@ -452,10 +508,18 @@ describe("requestWithdrawal", () => { }) it("reuses an existing pending withdrawal for the same account, amount, and external account", async () => { - const existingRow = makeRow("withdrawal-existing-001") + const existingRow = makeRow("withdrawal-existing-001", { + estimatedBridgeFeePercent: undefined, + estimatedBridgeFee: undefined, + estimatedGasBuffer: undefined, + estimatedCustomerFee: undefined, + }) ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( existingRow, ) + ;(BridgeAccountsRepo.updateWithdrawalFeeEstimates as jest.Mock).mockResolvedValue( + makeRow("withdrawal-existing-001"), + ) const result = await BridgeService.requestWithdrawal( ACCOUNT_ID, @@ -464,9 +528,19 @@ describe("requestWithdrawal", () => { ) expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() + expect(BridgeAccountsRepo.updateWithdrawalFeeEstimates).toHaveBeenCalledWith( + "withdrawal-existing-001", + MOCK_WITHDRAWAL_FEE_ESTIMATE, + ) expect(expectSuccess(result)).toMatchObject({ id: "withdrawal-existing-001", status: "pending", + estimatedBridgeFeePercent: "0.6", + estimatedBridgeFee: "0.30", + estimatedGasBuffer: "1.00", + estimatedCustomerFee: "2.30", + subtotalAmount: "47.70", + finalAmount: "47.70", }) }) @@ -501,7 +575,9 @@ describe("requestWithdrawal", () => { }) it("returns BridgeInsufficientFundsError when USDT balance is below the requested amount", async () => { - ;(getBalanceForWallet as jest.Mock).mockResolvedValue(getUSDTAmount(5)) // < AMOUNT=50 + ;(getBalanceForWallet as jest.Mock).mockImplementation(() => + Promise.resolve(getUSDTAmount(5)), + ) const result = await BridgeService.requestWithdrawal( ACCOUNT_ID, @@ -509,8 +585,10 @@ describe("requestWithdrawal", () => { EXTERNAL_ACCOUNT_ID, ) - const { BridgeInsufficientFundsError } = jest.requireActual("@services/bridge/errors") - expect(result).toBeInstanceOf(BridgeInsufficientFundsError) + expect(result).toMatchObject({ + name: "BridgeInsufficientFundsError", + message: expect.stringContaining("Insufficient USDT balance"), + }) expect(BridgeAccountsRepo.createWithdrawal).not.toHaveBeenCalled() }) @@ -601,10 +679,19 @@ describe("initiateWithdrawal — takes withdrawalId (step 2A)", () => { TRANSFER_ID, AMOUNT, "usd", + { + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }, ) expect(expectSuccess(result)).toMatchObject({ status: "submitted", bridgeTransferId: TRANSFER_ID, + flashFeeIsEstimate: false, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", }) }) @@ -788,6 +875,10 @@ describe("withdrawal request → confirm/cancel flow", () => { beforeEach(() => { jest.clearAllMocks() setupGuards() + const { resolveWithdrawalCustomerFeeEstimate } = jest.requireMock( + "@services/bridge/withdrawal-fees", + ) + resolveWithdrawalCustomerFeeEstimate.mockResolvedValue(MOCK_WITHDRAWAL_FEE_ESTIMATE) ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( null, ) @@ -804,6 +895,10 @@ describe("withdrawal request → confirm/cancel flow", () => { ...makeRow(WITHDRAWAL_ID), bridgeTransferId: TRANSFER_ID, status: "submitted" as const, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", }) }) @@ -829,6 +924,12 @@ describe("withdrawal request → confirm/cancel flow", () => { TRANSFER_ID, AMOUNT, "usd", + { + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }, ) }) @@ -842,6 +943,10 @@ describe("withdrawal request → confirm/cancel flow", () => { ...existingRow, bridgeTransferId: TRANSFER_ID, status: "submitted" as const, + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", }) const first = await BridgeService.requestWithdrawal( @@ -870,6 +975,12 @@ describe("withdrawal request → confirm/cancel flow", () => { TRANSFER_ID, AMOUNT, "usd", + { + bridgeDeveloperFee: "1.00", + bridgeExchangeFee: "0.10", + subtotalAmount: "48.90", + finalAmount: "48.90", + }, ) }) From 0c1113b8262e1405c28dcbf7229808861ad5325e Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 16:33:52 +0100 Subject: [PATCH 5/8] feat(bridge): add localized flash fee notice for withdrawals Expose flashFeeNotice copy explaining Flash, Bridge, and gas buffer components while totals remain estimates until Bridge settles. --- .../bridge/get-withdrawal-flash-fee-notice.ts | 12 ++++++ src/config/locales/en.json | 1 + src/config/locales/es.json | 3 +- .../get-withdrawal-flash-fee-notice.spec.ts | 39 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/app/bridge/get-withdrawal-flash-fee-notice.ts create mode 100644 test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts diff --git a/src/app/bridge/get-withdrawal-flash-fee-notice.ts b/src/app/bridge/get-withdrawal-flash-fee-notice.ts new file mode 100644 index 000000000..fd7ba3891 --- /dev/null +++ b/src/app/bridge/get-withdrawal-flash-fee-notice.ts @@ -0,0 +1,12 @@ +import { getI18nInstance } from "@config" +import { getLanguageOrDefault } from "@domain/locale" + +export const BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE = + "notification.bridgeWithdrawal.flashFeeNotice" + +export const getBridgeWithdrawalFlashFeeNotice = (locale: UserLanguage): string => + getI18nInstance().__({ phrase: BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE, locale }) + +export const getBridgeWithdrawalFlashFeeNoticeForUser = ( + user?: Pick, +): string => getBridgeWithdrawalFlashFeeNotice(getLanguageOrDefault(user?.language ?? "")) diff --git a/src/config/locales/en.json b/src/config/locales/en.json index 2b286f01c..4fe922f16 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -46,6 +46,7 @@ "title": "Deposit received" }, "bridgeWithdrawal": { + "flashFeeNotice": "Shown fees and amounts are estimates. Final fees may differ.", "cancelled": { "body": "Your withdrawal of {{amount}} has been cancelled.", "title": "Withdrawal cancelled" diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 9d3530d0a..0fc877e9d 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -42,6 +42,7 @@ "title": "Depósito recibido" }, "bridgeWithdrawal": { + "flashFeeNotice": "Las comisiones y montos mostrados son estimados. Las comisiones finales pueden variar.", "cancelled": { "body": "Su retiro de {{amount}} ha sido cancelado.", "title": "Retiro cancelado" @@ -57,4 +58,4 @@ } } } -} +} \ No newline at end of file diff --git a/test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts b/test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts new file mode 100644 index 000000000..baa6f3ac4 --- /dev/null +++ b/test/flash/unit/app/bridge/get-withdrawal-flash-fee-notice.spec.ts @@ -0,0 +1,39 @@ +jest.mock("@config", () => { + const path = require("path") + const { I18n } = require("i18n") + const i18n = new I18n() + i18n.configure({ + objectNotation: true, + updateFiles: false, + locales: ["en", "es"], + defaultLocale: "en", + retryInDefaultLocale: true, + directory: path.resolve(__dirname, "../../../../../src/config/locales"), + }) + return { + getI18nInstance: () => i18n, + getLocale: () => "en", + } +}) + +import { + BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE, + getBridgeWithdrawalFlashFeeNotice, + getBridgeWithdrawalFlashFeeNoticeForUser, +} from "@app/bridge/get-withdrawal-flash-fee-notice" + +describe("getBridgeWithdrawalFlashFeeNotice", () => { + it("uses the configured i18n phrase for supported languages", () => { + expect(BRIDGE_WITHDRAWAL_FLASH_FEE_NOTICE_PHRASE).toBe( + "notification.bridgeWithdrawal.flashFeeNotice", + ) + expect(getBridgeWithdrawalFlashFeeNotice("en")).toContain("estimates") + expect(getBridgeWithdrawalFlashFeeNotice("es")).toContain("estimados") + }) + + it("falls back to the default locale when the user language is empty", () => { + expect(getBridgeWithdrawalFlashFeeNoticeForUser({ language: "" })).toBe( + getBridgeWithdrawalFlashFeeNotice("en"), + ) + }) +}) From f49ee0c529270e81dc3b9d9070e1eb89074a9155 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 16:34:01 +0100 Subject: [PATCH 6/8] feat(bridge): expose withdrawal fee fields in GraphQL API Add estimated fee breakdown, subtotal/final amounts, receipt fees, and flashFeeNotice on BridgeWithdrawal for request and query paths. --- dev/apollo-federation/supergraph.graphql | 12 +++++ .../root/query/bridge-withdrawal-request.ts | 12 +---- src/graphql/public/schema.graphql | 12 +++++ .../public/types/object/bridge-withdrawal.ts | 22 +++++++++ .../types/object/bridge-contract.spec.ts | 46 +++++++++++++++++++ 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index f4545b6bb..dbe9b27db 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -374,13 +374,25 @@ type BridgeWithdrawal @join__type(graph: PUBLIC) { amount: String! + bridgeDeveloperFee: String + bridgeExchangeFee: String bridgeTransferId: String createdAt: String! currency: String! externalAccountId: String failureReason: String + finalAmount: String + estimatedBridgeFee: String + estimatedBridgeFeePercent: String + estimatedCustomerFee: String + estimatedGasBuffer: String + flashFee: String + flashFeeIsEstimate: Boolean! + flashFeeNotice: String + flashFeePercent: String id: ID! status: String! + subtotalAmount: String } """ diff --git a/src/graphql/public/root/query/bridge-withdrawal-request.ts b/src/graphql/public/root/query/bridge-withdrawal-request.ts index 289fd83d2..70a251ded 100644 --- a/src/graphql/public/root/query/bridge-withdrawal-request.ts +++ b/src/graphql/public/root/query/bridge-withdrawal-request.ts @@ -4,6 +4,7 @@ import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" import { BridgeConfig } from "@config" import { BridgeDisabledError } from "@services/bridge/errors" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { presentBridgeWithdrawal } from "@services/bridge/withdrawal-fees" import { RepositoryError } from "@domain/errors" const bridgeWithdrawalRequest = GT.Field({ @@ -24,16 +25,7 @@ const bridgeWithdrawalRequest = GT.Field({ // Ownership check — never expose another account's withdrawal if (withdrawal.accountId !== (domainAccount.id as string)) return null - return { - id: withdrawal.id, - amount: withdrawal.amount, - currency: withdrawal.currency, - externalAccountId: withdrawal.externalAccountId, - status: withdrawal.status, - bridgeTransferId: withdrawal.bridgeTransferId, - failureReason: withdrawal.failureReason, - createdAt: withdrawal.createdAt.toISOString(), - } + return presentBridgeWithdrawal(withdrawal) }, }) diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 26dd8eae0..a84a220a2 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -321,13 +321,25 @@ type BridgeVirtualAccount { type BridgeWithdrawal { amount: String! + bridgeDeveloperFee: String + bridgeExchangeFee: String bridgeTransferId: String createdAt: String! currency: String! externalAccountId: String failureReason: String + finalAmount: String + estimatedBridgeFee: String + estimatedBridgeFeePercent: String + estimatedCustomerFee: String + estimatedGasBuffer: String + flashFee: String + flashFeeIsEstimate: Boolean! + flashFeeNotice: String + flashFeePercent: String id: ID! status: String! + subtotalAmount: String } type BuildInformation { diff --git a/src/graphql/public/types/object/bridge-withdrawal.ts b/src/graphql/public/types/object/bridge-withdrawal.ts index 7d95cbf33..8b1740bf6 100644 --- a/src/graphql/public/types/object/bridge-withdrawal.ts +++ b/src/graphql/public/types/object/bridge-withdrawal.ts @@ -1,4 +1,6 @@ import { GT } from "@graphql/index" +import { getBridgeWithdrawalFlashFeeNoticeForUser } from "@app/bridge/get-withdrawal-flash-fee-notice" +import { isFlashFeeEstimate } from "@services/bridge/withdrawal-fees" const BridgeWithdrawal = GT.Object({ name: "BridgeWithdrawal", @@ -8,6 +10,26 @@ const BridgeWithdrawal = GT.Object({ currency: { type: GT.NonNull(GT.String) }, externalAccountId: { type: GT.String }, status: { type: GT.NonNull(GT.String) }, + estimatedBridgeFeePercent: { type: GT.String }, + estimatedBridgeFee: { type: GT.String }, + estimatedGasBuffer: { type: GT.String }, + estimatedCustomerFee: { type: GT.String }, + flashFeePercent: { type: GT.String }, + flashFee: { type: GT.String }, + flashFeeIsEstimate: { type: GT.NonNull(GT.Boolean) }, + flashFeeNotice: { + type: GT.String, + resolve: (parent, _, { user }: GraphQLPublicContext) => { + const isEstimate = + parent.flashFeeIsEstimate === true || isFlashFeeEstimate(parent) + if (!isEstimate) return null + return getBridgeWithdrawalFlashFeeNoticeForUser(user) + }, + }, + bridgeDeveloperFee: { type: GT.String }, + bridgeExchangeFee: { type: GT.String }, + subtotalAmount: { type: GT.String }, + finalAmount: { type: GT.String }, bridgeTransferId: { type: GT.String }, failureReason: { type: GT.String }, createdAt: { type: GT.NonNull(GT.String) }, diff --git a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts index 1a373d2c0..254bcca73 100644 --- a/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts +++ b/test/flash/unit/graphql/public/types/object/bridge-contract.spec.ts @@ -1,6 +1,25 @@ +jest.mock("@config", () => { + const path = require("path") + const { I18n } = require("i18n") + const i18n = new I18n() + i18n.configure({ + objectNotation: true, + updateFiles: false, + locales: ["en", "es"], + defaultLocale: "en", + retryInDefaultLocale: true, + directory: path.resolve(__dirname, "../../../../../../src/config/locales"), + }) + return { + getI18nInstance: () => i18n, + getLocale: () => "en", + } +}) + import BridgeVirtualAccount from "@graphql/public/types/object/bridge-virtual-account" import BridgeWithdrawal from "@graphql/public/types/object/bridge-withdrawal" import { defaultFieldResolver } from "graphql" +import { getBridgeWithdrawalFlashFeeNotice } from "@app/bridge/get-withdrawal-flash-fee-notice" describe("Bridge public GraphQL object contract", () => { it("exposes withdrawal fields returned by BridgeService", () => { @@ -11,6 +30,18 @@ describe("Bridge public GraphQL object contract", () => { expect(fields).toHaveProperty("currency") expect(fields).toHaveProperty("externalAccountId") expect(fields).toHaveProperty("status") + expect(fields).toHaveProperty("estimatedBridgeFeePercent") + expect(fields).toHaveProperty("estimatedBridgeFee") + expect(fields).toHaveProperty("estimatedGasBuffer") + expect(fields).toHaveProperty("estimatedCustomerFee") + expect(fields).toHaveProperty("flashFeePercent") + expect(fields).toHaveProperty("flashFee") + expect(fields).toHaveProperty("flashFeeIsEstimate") + expect(fields).toHaveProperty("flashFeeNotice") + expect(fields).toHaveProperty("bridgeDeveloperFee") + expect(fields).toHaveProperty("bridgeExchangeFee") + expect(fields).toHaveProperty("subtotalAmount") + expect(fields).toHaveProperty("finalAmount") expect(fields).toHaveProperty("bridgeTransferId") expect(fields).toHaveProperty("failureReason") expect(fields).toHaveProperty("createdAt") @@ -26,6 +57,9 @@ describe("Bridge public GraphQL object contract", () => { currency: "usdt", externalAccountId: "ext-001", status: "pending", + flashFeePercent: "2", + flashFee: "0.50", + flashFeeIsEstimate: true, bridgeTransferId: undefined, createdAt: "2026-06-05T00:00:00.000Z", } @@ -55,6 +89,18 @@ describe("Bridge public GraphQL object contract", () => { ).toBeUndefined() }) + it("resolves flashFeeNotice from the user locale when amounts are estimated", () => { + const fields = BridgeWithdrawal.getFields() + const withdrawal = { + flashFeeIsEstimate: true, + } + const ctx = { user: { language: "es" } } as GraphQLPublicContextAuth + + expect(fields.flashFeeNotice.resolve?.(withdrawal, {}, ctx, {})).toBe( + getBridgeWithdrawalFlashFeeNotice("es"), + ) + }) + it("uses bridgeVirtualAccountId as the virtual account id returned by read queries", () => { const idField = BridgeVirtualAccount.getFields().id const virtualAccount = { From b625a48d631aa6b4437c7f0dd8a1124263f23228 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Fri, 12 Jun 2026 17:02:37 +0100 Subject: [PATCH 7/8] test(bridge): fix unit test failures from fee field additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replay.spec.ts: spread jest.requireActual('@config') so getFeesConfig survives the partial @config mock - index.spec.ts: mock updateWithdrawalFeeEstimates in the dedup test so stale clearAllMocks() impl doesn't bleed in from a prior test - return-shapes.spec.ts: toEqual → toMatchObject for getWithdrawals shape check, now that presentBridgeWithdrawal emits fee fields - client-usd-wallet.spec.ts: spread jest.requireActual('ibex-client') so IbexUrls survives the partial ibex-client mock --- test/flash/unit/services/bridge/index.spec.ts | 1 + test/flash/unit/services/bridge/return-shapes.spec.ts | 2 +- test/flash/unit/services/bridge/webhook-server/replay.spec.ts | 1 + test/flash/unit/services/ibex/client-usd-wallet.spec.ts | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/flash/unit/services/bridge/index.spec.ts b/test/flash/unit/services/bridge/index.spec.ts index 7ff2e219d..226427eaa 100644 --- a/test/flash/unit/services/bridge/index.spec.ts +++ b/test/flash/unit/services/bridge/index.spec.ts @@ -938,6 +938,7 @@ describe("withdrawal request → confirm/cancel flow", () => { ;(BridgeAccountsRepo.findPendingWithdrawalWithoutTransfer as jest.Mock).mockResolvedValue( existingRow, ) + ;(BridgeAccountsRepo.updateWithdrawalFeeEstimates as jest.Mock).mockResolvedValue(existingRow) ;(BridgeAccountsRepo.findWithdrawalById as jest.Mock).mockResolvedValue(existingRow) ;(BridgeAccountsRepo.updateWithdrawalTransferId as jest.Mock).mockResolvedValue({ ...existingRow, diff --git a/test/flash/unit/services/bridge/return-shapes.spec.ts b/test/flash/unit/services/bridge/return-shapes.spec.ts index ceba1affa..2eb852c17 100644 --- a/test/flash/unit/services/bridge/return-shapes.spec.ts +++ b/test/flash/unit/services/bridge/return-shapes.spec.ts @@ -206,7 +206,7 @@ describe("getWithdrawals — BridgeWithdrawal GraphQL contract shape", () => { expect(result).not.toBeInstanceOf(Error) if (result instanceof Error) return expect(result).toHaveLength(1) - expect(result[0]).toEqual({ + expect(result[0]).toMatchObject({ id: WITHDRAWAL_ID, amount: AMOUNT, currency: "usdt", diff --git a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts index e74c7898b..dea9360af 100644 --- a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts @@ -2,6 +2,7 @@ // AC2: Replay CLI re-runs a stuck handler against a chosen transfer-id jest.mock("@config", () => ({ + ...jest.requireActual("@config"), BridgeConfig: { webhook: { replaySecret: "super-secret-replay-token-xyz" }, }, 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 0c6dc89e4..f61c7831e 100644 --- a/test/flash/unit/services/ibex/client-usd-wallet.spec.ts +++ b/test/flash/unit/services/ibex/client-usd-wallet.spec.ts @@ -17,6 +17,7 @@ jest.mock("ibex-client", () => { class IbexClientError extends Error {} return { + ...jest.requireActual("ibex-client"), __esModule: true, default: jest.fn().mockImplementation(() => ({ authentication: { From 30366550fc2c729802c79fbf6e7e2a32e93efdc6 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Sat, 13 Jun 2026 16:01:16 +0100 Subject: [PATCH 8/8] fix: remove the hardcoded bridge developer fees percentage --- src/services/bridge/withdrawal-fees.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/bridge/withdrawal-fees.ts b/src/services/bridge/withdrawal-fees.ts index ed777b3b0..6edd10be4 100644 --- a/src/services/bridge/withdrawal-fees.ts +++ b/src/services/bridge/withdrawal-fees.ts @@ -51,7 +51,7 @@ export const computeCustomerFeeEstimateFromGasMarket = ({ amount, gasMarket, config = getWithdrawalFeeEstimateConfig(), - developerFeePercent = BridgeConfig.developerFeePercent ?? 2, + developerFeePercent = BridgeConfig.developerFeePercent, }: { amount: string gasMarket: EthereumGasMarketSnapshot