diff --git a/.changeset/chilly-facts-press.md b/.changeset/chilly-facts-press.md new file mode 100644 index 000000000..d361c32c5 --- /dev/null +++ b/.changeset/chilly-facts-press.md @@ -0,0 +1,11 @@ +--- +'@reservoir0x/relay-bitcoin-wallet-adapter': minor +'@reservoir0x/relay-ethers-wallet-adapter': minor +'@reservoir0x/relay-sui-wallet-adapter': minor +'@reservoir0x/relay-svm-wallet-adapter': minor +'@reservoir0x/relay-kit-hooks': minor +'@reservoir0x/relay-sdk': minor +'@reservoir0x/relay-kit-ui': minor +--- + +Add websocket support and refactor executeSteps diff --git a/demo/components/providers/RelayKitProviderWrapper.tsx b/demo/components/providers/RelayKitProviderWrapper.tsx index 0194d4499..79bf5c921 100644 --- a/demo/components/providers/RelayKitProviderWrapper.tsx +++ b/demo/components/providers/RelayKitProviderWrapper.tsx @@ -1,7 +1,13 @@ -import { LogLevel, RelayChain } from '@reservoir0x/relay-sdk' +import { + createClient, + LogLevel, + MAINNET_RELAY_WS, + RelayChain +} from '@reservoir0x/relay-sdk' import { RelayKitProvider } from '@reservoir0x/relay-kit-ui' import { useTheme } from 'next-themes' -import { FC, ReactNode } from 'react' +import { useRouter } from 'next/router' +import { FC, ReactNode, useEffect, useState } from 'react' export const RelayKitProviderWrapper: FC<{ relayApi?: string @@ -9,6 +15,17 @@ export const RelayKitProviderWrapper: FC<{ children: ReactNode }> = ({ relayApi, dynamicChains, children }) => { const { theme } = useTheme() + const router = useRouter() + const [websocketsEnabled, setWebsocketsEnabled] = useState(false) + + // Handle websocket configuration from query params + useEffect(() => { + const websocketParam = router.query.websockets as string + if (websocketParam !== undefined) { + setWebsocketsEnabled(websocketParam === 'true') + } + }, [router.query.websockets]) + return ( {children} diff --git a/packages/sdk/src/actions/execute.test.ts b/packages/sdk/src/actions/execute.test.ts index eccacc069..5eede6929 100644 --- a/packages/sdk/src/actions/execute.test.ts +++ b/packages/sdk/src/actions/execute.test.ts @@ -34,7 +34,7 @@ let executeStepsSpy = vi return Promise.resolve(clonedQuote) } ) -vi.mock('../utils/executeSteps.js', () => { +vi.mock('../utils/executeSteps/index.js', () => { return { executeSteps: (...args: any[]) => { return executeStepsSpy(...args) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 54db0509f..f16e063d5 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -12,14 +12,25 @@ import type { RelayChain } from './types/index.js' import { LogLevel, log as logUtil } from './utils/logger.js' import * as actions from './actions/index.js' import * as utils from './utils/index.js' -import { MAINNET_RELAY_API } from './constants/servers.js' +import { MAINNET_RELAY_API, MAINNET_RELAY_WS } from './constants/servers.js' import { SDK_VERSION } from './version.js' /** * RelayClient Configuration Options - * @param source Used to manually override the source domain used to attribute local orders - * @param logLevel Log level from 0-4, the higher the more verbose. - * @param maxPollingAttemptsBeforeTimeout The maximum number of attempts the synced api is polled before timing out. The api is polled every 5 secs (default is 30) + * + * @property {string} [baseApiUrl] - The base URL for the Relay API. Defaults to the mainnet API if not provided. + * @property {string} [source] - The source to associate your onchain activity with, should be a domain. + * @property {LogLevel} [logLevel] - Log level from 0-4, the higher the more verbose. Defaults to LogLevel.None. + * @property {number} [pollingInterval] - Interval (in ms) for polling the API for status updates. + * @property {number} [maxPollingAttemptsBeforeTimeout] - The maximum number of polling attempts before timing out. The API is polled every 5 seconds by default (default is 30 attempts). + * @property {RelayChain[]} [chains] - List of supported chains. If not provided, defaults to all mainnet/testnet chains based on the API URL. + * @property {boolean} [useGasFeeEstimations] - Whether to use gas fee estimations. Defaults to true. + * @property {string} [uiVersion] - Optional UI version string for analytics/debugging. + * @property {(message: Parameters[0], level: LogLevel) => void} [logger] - Custom logger function. If not provided, uses the default logger. + * @property {number} [confirmationPollingInterval] - Interval (in ms) for polling transaction confirmations. + * @property {Object} [websocket] - Websocket configuration options. + * @property {boolean} [websocket.enabled] - Whether to enable websocket support. Defaults to false. + * @property {string} [websocket.url] - Custom websocket URL. If not provided, falls back to the default. */ export type RelayClientOptions = { baseApiUrl?: string @@ -32,6 +43,10 @@ export type RelayClientOptions = { uiVersion?: string logger?: (message: Parameters['0'], level: LogLevel) => void confirmationPollingInterval?: number + websocket?: { + enabled?: boolean + url?: string + } } let _client: RelayClient @@ -58,6 +73,8 @@ export class RelayClient { maxPollingAttemptsBeforeTimeout?: number useGasFeeEstimations: boolean chains: RelayChain[] + websocketEnabled: boolean + websocketUrl: string log( message: Parameters['0'], level: LogLevel = LogLevel.Info @@ -78,6 +95,8 @@ export class RelayClient { this.maxPollingAttemptsBeforeTimeout = options.maxPollingAttemptsBeforeTimeout this.useGasFeeEstimations = options.useGasFeeEstimations ?? true + this.websocketEnabled = options.websocket?.enabled ?? false + this.websocketUrl = options.websocket?.url ?? MAINNET_RELAY_WS if (options.chains) { this.chains = options.chains } else if (options.baseApiUrl?.includes('testnets')) { @@ -121,6 +140,11 @@ export class RelayClient { options.useGasFeeEstimations !== undefined ? options.useGasFeeEstimations : this.useGasFeeEstimations + this.websocketEnabled = + options.websocket?.enabled !== undefined + ? options.websocket.enabled + : this.websocketEnabled + this.websocketUrl = options.websocket?.url || this.websocketUrl if (options.logger) { this.log = options.logger diff --git a/packages/sdk/src/constants/servers.ts b/packages/sdk/src/constants/servers.ts index e7004ed85..d960048ce 100644 --- a/packages/sdk/src/constants/servers.ts +++ b/packages/sdk/src/constants/servers.ts @@ -1,3 +1,7 @@ export const MAINNET_RELAY_API = 'https://api.relay.link' export const TESTNET_RELAY_API = 'https://api.testnets.relay.link' export const ASSETS_RELAY_API = 'https://assets.relay.link' + +// WebSocket endpoints +export const MAINNET_RELAY_WS = 'wss://ws.relay.link' +export const DEV_RELAY_WS = 'wss://ws.dev.relay.link' diff --git a/packages/sdk/src/types/Execute.ts b/packages/sdk/src/types/Execute.ts index 4297270fa..dde205491 100644 --- a/packages/sdk/src/types/Execute.ts +++ b/packages/sdk/src/types/Execute.ts @@ -57,6 +57,7 @@ export type Execute = { | 'waiting' | 'failure' | 'pending' + | 'submitted' | 'success' | 'unknown' progressState?: TransactionStepState | SignatureStepState diff --git a/packages/sdk/src/types/SignatureStepItem.ts b/packages/sdk/src/types/SignatureStepItem.ts index e021a132b..88c9dcb92 100644 --- a/packages/sdk/src/types/SignatureStepItem.ts +++ b/packages/sdk/src/types/SignatureStepItem.ts @@ -2,7 +2,15 @@ import type { Execute } from './Execute.js' export type SignatureStepItem = Pick< NonNullable[0], - 'status' | 'orderIds' | 'orderIndexes' | 'orderData' + | 'status' + | 'orderIds' + | 'orderIndexes' + | 'orderData' + | 'progressState' + | 'txHashes' + | 'internalTxHashes' + | 'check' + | 'isValidatingSignature' > & { data?: { sign?: { diff --git a/packages/sdk/src/types/TransactionStepItem.ts b/packages/sdk/src/types/TransactionStepItem.ts index 8c074ae8c..b44c0afbc 100644 --- a/packages/sdk/src/types/TransactionStepItem.ts +++ b/packages/sdk/src/types/TransactionStepItem.ts @@ -2,9 +2,19 @@ import type { Execute } from './Execute.js' export type TransactionStepItem = Pick< NonNullable[0], - 'status' | 'orderIds' | 'orderIndexes' | 'orderData' | 'check' + | 'status' + | 'orderIds' + | 'orderIndexes' + | 'orderData' + | 'check' + | 'progressState' + | 'txHashes' + | 'internalTxHashes' + | 'receipt' + | 'checkStatus' > & { data: { + chainId?: number data: any from: `0x${string}` to: `0x${string}` diff --git a/packages/sdk/src/utils/executeSteps.ts b/packages/sdk/src/utils/executeSteps.ts deleted file mode 100644 index e2f0e2637..000000000 --- a/packages/sdk/src/utils/executeSteps.ts +++ /dev/null @@ -1,614 +0,0 @@ -import type { - Execute, - AdaptedWallet, - TransactionStepItem, - SignatureStepItem -} from '../types/index.js' -import { pollUntilHasData, pollUntilOk } from './pollApi.js' -import { axios, prepareHyperliquidSignatureStep } from '../utils/index.js' -import type { AxiosRequestConfig } from 'axios' -import { getClient } from '../client.js' -import { LogLevel } from '../utils/logger.js' -import { sendTransactionSafely } from './transaction.js' -import { - canBatchTransactions, - prepareBatchTransaction -} from './prepareBatchTransaction.js' -import { sendUsd } from './hyperliquid.js' - -export type SetStateData = Pick< - Execute, - 'steps' | 'fees' | 'breakdown' | 'details' | 'error' | 'refunded' -> - -// /** -// * When attempting to perform actions, such as, bridging or performing a cross chain action -// * the user's account needs to meet certain requirements. For -// * example, if the user attempts to bridge currency you must check if the -// * user has enough balance, before providing the transaction to be signed by -// * the user. This function executes all transactions and signatures, in order, to complete the -// * action. -// * @param chainId matching the chain to execute on -// * @param request AxiosRequestConfig object with at least a url set -// * @param wallet ReservoirWallet object that adheres to the ReservoirWallet interface -// * @param setState Callback to update UI state has execution progresses -// * @param newJson Data passed around, which contains steps and items etc -// * @returns A promise you can await on -// */ - -export async function executeSteps( - chainId: number, - request: AxiosRequestConfig = {}, - wallet: AdaptedWallet, - setState: (data: SetStateData) => any, - newJson?: Execute, - stepOptions?: { - [stepId: string]: { - gasLimit?: string - } - } -) { - const client = getClient() - - if (client?.baseApiUrl) { - request.baseURL = client.baseApiUrl - } - - const pollingInterval = client.pollingInterval ?? 5000 - - const maximumAttempts = - client.maxPollingAttemptsBeforeTimeout ?? - (2.5 * 60 * 1000) / pollingInterval - - const chain = client.chains.find((chain) => chain.id === chainId) - if (!chain) { - throw `Unable to find chain: Chain id ${chainId}` - } - - let json = newJson - let isAtomicBatchSupported = false - try { - if (!json) { - client.log(['Execute Steps: Fetching Steps', request], LogLevel.Verbose) - const res = await axios.request(request) - json = res.data as Execute - if (res.status !== 200) throw json - client.log(['Execute Steps: Steps retrieved', json], LogLevel.Verbose) - } - - // Handle errors - if (json.error || !json.steps) throw json - - // Check if step's transactions can be batched and if wallet supports atomic batch - // If so, manipulate steps to batch transactions - if (canBatchTransactions(json.steps)) { - isAtomicBatchSupported = Boolean( - wallet?.supportsAtomicBatch && - (await wallet?.supportsAtomicBatch(chainId)) - ) - if (isAtomicBatchSupported) { - const batchedStep = prepareBatchTransaction(json.steps) - json.steps = [batchedStep] - } - } - - //Check if Hyperliquid and if so, rewrite steps to become a signature step - if ( - chainId === 1337 && - json.steps[0] && - (json.steps[0].id as any) !== 'sign' - ) { - const activeWalletChainId = await wallet?.getChainId() - const signatureStep = prepareHyperliquidSignatureStep( - json.steps, - activeWalletChainId - ) - json.steps = [signatureStep] - } - - // Update state on first call or recursion - setState({ - steps: [...json?.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - - let incompleteStepIndex = -1 - let incompleteStepItemIndex = -1 - json.steps.find((step, i) => { - if (!step.items) { - return false - } - - incompleteStepItemIndex = step.items.findIndex( - (item) => item.status == 'incomplete' - ) - if (incompleteStepItemIndex !== -1) { - incompleteStepIndex = i - return true - } - }) - - // There are no more incomplete steps - if (incompleteStepIndex === -1) { - client.log(['Execute Steps: all steps complete'], LogLevel.Verbose) - return json - } - - const step = json.steps[incompleteStepIndex] - - if (stepOptions && stepOptions[step.id]) { - const currentStepOptions = stepOptions[step.id] - step.items?.forEach((stepItem) => { - if (currentStepOptions.gasLimit) { - stepItem.data.gas = currentStepOptions.gasLimit - } - }) - } - - let stepItems = json.steps[incompleteStepIndex].items - - if (!stepItems) { - client.log( - ['Execute Steps: skipping step, no items in step'], - LogLevel.Verbose - ) - return json - } - - let { kind } = step - let stepItem = stepItems[incompleteStepItemIndex] - // If step item is missing data, poll until it is ready - if (!stepItem.data) { - client.log( - ['Execute Steps: step item data is missing, begin polling'], - LogLevel.Verbose - ) - json = (await pollUntilHasData(request, (json) => { - client.log( - ['Execute Steps: step item data is missing, polling', json], - LogLevel.Verbose - ) - const data = json as Execute - // An item is ready if: - // - data became available - // - the status changed to "completed" - return data?.steps?.[incompleteStepIndex].items?.[ - incompleteStepItemIndex - ].data || - data?.steps?.[incompleteStepIndex].items?.[incompleteStepItemIndex] - .status === 'complete' - ? true - : false - })) as Execute - if (!json.steps || !json.steps[incompleteStepIndex].items) throw json - const items = json.steps[incompleteStepIndex].items - if ( - !items || - !items[incompleteStepItemIndex] || - !items[incompleteStepItemIndex].data - ) { - throw json - } - stepItems = items - stepItem = items[incompleteStepItemIndex] - setState({ - steps: [...json?.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - client.log( - [`Execute Steps: Begin processing step items for: ${step.action}`], - LogLevel.Verbose - ) - - const promises = stepItems - .filter((stepItem) => stepItem.status === 'incomplete') - .map((stepItem) => { - return new Promise(async (resolve, reject) => { - try { - const stepData = stepItem.data - - if (!json) { - return - } - // Handle each step based on it's kind - switch (kind) { - // Make an on-chain transaction - case 'transaction': { - try { - client.log( - [ - 'Execute Steps: Begin transaction step for, sending transaction' - ], - LogLevel.Verbose - ) - - // if chainId is present in the tx data field then you should relay the tx on that chain - // otherwise, it's assumed the chain id matched the network the api request was made on - const transactionChainId = stepItem?.data?.chainId ?? chainId - - const crossChainIntentChainId = chainId - stepItem.progressState = 'confirming' - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - - // If atomic batch is supported and first item in step, batch all items in the step - const transactionStepItems = - isAtomicBatchSupported && incompleteStepItemIndex === 0 - ? stepItems - : stepItem - - await sendTransactionSafely( - transactionChainId, - transactionStepItems as TransactionStepItem[], - step, - wallet, - (txHashes) => { - client.log( - [ - 'Execute Steps: Transaction step, got transactions', - txHashes - ], - LogLevel.Verbose - ) - stepItem.txHashes = txHashes - if (json) { - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - }, - (internalTxHashes) => { - stepItem.internalTxHashes = internalTxHashes - if (json) { - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - }, - request, - undefined, - crossChainIntentChainId, - (res) => { - if (res && res.data.status === 'delayed') { - stepItem.progressState = 'validating_delayed' - } else { - stepItem.progressState = 'validating' - } - if (json) { - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - }, - json?.details, - (receipt) => { - stepItem.receipt = receipt - if (json) { - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - }, - (checkStatus) => { - if (checkStatus != stepItem.checkStatus) { - stepItem.checkStatus = checkStatus - if (json) { - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - } - } - ) - } catch (e) { - throw e - } - break - } - - // Sign a message - case 'signature': { - let signature: string | undefined - const signData = stepData['sign'] - const postData = stepData['post'] - client.log( - ['Execute Steps: Begin signature step'], - LogLevel.Verbose - ) - if (signData) { - stepItem.progressState = 'signing' - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - signature = await wallet.handleSignMessageStep( - stepItem as SignatureStepItem, - step - ) - - if (signature) { - request.params = { - ...request.params, - signature - } - } - } - - //Special Logic for Hyperliquid to send signature - if (chainId === 1337 && signature) { - await sendUsd(client, signature, stepItem) - } - - if (postData) { - client.log(['Execute Steps: Posting order'], LogLevel.Verbose) - stepItem.progressState = 'posting' - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - const postOrderUrl = new URL( - `${request.baseURL}${postData.endpoint}` - ) - const headers = { - 'Content-Type': 'application/json' - } - - if (postData.body && !postData.body.referrer) { - postData.body.referrer = client.source - } - - try { - const getData = async function () { - let response = await axios.request({ - url: postOrderUrl.href, - data: postData.body - ? JSON.stringify(postData.body) - : undefined, - method: postData.method, - params: request.params, - headers - }) - - return response - } - - const res = await getData() - - // Append new steps if returned in response - if ( - res.data && - res.data.steps && - Array.isArray(res.data.steps) - ) { - json.steps = [...json.steps, ...res.data.steps] - setState({ - steps: [...json.steps, ...res.data.steps], - fees: { ...json.fees }, - breakdown: json.breakdown, - details: json.details - }) - client.log( - [ - `Execute Steps: New steps appended from ${postData.endpoint}`, - res.data.steps - ], - LogLevel.Verbose - ) - break - } - - if (res.status > 299 || res.status < 200) throw res.data - - if (res.data.results) { - stepItem.orderData = res.data.results - } else if (res.data && res.data.orderId) { - stepItem.orderData = [ - { - orderId: res.data.orderId, - crossPostingOrderId: res.data.crossPostingOrderId, - orderIndex: res.data.orderIndex || 0 - } - ] - } - setState({ - steps: [...json?.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } catch (err) { - throw err - } - } - - // If check, poll check until validated - if (stepItem?.check) { - stepItem.progressState = 'validating' - setState({ - steps: [...json.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - stepItem.isValidatingSignature = true - setState({ - steps: [...json?.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - - const headers = { - 'Content-Type': 'application/json' - } - - await pollUntilOk( - { - url: `${request.baseURL}${stepItem?.check.endpoint}`, - method: stepItem?.check.method, - headers - }, - (res) => { - client.log( - [ - `Execute Steps: Polling execute status to check if indexed`, - res - ], - LogLevel.Verbose - ) - - //set status - if ( - res?.data?.status === 'success' && - res?.data?.txHashes - ) { - const chainTxHashes: NonNullable< - Execute['steps'][0]['items'] - >[0]['txHashes'] = res.data?.txHashes?.map( - (hash: string) => { - return { - txHash: hash, - chainId: res.data.destinationChainId ?? chain?.id - } - } - ) - - if (res?.data?.inTxHashes) { - const chainInTxHashes: NonNullable< - Execute['steps'][0]['items'] - >[0]['txHashes'] = res.data?.inTxHashes?.map( - (hash: string) => { - return { - txHash: hash, - chainId: chain?.id ?? res.data.originChainId - } - } - ) - stepItem.internalTxHashes = chainInTxHashes - } - stepItem.txHashes = chainTxHashes - - return true - } else if (res?.data?.status === 'failure') { - throw Error(res?.data?.details || 'Transaction failed') - } else if (res?.data?.status === 'delayed') { - stepItem.progressState = 'validating_delayed' - } - return false - }, - maximumAttempts, - 0, - pollingInterval - ) - } - - break - } - - default: - break - } - - stepItem.status = 'complete' - stepItem.progressState = 'complete' - stepItem.isValidatingSignature = false - setState({ - steps: [...json?.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - resolve(stepItem) - } catch (e) { - const error = e as Error - const errorMessage = error - ? error.message - : 'Error: something went wrong' - - if (error && json?.steps) { - json.steps[incompleteStepIndex].error = errorMessage - stepItem.error = errorMessage - stepItem.errorData = (e as any)?.response?.data || e - stepItem.isValidatingSignature = false - setState({ - steps: [...json?.steps], - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details - }) - } - reject(error) - } - }) - }) - - await Promise.all(promises) - - // Recursively call executeSteps() - return await executeSteps( - chainId, - request, - wallet, - setState, - json, - stepOptions - ) - } catch (err: any) { - client.log(['Execute Steps: An error occurred', err], LogLevel.Error) - const error = err && err?.response?.data ? err.response.data : err - let refunded = false - if (error && error.message) { - refunded = error.message.includes('Refunded') - } else if (error && error.includes) { - refunded = error.includes('Refunded') - } - - if (json) { - json.error = error - setState({ - steps: json.steps ? [...json.steps] : ([{}] as any), - fees: { ...json?.fees }, - breakdown: json?.breakdown, - details: json?.details, - refunded: refunded, - error - }) - } else { - json = { - error, - steps: [], - refunded - } - setState(json) - } - throw err - } -} diff --git a/packages/sdk/src/utils/executeSteps.test.ts b/packages/sdk/src/utils/executeSteps/executeSteps.test.ts similarity index 74% rename from packages/sdk/src/utils/executeSteps.test.ts rename to packages/sdk/src/utils/executeSteps/executeSteps.test.ts index 49a4f45f8..be3094d82 100644 --- a/packages/sdk/src/utils/executeSteps.test.ts +++ b/packages/sdk/src/utils/executeSteps/executeSteps.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { axios } from './axios' -import { createClient } from '../client' -import { executeBridge } from '../../tests/data/executeBridge' -import { executeSteps } from './executeSteps' -import { MAINNET_RELAY_API } from '../constants/servers' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { axios } from '../axios' +import { createClient } from '../../client' +import { executeBridge } from '../../../tests/data/executeBridge' +import { executeSteps } from '.' +import { MAINNET_RELAY_API } from '../../constants/servers' import { http } from 'viem' import { mainnet } from 'viem/chains' -import { executeBridgeAuthorize } from '../../tests/data/executeBridgeAuthorize' -import type { ChainVM, Execute } from '../types' -import { postSignatureExtraSteps } from '../../tests/data/postSignatureExtraSteps' -import { swapWithApproval } from '../../tests/data/swapWithApproval' -import { adaptViemWallet } from './viemWallet' +import { executeBridgeAuthorize } from '../../../tests/data/executeBridgeAuthorize' +import type { ChainVM, Execute } from '../../types' +import { postSignatureExtraSteps } from '../../../tests/data/postSignatureExtraSteps' +import { swapWithApproval } from '../../../tests/data/swapWithApproval' +import { adaptViemWallet } from '../viemWallet' const waitForTransactionReceiptMock = vi.fn().mockResolvedValue({ blobGasPrice: 100n, @@ -165,9 +165,9 @@ describe('Should test the executeSteps method.', () => { }) it('Should throw: Unable to find chain error.', async () => { - await expect(executeSteps(12345, {}, wallet, () => {})).rejects.toThrow( - 'Unable to find chain' - ) + await expect( + executeSteps(12345, {}, wallet, () => {}, {} as Execute) + ).rejects.toThrow('Unable to find chain') }) it('Should throw: Current chain id does not match expected', async () => { @@ -1002,46 +1002,7 @@ describe('Base tests', () => { }) expect(errorMessage).toBe('Unable to find chain: Chain id 1337') }) - it('Should fetch json if missing and fail if error returned', async () => { - let fetchedJson = false - let errorMessage: string | undefined - vi.spyOn(axios, 'request').mockImplementation((config) => { - if (config.url === 'https://api.relay.link/get/quote') { - fetchedJson = true - return Promise.resolve({ - data: { status: 'failure', details: 'Failed to check' }, - status: 400 - }) - } - - return Promise.resolve({ - data: { status: 'success' }, - status: 200 - }) - }) - executeSteps( - 1, - { - method: 'GET', - url: 'https://api.relay.link/get/quote' - }, - wallet, - () => {}, - undefined, - undefined - ).catch((e) => { - errorMessage = e - }) - await vi.waitFor(() => { - if (!errorMessage) { - throw 'Waiting for error message' - } - }) - - expect(errorMessage).toBeDefined() - expect(fetchedJson).toBeTruthy() - }) it('Should throw an error when steps are missing', async () => { let errorMessage: string | undefined executeSteps(1, {}, wallet, () => {}, {} as any, undefined).catch((e) => { @@ -1055,47 +1016,6 @@ describe('Base tests', () => { }) expect(errorMessage).toBeDefined() }) - it('Should poll if step item data is missing', async () => { - let fetchedStepItem = false - vi.spyOn(axios, 'request').mockImplementation((config) => { - if (config.url === 'https://api.relay.link/get/quote') { - fetchedStepItem = true - return Promise.resolve({ - ...bridgeData, - status: 200 - }) - } - - return Promise.resolve({ - data: { status: 'success' }, - status: 200 - }) - }) - const _bridgeData: Execute = JSON.parse(JSON.stringify(bridgeData)) - _bridgeData.steps.forEach((step) => { - step.items?.forEach((item) => { - delete item.data - }) - }) - executeSteps( - 1, - { - url: 'https://api.relay.link/get/quote', - method: 'GET' - }, - wallet, - () => {}, - _bridgeData, - undefined - ) - - await vi.waitFor(() => { - if (!fetchedStepItem) { - throw 'Waiting to fetch step item' - } - }) - expect(fetchedStepItem).toBeTruthy() - }) it('Should return the final results in the execute response', async () => { const result = await executeSteps( @@ -1244,3 +1164,411 @@ describe('Should test atomic batch transactions', () => { expect(wallet.handleSendTransactionStep).toHaveBeenCalledTimes(2) }) }) + +describe('Should test WebSocket functionality', () => { + let mockWebSocket: any + let wsConstructorSpy: any + let originalSetTimeout: typeof setTimeout + let originalClearTimeout: typeof clearTimeout + + beforeEach(() => { + // Save original timer functions + originalSetTimeout = global.setTimeout + originalClearTimeout = global.clearTimeout + + vi.clearAllMocks() + vi.resetAllMocks() + // Mock axios to return pending status for WebSocket tests + axiosRequestSpy = vi + .spyOn(axios, 'request') + .mockImplementation((config) => { + if (config.url?.includes('/intents/status')) { + return Promise.resolve({ + data: { status: 'pending' }, + status: 200 + }) + } + if ( + config.url?.includes('transactions/index') || + config.url?.includes('/execute/permits') + ) { + return Promise.resolve({ + data: { status: 'success' }, + status: 200 + }) + } + return Promise.reject(new Error('Unexpected URL')) + }) + axiosPostSpy = mockAxiosPost() + bridgeData = JSON.parse(JSON.stringify(executeBridge)) + + // Add requestId to test data for WebSocket tests + bridgeData.steps[0].requestId = '0x123' + + // Mock WebSocket + mockWebSocket = { + send: vi.fn(), + close: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + readyState: 1, + onopen: null, + onmessage: null, + onerror: null, + onclose: null + } + + wsConstructorSpy = vi.fn(() => { + // Simulate connection opening after a brief delay + setTimeout(() => { + if (mockWebSocket.onopen) { + mockWebSocket.onopen(new Event('open')) + } + }, 10) + return mockWebSocket + }) + global.WebSocket = wsConstructorSpy as any + + // Mock window for browser environment check + if (typeof window === 'undefined') { + ;(global as any).window = { + WebSocket: wsConstructorSpy + } + } else { + ;(window as any).WebSocket = wsConstructorSpy + } + + wallet = { + vmType: 'evm' as ChainVM, + getChainId: () => Promise.resolve(1), + transport: http(mainnet.rpcUrls.default.http[0]), + address: () => Promise.resolve('0x'), + handleSignMessageStep: vi.fn().mockResolvedValue('0x'), + handleSendTransactionStep: vi.fn().mockResolvedValue('0x'), + handleConfirmTransactionStep: vi + .fn() + .mockImplementation(() => new Promise(() => {})), // Never resolves + switchChain: vi.fn().mockResolvedValue('0x'), + supportsAtomicBatch: vi.fn().mockResolvedValue(false), + handleBatchTransactionStep: vi.fn().mockResolvedValue('0x') + } + + client = createClient({ + baseApiUrl: MAINNET_RELAY_API, + websocket: { + enabled: true, + url: 'ws://test.relay.link' + } + }) + }) + + afterEach(() => { + // Restore original timer functions + global.setTimeout = originalSetTimeout + global.clearTimeout = originalClearTimeout + + // Ensure fake timers are restored in case a test failed + vi.useRealTimers() + + delete (global as any).WebSocket + delete (global as any).window + }) + + it('Should open WebSocket on last step only', async () => { + // Create multi-step data + const multiStepData = { + ...bridgeData, + steps: [ + { ...bridgeData.steps[0], requestId: '0x123' }, // First step incomplete + { + ...bridgeData.steps[0], + id: 'swap', + action: 'Swap', + requestId: '0x123' + } // Second step incomplete + ] + } as Execute + + executeSteps(1, {}, wallet, ({ steps }) => {}, multiStepData, undefined) + + // Wait for first step to start processing + await vi.waitFor(() => { + expect(wallet.handleSendTransactionStep).toHaveBeenCalledTimes(1) + }) + + // WebSocket should NOT be opened for first step + expect(wsConstructorSpy).not.toHaveBeenCalled() + }) + + it('Should disable polling when WebSocket connects', async () => { + const onProgressSpy = vi.fn() + + // Add logging to debug + const originalLog = client.log + client.log = vi.fn((...args) => { + console.log('CLIENT LOG:', ...args) + originalLog.call(client, ...args) + }) + + // Verify test setup + expect(client.websocketEnabled).toBe(true) + expect(bridgeData.steps[0].requestId).toBe('0x123') + expect(bridgeData.steps[0].items[0].status).toBe('incomplete') + + executeSteps( + 1, + {}, + wallet, + ({ steps }) => { + onProgressSpy(steps) + }, + bridgeData, + undefined + ) + + // Wait for WebSocket to be created + await vi.waitFor( + () => { + expect(wsConstructorSpy).toHaveBeenCalled() + }, + { timeout: 5000 } + ) + + // Simulate WebSocket open + mockWebSocket.onopen?.() + + // Polling should be disabled + await vi.waitFor(() => { + expect(axiosRequestSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('/intents/status') + }) + ) + }) + }) + + it('Should handle WebSocket success message', async () => { + let finalSteps: Execute['steps'] | undefined + + executeSteps( + 1, + {}, + wallet, + ({ steps }) => { + finalSteps = steps + }, + bridgeData, + undefined + ) + + // Wait for WebSocket to be created + await vi.waitFor(() => { + expect(wsConstructorSpy).toHaveBeenCalled() + }) + + // Simulate WebSocket open + mockWebSocket.onopen?.() + + // Simulate success message + const successMessage = { + data: JSON.stringify({ + event: 'request.status.updated', + data: { + status: 'success', + txHashes: ['0x123'], + inTxHashes: ['0xabc'], + destinationChainId: 8453, + originChainId: 1 + } + }) + } + + mockWebSocket.onmessage?.(successMessage) + + // Wait for state update + await vi.waitFor(() => { + const stepItem = finalSteps?.[0]?.items?.[0] + expect(stepItem?.status).toBe('complete') + expect(stepItem?.progressState).toBe('complete') + expect(stepItem?.checkStatus).toBe('success') + expect(stepItem?.txHashes).toEqual([{ txHash: '0x123', chainId: 8453 }]) + expect(stepItem?.internalTxHashes).toEqual([ + { txHash: '0xabc', chainId: 1 } + ]) + }) + + // WebSocket should be closed + expect(mockWebSocket.close).toHaveBeenCalled() + }) + + it('Should fall back to polling on WebSocket error', async () => { + const stateUpdates: any[] = [] + + executeSteps( + 1, + {}, + wallet, + ({ steps }) => { + stateUpdates.push(steps) + }, + bridgeData, + undefined + ) + + // Wait for WebSocket to be created and connected + await vi.waitFor(() => { + expect(wsConstructorSpy).toHaveBeenCalled() + }) + + // Wait for WebSocket to connect + await vi.waitFor(() => { + expect(mockWebSocket.send).toHaveBeenCalled() + }) + + // Simulate WebSocket error after connection + mockWebSocket.onerror?.(new Error('Connection failed')) + + // Polling should be re-enabled and start making requests + await vi.waitFor( + () => { + // Look for the GET request to check endpoint + const checkRequests = axiosRequestSpy.mock.calls.filter( + (call) => + call[0]?.url?.includes('/intents/status') && + call[0]?.method === 'GET' + ) + expect(checkRequests.length).toBeGreaterThan(0) + }, + { timeout: 2000 } + ) + }) + + it('Should handle WebSocket failure message after timeout', async () => { + // Use fake timers to control the timeout behavior + vi.useFakeTimers() + + try { + let finalSteps: Execute['steps'] | undefined + + const executePromise = executeSteps( + 1, + {}, + wallet, + ({ steps }) => { + finalSteps = steps + }, + bridgeData, + undefined + ) + + // Wait for WebSocket to be created + await vi.waitFor(() => { + expect(wsConstructorSpy).toHaveBeenCalled() + }) + + // Simulate WebSocket open + mockWebSocket.onopen?.() + + // Simulate failure message + const failureMessage = { + data: JSON.stringify({ + event: 'request.status.updated', + data: { + status: 'failure' + } + }) + } + + mockWebSocket.onmessage?.(failureMessage) + + // Fast-forward the timers and handle the expected rejection + const advancePromise = vi.advanceTimersByTimeAsync(2000) + + // Immediately start waiting for the rejection + const rejectionPromise = + expect(executePromise).rejects.toThrow('Transaction failed') + + // Wait for both the timer advancement and the rejection + await Promise.all([advancePromise, rejectionPromise]) + + // Verify the state was updated correctly + const stepItem = finalSteps?.[0]?.items?.[0] + expect(stepItem?.status).toBe('incomplete') + expect(stepItem?.checkStatus).toBe('failure') + } finally { + // Always restore real timers + vi.useRealTimers() + } + }) + + it('Should handle WebSocket refund message', async () => { + let finalSteps: Execute['steps'] | undefined + + const executePromise = executeSteps( + 1, + {}, + wallet, + ({ steps }) => { + finalSteps = steps + }, + bridgeData, + undefined + ) + + // Wait for WebSocket to be created + await vi.waitFor(() => { + expect(wsConstructorSpy).toHaveBeenCalled() + }) + + // Simulate WebSocket open + mockWebSocket.onopen?.() + + // Simulate refund message + const refundMessage = { + data: JSON.stringify({ + event: 'request.status.updated', + data: { + status: 'refund' + } + }) + } + + mockWebSocket.onmessage?.(refundMessage) + + // Wait for state update and expect executeSteps to reject + await vi.waitFor(() => { + const stepItem = finalSteps?.[0]?.items?.[0] + expect(stepItem?.status).toBe('incomplete') + expect(stepItem?.checkStatus).toBe('refund') + }) + + // executeSteps should reject with the refund error + await expect(executePromise).rejects.toThrow('Transaction failed: Refunded') + }) + + it('Should handle WebSocket disabled configuration', async () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API, + websocket: { + enabled: false + } + }) + + executeSteps(1, {}, wallet, ({ steps }) => {}, bridgeData, undefined) + + // Wait a bit to ensure WebSocket is not created + await new Promise((resolve) => setTimeout(resolve, 100)) + + // WebSocket should NOT be created + expect(wsConstructorSpy).not.toHaveBeenCalled() + + // Should use polling instead + await vi.waitFor(() => { + expect(axiosRequestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('/intents/status') + }) + ) + }) + }) +}) diff --git a/packages/sdk/src/utils/executeSteps/index.ts b/packages/sdk/src/utils/executeSteps/index.ts new file mode 100644 index 000000000..c9a2eb540 --- /dev/null +++ b/packages/sdk/src/utils/executeSteps/index.ts @@ -0,0 +1,470 @@ +import type { + Execute, + AdaptedWallet, + TransactionStepItem +} from '../../types/index.js' +import type { AxiosRequestConfig } from 'axios' +import { getClient, RelayClient } from '../../client.js' +import { LogLevel } from '../logger.js' +import { prepareHyperliquidSignatureStep } from '../../utils/index.js' +import { + canBatchTransactions, + prepareBatchTransaction +} from '../prepareBatchTransaction.js' +import { handleSignatureStepItem } from './signatureStep.js' +import { handleTransactionStepItem } from './transactionStep.js' +import { trackRequestStatus, extractDepositRequestId } from '../websocket.js' +import { handleWebSocketUpdate } from './websocketHandlers.js' + +export type SetStateData = Pick< + Execute, + 'steps' | 'fees' | 'breakdown' | 'details' | 'error' | 'refunded' +> + +/** + * Controls the coordination between WebSocket and polling mechanisms + * to prevent duplicate status monitoring for a single execution flow + */ +export type ExecutionStatusControl = { + websocketActive: boolean + websocketConnected: boolean + closeWebSocket: undefined | (() => void) + lastKnownStatus: undefined | string + websocketFailureTimeoutId?: ReturnType | null +} + +/** + * This function orchestrates the execution of multi-step operations returned from the Relay Quote API, + * such as bridging, swapping tokens, or performing cross-chain calls. It handles transaction + * signing, submission, and validation while providing real-time progress updates through the setState callback. + * + * @param chainId - The origin chain ID for execution + * @param request - AxiosRequestConfig for API requests + * @param wallet - Wallet adapter implementing the {@link AdaptedWallet} interface + * @param setState - Callback function to update UI state during execution progress + * @param newJson - Execute object containing the steps, fees, and details from Relay Quote API + * @param stepOptions - Optional configuration for specific steps (e.g., gas limits) + * @param sharedStatusControl - Optional shared status control object for coordinating WebSocket/polling across recursive calls + * @returns Promise - The final execution result with updated status + */ + +export async function executeSteps( + chainId: number, + request: AxiosRequestConfig = {}, + wallet: AdaptedWallet, + setState: (data: SetStateData) => any, + newJson: Execute, + stepOptions?: { + [stepId: string]: { + gasLimit?: string + } + }, + sharedStatusControl?: ExecutionStatusControl +): Promise { + const client = getClient() + + if (client?.baseApiUrl) { + request.baseURL = client.baseApiUrl + } + + const pollingInterval = client.pollingInterval ?? 5000 + + const maximumAttempts = + client.maxPollingAttemptsBeforeTimeout ?? + (2.5 * 60 * 1000) / pollingInterval + + const chain = client.chains.find((chain) => chain.id === chainId) + if (!chain) { + throw `Unable to find chain: Chain id ${chainId}` + } + + let json = newJson + let isAtomicBatchSupported = false + + // Manage WebSocket and polling coordination + const statusControl: ExecutionStatusControl = sharedStatusControl || { + websocketActive: false, + websocketConnected: false, + closeWebSocket: undefined, + lastKnownStatus: undefined, + websocketFailureTimeoutId: null + } + + // WebSocket terminal status promise - rejects when failure/refund status received + let terminalStatusPromise: Promise | null = null + let rejectTerminalStatus: ((error: Error) => void) | null = null + + // Promise-based approach for WebSocket failure handling + let websocketFailedPromise: Promise | null = null + let resolveWebsocketFailed: (() => void) | null = null + + const onWebsocketFailed = (): Promise => { + if (!websocketFailedPromise) { + websocketFailedPromise = new Promise((resolve) => { + resolveWebsocketFailed = resolve + }) + } + return websocketFailedPromise + } + + try { + // Handle errors + if (json.error || !json.steps) throw json + + // Check if step's transactions can be batched and if wallet supports atomic batch (EIP-5792) + // If so, manipulate steps to batch transactions + if (canBatchTransactions(json.steps)) { + isAtomicBatchSupported = Boolean( + wallet?.supportsAtomicBatch && + (await wallet?.supportsAtomicBatch(chainId)) + ) + if (isAtomicBatchSupported) { + const batchedStep = prepareBatchTransaction(json.steps) + json.steps = [batchedStep] + } + } + + // Check if Hyperliquid and if so, rewrite steps to become a signature step + if ( + chainId === 1337 && + json.steps[0] && + (json.steps[0].id as any) !== 'sign' + ) { + const activeWalletChainId = await wallet?.getChainId() + const signatureStep = prepareHyperliquidSignatureStep( + json.steps, + activeWalletChainId + ) + json.steps = [signatureStep] + } + + // Update state on first call or recursion + setState({ + steps: [...json?.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + + let incompleteStepIndex = -1 + let incompleteStepItemIndex = -1 + json.steps.find((step, i) => { + if (!step.items) { + return false + } + + incompleteStepItemIndex = step.items.findIndex( + (item) => item.status == 'incomplete' + ) + if (incompleteStepItemIndex !== -1) { + incompleteStepIndex = i + return true + } + }) + + // There are no more incomplete steps + if (incompleteStepIndex === -1) { + client.log(['Execute Steps: all steps complete'], LogLevel.Verbose) + // Clean up WebSocket and failure timeout if execution is complete + statusControl.closeWebSocket?.() + clearFailureTimeout(statusControl, client) + return json + } + + const step = json.steps[incompleteStepIndex] + + if (stepOptions && stepOptions[step.id]) { + const currentStepOptions = stepOptions[step.id] + step.items?.forEach((stepItem) => { + if (currentStepOptions.gasLimit) { + stepItem.data.gas = currentStepOptions.gasLimit + } + }) + } + + let stepItems = json.steps[incompleteStepIndex].items + + if (!stepItems) { + client.log( + ['Execute Steps: skipping step, no items in step'], + LogLevel.Verbose + ) + return json + } + + // ============================================= + // WebSocket Setup for Real-Time Status Updates + // ============================================= + // We initialize the WebSocket connection before awaiting the step execution promises. + // + // The WebSocket is only used when all of these conditions are met: + // - WebSocket is enabled in the client configuration + // - We have a valid requestId + // - We're on the last step of execution + // - The step has incomplete items + // - WebSocket isn't already active + // - The origin and destination chain is not bitcoin + const isLastStep = incompleteStepIndex === json.steps.length - 1 + const isStepIncomplete = stepItems.some( + (item) => item.status === 'incomplete' + ) + const requestId = extractDepositRequestId(json.steps) + + if ( + client.websocketEnabled && + requestId && + isLastStep && + isStepIncomplete && + !statusControl.websocketActive && + chainId !== 8253038 && + json?.details?.currencyOut?.currency?.chainId !== 8253038 + ) { + statusControl.websocketActive = true + + // Create the promises immediately so they're available for early failures + websocketFailedPromise = new Promise((resolve) => { + resolveWebsocketFailed = resolve + }) + + terminalStatusPromise = new Promise((_, reject) => { + rejectTerminalStatus = reject + }) + + statusControl.closeWebSocket = trackRequestStatus({ + event: 'request.status.updated', + requestId: requestId, + enabled: true, + url: client.websocketUrl, + onOpen: () => { + client.log(['Websocket open'], LogLevel.Verbose) + statusControl.websocketConnected = true + }, + onUpdate: (data) => { + handleWebSocketUpdate({ + data, + stepItems, + chainId, + setState, + json, + client, + statusControl, + onTerminalError: (error: Error) => { + // Immediately reject when terminal status received + rejectTerminalStatus?.(error) + } + }) + }, + onError: (err) => { + // Handle WebSocket connection/network errors by falling back to polling + if ( + !['success', 'refund'].includes(statusControl.lastKnownStatus || '') + ) { + client.log( + ['Websocket connection error, falling back to polling', err], + LogLevel.Verbose + ) + statusControl.websocketActive = false + statusControl.websocketConnected = false + + // Clean up any pending failure timeout when WebSocket errors + clearFailureTimeout(statusControl, client) + + // Trigger the websocket failed promise to start polling fallback + resolveWebsocketFailed?.() + } + }, + onClose: () => { + client.log(['Websocket closed'], LogLevel.Verbose) + + // Clean up any pending failure timeout when WebSocket closes + clearFailureTimeout(statusControl, client) + + // Only re-enable polling if we haven't reached a terminal state + if ( + !['success', 'refund'].includes( + statusControl.lastKnownStatus || '' + ) && + !stepItems[0].error + ) { + client.log( + ['Re-enabling polling due to unexpected WebSocket closure'], + LogLevel.Verbose + ) + // Trigger the websocket failed promise to start polling + resolveWebsocketFailed?.() + } + } + }).close + } + + let { kind } = step + + client.log( + [`Execute Steps: Begin processing step items for: ${step.action}`], + LogLevel.Verbose + ) + + const promises = stepItems + .filter((stepItem) => stepItem.status === 'incomplete') + .map((stepItem) => { + return new Promise(async (resolve, reject) => { + try { + // Create step execution promise + const stepExecutionPromise = (async () => { + // Handle each step based on it's kind + switch (kind) { + // Make an on-chain transaction + case 'transaction': { + await handleTransactionStepItem({ + stepItem: stepItem as TransactionStepItem, + step, + wallet, + setState, + request, + client, + json, + chainId, + isAtomicBatchSupported, + incompleteStepItemIndex, + stepItems, + onWebsocketFailed: statusControl.websocketActive + ? onWebsocketFailed + : null, + statusControl + }) + break + } + + // Sign a message + case 'signature': { + await handleSignatureStepItem({ + stepItem, + step, + wallet, + setState, + request, + client, + json, + maximumAttempts, + pollingInterval, + chain, + onWebsocketFailed: statusControl.websocketActive + ? onWebsocketFailed + : null + }) + break + } + + default: + throw new Error( + `Unknown step kind: ${kind}. Expected 'signature' or 'transaction'` + ) + } + })() + + // Allow WebSocket terminal status (failure/refund) to immediately interrupt and stop step execution + if (statusControl.websocketActive && terminalStatusPromise) { + await Promise.race([stepExecutionPromise, terminalStatusPromise]) + } else { + await stepExecutionPromise + } + + // Mark step item as complete + stepItem.status = 'complete' + stepItem.progressState = 'complete' + stepItem.isValidatingSignature = false + setState({ + steps: [...json?.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + resolve(stepItem) + } catch (e) { + const error = e as Error + const errorMessage = error + ? error.message + : 'Error: something went wrong' + + if (error && json?.steps) { + json.steps[incompleteStepIndex].error = errorMessage + stepItem.error = errorMessage + stepItem.errorData = (e as any)?.response?.data || e + stepItem.isValidatingSignature = false + setState({ + steps: [...json?.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + } + reject(error) + } + }) + }) + + await Promise.all(promises) + + // Recursively call executeSteps() + const result = await executeSteps( + chainId, + request, + wallet, + setState, + json, + stepOptions, + statusControl + ) + + // Clean up WebSocket and failure timeout if execution completes + statusControl.closeWebSocket?.() + clearFailureTimeout(statusControl, client) + + return result + } catch (err: any) { + client.log(['Execute Steps: An error occurred', err], LogLevel.Error) + + // Clean up WebSocket and failure timeout on error + statusControl.closeWebSocket?.() + clearFailureTimeout(statusControl, client) + + const error = err && err?.response?.data ? err.response.data : err + let refunded = false + if (error && error.message) { + refunded = error.message.includes('Refunded') + } else if (error && error.includes) { + refunded = error.includes('Refunded') + } + + if (json) { + json.error = error + setState({ + steps: json.steps ? [...json.steps] : ([{}] as any), + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details, + refunded: refunded, + error + }) + } else { + json = { + error, + steps: [], + refunded + } + setState(json) + } + throw err + } +} + +function clearFailureTimeout( + statusControl: ExecutionStatusControl, + client: RelayClient +): void { + if (statusControl.websocketFailureTimeoutId) { + clearTimeout(statusControl.websocketFailureTimeoutId) + statusControl.websocketFailureTimeoutId = null + client.log(['Cleared failure timeout during cleanup'], LogLevel.Verbose) + } +} diff --git a/packages/sdk/src/utils/executeSteps/signatureStep.ts b/packages/sdk/src/utils/executeSteps/signatureStep.ts new file mode 100644 index 000000000..cbc2aab8d --- /dev/null +++ b/packages/sdk/src/utils/executeSteps/signatureStep.ts @@ -0,0 +1,261 @@ +import type { + Execute, + SignatureStepItem, + AdaptedWallet, + RelayChain +} from '../../types/index.js' +import { axios } from '../index.js' +import type { AxiosRequestConfig } from 'axios' +import { LogLevel } from '../logger.js' +import type { RelayClient } from '../../client.js' +import type { SetStateData } from './index.js' +import { sendUsd } from '../hyperliquid.js' + +/** + * Handles the execution of a signature step item, including signing, posting, and validation. + */ +export async function handleSignatureStepItem({ + stepItem, + step, + wallet, + setState, + request, + client, + json, + maximumAttempts, + pollingInterval, + chain, + onWebsocketFailed +}: { + stepItem: SignatureStepItem + step: Execute['steps'][0] + wallet: AdaptedWallet + setState: (data: SetStateData) => any + request: AxiosRequestConfig + client: RelayClient + json: Execute + maximumAttempts: number + pollingInterval: number + chain: RelayChain + onWebsocketFailed: (() => Promise) | null +}): Promise { + if (!stepItem.data) { + throw `Step item is missing data` + } + + let signature: string | undefined + const signData = stepItem.data['sign'] + const postData = stepItem.data['post'] + + client.log(['Execute Steps: Begin signature step'], LogLevel.Verbose) + + if (signData) { + stepItem.progressState = 'signing' + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + signature = await wallet.handleSignMessageStep(stepItem, step) + + if (signature) { + request.params = { + ...request.params, + signature + } + } + } + + if (chain.id === 1337 && signature) { + await sendUsd(client, signature, stepItem) + } + + if (postData) { + client.log(['Execute Steps: Posting order'], LogLevel.Verbose) + stepItem.progressState = 'posting' + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + const postOrderUrl = new URL(`${request.baseURL}${postData.endpoint}`) + const headers = { + 'Content-Type': 'application/json' + } + + if (postData.body && !postData.body.referrer) { + postData.body.referrer = client.source + } + + try { + const getData = async function () { + let response = await axios.request({ + url: postOrderUrl.href, + data: postData.body ? JSON.stringify(postData.body) : undefined, + method: postData.method, + params: request.params, + headers + }) + + return response + } + + const res = await getData() + + // Append new steps if returned in response + if (res.data && res.data.steps && Array.isArray(res.data.steps)) { + json.steps = [...json.steps, ...res.data.steps] + setState({ + steps: [...json.steps, ...res.data.steps], + fees: { ...json.fees }, + breakdown: json.breakdown, + details: json.details + }) + client.log( + [ + `Execute Steps: New steps appended from ${postData.endpoint}`, + res.data.steps + ], + LogLevel.Verbose + ) + return + } + + if (res.status > 299 || res.status < 200) throw res.data + + if (res.data.results) { + stepItem.orderData = res.data.results + } else if (res.data && res.data.orderId) { + stepItem.orderData = [ + { + orderId: res.data.orderId, + crossPostingOrderId: res.data.crossPostingOrderId, + orderIndex: res.data.orderIndex || 0 + } + ] + } + setState({ + steps: [...json?.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + } catch (err) { + throw err + } + } + + // If check, poll check until validated + if (stepItem?.check) { + stepItem.progressState = 'validating' + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + stepItem.isValidatingSignature = true + setState({ + steps: [...json?.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + + const headers = { + 'Content-Type': 'application/json' + } + + // If websocket is enabled, wait for it to fail before falling back to polling + if (onWebsocketFailed) { + client.log( + [ + 'Waiting for WebSocket to fail before starting signature validation polling' + ], + LogLevel.Verbose + ) + try { + await onWebsocketFailed() + client.log( + ['WebSocket failed, starting signature polling'], + LogLevel.Verbose + ) + } catch (e) { + client.log( + ['WebSocket failed promise rejected, skipping signature polling'], + LogLevel.Verbose + ) + return + } + } + + // Start polling for signature validation + const pollWithCancellation = async () => { + let attemptCount = 0 + while (attemptCount < maximumAttempts) { + try { + const res = await axios.request({ + url: `${request.baseURL}${stepItem?.check?.endpoint}`, + method: stepItem?.check?.method, + headers + }) + + client.log( + [`Execute Steps: Polling execute status to check if indexed`, res], + LogLevel.Verbose + ) + + // Check status + if (res?.data?.status === 'success' && res?.data?.txHashes) { + const chainTxHashes: NonNullable< + Execute['steps'][0]['items'] + >[0]['txHashes'] = res.data?.txHashes?.map((hash: string) => { + return { + txHash: hash, + chainId: res.data.destinationChainId ?? chain?.id + } + }) + + if (res?.data?.inTxHashes) { + const chainInTxHashes: NonNullable< + Execute['steps'][0]['items'] + >[0]['txHashes'] = res.data.inTxHashes.map((hash: string) => { + return { + txHash: hash, + chainId: chain?.id ?? res.data.originChainId + } + }) + stepItem.internalTxHashes = chainInTxHashes + } + stepItem.txHashes = chainTxHashes + return // Success - exit polling + } else if (res?.data?.status === 'failure') { + throw Error(res?.data?.details || 'Transaction failed') + } else if (res?.data?.status === 'delayed') { + stepItem.progressState = 'validating_delayed' + setState({ + steps: [...json?.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + } + + attemptCount++ + await new Promise((resolve) => setTimeout(resolve, pollingInterval)) + } catch (error) { + throw error + } + } + + // Max attempts reached + throw new Error( + `Failed to get an ok response after ${attemptCount} attempt(s), aborting` + ) + } + + await pollWithCancellation() + } +} diff --git a/packages/sdk/src/utils/executeSteps/transactionStep.ts b/packages/sdk/src/utils/executeSteps/transactionStep.ts new file mode 100644 index 000000000..481f1d887 --- /dev/null +++ b/packages/sdk/src/utils/executeSteps/transactionStep.ts @@ -0,0 +1,139 @@ +import type { + Execute, + AdaptedWallet, + TransactionStepItem +} from '../../types/index.js' +import { sendTransactionSafely } from '../transaction.js' +import type { AxiosRequestConfig } from 'axios' +import { LogLevel } from '../logger.js' +import type { RelayClient } from '../../client.js' +import type { SetStateData } from './index.js' + +/** + * Handles the execution of a transaction step item, including transaction submission and validation. + */ +export async function handleTransactionStepItem({ + stepItem, + step, + wallet, + setState, + request, + client, + json, + chainId, + isAtomicBatchSupported, + incompleteStepItemIndex, + stepItems, + onWebsocketFailed, + statusControl +}: { + stepItem: TransactionStepItem + step: Execute['steps'][0] + wallet: AdaptedWallet + setState: (data: SetStateData) => void + request: AxiosRequestConfig + client: RelayClient + json: Execute + chainId: number + isAtomicBatchSupported: boolean + incompleteStepItemIndex: number + stepItems: Execute['steps'][0]['items'] + onWebsocketFailed: (() => Promise) | null + statusControl?: { lastKnownStatus?: string } +}): Promise { + if (!stepItem.data) { + throw `Step item is missing data` + } + + client.log( + ['Execute Steps: Begin transaction step for, sending transaction'], + LogLevel.Verbose + ) + + // if chainId is present in the tx data field then you should relay the tx on that chain + // otherwise, it's assumed the chain id matched the network the api request was made on + const transactionChainId = stepItem.data?.chainId ?? chainId + + const crossChainIntentChainId = chainId + stepItem.progressState = 'confirming' + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + + // If atomic batch is supported and first item in step, batch all items in the step + const transactionStepItems = + isAtomicBatchSupported && incompleteStepItemIndex === 0 + ? stepItems + : stepItem + + await sendTransactionSafely( + transactionChainId, + transactionStepItems as TransactionStepItem[], + step, + wallet, + (txHashes) => { + client.log( + ['Execute Steps: Transaction step, got transactions', txHashes], + LogLevel.Verbose + ) + stepItem.txHashes = txHashes + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + }, + (internalTxHashes) => { + stepItem.internalTxHashes = internalTxHashes + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + }, + onWebsocketFailed, + request, + undefined, + crossChainIntentChainId, + (res) => { + if (res && res.data.status === 'delayed') { + stepItem.progressState = 'validating_delayed' + } else { + stepItem.progressState = 'validating' + } + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + }, + json?.details, + (receipt) => { + stepItem.receipt = receipt + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + }, + (checkStatus) => { + if (checkStatus != stepItem.checkStatus) { + stepItem.checkStatus = checkStatus + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + } + }, + statusControl + ) +} diff --git a/packages/sdk/src/utils/executeSteps/websocketHandlers.ts b/packages/sdk/src/utils/executeSteps/websocketHandlers.ts new file mode 100644 index 000000000..624739549 --- /dev/null +++ b/packages/sdk/src/utils/executeSteps/websocketHandlers.ts @@ -0,0 +1,226 @@ +import type { Execute } from '../../types/index.js' +import type { RequestStatusUpdatedPayload } from '../websocket.js' +import type { SetStateData } from './index.js' +import { LogLevel } from '../logger.js' +import type { RelayClient } from '../../client.js' + +interface WebSocketUpdateHandlerParams { + data: RequestStatusUpdatedPayload + stepItems: Execute['steps'][0]['items'] + chainId: number + setState: (data: SetStateData) => void + json: Execute + client: RelayClient + statusControl: { + closeWebSocket?: () => void + lastKnownStatus?: string + websocketFailureTimeoutId?: ReturnType | null + } + onTerminalError?: (error: Error) => void +} + +export function handleWebSocketUpdate({ + data, + stepItems, + chainId, + setState, + json, + client, + statusControl, + onTerminalError +}: WebSocketUpdateHandlerParams): void { + statusControl.lastKnownStatus = data.status + + // Clear any existing failure timeout if we receive a non-failure status + // This handles the case where failure -> pending -> refund flow occurs + if (data.status !== 'failure') { + clearFailureTimeout( + statusControl, + client, + `status changed to ${data.status}` + ) + } + + // Handle terminal states (success, refund) + if (isTerminalStatus(data.status)) { + client.log( + ['WebSocket received terminal status: ', data.status], + LogLevel.Verbose + ) + statusControl.closeWebSocket?.() + + switch (data.status) { + case 'success': + handleSuccessStatus(data, stepItems, chainId, setState, json, client) + break + case 'refund': + handleRefundStatus(client, stepItems, onTerminalError) + break + } + } + // Handle failure status with delay (refund flow: pending -> failure -> pending -> refund) + else if (data.status === 'failure') { + handleFailureStatusWithDelay( + stepItems, + client, + statusControl, + onTerminalError + ) + } + // Handle pending status - just log it + else if (data.status === 'pending') { + client.log(['WebSocket received pending status'], LogLevel.Verbose) + } +} + +function isTerminalStatus(status: string): boolean { + // Note: 'failure' is not immediately terminal due to the refund flow: + // pending -> failure -> pending -> refund + return ['success', 'refund'].includes(status) +} + +function handleSuccessStatus( + data: RequestStatusUpdatedPayload, + stepItems: Execute['steps'][0]['items'], + chainId: number, + setState: (data: SetStateData) => void, + json: Execute, + client: RelayClient +): void { + // Update txHashes if provided + if (data.txHashes && data.txHashes.length > 0) { + const txHashes = data.txHashes.map((hash: string) => ({ + txHash: hash, + chainId: data.destinationChainId ?? chainId + })) + updateIncompleteItems(stepItems, (item) => { + item.txHashes = txHashes + }) + } + + // Update internalTxHashes if provided + if (data.inTxHashes && data.inTxHashes.length > 0) { + const internalTxHashes = data.inTxHashes.map((hash: string) => ({ + txHash: hash, + chainId: data.originChainId ?? chainId + })) + updateIncompleteItems(stepItems, (item) => { + item.internalTxHashes = internalTxHashes + }) + } + + // Mark step items as complete + updateIncompleteItems(stepItems, (item) => { + item.status = 'complete' + item.progressState = 'complete' + item.checkStatus = 'success' + }) + + // Update state with completed steps + setState({ + steps: [...json.steps], + fees: { ...json?.fees }, + breakdown: json?.breakdown, + details: json?.details + }) + + client.log(['WebSocket: Step completed successfully', data], LogLevel.Verbose) +} + +function handleFailureStatus( + client: RelayClient, + stepItems: Execute['steps'][0]['items'], + onTerminalError?: (error: Error) => void +): void { + client.log(['WebSocket: transaction failed'], LogLevel.Error) + updateIncompleteItems(stepItems, (item) => { + item.checkStatus = 'failure' + }) + + const error = new Error('Transaction failed') + onTerminalError?.(error) +} + +function handleRefundStatus( + client: RelayClient, + stepItems: Execute['steps'][0]['items'], + onTerminalError?: (error: Error) => void +): void { + client.log(['WebSocket: transaction refunded'], LogLevel.Verbose) + updateIncompleteItems(stepItems, (item) => { + item.checkStatus = 'refund' + }) + const error = new Error('Transaction failed: Refunded') + onTerminalError?.(error) +} + +function handleFailureStatusWithDelay( + stepItems: Execute['steps'][0]['items'], + client: RelayClient, + statusControl: { + closeWebSocket?: () => void + lastKnownStatus?: string + websocketFailureTimeoutId?: ReturnType | null + }, + onTerminalError?: (error: Error) => void +): void { + // Clear any existing failure timeout to handle multiple failure statuses + clearFailureTimeout(statusControl, client, 'new failure status received') + + client.log( + [ + 'WebSocket received failure status, waiting 2 seconds for potential status change' + ], + LogLevel.Verbose + ) + + // Set a 2-second timeout to handle the case where failure is truly terminal + statusControl.websocketFailureTimeoutId = setTimeout(() => { + // Only proceed with failure handling if timeout still exists and status is still failure + if ( + statusControl.websocketFailureTimeoutId && + statusControl.lastKnownStatus === 'failure' + ) { + client.log( + ['WebSocket: 2-second timeout expired, treating failure as terminal'], + LogLevel.Error + ) + + // Clear the timeout state + statusControl.websocketFailureTimeoutId = null + + // Handle as terminal failure + handleFailureStatus(client, stepItems, onTerminalError) + } else { + client.log( + ['WebSocket: Failure timeout cancelled due to status change'], + LogLevel.Verbose + ) + } + }, 2000) // 2 seconds +} + +function updateIncompleteItems( + stepItems: Execute['steps'][0]['items'], + updateFn: (item: any) => void +): void { + stepItems.forEach((item) => { + if (item.status === 'incomplete') { + updateFn(item) + } + }) +} + +function clearFailureTimeout( + statusControl: { + websocketFailureTimeoutId?: ReturnType | null + }, + client: RelayClient, + reason: string +): void { + if (statusControl.websocketFailureTimeoutId) { + clearTimeout(statusControl.websocketFailureTimeoutId) + statusControl.websocketFailureTimeoutId = null + client.log([`Cleared failure timeout: ${reason}`], LogLevel.Verbose) + } +} diff --git a/packages/sdk/src/utils/index.ts b/packages/sdk/src/utils/index.ts index 440208fa0..318b0780c 100644 --- a/packages/sdk/src/utils/index.ts +++ b/packages/sdk/src/utils/index.ts @@ -1,4 +1,4 @@ -export { executeSteps } from './executeSteps.js' +export { executeSteps } from './executeSteps/index.js' export { setParams } from './params.js' export { pollUntilOk, pollUntilHasData } from './pollApi.js' export { request, APIError, isAPIError } from './request.js' diff --git a/packages/sdk/src/utils/transaction.ts b/packages/sdk/src/utils/transaction.ts index 58201830c..981e738b7 100644 --- a/packages/sdk/src/utils/transaction.ts +++ b/packages/sdk/src/utils/transaction.ts @@ -42,6 +42,7 @@ export async function sendTransactionSafely( setInternalTxHashes: ( tx: NonNullable[0]['internalTxHashes'] ) => void, + onWebsocketFailed: (() => Promise) | null, request: AxiosRequestConfig, headers?: AxiosRequestHeaders, crossChainIntentChainId?: number, @@ -50,7 +51,8 @@ export async function sendTransactionSafely( setReceipt?: (receipt: TransactionReceipt | SvmReceipt | SuiReceipt) => void, setCheckStatus?: ( checkStatus: NonNullable[0]['checkStatus'] - ) => void + ) => void, + statusControl?: { lastKnownStatus?: string } ) { const client = getClient() try { @@ -195,12 +197,30 @@ export async function sendTransactionSafely( // Poll the confirmation url to confirm the transaction went through const pollForConfirmation = async (receiptController?: AbortController) => { isValidating?.() + + // If websocket is enabled, wait for it to fail before falling back to polling + if (onWebsocketFailed) { + try { + await onWebsocketFailed() + client.log(['WebSocket failed, starting polling'], LogLevel.Verbose) + } catch (e) { + client.log( + ['WebSocket failed promise rejected, skipping polling'], + LogLevel.Verbose + ) + return + } + } + + // Start polling while ( waitingForConfirmation && attemptCount < maximumAttempts && !transactionCancelled && !confirmationError ) { + client.log(['Polling for confirmation'], LogLevel.Verbose) + let res: AxiosResponse | undefined if (check?.endpoint && !request?.data?.useExternalLiquidity) { let endpoint = check?.endpoint @@ -267,6 +287,7 @@ export async function sendTransactionSafely( if (transactionCancelled) { throw Error('Transaction was cancelled') } + return true } @@ -410,6 +431,10 @@ export async function sendTransactionSafely( await confirmationPromise } else { waitingForConfirmation = false + // For same chain transactions, mark as complete to prevent WebSocket polling fallback + if (statusControl) { + statusControl.lastKnownStatus = 'success' + } } } diff --git a/packages/sdk/src/utils/websocket.ts b/packages/sdk/src/utils/websocket.ts new file mode 100644 index 000000000..93b969a55 --- /dev/null +++ b/packages/sdk/src/utils/websocket.ts @@ -0,0 +1,122 @@ +import type { Execute } from '../types/Execute.js' +import { MAINNET_RELAY_WS } from '../constants/servers.js' + +export interface RequestStatusUpdatedPayload { + status: string + inTxHashes?: string[] + txHashes?: string[] + updatedAt: number + originChainId?: number + destinationChainId?: number +} + +export type WebSocketEventPayloads = { + 'request.status.updated': RequestStatusUpdatedPayload +} + +export type WebSocketEvent = keyof WebSocketEventPayloads + +export interface TrackRequestStatusOptions { + event: E + requestId: string + onOpen?: () => void + onUpdate: (data: WebSocketEventPayloads[E]) => void + onError?: (err: any) => void + onClose?: () => void + isTestnet?: boolean + enabled?: boolean +} + +// @TODO: remove once requestId gets added to top level of quote response +export const extractDepositRequestId = (steps?: Execute['steps'] | null) => { + if (!steps?.length) return null + + // Find the first step that has a requestId + return steps.find((step) => step.requestId)?.requestId || null +} + +export function trackRequestStatus({ + event, + requestId, + onOpen, + onUpdate, + onError, + onClose, + url, + enabled = false +}: TrackRequestStatusOptions & { url?: string }) { + if ( + !enabled || + typeof window === 'undefined' || + typeof window.WebSocket === 'undefined' + ) { + return { close: () => {} } + } + + const socketUrl = url || MAINNET_RELAY_WS + const socket = new WebSocket(socketUrl) + + // Set up a connection timeout + const connectionTimeout = setTimeout(() => { + if (socket.readyState === WebSocket.CONNECTING) { + socket.close() + onError?.(new Error('WebSocket connection timeout')) + } + }, 2000) // 2 second timeout + + socket.onopen = () => { + clearTimeout(connectionTimeout) + if (onOpen) onOpen() + socket.send( + JSON.stringify({ + type: 'subscribe', + event, + filters: { id: requestId } + }) + ) + } + + socket.onmessage = async (eventMsg) => { + try { + // Handle both string and Blob data + let data: string + if (eventMsg.data instanceof Blob) { + data = await eventMsg.data.text() + } else { + data = eventMsg.data + } + + const msg = JSON.parse(data) + + // Handle different message types + if (msg.type === 'connection') { + // Connection established + } else if (msg.type === 'subscribe') { + // Subscription confirmed + } else if (msg.event === event && msg.data) { + onUpdate(msg.data) + } else if (msg.data?.event === event && msg.data?.data) { + onUpdate(msg.data.data) + } + } catch (err) { + onError?.(err) + } + } + + socket.onerror = (err) => { + clearTimeout(connectionTimeout) + onError?.(err) + } + + socket.onclose = () => { + clearTimeout(connectionTimeout) + onClose?.() + } + + return { + close: () => { + clearTimeout(connectionTimeout) + socket.close() + } + } +}