From 8f08f215a4be1299b25a65ec18e197f457b7dd13 Mon Sep 17 00:00:00 2001 From: Jose Felix Date: Fri, 7 Jun 2024 16:26:27 -0400 Subject: [PATCH] feat: add bridge provider external url generator functions --- .../axelar/__tests__/external-urls.spec.ts | 107 ++++++++++++++++++ packages/bridge/src/axelar/external-urls.ts | 60 ++++++++++ packages/bridge/src/axelar/queries.ts | 107 +++++++++++++++++- packages/bridge/src/axelar/utils.ts | 17 --- packages/bridge/src/interface.ts | 4 + .../src/skip/__tests__/external-urls.spec.ts | 57 ++++++++++ .../src/skip/{utils.ts => external-urls.ts} | 0 .../src/squid/__tests__/external-url.spec.ts | 57 ++++++++++ .../src/squid/{utils.ts => external-urls.ts} | 0 9 files changed, 387 insertions(+), 22 deletions(-) create mode 100644 packages/bridge/src/axelar/__tests__/external-urls.spec.ts create mode 100644 packages/bridge/src/axelar/external-urls.ts delete mode 100644 packages/bridge/src/axelar/utils.ts create mode 100644 packages/bridge/src/skip/__tests__/external-urls.spec.ts rename packages/bridge/src/skip/{utils.ts => external-urls.ts} (100%) create mode 100644 packages/bridge/src/squid/__tests__/external-url.spec.ts rename packages/bridge/src/squid/{utils.ts => external-urls.ts} (100%) diff --git a/packages/bridge/src/axelar/__tests__/external-urls.spec.ts b/packages/bridge/src/axelar/__tests__/external-urls.spec.ts new file mode 100644 index 00000000000..3478d8e31b0 --- /dev/null +++ b/packages/bridge/src/axelar/__tests__/external-urls.spec.ts @@ -0,0 +1,107 @@ +import { NativeEVMTokenConstantAddress } from "../../ethereum"; +import { getAxelarExternalUrl } from "../external-urls"; + +describe("getAxelarExternalUrl", () => { + it("should return the correct URL for Eth <> axlEth", async () => { + const params = { + fromChain: { chainId: 1, chainType: "evm" }, + toChain: { chainId: "osmosis-1", chainType: "cosmos" }, + fromAsset: { + denom: "ETH", + sourceDenom: "weth-wei", + decimals: 18, + address: NativeEVMTokenConstantAddress, + }, + toAsset: { + address: + "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + decimals: 18, + denom: "ETH", + sourceDenom: "weth-wei", + }, + env: "mainnet", + toAddress: "destination-address", + } as Parameters[0]; + + const url = await getAxelarExternalUrl(params); + + expect(url).toBe( + "https://satellite.money/?source=Ethereum&destination=osmosis&asset_denom=eth-wei&destination_address=destination-address" + ); + }); + + it("should throw an error if fromChain is not found", async () => { + const params = { + fromChain: { chainId: "nonexistent", chainType: "evm" }, + toChain: { chainId: "chain2", chainType: "cosmos" }, + fromAsset: { + address: "address1", + denom: "denom1", + sourceDenom: "sourceDenom1", + decimals: 18, + }, + toAsset: { + address: "address2", + denom: "denom2", + sourceDenom: "sourceDenom2", + decimals: 18, + }, + env: "mainnet", + toAddress: "destination-address", + } as Parameters[0]; + + await expect(getAxelarExternalUrl(params)).rejects.toThrow( + "Chain not found: nonexistent" + ); + }); + + it("should throw an error if toChain is not found", async () => { + const params = { + fromChain: { chainId: "chain1", chainType: "evm" }, + toChain: { chainId: "nonexistent", chainType: "cosmos" }, + fromAsset: { + address: "address1", + denom: "denom1", + sourceDenom: "sourceDenom1", + decimals: 18, + }, + toAsset: { + address: "address2", + denom: "denom2", + sourceDenom: "sourceDenom2", + decimals: 18, + }, + env: "mainnet", + toAddress: "destination-address", + } as Parameters[0]; + + await expect(getAxelarExternalUrl(params)).rejects.toThrow( + "Chain not found: chain1" + ); + }); + + it("should throw an error if toAsset is not found", async () => { + const params = { + fromChain: { chainId: 1, chainType: "evm" }, + toChain: { chainId: "osmosis-1", chainType: "cosmos" }, + fromAsset: { + address: "address1", + denom: "denom1", + sourceDenom: "sourceDenom1", + decimals: 18, + }, + toAsset: { + address: "nonexistent", + denom: "denom2", + sourceDenom: "sourceDenom2", + decimals: 18, + }, + env: "mainnet", + toAddress: "destination-address", + } as Parameters[0]; + + await expect(getAxelarExternalUrl(params)).rejects.toThrow( + "Asset not found: nonexistent" + ); + }); +}); diff --git a/packages/bridge/src/axelar/external-urls.ts b/packages/bridge/src/axelar/external-urls.ts new file mode 100644 index 00000000000..2842a248de6 --- /dev/null +++ b/packages/bridge/src/axelar/external-urls.ts @@ -0,0 +1,60 @@ +import { isNil } from "@osmosis-labs/utils"; + +import { + BridgeProviderContext, + GetBridgeExternalUrlParams, +} from "../interface"; +import { getAxelarAssets, getAxelarChains } from "./queries"; + +export async function getAxelarExternalUrl({ + fromChain, + toChain, + toAsset, + env, + toAddress, +}: GetBridgeExternalUrlParams & { + env: BridgeProviderContext["env"]; +}): Promise { + const [axelarChains, axelarAssets] = await Promise.all([ + getAxelarChains({ env }), + getAxelarAssets({ env }), + ]); + + const fromAxelarChain = axelarChains.find( + (chain) => String(chain.chain_id) === String(fromChain.chainId) + ); + + if (!fromAxelarChain) { + throw new Error(`Chain not found: ${fromChain.chainId}`); + } + + const toAxelarChain = axelarChains.find( + (chain) => String(chain.chain_id) === String(toChain.chainId) + ); + + if (!toAxelarChain) { + throw new Error(`Chain not found: ${toChain.chainId}`); + } + + const toAxelarAsset = axelarAssets.find((axelarAsset) => { + return ( + !isNil(axelarAsset.addresses[toAxelarChain.chain_name]) && + (axelarAsset.addresses[toAxelarChain.chain_name].ibc_denom === + toAsset.address || + axelarAsset.addresses[toAxelarChain.chain_name].address === + toAsset.address) + ); + }); + + if (!toAxelarAsset) { + throw new Error(`Asset not found: ${toAsset.address}`); + } + + const url = new URL("https://satellite.money/"); + url.searchParams.set("source", fromAxelarChain.chain_name); + url.searchParams.set("destination", toAxelarChain.chain_name); + url.searchParams.set("asset_denom", toAxelarAsset.id); + url.searchParams.set("destination_address", toAddress); + + return url.toString(); +} diff --git a/packages/bridge/src/axelar/queries.ts b/packages/bridge/src/axelar/queries.ts index 4ed2bcc9cfc..a7d4de4cca4 100644 --- a/packages/bridge/src/axelar/queries.ts +++ b/packages/bridge/src/axelar/queries.ts @@ -1,3 +1,9 @@ +import { apiClient } from "@osmosis-labs/utils"; +import { CacheEntry, cachified } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { BridgeProviderContext } from "../interface"; + export type TransferStep = { id: string; type: @@ -77,14 +83,105 @@ export async function getTransferStatus( origin = "https://api.axelarscan.io" ): Promise { try { - const response = await fetch( - `${origin}/cross-chain/transfers-status?txHash=${sendTxHash}` - ); - const data = await response.json(); + const url = new URL("/cross-chain/transfers-status", origin); + url.searchParams.set("txHash", sendTxHash); - return data as TransferStatus; + return apiClient(url.toString()); } catch { console.error("Failed to fetch transfer status for tx hash: ", sendTxHash); return []; } } + +interface AxelarChain { + id: string; + chain_id: number | string; + chain_name: string; + maintainer_id: string; + endpoints: { + rpc: string[]; + lcd: string[]; + }; + native_token: { + symbol: string; + name: string; + decimals: number; + }; + name: string; + short_name: string; + image: string; + color: string; + explorer: { + name: string; + url: string; + icon: string; + block_path: string; + address_path: string; + contract_path: string; + contract_0_path: string; + transaction_path: string; + asset_path: string; + }; + prefix_address: string; + prefix_chain_ids: string[]; + chain_type: string; + provider_params: object[]; +} + +const cache = new LRUCache({ + max: 5, +}); + +export async function getAxelarChains({ + env, +}: { + env: BridgeProviderContext["env"]; +}) { + return cachified({ + key: `axelar-chains`, + cache, + ttl: 1000 * 60 * 30, // 30 minutes + getFreshValue: () => + apiClient( + env === "mainnet" + ? "https://api.axelarscan.io/api/getChains" + : "https://testnet.api.axelarscan.io/api/getChains" + ), + }); +} + +interface AxelarAsset { + id: string; // ID using in general purpose + denom: string; + native_chain: string; // general ID of chain that asset is native on + name: string; // display name + symbol: string; + decimals: number; // token decimals + image: string; // logo path + coingecko_id: string; // asset identifier on coingecko service + addresses: { + [chain: string]: { + address: string; // EVM token address + ibc_denom: string; // Cosmos token address (denom) + symbol: string; // symbol of asset on each chain + }; + }; +} + +export async function getAxelarAssets({ + env, +}: { + env: BridgeProviderContext["env"]; +}) { + return cachified({ + key: "axelar-assets", + cache, + ttl: 1000 * 60 * 30, // 30 minutes + getFreshValue: () => + apiClient( + env === "mainnet" + ? "https://api.axelarscan.io/api/getAssets" + : "https://testnet.api.axelarscan.io/api/getAssets" + ), + }); +} diff --git a/packages/bridge/src/axelar/utils.ts b/packages/bridge/src/axelar/utils.ts deleted file mode 100644 index 628f554bf24..00000000000 --- a/packages/bridge/src/axelar/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { GetBridgeExternalUrlParams } from "../interface"; - -// https://satellite.money/?source=ethereum&destination=osmosis&asset_denom=uusdc -export function getAxelarExternalUrl({ - fromChain, - toChain, - fromAsset, - toAsset, -}: GetBridgeExternalUrlParams): string { - const url = new URL("https://satellite.money/"); - url.searchParams.set("src_chain", String(fromChain.chainId)); - url.searchParams.set("src_asset", fromAsset.address); - url.searchParams.set("dest_chain", String(toChain.chainId)); - url.searchParams.set("dest_asset", toAsset.address); - - return url.toString(); -} diff --git a/packages/bridge/src/interface.ts b/packages/bridge/src/interface.ts index 444611ebc2f..5f113725299 100644 --- a/packages/bridge/src/interface.ts +++ b/packages/bridge/src/interface.ts @@ -157,6 +157,10 @@ export const getBridgeExternalUrlSchema = z.object({ * The asset on the destination chain. */ toAsset: bridgeAssetSchema, + /** + * The address on the destination chain where the assets are to be received. + */ + toAddress: z.string(), }); export type GetBridgeExternalUrlParams = z.infer< diff --git a/packages/bridge/src/skip/__tests__/external-urls.spec.ts b/packages/bridge/src/skip/__tests__/external-urls.spec.ts new file mode 100644 index 00000000000..4ca3f1fad17 --- /dev/null +++ b/packages/bridge/src/skip/__tests__/external-urls.spec.ts @@ -0,0 +1,57 @@ +import { getSkipExternalUrl } from "../external-urls"; + +describe("getSkipExternalUrl", () => { + it("should generate the correct URL for given parameters", () => { + const params = { + fromChain: { chainId: "cosmoshub-4" }, + toChain: { chainId: "agoric-3" }, + fromAsset: { address: "uatom" }, + toAsset: { address: "ubld" }, + } as Parameters[0]; + + const expectedUrl = + "https://ibc.fun/?src_chain=cosmoshub-4&src_asset=uatom&dest_chain=agoric-3&dest_asset=ubld"; + const result = getSkipExternalUrl(params); + + expect(result).toBe(expectedUrl); + }); + + it("should encode asset addresses correctly", () => { + const params = { + fromChain: { chainId: "akashnet-2" }, + toChain: { chainId: "andromeda-1" }, + fromAsset: { + address: + "ibc/2e5d0ac026ac1afa65a23023ba4f24bb8ddf94f118edc0bad6f625bfc557cded", + }, + toAsset: { + address: + "ibc/976c73350f6f48a69de740784c8a92931c696581a5c720d96ddf4afa860fff97", + }, + } as Parameters[0]; + + const expectedUrl = + "https://ibc.fun/?src_chain=akashnet-2&src_asset=ibc%2F2e5d0ac026ac1afa65a23023ba4f24bb8ddf94f118edc0bad6f625bfc557cded&dest_chain=andromeda-1&dest_asset=ibc%2F976c73350f6f48a69de740784c8a92931c696581a5c720d96ddf4afa860fff97"; + const result = getSkipExternalUrl(params); + + expect(result).toBe(expectedUrl); + }); + + it("should handle numeric chain IDs correctly", () => { + const params = { + fromChain: { chainId: 42161 }, + toChain: { chainId: "andromeda-1" }, + fromAsset: { address: "0xff970a61a04b1ca14834a43f5de4533ebddb5cc8" }, + toAsset: { + address: + "ibc/976c73350f6f48a69de740784c8a92931c696581a5c720d96ddf4afa860fff97", + }, + } as Parameters[0]; + + const expectedUrl = + "https://ibc.fun/?src_chain=42161&src_asset=0xff970a61a04b1ca14834a43f5de4533ebddb5cc8&dest_chain=andromeda-1&dest_asset=ibc%2F976c73350f6f48a69de740784c8a92931c696581a5c720d96ddf4afa860fff97"; + const result = getSkipExternalUrl(params); + + expect(result).toBe(expectedUrl); + }); +}); diff --git a/packages/bridge/src/skip/utils.ts b/packages/bridge/src/skip/external-urls.ts similarity index 100% rename from packages/bridge/src/skip/utils.ts rename to packages/bridge/src/skip/external-urls.ts diff --git a/packages/bridge/src/squid/__tests__/external-url.spec.ts b/packages/bridge/src/squid/__tests__/external-url.spec.ts new file mode 100644 index 00000000000..b24098b4855 --- /dev/null +++ b/packages/bridge/src/squid/__tests__/external-url.spec.ts @@ -0,0 +1,57 @@ +import { getSquidExternalUrl } from "../external-urls"; + +describe("getSquidExternalUrl", () => { + it("should generate the correct URL for given parameters", () => { + const params = { + fromChain: { chainId: "8453" }, + toChain: { chainId: "osmosis-1" }, + fromAsset: { address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, + toAsset: { + address: + "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + }, + } as Parameters[0]; + + const expectedUrl = + "https://app.squidrouter.com/?chains=8453%2Cosmosis-1&tokens=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE%2Cibc%2FEA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5"; + const result = getSquidExternalUrl(params); + + expect(result).toBe(expectedUrl); + }); + + it("should encode asset addresses correctly", () => { + const params = { + fromChain: { chainId: "8453" }, + toChain: { chainId: "osmosis-1" }, + fromAsset: { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }, + toAsset: { + address: + "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + }, + } as Parameters[0]; + + const expectedUrl = + "https://app.squidrouter.com/?chains=8453%2Cosmosis-1&tokens=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913%2Cibc%2F498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; + const result = getSquidExternalUrl(params); + + expect(result).toBe(expectedUrl); + }); + + it("should handle numeric chain IDs correctly", () => { + const params = { + fromChain: { chainId: "43114" }, + toChain: { chainId: "osmosis-1" }, + fromAsset: { address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E" }, + toAsset: { + address: + "ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + }, + } as Parameters[0]; + + const expectedUrl = + "https://app.squidrouter.com/?chains=43114%2Cosmosis-1&tokens=0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E%2Cibc%2F498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4"; + const result = getSquidExternalUrl(params); + + expect(result).toBe(expectedUrl); + }); +}); diff --git a/packages/bridge/src/squid/utils.ts b/packages/bridge/src/squid/external-urls.ts similarity index 100% rename from packages/bridge/src/squid/utils.ts rename to packages/bridge/src/squid/external-urls.ts