diff --git a/src/chains/index.ts b/src/chains/index.ts index e52069d3..557e25eb 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -41,7 +41,7 @@ export async function loadChains(config: LoadChainConfig) { const s3UrlMap: Record = { "devnet-amplifier": - "https://axelar-devnet-amplifier.s3.us-east-2.amazonaws.com/configs/devnet-amplifier-config-1.x.json", + "https://raw.githubusercontent.com/axelarnetwork/axelar-contract-deployments/refs/heads/main/axelar-chains-config/info/devnet-amplifier.json", testnet: "https://axelar-testnet.s3.us-east-2.amazonaws.com/configs/testnet-config-1.x.json", mainnet: "https://axelar-mainnet.s3.us-east-2.amazonaws.com/configs/mainnet-config-1.x.json", }; diff --git a/src/constants/index.ts b/src/constants/index.ts index 9e02a781..0e8463d5 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -37,6 +37,18 @@ const devnetConfigs: EnvironmentConfigs = { axelarscanUrl: "", wssStatus: "", }; +const devnetAmplifierConfigs: EnvironmentConfigs = { + resourceUrl: "https://nest-server-testnet.axelar.dev", + axelarRpcUrl: "http://devnet-amplifier.axelar.dev:26657", + axelarLcdUrl: "http://devnet-amplifier.axelar.dev:1317", + axelarGMPApiUrl: "https://devnet-amplifier.api.gmp.axelarscan.io", + axelarscanBaseApiUrl: "https://devnet-amplifier.api.axelarscan.io", + depositServiceUrl: "", + recoveryApiUrl: "", + axelarCrosschainApiUrl: "", + axelarscanUrl: "https://devnet-amplifier.axelarscan.io", + wssStatus: "", +}; const testnetConfigs: EnvironmentConfigs = { resourceUrl: "https://nest-server-testnet.axelar.dev", axelarRpcUrl: "https://testnet.rpc.axelar.dev/chain/axelar", @@ -64,6 +76,7 @@ const mainnetConfigs: EnvironmentConfigs = { configsMap["local"] = localConfigs; configsMap["devnet"] = devnetConfigs; +configsMap["devnet-amplifier"] = devnetAmplifierConfigs; configsMap["testnet"] = testnetConfigs; configsMap["mainnet"] = mainnetConfigs; diff --git a/src/libs/AxelarQueryAPI.ts b/src/libs/AxelarQueryAPI.ts index 858a3206..291efb5d 100644 --- a/src/libs/AxelarQueryAPI.ts +++ b/src/libs/AxelarQueryAPI.ts @@ -20,7 +20,7 @@ import { TransferFeeResponse, } from "@axelar-network/axelarjs-types/axelar/nexus/v1beta1/query"; import { throwIfInvalidChainIds } from "../utils"; -import { loadChains } from "../chains"; +import { importS3Config, loadChains } from "../chains"; import s3 from "./TransactionRecoveryApi/constants/s3"; import { BigNumber, BigNumberish, ethers } from "ethers"; import { ChainInfo } from "../chains/types"; @@ -55,14 +55,104 @@ export interface AxelarQueryAPIFeeResponse { apiResponse: any; isExpressSupported: boolean; } + +interface HopParams { + /** The destination chain for the GMP transaction */ + destinationChain: string; + + /** The source chain for the GMP transaction */ + sourceChain: string; + + /** + * The gasLimit needed for execution on the destination chain. + * For OP Stack chains (Optimism, Base, Scroll, Fraxtal, Blast, Mantle, etc.), + * only specify the gasLimit for L2 (L2GasLimit). + * The endpoint estimates and bundles the gas needed for L1 (L1GasLimit) automatically. + */ + gasLimit: string; + + /** + * The multiplier of gas to be used on execution + * @default 'auto' (multiplier used by relayer) + */ + gasMultiplier?: number | "auto"; + + /** + * Minimum destination gas price + * @default minimum gas price used by relayer + */ + minGasPrice?: string; + + /** The token symbol on the source chain */ + sourceTokenSymbol?: string; + + /** + * The token address on the source chain + * @default "ZeroAddress" for native token + */ + sourceTokenAddress?: string; + + /** Source address for checking if express is supported */ + sourceContractAddress?: string; + + /** The payload that will be used on destination */ + executeData?: string; + + /** Destination contract address for checking if express is supported */ + destinationContractAddress?: string; + + /** Symbol that is used in callContractWithToken for checking if express is supported */ + symbol?: string; + + /** + * Token amount (in units) that is used in callContractWithToken for checking if express is supported + */ + amountInUnits?: string; +} + +/** + * Represents detailed fee information for a single hop + */ +interface HopFeeDetails { + isExpressSupported: boolean; + baseFee: string; + expressFee: string; + executionFee: string; + executionFeeWithMultiplier: string; + totalFee: string; + gasLimit: string; + gasLimitWithL1Fee: string; + gasMultiplier: number; + minGasPrice: string; +} + +/** + * Response for fee estimation with detailed breakdown + */ +export interface DetailedFeeResponse { + isExpressSupported: boolean; + baseFee: string; + expressFee: string; + executionFee: string; + executionFeeWithMultiplier: string; + totalFee: string; + details?: HopFeeDetails[]; +} + +interface EstimateMultihopFeeOptions { + showDetailedFees?: boolean; +} + export class AxelarQueryAPI { readonly environment: Environment; readonly lcdApi: RestService; readonly rpcApi: RestService; readonly axelarGMPServiceApi: RestService; + readonly axelarscanApi: RestService; readonly axelarRpcUrl: string; readonly axelarLcdUrl: string; readonly axelarGMPServiceUrl: string; + readonly axelarscanBaseApiUrl: string; private allAssets: AssetConfig[]; private axelarQueryClient: AxelarQueryClientType; private chainsList: ChainInfo[] = []; @@ -74,11 +164,13 @@ export class AxelarQueryAPI { this.axelarRpcUrl = axelarRpcUrl || links.axelarRpcUrl; this.axelarLcdUrl = axelarLcdUrl || links.axelarLcdUrl; this.axelarGMPServiceUrl = links.axelarGMPApiUrl; + this.axelarscanBaseApiUrl = links.axelarscanBaseApiUrl; this.environment = environment; this.lcdApi = new RestService(this.axelarLcdUrl); this.rpcApi = new RestService(this.axelarRpcUrl); this.axelarGMPServiceApi = new RestService(this.axelarGMPServiceUrl); + this.axelarscanApi = new RestService(this.axelarscanBaseApiUrl); this._initializeAssets(); } @@ -436,6 +528,46 @@ export class AxelarQueryAPI { : l1ExecutionFeeWithMultiplier.add(executionFeeWithMultiplier).add(baseFee).toString(); } + /** + * Estimates the total gas fee for a multi-hop GMP transfer via Axelar + * @param hops Array of hop parameters defining each step of the transfer path + * @param options Optional parameters for fee estimation + * @throws {Error} If no hops are provided or chain validation fails + * @returns Promise containing the estimated fees if the showDetailedFees option is not provided, or an object containing the detailed fees if showDetailedFees is true + */ + public async estimateMultihopFee( + hops: HopParams[], + options?: EstimateMultihopFeeOptions + ): Promise { + if (hops.length === 0) { + throw new Error("At least one hop parameter must be provided"); + } + + const chainsToValidate = Array.from( + new Set([...hops.map((hop) => hop.destinationChain), ...hops.map((hop) => hop.sourceChain)]) + ); + + await throwIfInvalidChainIds(chainsToValidate, this.environment); + + try { + const response = await this.axelarscanApi.post("/gmp/estimateGasFeeForNHops", { + params: hops, + showDetailedFees: options?.showDetailedFees ?? false, + }); + + if (options?.showDetailedFees) { + return this.mapToDetailedFeeResponse(response); + } + + return response; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to estimate multi-hop gas fee: ${error.message}`); + } + throw error; + } + } + /** * Get the denom for an asset given its symbol on a chain * @param symbol @@ -666,4 +798,37 @@ export class AxelarQueryAPI { if (!assetConfig) throw `Asset ${denom} not found`; return assetConfig.wrapped_erc20 ? assetConfig.wrapped_erc20 : denom; } + + /** + * Maps raw API response to simplified hop fee details + */ + private mapToHopFeeDetails(rawHopDetails: any): HopFeeDetails { + return { + isExpressSupported: rawHopDetails.isExpressSupported, + baseFee: rawHopDetails.baseFee, + expressFee: rawHopDetails.expressFee, + executionFee: rawHopDetails.executionFee, + executionFeeWithMultiplier: rawHopDetails.executionFeeWithMultiplier, + totalFee: rawHopDetails.totalFee, + gasLimit: rawHopDetails.gasLimit, + gasLimitWithL1Fee: rawHopDetails.gasLimitWithL1Fee, + gasMultiplier: rawHopDetails.gasMultiplier, + minGasPrice: rawHopDetails.minGasPrice, + }; + } + + /** + * Maps raw API response to simplified detailed fee response + */ + private mapToDetailedFeeResponse(rawResponse: any): DetailedFeeResponse { + return { + isExpressSupported: rawResponse.isExpressSupported, + baseFee: rawResponse.baseFee, + expressFee: rawResponse.expressFee, + executionFee: rawResponse.executionFee, + executionFeeWithMultiplier: rawResponse.executionFeeWithMultiplier, + totalFee: rawResponse.totalFee, + details: rawResponse.details?.map(this.mapToHopFeeDetails), + }; + } } diff --git a/src/libs/test/AxelarQueryAPI.spec.ts b/src/libs/test/AxelarQueryAPI.spec.ts index 8532bf2b..73a8e108 100644 --- a/src/libs/test/AxelarQueryAPI.spec.ts +++ b/src/libs/test/AxelarQueryAPI.spec.ts @@ -5,11 +5,12 @@ import { import { BigNumber, BigNumberish, ethers } from "ethers"; import { parseEther, parseUnits } from "ethers/lib/utils"; import { CHAINS } from "../../chains"; -import { AxelarQueryAPI } from "../AxelarQueryAPI"; +import { AxelarQueryAPI, DetailedFeeResponse } from "../AxelarQueryAPI"; import { Environment } from "../types"; import { EvmChain } from "../../constants/EvmChain"; import { GasToken } from "../../constants/GasToken"; import { activeChainsStub, getFeeStub } from "./stubs"; +import { SpyInstance } from "vitest"; const MOCK_EXECUTE_DATA = "0x1a98b2e0e68ba0eb84262d4bcf91955ec2680b37bcedd59a1f48e18d183dac9961bf9d1400000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000d40000000000000000000000000000000000000000000000000000000000deac2c6000000000000000000000000000000000000000000000000000000000000000762696e616e636500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a307863653136463639333735353230616230313337376365374238386635424138433438463844363636000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc000000000000000000000000000000000000000000000000000000000000000400000000000000000000000004607cad6135d7a119185ebe062d3b369b1b536ef000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000003600000000000000000000000000000000000000000000000000000000000000580000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000009200000000000000000000000000000000000000000000000000000000000000a8000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc450000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000ff970a61a04b1ca14834a43f5de4533ebddb5cc800000000000000000000000000000000000000000000000000000000000000640000000000000000000000004fd39c9e151e50580779bd04b1f7ecc310079fd3000000000000000000000000000000000000000000000000000000000deac2c6000000000000000000000000000000000000000000000000000000000dc647500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f40521500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000001000000000000000000000000ff970a61a04b1ca14834a43f5de4533ebddb5cc8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc450000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000ff970a61a04b1ca14834a43f5de4533ebddb5cc80000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000ff970a61a04b1ca14834a43f5de4533ebddb5cc800000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab100000000000000000000000000000000000000000000000000000000000000640000000000000000000000004fd39c9e151e50580779bd04b1f7ecc310079fd3000000000000000000000000000000000000000000000000000000000de83dbf000000000000000000000000000000000000000000000000015d8c7908dbe7130000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000ff970a61a04b1ca14834a43f5de4533ebddb5cc80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000242e1a7d4d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000004607cad6135d7a119185ebe062d3b369b1b536ef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000761786c5553444300000000000000000000000000000000000000000000000000"; @@ -118,6 +119,229 @@ describe("AxelarQueryAPI", () => { }); }); + describe("estimateMultihopFee", () => { + let amplifierApi: AxelarQueryAPI; + let mockAxelarscanApiPost: SpyInstance; + let mockThrowIfInvalidChainIds: SpyInstance; + + beforeEach(async () => { + amplifierApi = new AxelarQueryAPI({ + environment: Environment.DEVNET, + }); + // Use vi instead of jest + mockAxelarscanApiPost = vi + .spyOn(amplifierApi["axelarscanApi"], "post") + .mockResolvedValue({ test: "response" }); + mockThrowIfInvalidChainIds = vi + .spyOn(await import("../../utils/validateChain"), "throwIfInvalidChainIds") + .mockResolvedValueOnce(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("should call API with correct path and parameters", async () => { + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + await amplifierApi.estimateMultihopFee(hops); + + expect(mockAxelarscanApiPost).toHaveBeenCalledWith("/gmp/estimateGasFeeForNHops", { + params: hops, + showDetailedFees: false, + }); + }); + + test("should validate chains before making API call", async () => { + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + await amplifierApi.estimateMultihopFee(hops); + + expect(mockThrowIfInvalidChainIds).toHaveBeenCalledWith( + ["avalanche-fuji", "axelarnet"], + Environment.DEVNET + ); + }); + + test("should deduplicate chains for validation", async () => { + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + await amplifierApi.estimateMultihopFee(hops); + + expect(mockThrowIfInvalidChainIds).toHaveBeenCalledWith( + ["avalanche-fuji", "axelarnet"], + Environment.DEVNET + ); + }); + + test("should throw error for empty hops array", async () => { + await expect(amplifierApi.estimateMultihopFee([])).rejects.toThrow( + "At least one hop parameter must be provided" + ); + + expect(mockThrowIfInvalidChainIds).not.toHaveBeenCalled(); + expect(mockAxelarscanApiPost).not.toHaveBeenCalled(); + }); + + test("should pass showDetailedFees option to API", async () => { + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + await amplifierApi.estimateMultihopFee(hops, { showDetailedFees: true }); + + expect(mockAxelarscanApiPost).toHaveBeenCalledWith("/gmp/estimateGasFeeForNHops", { + params: hops, + showDetailedFees: true, + }); + }); + + test("should retain original input array order", async () => { + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + { + destinationChain: "sui-test2", + sourceChain: "polygon-mumbai", + gasLimit: "800000", + }, + ]; + + const originalHops = [...hops]; + + await amplifierApi.estimateMultihopFee(hops); + + expect(hops).toEqual(originalHops); + expect(mockAxelarscanApiPost).toHaveBeenCalledWith("/gmp/estimateGasFeeForNHops", { + params: originalHops, + showDetailedFees: false, + }); + }); + + test("should chain validation fail before API call", async () => { + mockThrowIfInvalidChainIds.mockReset(); + mockThrowIfInvalidChainIds.mockRejectedValueOnce("Invalid chain"); + + const hops = [ + { + destinationChain: "invalid-chain", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + await expect(amplifierApi.estimateMultihopFee(hops)).rejects.toThrow("Invalid chain"); + + expect(mockAxelarscanApiPost).not.toHaveBeenCalled(); + }); + + test("should return API response directly if showDetailedFees is undefined", async () => { + const mockResponse = { test: "response" }; + mockAxelarscanApiPost.mockResolvedValueOnce(mockResponse); + + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + const response = await amplifierApi.estimateMultihopFee(hops); + + expect(response).toBe(mockResponse); + }); + + test("should map the response to HopFeeDetails if showDetailedFees is true", async () => { + const mockResponse = { + isExpressSupported: false, + baseFee: "1", + expressFee: "2", + executionFee: "3", + executionFeeWithMultiplier: "1", + totalFee: "3", + details: [ + { + isExpressSupported: false, + baseFee: "1", + expressFee: "2", + executionFee: "3", + executionFeeWithMultiplier: "1", + totalFee: "3", + gasLimit: "700000", + gasLimitWithL1Fee: "700000", + gasMultiplier: 1, + minGasPrice: "1", + }, + ], + }; + mockAxelarscanApiPost.mockResolvedValueOnce(mockResponse); + + const hops = [ + { + destinationChain: "avalanche-fuji", + sourceChain: "axelarnet", + gasLimit: "700000", + }, + ]; + + const response = await amplifierApi.estimateMultihopFee(hops, { showDetailedFees: true }); + + expect(response).toEqual({ + isExpressSupported: false, + baseFee: "1", + expressFee: "2", + executionFee: "3", + executionFeeWithMultiplier: "1", + totalFee: "3", + details: [ + { + isExpressSupported: false, + baseFee: "1", + expressFee: "2", + executionFee: "3", + executionFeeWithMultiplier: "1", + totalFee: "3", + gasLimit: "700000", + gasLimitWithL1Fee: "700000", + gasMultiplier: 1, + minGasPrice: "1", + }, + ], + }); + }); + }); + describe("estimateGasFee", () => { test("It should return estimated gas amount that makes sense for USDC", async () => { const gasAmount = await api.estimateGasFee( diff --git a/src/utils/validateChain.ts b/src/utils/validateChain.ts index 8f42b0a5..71f0a8d8 100644 --- a/src/utils/validateChain.ts +++ b/src/utils/validateChain.ts @@ -31,6 +31,8 @@ export async function validateChainIdentifier(chainIdentifier: string, environme }; const chainIdentifiers = Object.keys(s3.chains); + const axelarIdentifier = s3["axelar"]?.axelarId || "axelar"; + chainIdentifiers.push(axelarIdentifier); const foundChain = chainIdentifiers.find( (identifier: string) => identifier === chainIdentifier.toLowerCase()