Skip to content

Commit d76cdd3

Browse files
refactor: timeouts and logging in wallet capabilities
1 parent c03a2d3 commit d76cdd3

8 files changed

Lines changed: 174 additions & 154 deletions

File tree

apps/explorer/src/api/operator/operatorApi.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,17 @@ export { getAccountOrders } from './accountOrderUtils'
2020
const ENV_REQUEST_TIMEOUT_MS = 12_000
2121

2222
function withBarnTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
23-
return withTimeout(promise, ENV_REQUEST_TIMEOUT_MS, `${operation}: BARN`)
23+
return withTimeout(promise, {
24+
timeout: ENV_REQUEST_TIMEOUT_MS,
25+
timeoutMessage: `${operation}: BARN. Timeout after ${ENV_REQUEST_TIMEOUT_MS} ms`,
26+
})
2427
}
2528

2629
function withProdTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
27-
return withTimeout(promise, ENV_REQUEST_TIMEOUT_MS, `${operation}: PROD`)
30+
return withTimeout(promise, {
31+
timeout: ENV_REQUEST_TIMEOUT_MS,
32+
timeoutMessage: `${operation}: PROD. Timeout after ${ENV_REQUEST_TIMEOUT_MS} ms`,
33+
})
2834
}
2935

3036
/**

libs/common-utils/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ export * from './misc'
6363
export * from './node'
6464
export * from './normalizeRawAmount'
6565
export * from './parseENSAddress'
66-
export * from './promiseWithTimeout'
6766
export * from './rawToTokenAmount'
6867
export * from './request'
6968
export * from './resolveENSContentHash'

libs/common-utils/src/misc.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
1-
import { isRejectRequestProviderError } from './misc'
1+
import { isRejectRequestProviderError, TimeoutError, withTimeout } from './misc'
2+
3+
describe('withTimeout', () => {
4+
it('resolves when the promise settles before the timeout', async () => {
5+
await expect(withTimeout(Promise.resolve('ok'), { timeout: 1000, timeoutMessage: 'Timed out' })).resolves.toBe('ok')
6+
})
7+
8+
it('rejects with TimeoutError when the promise times out', async () => {
9+
await expect(
10+
withTimeout(new Promise(() => undefined), { timeout: 50, timeoutMessage: 'Timed out' }),
11+
).rejects.toThrow(TimeoutError)
12+
})
13+
14+
it('rejects with the provided timeout message', async () => {
15+
await expect(
16+
withTimeout(new Promise(() => undefined), {
17+
timeout: 50,
18+
timeoutMessage: 'Timed out',
19+
}),
20+
).rejects.toThrow('Timed out')
21+
})
22+
})
223

324
describe('isRejectRequestProviderError', () => {
425
it('detects the standard EIP-1193 rejection code', () => {

libs/common-utils/src/misc.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,26 @@ export function isPromiseFulfilled<T>(
5858
return promiseResult.status === 'fulfilled'
5959
}
6060

61-
export function withTimeout<T>(promise: Promise<T>, ms: number, context?: string): Promise<T> {
62-
const failOnTimeout = delay(ms).then(() => {
63-
const errorMessage = 'Timeout after ' + ms + ' ms'
64-
throw new Error(context ? `${context}. ${errorMessage}` : errorMessage)
61+
export class TimeoutError extends Error {}
62+
63+
interface TimeoutOptions {
64+
timeout: number
65+
timeoutMessage: string
66+
}
67+
68+
export async function withTimeout<T>(promise: Promise<T>, options: TimeoutOptions): Promise<T> {
69+
const { timeout, timeoutMessage } = options
70+
let timeoutId: ReturnType<typeof setTimeout> | undefined
71+
72+
const failOnTimeout = new Promise<never>((_, reject) => {
73+
timeoutId = setTimeout(() => reject(new TimeoutError(timeoutMessage)), timeout)
6574
})
6675

67-
return Promise.race([promise, failOnTimeout])
76+
try {
77+
return await Promise.race([promise, failOnTimeout])
78+
} finally {
79+
if (timeoutId) clearTimeout(timeoutId)
80+
}
6881
}
6982

7083
type WindowWithMapping = Window & typeof globalThis & Record<string, unknown>

libs/common-utils/src/promiseWithTimeout.test.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

libs/common-utils/src/promiseWithTimeout.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

libs/wallet/src/api/state/walletCapabilitiesAtom.test.ts

Lines changed: 78 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const writableIsSafeWalletAtom = isSafeWalletAtom as WritableAtom<boolean, [bool
3434

3535
import type { WalletCapabilities } from './walletCapabilitiesAtom'
3636
import type { WalletInfo } from '../types'
37-
import type { EIP1193Provider } from 'viem'
37+
import type { EIP1193Provider, PublicClient } from 'viem'
3838
import type { Connector } from 'wagmi'
3939

4040
jest.mock('../../wagmi/state/walletMetadata.atoms', () => {
@@ -53,25 +53,22 @@ const MOCK_ACCOUNT = '0x1234567890123456789012345678901234567890' as const
5353
const MOCK_CHAIN_ID = SupportedChainId.MAINNET
5454
const MOCK_CONNECTOR = { type: 'injected' } as Connector
5555

56-
const mockLogWalletWarn = jest.fn()
5756
const mockGetCapabilities = jest.fn()
5857
const mockWagmiConfigGetClient = jest.fn()
5958
const mockGetIsWalletConnect = jest.fn()
6059
const mockIsMobile = { value: false }
6160

62-
jest.mock('@cowprotocol/common-utils', () => ({
63-
getCurrentChainIdFromUrl: () => 1,
64-
get isMobile() {
65-
return mockIsMobile.value
66-
},
67-
logWallet: {
68-
warn: (...args: unknown[]) => mockLogWalletWarn(...args),
69-
},
70-
PromiseWithTimeout: <T>(_: number, handler: (resolve: (value: T) => void) => void): Promise<T> =>
71-
new Promise<T>((resolve) => {
72-
handler(resolve)
73-
}),
74-
}))
61+
jest.mock('@cowprotocol/common-utils', () => {
62+
const actual = jest.requireActual<typeof import('@cowprotocol/common-utils')>('@cowprotocol/common-utils')
63+
64+
return {
65+
...actual,
66+
getCurrentChainIdFromUrl: () => 1,
67+
get isMobile() {
68+
return mockIsMobile.value
69+
},
70+
}
71+
})
7572

7673
jest.mock('../../wagmi/hooks/useIsWalletConnect', () => ({
7774
getIsWalletConnect: (...args: unknown[]) => mockGetIsWalletConnect(...args),
@@ -101,7 +98,7 @@ function setWalletInfo(
10198
account: string
10299
chainId: SupportedChainId
103100
connector: Connector
104-
provider: EIP1193Provider
101+
provider: EIP1193Provider | PublicClient
105102
}> = {},
106103
): void {
107104
store.set(walletInfoAtom, {
@@ -303,21 +300,34 @@ describe('walletCapabilitiesAtom', () => {
303300
})
304301
})
305302

306-
it('returns empty object when getCapabilities does not settle before timeout', async () => {
303+
it('returns null when getCapabilities fails and provider does not support EIP-1193 requests', async () => {
304+
const error = new Error('viem error')
305+
mockGetCapabilities.mockRejectedValue(error)
306+
307+
const store = createStore()
308+
setWalletInfo(store, { provider: {} as PublicClient })
309+
310+
const result = await store.get(walletCapabilitiesAtom)
311+
312+
expect(result).toBeNull()
313+
})
314+
315+
it('returns null when getCapabilities times out and provider does not support EIP-1193 requests', async () => {
307316
jest.useFakeTimers()
308317
mockGetCapabilities.mockImplementation(() => new Promise(() => undefined))
309318

310319
const store = createStore()
311-
setWalletInfo(store)
312-
313-
const resultPromise = store.get(walletCapabilitiesAtom)
314-
await jest.advanceTimersByTimeAsync(5_000)
315-
const result = await resultPromise
320+
setWalletInfo(store, { provider: {} as PublicClient })
316321

317-
expect(result).toEqual({})
318-
expect(mockLogWalletWarn).toHaveBeenCalledWith(expect.stringContaining('Wallet capabilities loading timed out'))
322+
try {
323+
const resultPromise = store.get(walletCapabilitiesAtom)
324+
await jest.advanceTimersByTimeAsync(5_000)
325+
const result = await resultPromise
319326

320-
jest.useRealTimers()
327+
expect(result).toBeNull()
328+
} finally {
329+
jest.useRealTimers()
330+
}
321331
})
322332
})
323333

@@ -347,6 +357,49 @@ describe('walletCapabilitiesAtom', () => {
347357

348358
expect(result).toEqual(capabilities)
349359
})
360+
361+
it('uses legacy fallback when getCapabilities times out', async () => {
362+
jest.useFakeTimers()
363+
const capabilities: WalletCapabilities = { atomic: { status: 'supported' } }
364+
mockGetCapabilities.mockImplementation(() => new Promise(() => undefined))
365+
const mockRequest = jest.fn().mockResolvedValue({ '0x1': capabilities })
366+
367+
const store = createStore()
368+
setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) })
369+
370+
try {
371+
const resultPromise = store.get(walletCapabilitiesAtom)
372+
await jest.advanceTimersByTimeAsync(5_000)
373+
const result = await resultPromise
374+
375+
expect(result).toEqual(capabilities)
376+
expect(mockRequest).toHaveBeenCalledWith({
377+
method: 'wallet_getCapabilities',
378+
params: [MOCK_ACCOUNT],
379+
})
380+
} finally {
381+
jest.useRealTimers()
382+
}
383+
})
384+
385+
it('returns null when legacy fallback times out', async () => {
386+
jest.useFakeTimers()
387+
mockGetCapabilities.mockRejectedValue(new Error('viem error'))
388+
const mockRequest = jest.fn().mockImplementation(() => new Promise(() => undefined))
389+
390+
const store = createStore()
391+
setWalletInfo(store, { provider: createMockEip1193Provider(mockRequest) })
392+
393+
try {
394+
const resultPromise = store.get(walletCapabilitiesAtom)
395+
await jest.advanceTimersByTimeAsync(5_000)
396+
const result = await resultPromise
397+
398+
expect(result).toBeNull()
399+
} finally {
400+
jest.useRealTimers()
401+
}
402+
})
350403
})
351404
})
352405

0 commit comments

Comments
 (0)