diff --git a/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts b/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts index 64177cb7d7..551acca3c7 100644 --- a/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts +++ b/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts @@ -4,7 +4,11 @@ import { rest } from "msw"; import { MockChains } from "../../__tests__/mock-chains"; import { server } from "../../__tests__/msw"; import { BridgeEnvironment, TransferStatusReceiver } from "../../interface"; -import { SkipTransferStatusProvider } from "../transfer-status"; +import { SkipApiClient } from "../client"; +import { + SkipStatusProvider, + SkipTransferStatusProvider, +} from "../transfer-status"; jest.mock("@osmosis-labs/utils", () => ({ ...jest.requireActual("@osmosis-labs/utils"), @@ -19,6 +23,14 @@ jest.mock("@osmosis-labs/utils", () => ({ }), })); +const SkipStatusProvider: SkipStatusProvider = { + transactionStatus: ({ chainID, txHash, env }) => { + const client = new SkipApiClient(env); + return client.transactionStatus({ chainID, txHash }); + }, + trackTransaction: () => Promise.resolve(), +}; + // silence console errors jest.spyOn(console, "error").mockImplementation(() => {}); @@ -31,7 +43,8 @@ describe("SkipTransferStatusProvider", () => { beforeEach(() => { provider = new SkipTransferStatusProvider( "mainnet" as BridgeEnvironment, - MockChains + MockChains, + SkipStatusProvider ); provider.statusReceiverDelegate = mockReceiver; }); @@ -108,7 +121,8 @@ describe("SkipTransferStatusProvider", () => { it("should generate correct explorer URL for testnet", () => { const testnetProvider = new SkipTransferStatusProvider( "testnet" as BridgeEnvironment, - MockChains + MockChains, + SkipStatusProvider ); const url = testnetProvider.makeExplorerUrl( JSON.stringify({ @@ -123,7 +137,8 @@ describe("SkipTransferStatusProvider", () => { it("should generate correct explorer URL for a cosmos chain", () => { const cosmosProvider = new SkipTransferStatusProvider( "mainnet" as BridgeEnvironment, - MockChains + MockChains, + SkipStatusProvider ); const url = cosmosProvider.makeExplorerUrl( JSON.stringify({ diff --git a/packages/bridge/src/skip/client.ts b/packages/bridge/src/skip/client.ts index 51507064ff..bcf4f2a673 100644 --- a/packages/bridge/src/skip/client.ts +++ b/packages/bridge/src/skip/client.ts @@ -14,6 +14,7 @@ import { export class SkipApiClient { constructor( readonly env: BridgeEnvironment, + protected readonly apiKey = process.env.SKIP_API_KEY, readonly baseUrl = "https://api.skip.money" ) {} @@ -122,7 +123,7 @@ export class SkipApiClient { return apiClient(args[0], args[1]); } - const key = process.env.SKIP_API_KEY; + const key = this.apiKey; if (!key) throw new Error("SKIP_API_KEY is not set"); return apiClient(args[0], { diff --git a/packages/bridge/src/skip/index.ts b/packages/bridge/src/skip/index.ts index 4bce785b66..679f6e05a5 100644 --- a/packages/bridge/src/skip/index.ts +++ b/packages/bridge/src/skip/index.ts @@ -936,4 +936,5 @@ export class SkipBridgeProvider implements BridgeProvider { } } +export * from "./client"; export * from "./transfer-status"; diff --git a/packages/bridge/src/skip/transfer-status.ts b/packages/bridge/src/skip/transfer-status.ts index d937f56135..650cce723b 100644 --- a/packages/bridge/src/skip/transfer-status.ts +++ b/packages/bridge/src/skip/transfer-status.ts @@ -9,8 +9,23 @@ import type { TransferStatusProvider, TransferStatusReceiver, } from "../interface"; -import { SkipApiClient } from "./client"; import { SkipBridgeProvider } from "./index"; +import { SkipTxStatusResponse } from "./types"; + +type Transaction = { + chainID: string; + txHash: string; + env: BridgeEnvironment; +}; + +export interface SkipStatusProvider { + transactionStatus: ({ + chainID, + txHash, + env, + }: Transaction) => Promise; + trackTransaction: ({ chainID, txHash, env }: Transaction) => Promise; +} /** Tracks (polls skip endpoint) and reports status updates on Skip bridge transfers. */ export class SkipTransferStatusProvider implements TransferStatusProvider { @@ -19,12 +34,13 @@ export class SkipTransferStatusProvider implements TransferStatusProvider { statusReceiverDelegate?: TransferStatusReceiver | undefined; - readonly skipClient: SkipApiClient; readonly axelarScanBaseUrl: string; - constructor(env: BridgeEnvironment, protected readonly chainList: Chain[]) { - this.skipClient = new SkipApiClient(env); - + constructor( + protected readonly env: BridgeEnvironment, + protected readonly chainList: Chain[], + protected readonly skipStatusProvider: SkipStatusProvider + ) { this.axelarScanBaseUrl = env === "mainnet" ? "https://axelarscan.io" @@ -40,39 +56,38 @@ export class SkipTransferStatusProvider implements TransferStatusProvider { await poll({ fn: async () => { - try { - const txStatus = await this.skipClient.transactionStatus({ - chainID: fromChainId.toString(), - txHash: sendTxHash, + const tx = { + chainID: fromChainId.toString(), + txHash: sendTxHash, + env: this.env, + }; + + const txStatus = await this.skipStatusProvider + .transactionStatus(tx) + .catch(async (error) => { + if (error instanceof Error && error.message.includes("not found")) { + // if we get an error that it's not found, prompt skip to track it first + // then try again + await this.skipStatusProvider.trackTransaction(tx); + return this.skipStatusProvider.transactionStatus(tx); + } + + throw error; }); - let status: TransferStatus = "pending"; - if (txStatus.state === "STATE_COMPLETED_SUCCESS") { - status = "success"; - } - - if (txStatus.state === "STATE_COMPLETED_ERROR") { - status = "failed"; - } - - return { - id: sendTxHash, - status, - }; - } catch (error: any) { - if ("message" in error) { - if (error.message.includes("not found")) { - await this.skipClient.trackTransaction({ - chainID: fromChainId.toString(), - txHash: sendTxHash, - }); - - return undefined; - } - } + let status: TransferStatus = "pending"; + if (txStatus.state === "STATE_COMPLETED_SUCCESS") { + status = "success"; + } - throw error; + if (txStatus.state === "STATE_COMPLETED_ERROR") { + status = "failed"; } + + return { + id: sendTxHash, + status, + }; }, validate: (incomingStatus) => { if (!incomingStatus) { diff --git a/packages/web/pages/api/skip-track-tx.ts b/packages/web/pages/api/skip-track-tx.ts new file mode 100644 index 0000000000..c5a551635b --- /dev/null +++ b/packages/web/pages/api/skip-track-tx.ts @@ -0,0 +1,32 @@ +import { BridgeEnvironment, SkipApiClient } from "@osmosis-labs/bridge"; +import { NextApiRequest, NextApiResponse } from "next"; + +/** This edge function is necessary to invoke the SkipApiClient on the server + * as a secret API key is required for the client. + */ +export default async function skipTrackTx( + req: NextApiRequest, + res: NextApiResponse +) { + const { chainID, txHash, env } = req.query as { + chainID: string; + txHash: string; + env: BridgeEnvironment; + }; + + if (!chainID || !txHash || !env) { + return res.status(400).json({ error: "Missing required query parameters" }); + } + + const skipClient = new SkipApiClient(env); + + try { + const status = await skipClient.trackTransaction({ chainID, txHash }); + return res.status(200).json(status); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: "An unknown error occurred" }); + } +} diff --git a/packages/web/pages/api/skip-tx-status.ts b/packages/web/pages/api/skip-tx-status.ts new file mode 100644 index 0000000000..7f53bb563e --- /dev/null +++ b/packages/web/pages/api/skip-tx-status.ts @@ -0,0 +1,32 @@ +import { BridgeEnvironment, SkipApiClient } from "@osmosis-labs/bridge"; +import { NextApiRequest, NextApiResponse } from "next"; + +/** This edge function is necessary to invoke the SkipApiClient on the server + * as a secret API key is required for the client. + */ +export default async function skipTxStatus( + req: NextApiRequest, + res: NextApiResponse +) { + const { chainID, txHash, env } = req.query as { + chainID: string; + txHash: string; + env: BridgeEnvironment; + }; + + if (!chainID || !txHash || !env) { + return res.status(400).json({ error: "Missing required query parameters" }); + } + + const skipClient = new SkipApiClient(env); + + try { + const status = await skipClient.transactionStatus({ chainID, txHash }); + return res.status(200).json(status); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + return res.status(500).json({ error: "An unknown error occurred" }); + } +} diff --git a/packages/web/stores/root.ts b/packages/web/stores/root.ts index cd58c88026..a002d7de34 100644 --- a/packages/web/stores/root.ts +++ b/packages/web/stores/root.ts @@ -249,7 +249,33 @@ export class RootStore { ), new SkipTransferStatusProvider( IS_TESTNET ? "testnet" : "mainnet", - ChainList + ChainList, + { + transactionStatus: async ({ chainID, txHash, env }) => { + const response = await fetch( + `/api/skip-tx-status?chainID=${chainID}&txHash=${txHash}&env=${env}` + ); + const responseJson = await response.json(); + if (!response.ok) { + throw new Error( + "Failed to fetch transaction status: " + responseJson.error + ); + } + return responseJson; + }, + trackTransaction: async ({ chainID, txHash, env }) => { + const response = await fetch( + `/api/skip-track-tx?chainID=${chainID}&txHash=${txHash}&env=${env}` + ); + const responseJson = await response.json(); + if (!response.ok) { + throw new Error( + "Failed to track transaction: " + responseJson.error + ); + } + return responseJson; + }, + } ), new IbcTransferStatusProvider(ChainList, AssetLists), ];