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..df17752406 --- /dev/null +++ b/packages/internal/bridge/sdk/src/lib/axelarFee.test.ts @@ -0,0 +1,271 @@ +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', + ], + [ + '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); + + 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', { 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 promise = getAxelarFeeWithFallback(withdrawParams()); + + 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 () => { + 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.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 } } }); + + 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..15459b479a --- /dev/null +++ b/packages/internal/bridge/sdk/src/lib/axelarFee.ts @@ -0,0 +1,261 @@ +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); + +/** + * 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 = 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, 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 = { + 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 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; + + constructor(reason: string) { + super(reason); + this.reason = reason; + } +} + +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 = { + 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) { + 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 axelarRejection(err.response.data?.error ? err.response.data.message : `HTTP ${status}`); + } + + if (data?.error) { + throw axelarRejection(data.message); + } + + // A successful response body is the fee as a bare integer string. + if (!/^\d+$/.test(`${data}`.trim())) { + throw axelarRejection(`unexpected response body ${JSON.stringify(data)?.slice(0, 100)}`); + } + 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), + }; +} + +/** + * 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 + * + * 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 * resolveFallbackMultiplier(params.gasMultiplier)) + / BigInt(MULTIPLIER_SCALE); + 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 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]`. + */ +export async function getAxelarFeeWithFallback(params: AxelarFeeParams): Promise { + let reason: string; + try { + return { fee: await fetchAxelarFee(params), source: 'axelar' }; + } catch (err: any) { + if (!(err instanceof AxelarFeeAPIError)) throw err; + reason = err.reason; + } + + 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..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. @@ -218,6 +222,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 +232,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.