From eadb9ca9f55caa40f6ba6c6ccbd8ca03502e9903 Mon Sep 17 00:00:00 2001 From: Ermyas Abebe Date: Wed, 7 Oct 2026 10:02:31 +1100 Subject: [PATCH 1/2] fix(bridge): estimate the Axelar fee locally when the Axelar API is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mainnet requests to api.gmp.axelarscan.io intermittently receive a Cloudflare challenge (403) or a rate limit (429). In browsers the CORS preflight fails, getAxelarFee read `error.response.data` on an undefined response, and the bridge widget stalled on the fee skeleton. getAxelarFee now tries the Axelar API on every call with a 5s timeout and, on any failure, estimates the fee from the destination gas price and USD prices from the checkout API: (gas limit + 250k base gas) × max(gas price, 1 gwei) × 2, plus 5 cents The 250k base gas covers Axelar's base fee, which tracks destination gas (~185k gas observed for Ethereum on 2026-10-07). Axelar refunds unused gas to the sender, so the estimate errs high. Against mainnet quotes on 2026-10-07 the fallback is ~2.3x for withdrawals and ~3.6x for deposits. On testnet, withdrawals estimate ~50x Axelar's quote because Sepolia gas sits below the 1 gwei floor and Axelar's testnet quotes use synthetic token prices. Each fallback logs a console warning prefixed `[imtbl-bridge]` with the reason, and fee responses carry `bridgeFeeSource: 'axelar' | 'fallback'`. If the fallback also fails, getAxelarFee throws AXELAR_GAS_ESTIMATE_FAILED. Also drop the Content-Type header from the GMP status GET so browsers send it without a preflight. Co-Authored-By: Claude Opus 5.5 --- .../bridge/sdk/src/constants/bridges.ts | 10 + packages/internal/bridge/sdk/src/index.ts | 1 + .../bridge/sdk/src/lib/axelarFee.test.ts | 232 ++++++++++++++++++ .../internal/bridge/sdk/src/lib/axelarFee.ts | 229 +++++++++++++++++ .../bridge/sdk/src/lib/gmpRecovery.ts | 2 +- packages/internal/bridge/sdk/src/lib/utils.ts | 5 + .../internal/bridge/sdk/src/tokenBridge.ts | 74 +++--- .../internal/bridge/sdk/src/types/index.ts | 9 + 8 files changed, 518 insertions(+), 44 deletions(-) create mode 100644 packages/internal/bridge/sdk/src/lib/axelarFee.test.ts create mode 100644 packages/internal/bridge/sdk/src/lib/axelarFee.ts diff --git a/packages/internal/bridge/sdk/src/constants/bridges.ts b/packages/internal/bridge/sdk/src/constants/bridges.ts index 680c70a92e..8031a7b9c5 100644 --- a/packages/internal/bridge/sdk/src/constants/bridges.ts +++ b/packages/internal/bridge/sdk/src/constants/bridges.ts @@ -153,6 +153,16 @@ export const axelarAPIEndpoints:Record = { devnet: 'https://stagenet.api.gmp.axelarscan.io', }; +/** + * @typedef {Object} priceAPIEndpoints - Checkout API endpoints serving token USD prices, used by the fallback + * Axelar fee estimate. + */ +export const priceAPIEndpoints:Record = { + mainnet: 'https://checkout-api.immutable.com', + testnet: 'https://checkout-api.sandbox.immutable.com', + devnet: 'https://checkout-api.dev.immutable.com', +}; + /** * @typedef {Object} tenderlyAPIEndpoints - API endpoints for the testnet & mainnet Axelar environment configurations */ diff --git a/packages/internal/bridge/sdk/src/index.ts b/packages/internal/bridge/sdk/src/index.ts index 958824dcad..8fd6446859 100644 --- a/packages/internal/bridge/sdk/src/index.ts +++ b/packages/internal/bridge/sdk/src/index.ts @@ -25,6 +25,7 @@ export type { WithdrawERC20FeeRequest, FinaliseFeeRequest, BridgeFeeResponse, + BridgeFeeSource, ApproveBridgeRequest, ApproveBridgeResponse, BridgeTxRequest, diff --git a/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts b/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts new file mode 100644 index 0000000000..a9065b13dc --- /dev/null +++ b/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts @@ -0,0 +1,232 @@ +import axios from 'axios'; +import { FeeData } from 'ethers'; +import { BridgeError, BridgeErrorType } from '../errors'; +import { + AxelarFeeParams, + estimateFallbackAxelarFee, + getAxelarFeeWithFallback, +} from './axelarFee'; + +jest.mock('axios', () => ({ + post: jest.fn(), + get: jest.fn(), +})); + +const mockedAxios = axios as jest.Mocked; + +const gwei = (value: number) => BigInt(Math.round(value * 1e9)); + +const feeData = (gasPrice: bigint) => ({ + gasPrice, + maxFeePerGas: null, + maxPriorityFeePerGas: null, +} as unknown as FeeData); + +const ETHEREUM = { id: 'ethereum', symbol: 'ETH' }; +const IMMUTABLE = { id: 'immutable', symbol: 'IMX' }; + +// Mainnet quotes from the Axelar GMP API on 2026-10-07 (gasLimit 250000, minGasPrice 1 gwei). +const AXELAR_QUOTES = { + withdraw: { + totalFee: BigInt('8901890674475210000'), // 8.90 IMX + destinationGasPrice: gwei(1.205559871), + prices: { ethereum: { usd: 2697.48 }, 'immutable-x': { usd: 0.187762 } }, + }, + deposit: { + totalFee: BigInt('5405031339459'), // 0.0000054 ETH + destinationGasPrice: gwei(11.000000049), + prices: { ethereum: { usd: 2697.48 }, 'immutable-x': { usd: 0.187762 } }, + }, +}; + +const withdrawParams = (overrides: Partial = {}): AxelarFeeParams => ({ + axelarAPIEndpoint: 'https://api.gmp.axelarscan.io', + priceAPIEndpoint: 'https://checkout-api.immutable.com', + sourceAxelar: IMMUTABLE, + destinationAxelar: ETHEREUM, + destinationChainGasLimit: 250000, + gasMultiplier: 'auto', + getDestinationFeeData: async () => feeData(AXELAR_QUOTES.withdraw.destinationGasPrice), + ...overrides, +}); + +const depositParams = (overrides: Partial = {}): AxelarFeeParams => ({ + ...withdrawParams(), + sourceAxelar: ETHEREUM, + destinationAxelar: IMMUTABLE, + getDestinationFeeData: async () => feeData(AXELAR_QUOTES.deposit.destinationGasPrice), + ...overrides, +}); + +// The browser reports a request rejected by CORS as an axios network error without a response. +const corsError = () => Object.assign(new Error('Network Error'), { code: 'ERR_NETWORK' }); + +describe('axelarFee', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.resetAllMocks(); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockedAxios.get.mockResolvedValue({ data: AXELAR_QUOTES.withdraw.prices }); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + describe('getAxelarFeeWithFallback', () => { + it('returns the Axelar API fee when the API responds', async () => { + mockedAxios.post.mockResolvedValue({ data: '8901890674475210000' }); + + const result = await getAxelarFeeWithFallback(withdrawParams()); + + expect(result).toEqual({ fee: BigInt('8901890674475210000'), source: 'axelar' }); + expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('sends the existing estimateGasFee request with a timeout', async () => { + mockedAxios.post.mockResolvedValue({ data: '1' }); + + await getAxelarFeeWithFallback(withdrawParams({ gasMultiplier: 1.1 })); + + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://api.gmp.axelarscan.io', + { + method: 'estimateGasFee', + sourceChain: 'immutable', + destinationChain: 'ethereum', + symbol: 'IMX', + gasLimit: 250000, + gasMultiplier: 1.1, + minGasPrice: 1000000000, + }, + { timeout: 5000 }, + ); + }); + + it.each([ + ['a network or CORS error', corsError(), 'network error, possibly CORS (Network Error)'], + ['a timeout', Object.assign(new Error('timeout'), { code: 'ECONNABORTED' }), 'timeout'], + [ + 'a Cloudflare challenge', + Object.assign(new Error('403'), { response: { status: 403, data: '' } }), + 'HTTP 403', + ], + [ + 'an Axelar error body on a failed response', + Object.assign(new Error('400'), { response: { status: 400, data: { error: true, message: 'bad chain' } } }), + 'Axelar error: bad chain', + ], + ])('falls back on %s and logs the reason', async (_, error, reason) => { + mockedAxios.post.mockRejectedValue(error); + + const result = await getAxelarFeeWithFallback(withdrawParams()); + + expect(result.source).toBe('fallback'); + expect(result.fee).toBeGreaterThan(BigInt(0)); + expect(warnSpy).toHaveBeenCalledWith( + '[imtbl-bridge] Axelar fee API unavailable, using fallback estimate', + expect.objectContaining({ reason, sourceChain: 'immutable', destinationChain: 'ethereum' }), + ); + }); + + it.each([ + ['an Axelar error body', { error: true, message: 'bad chain' }, 'Axelar error: bad chain'], + ['a non-numeric body', 'Just a moment...', 'unexpected response body'], + ['an empty body', undefined, 'unexpected response body'], + ])('falls back when a successful response carries %s', async (_, data, reason) => { + mockedAxios.post.mockResolvedValue({ data }); + + const result = await getAxelarFeeWithFallback(withdrawParams()); + + expect(result.source).toBe('fallback'); + expect(warnSpy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ reason })); + }); + + it('returns to the Axelar API fee on the next call once the API responds again', async () => { + mockedAxios.post.mockRejectedValueOnce(corsError()).mockResolvedValueOnce({ data: '42' }); + + const first = await getAxelarFeeWithFallback(withdrawParams()); + const second = await getAxelarFeeWithFallback(withdrawParams()); + + expect(first.source).toBe('fallback'); + expect(second).toEqual({ fee: BigInt(42), source: 'axelar' }); + }); + + it('throws AXELAR_GAS_ESTIMATE_FAILED with both reasons when the fallback also fails', async () => { + mockedAxios.post.mockRejectedValue(corsError()); + mockedAxios.get.mockRejectedValue(new Error('price API down')); + + const promise = getAxelarFeeWithFallback(withdrawParams()); + + await expect(promise).rejects.toBeInstanceOf(BridgeError); + await expect(promise).rejects.toMatchObject({ + type: BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, + message: expect.stringMatching(/network error, possibly CORS.*price API down/), + }); + }); + }); + + describe('estimateFallbackAxelarFee', () => { + it('requests both prices without custom headers', async () => { + await estimateFallbackAxelarFee(withdrawParams()); + + expect(mockedAxios.get).toHaveBeenCalledWith( + 'https://checkout-api.immutable.com/v1/fiat/conversion?ids=immutable-x,ethereum¤cies=usd', + { timeout: 5000 }, + ); + }); + + it('computes (gas limit + base gas) × gas price × 2, converted, plus the fixed base fee', async () => { + mockedAxios.get.mockResolvedValue({ data: { ethereum: { usd: 2000 }, 'immutable-x': { usd: 0.2 } } }); + + const fee = await estimateFallbackAxelarFee(withdrawParams({ + getDestinationFeeData: async () => feeData(gwei(2)), + })); + + // (250k + 250k) gas × 2 gwei × 2 = 0.002 ETH = 20 IMX at 10,000 IMX/ETH; 5 cents = 0.25 IMX + expect(fee).toBe(BigInt('20250000000000000000')); + }); + + it('applies the 1 gwei minimum gas price', async () => { + mockedAxios.get.mockResolvedValue({ data: { ethereum: { usd: 2000 }, 'immutable-x': { usd: 0.2 } } }); + + const lowGas = await estimateFallbackAxelarFee(withdrawParams({ + getDestinationFeeData: async () => feeData(gwei(0.1)), + })); + const oneGwei = await estimateFallbackAxelarFee(withdrawParams({ + getDestinationFeeData: async () => feeData(gwei(1)), + })); + + expect(lowGas).toBe(oneGwei); + }); + + it.each([ + ['withdraw', withdrawParams, AXELAR_QUOTES.withdraw], + ['deposit', depositParams, AXELAR_QUOTES.deposit], + ])('stays between 1.5x and 4x of the Axelar quote for a %s', async (_, params, quote) => { + mockedAxios.get.mockResolvedValue({ data: quote.prices }); + + const fee = await estimateFallbackAxelarFee(params()); + + expect(fee).toBeGreaterThanOrEqual((quote.totalFee * BigInt(3)) / BigInt(2)); + expect(fee).toBeLessThanOrEqual(quote.totalFee * BigInt(4)); + }); + + it.each([ + ['a missing price', { ethereum: { usd: 2000 } }, 'no USD price for IMX'], + ['a zero price', { ethereum: { usd: 0 }, 'immutable-x': { usd: 0.2 } }, 'no USD price for ETH'], + ])('rejects %s', async (_, data, message) => { + mockedAxios.get.mockResolvedValue({ data }); + + await expect(estimateFallbackAxelarFee(withdrawParams())).rejects.toThrow(message); + }); + + it('rejects when the destination chain returns no gas price', async () => { + await expect(estimateFallbackAxelarFee(withdrawParams({ + getDestinationFeeData: async () => ({ gasPrice: null, maxFeePerGas: null, maxPriorityFeePerGas: null } as any), + }))).rejects.toThrow('destination chain returned no gas price'); + }); + }); +}); diff --git a/packages/internal/bridge/sdk/src/lib/axelarFee.ts b/packages/internal/bridge/sdk/src/lib/axelarFee.ts new file mode 100644 index 0000000000..5110effe7e --- /dev/null +++ b/packages/internal/bridge/sdk/src/lib/axelarFee.ts @@ -0,0 +1,229 @@ +import axios from 'axios'; +import { FeeData } from 'ethers'; +import { BridgeError, BridgeErrorType } from '../errors'; +import { AxelarChainDetails, BridgeFeeSource } from '../types'; +import { getGasPriceInWei } from './gas'; + +/** + * Request timeout for the Axelar GMP fee API. A blocked request (e.g. a CORS failure) fails fast, + * but a stalled one would otherwise hold the fee estimate until the browser gives up. + */ +export const AXELAR_FEE_API_TIMEOUT_MS = 5000; + +/** + * Request timeout for the price API used by the fallback estimate. + */ +export const PRICE_API_TIMEOUT_MS = 5000; + +/** + * Minimum destination gas price used when estimating the Axelar fee, matching the `minGasPrice` + * sent to the Axelar API. + */ +export const AXELAR_MIN_GAS_PRICE = BigInt(1000000000); // 1 gwei + +/** + * Destination-chain gas covered by Axelar's base fee, on top of the execution gas limit. + * The base fee pays relayers to approve the message on the destination gateway and scales with the + * destination gas price: Ethereum-bound quotes taken on 2026-10-07 at 1.21 gwei and 0.93 gwei both + * imply ~185k gas. The constant carries headroom above that. + */ +export const FALLBACK_BASE_FEE_DESTINATION_GAS = BigInt(250000); + +/** + * Fixed part of Axelar's base fee (confirmation on the Axelar network), in USD cents. + * Observed at ~1.3 cents in both directions on 2026-10-07; the constant carries headroom above that. + */ +export const FALLBACK_BASE_FEE_USD_CENTS = BigInt(5); + +/** + * Multiplier applied to destination gas in the fallback estimate. Axelar's own multiplier was 1.15-1.32 + * on 2026-10-07. A larger buffer is used because the fallback cannot see Axelar's pricing, and Axelar + * refunds unused gas to the sender after execution, while an underpaid message waits for a gas top-up. + */ +export const FALLBACK_GAS_MULTIPLIER = BigInt(2); + +/** + * CoinGecko ids, as served by the checkout price API, for the native token symbols in `axelarChains`. + */ +export const coingeckoIds: Record = { + ETH: 'ethereum', + IMX: 'immutable-x', +}; + +const LOG_PREFIX = '[imtbl-bridge]'; + +// Prices are carried as integers scaled by 1e8 so the fee arithmetic stays in bigint. +const PRICE_SCALE = 1e8; +const WEI_PER_TOKEN = BigInt(10) ** BigInt(18); + +export type AxelarFeeEstimate = { + fee: bigint; + source: BridgeFeeSource; +}; + +export type AxelarFeeParams = { + axelarAPIEndpoint: string; + priceAPIEndpoint: string; + sourceAxelar: AxelarChainDetails; + destinationAxelar: AxelarChainDetails; + destinationChainGasLimit: number; + gasMultiplier: number | string; + getDestinationFeeData: () => Promise; +}; + +/** + * Error raised when the Axelar API returns no usable fee. `reason` is a short description for logs. + */ +class AxelarFeeAPIError extends Error { + public readonly reason: string; + + constructor(reason: string) { + super(reason); + this.reason = reason; + } +} + +const describeRequestError = (err: any): string => { + if (err?.code === 'ECONNABORTED' || err?.code === 'ETIMEDOUT') return 'timeout'; + if (err?.response?.status) return `HTTP ${err.response.status}`; + // A browser reports a CORS rejection as a network error with no response. + return `network error, possibly CORS (${err?.message ?? 'unknown'})`; +}; + +/** + * Queries the Axelar GMP API for the fee to pay on the source chain, in the source chain's native token. + */ +export async function fetchAxelarFee(params: AxelarFeeParams): Promise { + const estimateGasReq = { + method: 'estimateGasFee', + sourceChain: params.sourceAxelar.id, + destinationChain: params.destinationAxelar.id, + symbol: params.sourceAxelar.symbol, + gasLimit: params.destinationChainGasLimit, + gasMultiplier: params.gasMultiplier, + minGasPrice: Number(AXELAR_MIN_GAS_PRICE), + }; + + let data: any; + try { + const response = await axios.post(params.axelarAPIEndpoint, estimateGasReq, { + timeout: AXELAR_FEE_API_TIMEOUT_MS, + }); + data = response.data; + } catch (err: any) { + if (err?.response?.data?.error) { + throw new AxelarFeeAPIError(`Axelar error: ${err.response.data.message}`); + } + throw new AxelarFeeAPIError(describeRequestError(err)); + } + + if (data?.error) { + throw new AxelarFeeAPIError(`Axelar error: ${data.message}`); + } + + // A successful response body is the fee as a bare integer string. + if (!/^\d+$/.test(`${data}`.trim())) { + throw new AxelarFeeAPIError('unexpected response body'); + } + return BigInt(`${data}`.trim()); +} + +const toScaledPrice = (value: unknown, symbol: string): bigint => { + const price = Number(value); + if (!Number.isFinite(price) || price <= 0) { + throw new Error(`no USD price for ${symbol}`); + } + return BigInt(Math.round(price * PRICE_SCALE)); +}; + +/** + * Fetches USD prices for the source and destination native tokens from the checkout price API. + * The request carries no custom headers, so the browser sends it without a CORS preflight. + */ +export async function fetchUSDPrices( + priceAPIEndpoint: string, + sourceSymbol: string, + destinationSymbol: string, +): Promise<{ sourcePrice: bigint, destinationPrice: bigint }> { + const sourceId = coingeckoIds[sourceSymbol]; + const destinationId = coingeckoIds[destinationSymbol]; + if (!sourceId || !destinationId) { + throw new Error(`no price id for ${sourceId ? destinationSymbol : sourceSymbol}`); + } + + const response = await axios.get( + `${priceAPIEndpoint}/v1/fiat/conversion?ids=${sourceId},${destinationId}¤cies=usd`, + { timeout: PRICE_API_TIMEOUT_MS }, + ); + + return { + sourcePrice: toScaledPrice(response.data?.[sourceId]?.usd, sourceSymbol), + destinationPrice: toScaledPrice(response.data?.[destinationId]?.usd, destinationSymbol), + }; +} + +/** + * Estimates the Axelar fee without the Axelar API, in the source chain's native token (wei). + * + * destination gas = (execution gas limit + base fee gas) × max(destination gas price, 1 gwei) × multiplier + * fee = destination gas converted to the source token at USD prices + fixed base fee in USD + * + * Both native tokens (ETH, IMX) have 18 decimals, so the conversion is a ratio of USD prices. + */ +export async function estimateFallbackAxelarFee(params: AxelarFeeParams): Promise { + const [feeData, { sourcePrice, destinationPrice }] = await Promise.all([ + params.getDestinationFeeData(), + fetchUSDPrices(params.priceAPIEndpoint, params.sourceAxelar.symbol, params.destinationAxelar.symbol), + ]); + + const reportedGasPrice = getGasPriceInWei(feeData); + if (reportedGasPrice === null) { + throw new Error('destination chain returned no gas price'); + } + const gasPrice = reportedGasPrice > AXELAR_MIN_GAS_PRICE ? reportedGasPrice : AXELAR_MIN_GAS_PRICE; + + const destinationGas = BigInt(params.destinationChainGasLimit) + FALLBACK_BASE_FEE_DESTINATION_GAS; + const destinationCost = destinationGas * gasPrice * FALLBACK_GAS_MULTIPLIER; + const destinationCostInSource = (destinationCost * destinationPrice) / sourcePrice; + + // cents × 1e18 × PRICE_SCALE / (100 × scaled price) = wei of the source token + const fixedBaseFeeInSource = (FALLBACK_BASE_FEE_USD_CENTS * WEI_PER_TOKEN * BigInt(PRICE_SCALE)) + / (BigInt(100) * sourcePrice); + + return destinationCostInSource + fixedBaseFeeInSource; +} + +/** + * Returns the Axelar fee from the Axelar API, or from `estimateFallbackAxelarFee` when the API fails. + * The API is tried on every call, so estimates return to the Axelar API as soon as it responds again. + * Each fallback logs a console warning prefixed with `[imtbl-bridge]`. + */ +export async function getAxelarFeeWithFallback(params: AxelarFeeParams): Promise { + let reason: string; + try { + return { fee: await fetchAxelarFee(params), source: 'axelar' }; + } catch (err: any) { + reason = err instanceof AxelarFeeAPIError ? err.reason : `${err?.message ?? err}`; + } + + let fee: bigint; + try { + fee = await estimateFallbackAxelarFee(params); + } catch (err: any) { + throw new BridgeError( + `Estimating Axelar Gas failed. Axelar API: ${reason}. Fallback estimate: ${err?.message ?? err}`, + BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, + ); + } + + // eslint-disable-next-line no-console + console.warn(`${LOG_PREFIX} Axelar fee API unavailable, using fallback estimate`, { + reason, + sourceChain: params.sourceAxelar.id, + destinationChain: params.destinationAxelar.id, + destinationChainGasLimit: params.destinationChainGasLimit, + fee: fee.toString(), + }); + + return { fee, source: 'fallback' }; +} diff --git a/packages/internal/bridge/sdk/src/lib/gmpRecovery.ts b/packages/internal/bridge/sdk/src/lib/gmpRecovery.ts index 61ee27db1e..df668b8b24 100644 --- a/packages/internal/bridge/sdk/src/lib/gmpRecovery.ts +++ b/packages/internal/bridge/sdk/src/lib/gmpRecovery.ts @@ -34,7 +34,7 @@ const parseGMPError = (response: any): GMPError | undefined => { const execGet = async (base: string, params?: any) => fetch(`${base}?${new URLSearchParams(params).toString()}`, { method: 'GET', - headers: { 'Content-Type': 'application/json' }, + // No custom headers: the browser then sends this without a CORS preflight. cache: 'no-store', }) .then((res) => res.json()) diff --git a/packages/internal/bridge/sdk/src/lib/utils.ts b/packages/internal/bridge/sdk/src/lib/utils.ts index b7564c3073..4e54621f8f 100644 --- a/packages/internal/bridge/sdk/src/lib/utils.ts +++ b/packages/internal/bridge/sdk/src/lib/utils.ts @@ -5,6 +5,7 @@ import { childChains, axelarGateways, axelarAPIEndpoints, + priceAPIEndpoints, tenderlyAPIEndpoints, childWIMXs, NATIVE, @@ -64,6 +65,10 @@ export function getAxelarEndpoint(source: string) { return getAddresses(source, axelarAPIEndpoints); } +export function getPriceAPIEndpoint(source: string) { + return getAddresses(source, priceAPIEndpoints); +} + export function getTenderlyEndpoint(source: string) { return getAddresses(source, tenderlyAPIEndpoints); } diff --git a/packages/internal/bridge/sdk/src/tokenBridge.ts b/packages/internal/bridge/sdk/src/tokenBridge.ts index 318bff4606..50871176b1 100644 --- a/packages/internal/bridge/sdk/src/tokenBridge.ts +++ b/packages/internal/bridge/sdk/src/tokenBridge.ts @@ -1,6 +1,5 @@ /* eslint-disable no-console */ /* eslint-disable class-methods-use-this */ -import axios, { AxiosResponse } from 'axios'; import { concat, Contract, getAddress, keccak256, Provider, toBeHex, toQuantity, TransactionRequest, @@ -13,7 +12,7 @@ import { validateGetFee, } from './lib/validation'; import { - getAxelarEndpoint, getAxelarGateway, getChildAdaptor, getChildchain, getRootAdaptor, + getAxelarEndpoint, getAxelarGateway, getChildAdaptor, getChildchain, getPriceAPIEndpoint, getRootAdaptor, isValidDeposit, isValidWithdraw, isWithdrawNativeIMX, @@ -24,6 +23,7 @@ import { } from './lib/utils'; import { TenderlyResult, TenderlySimulation } from './types/tenderly'; import { calculateGasFee } from './lib/gas'; +import { AxelarFeeEstimate, getAxelarFeeWithFallback } from './lib/axelarFee'; import { createContract } from './contracts/createContract'; import { getWithdrawRootToken, genAxelarWithdrawPayload, genUniqueAxelarCommandId } from './lib/axelarUtils'; import { StateObject, submitTenderlySimulations } from './lib/tenderly'; @@ -48,6 +48,7 @@ import { BridgeConfiguration } from './config'; import { BridgeFeeRequest, BridgeFeeResponse, + BridgeFeeSource, BridgeMethodsGasLimit, BridgeTxRequest, BridgeFeeActions, @@ -175,7 +176,7 @@ export class TokenBridge { } private async getDepositOrWithdrawFee(req: BridgeFeeRequest): - Promise<{ sourceChainFee: bigint, approvalFee: bigint, bridgeFee: bigint }> { + Promise<{ sourceChainFee: bigint, approvalFee: bigint, bridgeFee: bigint, bridgeFeeSource: BridgeFeeSource }> { let feeData; if (req.sourceChainId === this.config.bridgeInstance.rootChainID) { feeData = await this.config.rootProvider.getFeeData(); @@ -231,13 +232,16 @@ export class TokenBridge { axelarGasLimit = BridgeMethodsGasLimit.WITHDRAW_DESTINATION; } // Get bridge fee - bridgeFee = await this.getAxelarFee( + const axelarFee = await this.getAxelarFee( req.sourceChainId, req.destinationChainId, axelarGasLimit, req.gasMultiplier, ); - return { sourceChainFee, approvalFee, bridgeFee }; + bridgeFee = axelarFee.fee; + return { + sourceChainFee, approvalFee, bridgeFee, bridgeFeeSource: axelarFee.source, + }; } private async getFeePrivate(req: BridgeFeeRequest): Promise { @@ -246,6 +250,7 @@ export class TokenBridge { let sourceChainFee: bigint = BigInt(0); let approvalFee: bigint = BigInt(0); let bridgeFee: bigint = BigInt(0); + let bridgeFeeSource: BridgeFeeSource | undefined; if (req.action === BridgeFeeActions.FINALISE_WITHDRAWAL) { sourceChainFee = await this.getFinaliseWithdrawFee(); @@ -254,6 +259,7 @@ export class TokenBridge { sourceChainFee = fees.sourceChainFee; approvalFee = fees.approvalFee; bridgeFee = fees.bridgeFee; + bridgeFeeSource = fees.bridgeFeeSource; } const totalFees: bigint = sourceChainFee + approvalFee + bridgeFee; @@ -266,6 +272,7 @@ export class TokenBridge { bridgeFee, imtblFee, totalFees, + ...(bridgeFeeSource ? { bridgeFeeSource } : {}), }; } @@ -627,7 +634,7 @@ export class TokenBridge { let unsignedApprovalTx: TransactionRequest | null; let sourceChainFee: bigint = BigInt(0); let approvalFee: bigint = BigInt(0); - const bridgeFee: bigint = axelarFee; + const bridgeFee: bigint = axelarFee.fee; const imtblFee: bigint = BigInt(0); // Approval required for non-native tokens with insufficient allowance. @@ -680,6 +687,7 @@ export class TokenBridge { bridgeFee, imtblFee, totalFees, + bridgeFeeSource: axelarFee.source, }, contractToApprove, unsignedApprovalTx, @@ -723,7 +731,7 @@ export class TokenBridge { let unsignedApprovalTx: TransactionRequest | null; let sourceChainFee: bigint = BigInt(0); let approvalFee: bigint = BigInt(0); - const bridgeFee: bigint = axelarFee; + const bridgeFee: bigint = axelarFee.fee; const imtblFee: bigint = BigInt(0); // Approval required only for WIMX tokens with insufficient allowance. @@ -776,6 +784,7 @@ export class TokenBridge { bridgeFee, imtblFee, totalFees, + bridgeFeeSource: axelarFee.source, }, contractToApprove, unsignedApprovalTx, @@ -965,19 +974,20 @@ export class TokenBridge { } /** - * Query the axelar fee for a transaction using axelarjs-sdk. + * Query the Axelar fee for a transaction from the Axelar GMP API, falling back to an SDK estimate when the + * API is unavailable. See `getAxelarFeeWithFallback`. * @param {*} sourceChainId - The source chainId. * @param {*} destinationChainId - The destination chainId. * @param {*} destinationChainGaslimit - The gas limit for the desired operation. * @param {*} gasMultiplier - The gas multiplier to add buffer to the fees. - * @returns {ethers.BigNumber} - The Axelar Gas amount in the source chain currency. + * @returns {AxelarFeeEstimate} - The Axelar gas amount in the source chain currency, and where it came from. */ private async getAxelarFee( sourceChainId: string, destinationChainId: string, destinationChainGaslimit: number, gasMultiplier: number | string = 'auto', - ): Promise { + ): Promise { const sourceAxelar: AxelarChainDetails = axelarChains[sourceChainId]; const destinationAxelar: AxelarChainDetails = axelarChains[destinationChainId]; @@ -995,41 +1005,19 @@ export class TokenBridge { ); } - const axelarAPIEndpoint: string = getAxelarEndpoint(sourceChainId); + const destinationProvider = destinationChainId === this.config.bridgeInstance.rootChainID + ? this.config.rootProvider + : this.config.childProvider; - const estimateGasReq = { - method: 'estimateGasFee', - sourceChain: sourceAxelar.id, - destinationChain: destinationAxelar.id, - symbol: sourceAxelar.symbol, - gasLimit: destinationChainGaslimit, + return getAxelarFeeWithFallback({ + axelarAPIEndpoint: getAxelarEndpoint(sourceChainId), + priceAPIEndpoint: getPriceAPIEndpoint(sourceChainId), + sourceAxelar, + destinationAxelar, + destinationChainGasLimit: destinationChainGaslimit, gasMultiplier, - minGasPrice: 1000000000, // 1 gwei - }; - - let axiosResponse: AxiosResponse; - - try { - axiosResponse = await axios.post(axelarAPIEndpoint, estimateGasReq); - } catch (error: any) { - axiosResponse = error.response; - } - - if (axiosResponse.data.error) { - throw new BridgeError( - `Estimating Axelar Gas failed with the reason: ${axiosResponse.data.message}`, - BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, - ); - } - - try { - return BigInt(`${axiosResponse.data}`); - } catch (err) { - throw new BridgeError( - `Estimating Axelar Gas failed with the reason: ${err}`, - BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, - ); - } + getDestinationFeeData: () => destinationProvider.getFeeData(), + }); } /** diff --git a/packages/internal/bridge/sdk/src/types/index.ts b/packages/internal/bridge/sdk/src/types/index.ts index b2ed359869..85c32b2e3f 100644 --- a/packages/internal/bridge/sdk/src/types/index.ts +++ b/packages/internal/bridge/sdk/src/types/index.ts @@ -218,6 +218,9 @@ export interface FinaliseFeeRequest { * @property {bigint} totalFees - The total fees the user will be charged which is; * sourceChainGas + approvalFee + bridgeFee + imtblFee. * - priced in the source chain's native token. + * @property {BridgeFeeSource} [bridgeFeeSource] - Where bridgeFee came from: 'axelar' when quoted by the + * Axelar GMP API, 'fallback' when the API was unavailable and the SDK estimated it. Absent when there is no + * bridge fee (finalising a withdrawal). */ export interface BridgeFeeResponse { sourceChainGas: bigint, @@ -225,8 +228,14 @@ export interface BridgeFeeResponse { bridgeFee: bigint, imtblFee: bigint, totalFees: bigint, + bridgeFeeSource?: BridgeFeeSource, } +/** + * Where a bridge fee came from: the Axelar GMP API ('axelar') or the SDK's fallback estimate ('fallback'). + */ +export type BridgeFeeSource = 'axelar' | 'fallback'; + /** * @typedef {Object} ApproveBridgeRequest * @property {string} senderAddress - The address of the depositor. From 230ad204249557c00a8b4869e7df684783fdcab0 Mon Sep 17 00:00:00 2001 From: Ermyas Abebe Date: Wed, 7 Oct 2026 10:33:32 +1100 Subject: [PATCH 2/2] fix(bridge): fall back only when the Axelar API is unavailable Axelar error replies ({error: true, message}), 4xx responses other than 403/429, and unparseable 2xx bodies now throw AXELAR_GAS_ESTIMATE_FAILED, as before the fallback was added. The fallback estimate runs only for no response (including CORS rejections), timeouts, 403 (Cloudflare challenge), 429 and 5xx. The fallback multiplier is now max(gasMultiplier, 2) for a numeric gasMultiplier, and 2 for 'auto', so a caller asking for a larger buffer keeps it during an outage. The gasMultiplier docs on the fee request types describe this. Co-Authored-By: Claude Opus 5.5 --- .../bridge/sdk/src/lib/axelarFee.test.ts | 61 +++++++++++++--- .../internal/bridge/sdk/src/lib/axelarFee.ts | 72 +++++++++++++------ .../internal/bridge/sdk/src/types/index.ts | 12 ++-- 3 files changed, 110 insertions(+), 35 deletions(-) diff --git a/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts b/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts index a9065b13dc..df17752406 100644 --- a/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts +++ b/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts @@ -114,9 +114,14 @@ describe('axelarFee', () => { 'HTTP 403', ], [ - 'an Axelar error body on a failed response', - Object.assign(new Error('400'), { response: { status: 400, data: { error: true, message: 'bad chain' } } }), - 'Axelar error: bad chain', + 'a rate limit', + Object.assign(new Error('429'), { response: { status: 429, data: { error: true, message: 'slow down' } } }), + 'HTTP 429', + ], + [ + 'a server error', + Object.assign(new Error('502'), { response: { status: 502, data: 'Bad Gateway' } }), + 'HTTP 502', ], ])('falls back on %s and logs the reason', async (_, error, reason) => { mockedAxios.post.mockRejectedValue(error); @@ -132,16 +137,33 @@ describe('axelarFee', () => { }); it.each([ - ['an Axelar error body', { error: true, message: 'bad chain' }, 'Axelar error: bad chain'], - ['a non-numeric body', 'Just a moment...', 'unexpected response body'], - ['an empty body', undefined, 'unexpected response body'], - ])('falls back when a successful response carries %s', async (_, data, reason) => { - mockedAxios.post.mockResolvedValue({ data }); + ['an Axelar error body', { data: { error: true, message: 'bad chain' } }, 'bad chain'], + ['a non-numeric body', { data: 'not a fee' }, 'unexpected response body'], + ['an empty body', { data: undefined }, 'unexpected response body'], + ])('throws AXELAR_GAS_ESTIMATE_FAILED without falling back on %s', async (_, response, message) => { + mockedAxios.post.mockResolvedValue(response); - const result = await getAxelarFeeWithFallback(withdrawParams()); + const promise = getAxelarFeeWithFallback(withdrawParams()); - expect(result.source).toBe('fallback'); - expect(warnSpy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ reason })); + await expect(promise).rejects.toMatchObject({ + type: BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, + message: expect.stringContaining(message), + }); + expect(mockedAxios.get).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['with an Axelar error body', { status: 400, data: { error: true, message: 'invalid gas limit' } }, 'invalid gas limit'], + ['without an Axelar error body', { status: 404, data: 'Not Found' }, 'HTTP 404'], + ])('throws AXELAR_GAS_ESTIMATE_FAILED without falling back on a 4xx %s', async (_, response, message) => { + mockedAxios.post.mockRejectedValue(Object.assign(new Error('4xx'), { response })); + + await expect(getAxelarFeeWithFallback(withdrawParams())).rejects.toMatchObject({ + type: BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, + message: expect.stringContaining(message), + }); + expect(mockedAxios.get).not.toHaveBeenCalled(); }); it('returns to the Axelar API fee on the next call once the API responds again', async () => { @@ -189,6 +211,23 @@ describe('axelarFee', () => { expect(fee).toBe(BigInt('20250000000000000000')); }); + it.each([ + ['auto', 'auto', '20250000000000000000'], + ['a number below 2', 1.2, '20250000000000000000'], + ['a number above 2', 3, '30250000000000000000'], + ['a fractional number above 2', 2.5, '25250000000000000000'], + ])('uses max(gasMultiplier, 2) for %s', async (_, gasMultiplier, expected) => { + mockedAxios.get.mockResolvedValue({ data: { ethereum: { usd: 2000 }, 'immutable-x': { usd: 0.2 } } }); + + const fee = await estimateFallbackAxelarFee(withdrawParams({ + gasMultiplier, + getDestinationFeeData: async () => feeData(gwei(2)), + })); + + // (250k + 250k) gas × 2 gwei × multiplier = 0.001 ETH × multiplier = 10 IMX × multiplier; plus 0.25 IMX + expect(fee).toBe(BigInt(expected)); + }); + it('applies the 1 gwei minimum gas price', async () => { mockedAxios.get.mockResolvedValue({ data: { ethereum: { usd: 2000 }, 'immutable-x': { usd: 0.2 } } }); diff --git a/packages/internal/bridge/sdk/src/lib/axelarFee.ts b/packages/internal/bridge/sdk/src/lib/axelarFee.ts index 5110effe7e..15459b479a 100644 --- a/packages/internal/bridge/sdk/src/lib/axelarFee.ts +++ b/packages/internal/bridge/sdk/src/lib/axelarFee.ts @@ -36,11 +36,12 @@ export const FALLBACK_BASE_FEE_DESTINATION_GAS = BigInt(250000); export const FALLBACK_BASE_FEE_USD_CENTS = BigInt(5); /** - * Multiplier applied to destination gas in the fallback estimate. Axelar's own multiplier was 1.15-1.32 - * on 2026-10-07. A larger buffer is used because the fallback cannot see Axelar's pricing, and Axelar - * refunds unused gas to the sender after execution, while an underpaid message waits for a gas top-up. + * Minimum multiplier applied to destination gas in the fallback estimate. A numeric `gasMultiplier` from + * the caller applies when larger. Axelar's own multiplier was 1.15-1.32 on 2026-10-07. A larger buffer is + * used because the fallback cannot see Axelar's pricing, and Axelar refunds unused gas to the sender after + * execution, while an underpaid message waits for a gas top-up. */ -export const FALLBACK_GAS_MULTIPLIER = BigInt(2); +export const FALLBACK_GAS_MULTIPLIER = 2; /** * CoinGecko ids, as served by the checkout price API, for the native token symbols in `axelarChains`. @@ -52,8 +53,10 @@ export const coingeckoIds: Record = { const LOG_PREFIX = '[imtbl-bridge]'; -// Prices are carried as integers scaled by 1e8 so the fee arithmetic stays in bigint. +// Prices are carried as integers scaled by 1e8, and multipliers scaled by 100, so the fee arithmetic +// stays in bigint. const PRICE_SCALE = 1e8; +const MULTIPLIER_SCALE = 100; const WEI_PER_TOKEN = BigInt(10) ** BigInt(18); export type AxelarFeeEstimate = { @@ -72,7 +75,9 @@ export type AxelarFeeParams = { }; /** - * Error raised when the Axelar API returns no usable fee. `reason` is a short description for logs. + * Error raised when the Axelar API could not be reached or did not answer: no response (including a + * CORS rejection), a timeout, HTTP 403 (Cloudflare challenge), HTTP 429 or HTTP 5xx. These fall back to + * the local estimate. `reason` is a short description for logs. */ class AxelarFeeAPIError extends Error { public readonly reason: string; @@ -83,15 +88,17 @@ class AxelarFeeAPIError extends Error { } } -const describeRequestError = (err: any): string => { - if (err?.code === 'ECONNABORTED' || err?.code === 'ETIMEDOUT') return 'timeout'; - if (err?.response?.status) return `HTTP ${err.response.status}`; - // A browser reports a CORS rejection as a network error with no response. - return `network error, possibly CORS (${err?.message ?? 'unknown'})`; -}; +const isUnavailableStatus = (status: number) => status === 403 || status === 429 || status >= 500; + +const axelarRejection = (reason: string) => new BridgeError( + `Estimating Axelar Gas failed with the reason: ${reason}`, + BridgeErrorType.AXELAR_GAS_ESTIMATE_FAILED, +); /** * Queries the Axelar GMP API for the fee to pay on the source chain, in the source chain's native token. + * Throws `AxelarFeeAPIError` when the API is unavailable, and a `BridgeError` of type + * AXELAR_GAS_ESTIMATE_FAILED when the API answers with an error or an unparseable fee. */ export async function fetchAxelarFee(params: AxelarFeeParams): Promise { const estimateGasReq = { @@ -111,19 +118,27 @@ export async function fetchAxelarFee(params: AxelarFeeParams): Promise { }); data = response.data; } catch (err: any) { - if (err?.response?.data?.error) { - throw new AxelarFeeAPIError(`Axelar error: ${err.response.data.message}`); + const status: number | undefined = err?.response?.status; + if (err?.code === 'ECONNABORTED' || err?.code === 'ETIMEDOUT') { + throw new AxelarFeeAPIError('timeout'); + } + if (!status) { + // A browser reports a CORS rejection as a network error with no response. + throw new AxelarFeeAPIError(`network error, possibly CORS (${err?.message ?? 'unknown'})`); + } + if (isUnavailableStatus(status)) { + throw new AxelarFeeAPIError(`HTTP ${status}`); } - throw new AxelarFeeAPIError(describeRequestError(err)); + throw axelarRejection(err.response.data?.error ? err.response.data.message : `HTTP ${status}`); } if (data?.error) { - throw new AxelarFeeAPIError(`Axelar error: ${data.message}`); + throw axelarRejection(data.message); } // A successful response body is the fee as a bare integer string. if (!/^\d+$/.test(`${data}`.trim())) { - throw new AxelarFeeAPIError('unexpected response body'); + throw axelarRejection(`unexpected response body ${JSON.stringify(data)?.slice(0, 100)}`); } return BigInt(`${data}`.trim()); } @@ -162,9 +177,22 @@ export async function fetchUSDPrices( }; } +/** + * Returns the fallback multiplier scaled by MULTIPLIER_SCALE: the caller's numeric `gasMultiplier` when it + * exceeds FALLBACK_GAS_MULTIPLIER, otherwise FALLBACK_GAS_MULTIPLIER. 'auto' resolves to the minimum. + */ +const resolveFallbackMultiplier = (gasMultiplier: number | string): bigint => { + const requested = Number(gasMultiplier); + const multiplier = Number.isFinite(requested) && requested > FALLBACK_GAS_MULTIPLIER + ? requested + : FALLBACK_GAS_MULTIPLIER; + return BigInt(Math.ceil(multiplier * MULTIPLIER_SCALE)); +}; + /** * Estimates the Axelar fee without the Axelar API, in the source chain's native token (wei). * + * multiplier = max(numeric gasMultiplier, 2) * destination gas = (execution gas limit + base fee gas) × max(destination gas price, 1 gwei) × multiplier * fee = destination gas converted to the source token at USD prices + fixed base fee in USD * @@ -183,7 +211,8 @@ export async function estimateFallbackAxelarFee(params: AxelarFeeParams): Promis const gasPrice = reportedGasPrice > AXELAR_MIN_GAS_PRICE ? reportedGasPrice : AXELAR_MIN_GAS_PRICE; const destinationGas = BigInt(params.destinationChainGasLimit) + FALLBACK_BASE_FEE_DESTINATION_GAS; - const destinationCost = destinationGas * gasPrice * FALLBACK_GAS_MULTIPLIER; + const destinationCost = (destinationGas * gasPrice * resolveFallbackMultiplier(params.gasMultiplier)) + / BigInt(MULTIPLIER_SCALE); const destinationCostInSource = (destinationCost * destinationPrice) / sourcePrice; // cents × 1e18 × PRICE_SCALE / (100 × scaled price) = wei of the source token @@ -194,7 +223,9 @@ export async function estimateFallbackAxelarFee(params: AxelarFeeParams): Promis } /** - * Returns the Axelar fee from the Axelar API, or from `estimateFallbackAxelarFee` when the API fails. + * Returns the Axelar fee from the Axelar API, or from `estimateFallbackAxelarFee` when the API is + * unavailable (see `AxelarFeeAPIError`). An error answer from the API is thrown as + * AXELAR_GAS_ESTIMATE_FAILED without falling back. * The API is tried on every call, so estimates return to the Axelar API as soon as it responds again. * Each fallback logs a console warning prefixed with `[imtbl-bridge]`. */ @@ -203,7 +234,8 @@ export async function getAxelarFeeWithFallback(params: AxelarFeeParams): Promise try { return { fee: await fetchAxelarFee(params), source: 'axelar' }; } catch (err: any) { - reason = err instanceof AxelarFeeAPIError ? err.reason : `${err?.message ?? err}`; + if (!(err instanceof AxelarFeeAPIError)) throw err; + reason = err.reason; } let fee: bigint; diff --git a/packages/internal/bridge/sdk/src/types/index.ts b/packages/internal/bridge/sdk/src/types/index.ts index 85c32b2e3f..698128ce24 100644 --- a/packages/internal/bridge/sdk/src/types/index.ts +++ b/packages/internal/bridge/sdk/src/types/index.ts @@ -133,7 +133,8 @@ export type BridgeFeeRequest = DepositNativeFeeRequest /** * @typedef {Object} DepositNativeFeeRequest * @property {BridgeFeeActions} method - The method for which the bridge fee is being requested. - * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier + * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier. + * When the Axelar API is unavailable, the fallback estimate uses max(gasMultiplier, 2), and 2 for 'auto'. * @property {string} sourceChainId - The chain ID of the source chain. * @property {string} destinationChainId - The chain ID of the destination chain. */ @@ -147,7 +148,8 @@ export interface DepositNativeFeeRequest { /** * @typedef {Object} DepositERC20FeeRequest * @property {BridgeFeeActions} method - The method for which the bridge fee is being requested. - * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier + * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier. + * When the Axelar API is unavailable, the fallback estimate uses max(gasMultiplier, 2), and 2 for 'auto'. * @property {string} sourceChainId - The chain ID of the source chain. * @property {string} destinationChainId - The chain ID of the destination chain. * @property {FungibleToken} token - The token to be deposited. @@ -165,7 +167,8 @@ export interface DepositERC20FeeRequest { /** * @typedef {Object} WithdrawNativeFeeRequest * @property {BridgeFeeActions} method - The method for which the bridge fee is being requested. - * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier + * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier. + * When the Axelar API is unavailable, the fallback estimate uses max(gasMultiplier, 2), and 2 for 'auto'. * @property {string} sourceChainId - The chain ID of the source chain. * @property {string} destinationChainId - The chain ID of the destination chain. */ @@ -179,7 +182,8 @@ export interface WithdrawNativeFeeRequest { /** * @typedef {Object} WithdrawERC20FeeRequest * @property {BridgeFeeActions} method - The method for which the bridge fee is being requested. - * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier + * @property {number} gasMultiplier - How much buffer to add to the gas fee, or 'auto' to use Axelar's automatic gas multiplier. + * When the Axelar API is unavailable, the fallback estimate uses max(gasMultiplier, 2), and 2 for 'auto'. * @property {string} sourceChainId - The chain ID of the source chain. * @property {string} destinationChainId - The chain ID of the destination chain. * @property {FungibleToken} token - The token to be withdrawn.