From 609d3387b8aaa90d5e22468b8bb124dc40a90cd6 Mon Sep 17 00:00:00 2001 From: 0xtxbi Date: Thu, 19 Feb 2026 11:04:00 +0100 Subject: [PATCH 1/6] update types --- packages/sdk/src/types/Requests.ts | 8 ++++++++ packages/sdk/src/types/index.ts | 1 + 2 files changed, 9 insertions(+) create mode 100644 packages/sdk/src/types/Requests.ts diff --git a/packages/sdk/src/types/Requests.ts b/packages/sdk/src/types/Requests.ts new file mode 100644 index 00000000..673a62f1 --- /dev/null +++ b/packages/sdk/src/types/Requests.ts @@ -0,0 +1,8 @@ +import type { paths } from './api.js' + +export type UserTransactionsResponse = + paths['/requests/v2']['get']['responses']['200']['content']['application/json'] + +export type RelayTransaction = NonNullable< + NonNullable['requests'] +>[0] diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 417eb325..a63a161a 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -6,3 +6,4 @@ export * from './AdaptedWallet.js' export * from './RelayChain.js' export * from './Progress.js' export * from './BatchExecutor.js' +export * from './Requests.js' From 806f2466286e2c7ef612c5c6482007eb4ee9f977 Mon Sep 17 00:00:00 2001 From: 0xtxbi Date: Thu, 19 Feb 2026 11:04:42 +0100 Subject: [PATCH 2/6] add callback --- packages/sdk/src/actions/execute.ts | 149 +++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/actions/execute.ts b/packages/sdk/src/actions/execute.ts index e9a2949f..9ad029cd 100644 --- a/packages/sdk/src/actions/execute.ts +++ b/packages/sdk/src/actions/execute.ts @@ -1,10 +1,17 @@ -import type { AdaptedWallet, ProgressData, Execute } from '../types/index.js' +import type { + AdaptedWallet, + ProgressData, + Execute, + RelayTransaction +} from '../types/index.js' import { getClient } from '../client.js' import { executeSteps, adaptViemWallet, getCurrentStepData, - safeStructuredClone + safeStructuredClone, + request as requestApi, + getApiKeyHeader } from '../utils/index.js' import { type WalletClient } from 'viem' import { @@ -12,12 +19,14 @@ import { type AdaptViemWalletOptions } from '../utils/viemWallet.js' import { isDeadAddress } from '../constants/address.js' +import { extractDepositRequestId } from '../utils/websocket.js' export type ExecuteActionParameters = { quote: Execute wallet: AdaptedWallet | WalletClient depositGasLimit?: string onProgress?: (data: ProgressData) => any + onTransactionReceived?: (transaction: RelayTransaction) => any } & AdaptViemWalletOptions /** @@ -26,6 +35,7 @@ export type ExecuteActionParameters = { * @param data.depositGasLimit A gas limit to use in base units (wei, etc) * @param data.wallet Wallet object that adheres to the AdaptedWakket interface or a viem WalletClient * @param data.onProgress Callback to update UI state as execution progresses + * @param data.onTransactionReceived Callback fired when /requests metadata is available * @param abortController Optional AbortController to cancel the execution */ export function execute(data: ExecuteActionParameters): Promise<{ @@ -34,8 +44,14 @@ export function execute(data: ExecuteActionParameters): Promise<{ }> & { abortController: AbortController } { - const { quote, wallet, depositGasLimit, onProgress, disableCapabilitiesCheck } = - data + const { + quote, + wallet, + depositGasLimit, + onProgress, + onTransactionReceived, + disableCapabilitiesCheck + } = data const client = getClient() if (!client.baseApiUrl || !client.baseApiUrl.length) { @@ -117,6 +133,12 @@ export function execute(data: ExecuteActionParameters): Promise<{ ) .then((data) => { resolve({ data, abortController }) + enrichExecutionWithRequestMetadata({ + data, + abortController, + onProgress, + onTransactionReceived + }).catch(() => undefined) }) .catch(reject) }) @@ -132,3 +154,122 @@ export function execute(data: ExecuteActionParameters): Promise<{ throw err } } + +async function enrichExecutionWithRequestMetadata({ + data, + abortController, + onProgress, + onTransactionReceived +}: { + data: Execute + abortController: AbortController + onProgress?: (data: ProgressData) => any + onTransactionReceived?: (transaction: RelayTransaction) => any +}) { + try { + const requestId = extractDepositRequestId(data.steps) + if (!requestId) { + return + } + + const transaction = await pollRequestMetadataById(requestId) + if (!transaction) { + return + } + + const metadata = transaction.data?.metadata + const nextCurrencyOut = metadata?.currencyOut + + if (!nextCurrencyOut) { + return + } + + onTransactionReceived?.(transaction) + + const existingCurrencyOut = data.details?.currencyOut + const amountChanged = + nextCurrencyOut.amount !== existingCurrencyOut?.amount || + nextCurrencyOut.amountFormatted !== existingCurrencyOut?.amountFormatted || + nextCurrencyOut.amountUsd !== existingCurrencyOut?.amountUsd + + if (!amountChanged) { + return + } + + data.details = { + ...data.details, + sender: metadata?.sender ?? data.details?.sender, + recipient: metadata?.recipient ?? data.details?.recipient, + currencyIn: metadata?.currencyIn ?? data.details?.currencyIn, + currencyOut: nextCurrencyOut, + currencyGasTopup: metadata?.currencyGasTopup ?? data.details?.currencyGasTopup + } + + if (!onProgress || abortController.signal.aborted) { + return + } + + const { currentStep, currentStepItem, txHashes } = getCurrentStepData( + data.steps + ) + onProgress({ + steps: data.steps, + fees: data.fees, + breakdown: data.breakdown, + details: data.details, + currentStep, + currentStepItem, + txHashes, + refunded: data.refunded, + error: data.error + }) + } catch { + return + } +} + +async function pollRequestMetadataById( + requestId: string +): Promise { + const client = getClient() + const pollingInterval = client.pollingInterval ?? 5000 + const maxAttempts = + client.maxPollingAttemptsBeforeTimeout ?? + (2.5 * 60 * 1000) / pollingInterval + const requestConfig = { + url: `${client.baseApiUrl}/requests/v2`, + method: 'get' as const, + params: { + id: requestId, + limit: 1, + sortBy: 'updatedAt' as const, + sortDirection: 'desc' as const + }, + headers: { + 'Content-Type': 'application/json', + ...getApiKeyHeader(client, client.baseApiUrl), + 'relay-sdk-version': client.version ?? 'unknown' + } + } + + let transaction: RelayTransaction | undefined = undefined + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const res = (await requestApi(requestConfig)) as { + data?: { + requests?: RelayTransaction[] + } + } + transaction = res.data?.requests?.[0] + + if (transaction?.data?.metadata?.currencyOut) { + break + } + + if (attempt < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, pollingInterval)) + } + } + + return transaction?.data?.metadata?.currencyOut ? transaction : undefined +} From ed6538f246d64ac4e600270c9c8b226dd79eee03 Mon Sep 17 00:00:00 2001 From: 0xtxbi Date: Thu, 19 Feb 2026 11:05:12 +0100 Subject: [PATCH 3/6] add test --- packages/sdk/src/actions/execute.test.ts | 70 ++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/sdk/src/actions/execute.test.ts b/packages/sdk/src/actions/execute.test.ts index 5eede692..60cda3f8 100644 --- a/packages/sdk/src/actions/execute.test.ts +++ b/packages/sdk/src/actions/execute.test.ts @@ -6,6 +6,7 @@ import { MAINNET_RELAY_API } from '../constants' import { executeBridge } from '../../tests/data/executeBridge' import type { AdaptedWallet, Execute } from '../types' import { evmDeadAddress } from '../constants/address' +import { axios } from '../utils' let client: RelayClient | undefined let wallet: AdaptedWallet = { @@ -174,4 +175,73 @@ describe('Should test the execute action.', () => { }) ).toThrow('Recipient should never be burn address') }) + + it('Should emit settled metadata through onProgress and onTransactionReceived', async () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API + }) + + const onProgress = vi.fn() + const onTransactionReceived = vi.fn() + const settledAmount = '1002000000000000' + + executeStepsSpy.mockImplementation( + ( + chainId: any, + request: any, + wallet: any, + progress: any, + clonedQuote: Execute, + options?: any + ) => { + progress({ + steps: clonedQuote.steps, + fees: clonedQuote.fees, + breakdown: clonedQuote.breakdown, + details: clonedQuote.details + }) + return Promise.resolve(clonedQuote) + } + ) + + const axiosRequestSpy = vi.spyOn(axios, 'request').mockResolvedValue({ + data: { + requests: [ + { + id: '0xabc', + data: { + metadata: { + currencyOut: { + ...quote.details?.currencyOut, + amount: settledAmount, + amountFormatted: '0.001002' + } + } + } + } + ] + } + } as any) + + await client?.actions?.execute({ + wallet, + quote, + onProgress, + onTransactionReceived + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(onTransactionReceived).toHaveBeenCalledWith( + expect.objectContaining({ + id: '0xabc' + }) + ) + expect(onProgress).toHaveBeenCalled() + expect(onProgress.mock.calls.at(-1)?.[0]?.details?.currencyOut?.amount).toBe( + settledAmount + ) + + axiosRequestSpy.mockRestore() + }) }) From 69d9c7381631ff8169bcbbb8ff4081bb8f59f9d0 Mon Sep 17 00:00:00 2001 From: 0xtxbi Date: Thu, 19 Feb 2026 11:07:51 +0100 Subject: [PATCH 4/6] feat: changeset --- .changeset/quiet-candies-act.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-candies-act.md diff --git a/.changeset/quiet-candies-act.md b/.changeset/quiet-candies-act.md new file mode 100644 index 00000000..d94c37dc --- /dev/null +++ b/.changeset/quiet-candies-act.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-sdk': patch +--- + +Fix execute() to return settled currencyOut; add onTransactionReceived From 6f3d4c6de55ffc608339e668a66e1b1ddc6e26e4 Mon Sep 17 00:00:00 2001 From: 0xtxbi Date: Tue, 24 Feb 2026 19:41:16 +0100 Subject: [PATCH 5/6] clean up --- .changeset/quiet-candies-act.md | 2 +- packages/sdk/src/actions/execute.test.ts | 100 +++++++++++++++++++---- packages/sdk/src/actions/execute.ts | 2 +- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.changeset/quiet-candies-act.md b/.changeset/quiet-candies-act.md index d94c37dc..13d581d9 100644 --- a/.changeset/quiet-candies-act.md +++ b/.changeset/quiet-candies-act.md @@ -2,4 +2,4 @@ '@relayprotocol/relay-sdk': patch --- -Fix execute() to return settled currencyOut; add onTransactionReceived +Add onTransactionReceived to execute() for settled request metadata diff --git a/packages/sdk/src/actions/execute.test.ts b/packages/sdk/src/actions/execute.test.ts index 60cda3f8..309c4aa8 100644 --- a/packages/sdk/src/actions/execute.test.ts +++ b/packages/sdk/src/actions/execute.test.ts @@ -178,7 +178,9 @@ describe('Should test the execute action.', () => { it('Should emit settled metadata through onProgress and onTransactionReceived', async () => { client = createClient({ - baseApiUrl: MAINNET_RELAY_API + baseApiUrl: MAINNET_RELAY_API, + pollingInterval: 1, + maxPollingAttemptsBeforeTimeout: 3 }) const onProgress = vi.fn() @@ -204,24 +206,50 @@ describe('Should test the execute action.', () => { } ) - const axiosRequestSpy = vi.spyOn(axios, 'request').mockResolvedValue({ - data: { - requests: [ - { - id: '0xabc', - data: { - metadata: { - currencyOut: { - ...quote.details?.currencyOut, - amount: settledAmount, - amountFormatted: '0.001002' + const axiosRequestSpy = vi + .spyOn(axios, 'request') + .mockResolvedValueOnce({ + data: { + requests: [ + { + id: '0xabc', + data: { + metadata: {} + } + } + ] + } + } as any) + .mockResolvedValueOnce({ + data: { + requests: [ + { + id: '0xabc', + data: { + metadata: {} + } + } + ] + } + } as any) + .mockResolvedValueOnce({ + data: { + requests: [ + { + id: '0xabc', + data: { + metadata: { + currencyOut: { + ...quote.details?.currencyOut, + amount: settledAmount, + amountFormatted: '0.001002' + } } } } - } - ] - } - } as any) + ] + } + } as any) await client?.actions?.execute({ wallet, @@ -230,13 +258,16 @@ describe('Should test the execute action.', () => { onTransactionReceived }) - await new Promise((resolve) => setTimeout(resolve, 0)) + await vi.waitFor(() => { + expect(onTransactionReceived).toHaveBeenCalledTimes(1) + }) expect(onTransactionReceived).toHaveBeenCalledWith( expect.objectContaining({ id: '0xabc' }) ) + expect(axiosRequestSpy).toHaveBeenCalledTimes(3) expect(onProgress).toHaveBeenCalled() expect(onProgress.mock.calls.at(-1)?.[0]?.details?.currencyOut?.amount).toBe( settledAmount @@ -244,4 +275,39 @@ describe('Should test the execute action.', () => { axiosRequestSpy.mockRestore() }) + + it('Should not emit onTransactionReceived when settled metadata is unavailable', async () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API, + pollingInterval: 1, + maxPollingAttemptsBeforeTimeout: 1 + }) + + const onTransactionReceived = vi.fn() + const axiosRequestSpy = vi.spyOn(axios, 'request').mockResolvedValue({ + data: { + requests: [ + { + id: '0xabc', + data: { + metadata: {} + } + } + ] + } + } as any) + + await client?.actions?.execute({ + wallet, + quote, + onTransactionReceived + }) + + await vi.waitFor(() => { + expect(axiosRequestSpy).toHaveBeenCalledTimes(1) + }) + expect(onTransactionReceived).not.toHaveBeenCalled() + + axiosRequestSpy.mockRestore() + }) }) diff --git a/packages/sdk/src/actions/execute.ts b/packages/sdk/src/actions/execute.ts index 9ad029cd..99b04954 100644 --- a/packages/sdk/src/actions/execute.ts +++ b/packages/sdk/src/actions/execute.ts @@ -35,7 +35,7 @@ export type ExecuteActionParameters = { * @param data.depositGasLimit A gas limit to use in base units (wei, etc) * @param data.wallet Wallet object that adheres to the AdaptedWakket interface or a viem WalletClient * @param data.onProgress Callback to update UI state as execution progresses - * @param data.onTransactionReceived Callback fired when /requests metadata is available + * @param data.onTransactionReceived Callback fired asynchronously when /requests metadata is available * @param abortController Optional AbortController to cancel the execution */ export function execute(data: ExecuteActionParameters): Promise<{ From 835ab2ae4189ace2fc4215d35669cd361e70e2a0 Mon Sep 17 00:00:00 2001 From: 0xtxbi Date: Tue, 5 May 2026 08:03:21 +0100 Subject: [PATCH 6/6] handle aborted metadata polling --- packages/sdk/src/actions/execute.test.ts | 88 +++++++++++++++++++++++- packages/sdk/src/actions/execute.ts | 50 ++++++++++++-- 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/actions/execute.test.ts b/packages/sdk/src/actions/execute.test.ts index 309c4aa8..706bc34c 100644 --- a/packages/sdk/src/actions/execute.test.ts +++ b/packages/sdk/src/actions/execute.test.ts @@ -269,9 +269,9 @@ describe('Should test the execute action.', () => { ) expect(axiosRequestSpy).toHaveBeenCalledTimes(3) expect(onProgress).toHaveBeenCalled() - expect(onProgress.mock.calls.at(-1)?.[0]?.details?.currencyOut?.amount).toBe( - settledAmount - ) + expect( + onProgress.mock.calls.at(-1)?.[0]?.details?.currencyOut?.amount + ).toBe(settledAmount) axiosRequestSpy.mockRestore() }) @@ -310,4 +310,86 @@ describe('Should test the execute action.', () => { axiosRequestSpy.mockRestore() }) + + it('Should not emit settled metadata after aborting execution', async () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API, + pollingInterval: 1, + maxPollingAttemptsBeforeTimeout: 1 + }) + + const onProgress = vi.fn() + const onTransactionReceived = vi.fn() + const settledAmount = '1002000000000000' + let resolveRequest: (value: any) => void = () => {} + + executeStepsSpy.mockImplementation( + ( + chainId: any, + request: any, + wallet: any, + progress: any, + clonedQuote: Execute, + options?: any + ) => { + progress({ + steps: clonedQuote.steps, + fees: clonedQuote.fees, + breakdown: clonedQuote.breakdown, + details: clonedQuote.details + }) + return Promise.resolve(clonedQuote) + } + ) + + const axiosRequestSpy = vi.spyOn(axios, 'request').mockImplementation( + (config: any) => + new Promise((resolve) => { + expect(config.signal).toBeDefined() + resolveRequest = resolve + }) as any + ) + + const execution = client?.actions?.execute({ + wallet, + quote, + onProgress, + onTransactionReceived + }) + + await execution + execution?.abortController.abort() + + resolveRequest({ + data: { + requests: [ + { + id: '0xabc', + data: { + metadata: { + currencyOut: { + ...quote.details?.currencyOut, + amount: settledAmount, + amountFormatted: '0.001002' + } + } + } + } + ] + } + }) + + await vi.waitFor(() => { + expect(axiosRequestSpy).toHaveBeenCalledTimes(1) + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(onTransactionReceived).not.toHaveBeenCalled() + expect( + onProgress.mock.calls.at(-1)?.[0]?.details?.currencyOut?.amount + ).not.toBe(settledAmount) + + axiosRequestSpy.mockRestore() + }) }) diff --git a/packages/sdk/src/actions/execute.ts b/packages/sdk/src/actions/execute.ts index 99b04954..b3054fb2 100644 --- a/packages/sdk/src/actions/execute.ts +++ b/packages/sdk/src/actions/execute.ts @@ -172,8 +172,11 @@ async function enrichExecutionWithRequestMetadata({ return } - const transaction = await pollRequestMetadataById(requestId) - if (!transaction) { + const transaction = await pollRequestMetadataById( + requestId, + abortController.signal + ) + if (!transaction || abortController.signal.aborted) { return } @@ -189,7 +192,8 @@ async function enrichExecutionWithRequestMetadata({ const existingCurrencyOut = data.details?.currencyOut const amountChanged = nextCurrencyOut.amount !== existingCurrencyOut?.amount || - nextCurrencyOut.amountFormatted !== existingCurrencyOut?.amountFormatted || + nextCurrencyOut.amountFormatted !== + existingCurrencyOut?.amountFormatted || nextCurrencyOut.amountUsd !== existingCurrencyOut?.amountUsd if (!amountChanged) { @@ -202,7 +206,8 @@ async function enrichExecutionWithRequestMetadata({ recipient: metadata?.recipient ?? data.details?.recipient, currencyIn: metadata?.currencyIn ?? data.details?.currencyIn, currencyOut: nextCurrencyOut, - currencyGasTopup: metadata?.currencyGasTopup ?? data.details?.currencyGasTopup + currencyGasTopup: + metadata?.currencyGasTopup ?? data.details?.currencyGasTopup } if (!onProgress || abortController.signal.aborted) { @@ -229,7 +234,8 @@ async function enrichExecutionWithRequestMetadata({ } async function pollRequestMetadataById( - requestId: string + requestId: string, + signal: AbortSignal ): Promise { const client = getClient() const pollingInterval = client.pollingInterval ?? 5000 @@ -255,11 +261,20 @@ async function pollRequestMetadataById( let transaction: RelayTransaction | undefined = undefined for (let attempt = 0; attempt < maxAttempts; attempt++) { - const res = (await requestApi(requestConfig)) as { + if (signal.aborted) { + return undefined + } + + const res = (await requestApi({ ...requestConfig, signal })) as { data?: { requests?: RelayTransaction[] } } + + if (signal.aborted) { + return undefined + } + transaction = res.data?.requests?.[0] if (transaction?.data?.metadata?.currencyOut) { @@ -267,9 +282,30 @@ async function pollRequestMetadataById( } if (attempt < maxAttempts - 1) { - await new Promise((resolve) => setTimeout(resolve, pollingInterval)) + await waitForPollInterval(pollingInterval, signal) } } return transaction?.data?.metadata?.currencyOut ? transaction : undefined } + +function waitForPollInterval(pollingInterval: number, signal: AbortSignal) { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, pollingInterval) + + const onAbort = () => { + clearTimeout(timeout) + resolve() + } + + signal.addEventListener('abort', onAbort, { once: true }) + }) +}