Skip to content
12 changes: 12 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

"""
Expand Down
10 changes: 9 additions & 1 deletion dev/config/base-config.yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should never check in an API key, not even a sandbox one. Remove this and purge it from commit history

Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@ ibex:

bridge:
enabled: true
apiKey: "sk-test-3bd6463c9cd77c3d8858c60b9997d0c6"
apiKey: "<replace>"
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
Expand Down
12 changes: 12 additions & 0 deletions src/app/bridge/get-withdrawal-flash-fee-notice.ts
Original file line number Diff line number Diff line change
@@ -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<User, "language">,
): string => getBridgeWithdrawalFlashFeeNotice(getLanguageOrDefault(user?.language ?? ""))
1 change: 1 addition & 0 deletions src/config/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion src/config/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -57,4 +58,4 @@
}
}
}
}
}
12 changes: 12 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depending on only one URL for gas fees is a bottleneck. At a minimum we should be getting multiple sources and using an average, or getting the gas fee estimate from Bridge directly

fallbackGasPriceGwei: { type: "number", default: 30 },
ethUsdFallback: { type: "number", default: 3000 },
},
},
timeoutMs: { type: "integer", default: 10000 },
webhook: {
type: "object",
Expand Down
11 changes: 11 additions & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 2 additions & 10 deletions src/graphql/public/root/query/bridge-withdrawal-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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)
},
})

Expand Down
12 changes: 12 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions src/graphql/public/types/object/bridge-withdrawal.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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) },
Expand Down
14 changes: 13 additions & 1 deletion src/services/bridge/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,19 @@ export interface Transfer {
bank_routing_number?: string
to_address?: string
}
receipt?: Record<string, unknown>
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
}
Expand Down
151 changes: 151 additions & 0 deletions src/services/bridge/ethereum-gas-estimate.ts
Original file line number Diff line number Diff line change
@@ -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<number | Error> => {
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<number | Error> => {
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<EthereumGasMarketSnapshot> => {
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 }
}
Loading