From ed4b22cb6990c4277dbdeff45f506bbef3683600 Mon Sep 17 00:00:00 2001 From: npty Date: Fri, 8 Nov 2024 15:26:13 +0700 Subject: [PATCH] feat: implement api and tests --- src/chains/index.ts | 2 +- src/libs/AxelarQueryAPI.ts | 40 +++++- src/libs/test/AxelarQueryAPI.spec.ts | 187 +++++++++++++++++++++++++++ src/utils/validateChain.ts | 2 + 4 files changed, 226 insertions(+), 5 deletions(-) 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/libs/AxelarQueryAPI.ts b/src/libs/AxelarQueryAPI.ts index 5e4926b6..54d2f0f5 100644 --- a/src/libs/AxelarQueryAPI.ts +++ b/src/libs/AxelarQueryAPI.ts @@ -75,7 +75,7 @@ interface HopParams { * The multiplier of gas to be used on execution * @default 'auto' (multiplier used by relayer) */ - gasMultiplier?: number; + gasMultiplier?: number | "auto"; /** * Minimum destination gas price @@ -88,7 +88,7 @@ interface HopParams { /** * The token address on the source chain - * @default "ZeroAddress" + * @default "ZeroAddress" for native token */ sourceTokenAddress?: string; @@ -499,8 +499,40 @@ export class AxelarQueryAPI { : l1ExecutionFeeWithMultiplier.add(executionFeeWithMultiplier).add(baseFee).toString(); } - public async estimateMultihopFee(params: HopParams, options: EstimateMultihopFeeOptions) { - const response = this.axelarscanApi.post("estimateGasFeeForNHops", {}); + /** + * 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, + }); + + return response; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to estimate multi-hop gas fee: ${error.message}`); + } + throw error; + } } /** diff --git a/src/libs/test/AxelarQueryAPI.spec.ts b/src/libs/test/AxelarQueryAPI.spec.ts index 8532bf2b..f4ec57fb 100644 --- a/src/libs/test/AxelarQueryAPI.spec.ts +++ b/src/libs/test/AxelarQueryAPI.spec.ts @@ -10,6 +10,7 @@ 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,192 @@ 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") + .mockResolvedValue(undefined); + }); + + afterEach(() => { + vitest.restoreAllMocks(); + }); + + 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.mockRejectedValueOnce(new Error("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", 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); + }); + }); + + // describe.only("estimateMultihopFee", () => { + // const amplifierApi = new AxelarQueryAPI({ + // environment: Environment.DEVNET, + // }); + // + // test("It should return estimated gas fee", async () => { + // const fee = await amplifierApi.estimateMultihopFee([ + // { + // destinationChain: "avalanche-fuji", + // sourceChain: "axelarnet", + // gasLimit: "700000", + // }, + // { + // destinationChain: "sui-test2", + // sourceChain: "axelarnet", + // gasLimit: "700000", + // }, + // ]); + // + // expect(fee).toBeDefined(); + // }); + // }); + // 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..aaf8745e 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; + chainIdentifiers.push(axelarIdentifier); const foundChain = chainIdentifiers.find( (identifier: string) => identifier === chainIdentifier.toLowerCase()