@portal-hq/web 3.13.2 → 3.14.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/commonjs/index.js +127 -9
  2. package/lib/commonjs/index.test.js +13 -0
  3. package/lib/commonjs/integrations/delegations/index.js +109 -2
  4. package/lib/commonjs/integrations/delegations/index.test.js +171 -0
  5. package/lib/commonjs/integrations/ramps/noah/index.test.js +18 -5
  6. package/lib/commonjs/integrations/trading/index.js +16 -5
  7. package/lib/commonjs/integrations/trading/lifi/index.js +297 -25
  8. package/lib/commonjs/integrations/trading/lifi/lifi.tradeAsset.test.js +360 -0
  9. package/lib/commonjs/integrations/trading/lifi/lifiStatusPoll.js +118 -0
  10. package/lib/commonjs/integrations/trading/lifi/lifiStatusPoll.test.js +66 -0
  11. package/lib/commonjs/integrations/trading/zero-x/index.js +129 -26
  12. package/lib/commonjs/integrations/trading/zero-x/index.test.js +163 -1
  13. package/lib/commonjs/integrations/yield/index.js +18 -4
  14. package/lib/commonjs/integrations/yield/yieldxyz.getValidators.test.js +71 -0
  15. package/lib/commonjs/integrations/yield/yieldxyz.highLevel.test.js +330 -0
  16. package/lib/commonjs/integrations/yield/yieldxyz.js +517 -1
  17. package/lib/commonjs/internal/pollLoop.js +64 -0
  18. package/lib/commonjs/internal/pollLoop.test.js +100 -0
  19. package/lib/commonjs/internal/stripStalePlanningNonce.js +65 -0
  20. package/lib/commonjs/internal/stripStalePlanningNonce.test.js +35 -0
  21. package/lib/commonjs/internal/waitForEvmOrUserOpConfirmation.js +155 -0
  22. package/lib/commonjs/internal/waitForEvmOrUserOpConfirmation.test.js +33 -0
  23. package/lib/commonjs/internal/waitForEvmTxConfirmation.js +104 -0
  24. package/lib/commonjs/internal/waitForSolanaTxConfirmation.js +106 -0
  25. package/lib/commonjs/internal/yieldEvmNetwork.js +60 -0
  26. package/lib/commonjs/mpc/index.js +116 -1
  27. package/lib/commonjs/provider/index.js +17 -0
  28. package/lib/commonjs/shared/trace/index.js +0 -1
  29. package/lib/esm/index.js +127 -9
  30. package/lib/esm/index.test.js +13 -0
  31. package/lib/esm/integrations/delegations/index.js +109 -2
  32. package/lib/esm/integrations/delegations/index.test.js +171 -0
  33. package/lib/esm/integrations/ramps/noah/index.test.js +18 -5
  34. package/lib/esm/integrations/trading/index.js +16 -5
  35. package/lib/esm/integrations/trading/lifi/index.js +292 -25
  36. package/lib/esm/integrations/trading/lifi/lifi.tradeAsset.test.js +332 -0
  37. package/lib/esm/integrations/trading/lifi/lifiStatusPoll.js +113 -0
  38. package/lib/esm/integrations/trading/lifi/lifiStatusPoll.test.js +64 -0
  39. package/lib/esm/integrations/trading/zero-x/index.js +129 -26
  40. package/lib/esm/integrations/trading/zero-x/index.test.js +141 -2
  41. package/lib/esm/integrations/yield/index.js +18 -4
  42. package/lib/esm/integrations/yield/yieldxyz.getValidators.test.js +66 -0
  43. package/lib/esm/integrations/yield/yieldxyz.highLevel.test.js +325 -0
  44. package/lib/esm/integrations/yield/yieldxyz.js +517 -1
  45. package/lib/esm/internal/pollLoop.js +59 -0
  46. package/lib/esm/internal/pollLoop.test.js +98 -0
  47. package/lib/esm/internal/stripStalePlanningNonce.js +61 -0
  48. package/lib/esm/internal/stripStalePlanningNonce.test.js +33 -0
  49. package/lib/esm/internal/waitForEvmOrUserOpConfirmation.js +151 -0
  50. package/lib/esm/internal/waitForEvmOrUserOpConfirmation.test.js +31 -0
  51. package/lib/esm/internal/waitForEvmTxConfirmation.js +100 -0
  52. package/lib/esm/internal/waitForSolanaTxConfirmation.js +102 -0
  53. package/lib/esm/internal/yieldEvmNetwork.js +55 -0
  54. package/lib/esm/mpc/index.js +116 -1
  55. package/lib/esm/provider/index.js +17 -0
  56. package/lib/esm/shared/trace/index.js +0 -1
  57. package/noah-types.d.ts +16 -2
  58. package/package.json +3 -2
  59. package/src/index.test.ts +15 -0
  60. package/src/index.ts +203 -14
  61. package/src/integrations/delegations/index.test.ts +251 -0
  62. package/src/integrations/delegations/index.ts +202 -4
  63. package/src/integrations/ramps/noah/index.test.ts +18 -5
  64. package/src/integrations/trading/index.ts +10 -7
  65. package/src/integrations/trading/lifi/index.ts +388 -28
  66. package/src/integrations/trading/lifi/lifi.tradeAsset.test.ts +436 -0
  67. package/src/integrations/trading/lifi/lifiStatusPoll.test.ts +74 -0
  68. package/src/integrations/trading/lifi/lifiStatusPoll.ts +158 -0
  69. package/src/integrations/trading/zero-x/index.test.ts +297 -1
  70. package/src/integrations/trading/zero-x/index.ts +181 -27
  71. package/src/integrations/yield/index.ts +24 -4
  72. package/src/integrations/yield/yieldxyz.getValidators.test.ts +70 -0
  73. package/src/integrations/yield/yieldxyz.highLevel.test.ts +403 -0
  74. package/src/integrations/yield/yieldxyz.ts +740 -8
  75. package/src/internal/pollLoop.test.ts +109 -0
  76. package/src/internal/pollLoop.ts +87 -0
  77. package/src/internal/stripStalePlanningNonce.test.ts +38 -0
  78. package/src/internal/stripStalePlanningNonce.ts +66 -0
  79. package/src/internal/waitForEvmOrUserOpConfirmation.test.ts +31 -0
  80. package/src/internal/waitForEvmOrUserOpConfirmation.ts +194 -0
  81. package/src/internal/waitForEvmTxConfirmation.ts +155 -0
  82. package/src/internal/waitForSolanaTxConfirmation.ts +135 -0
  83. package/src/internal/yieldEvmNetwork.ts +57 -0
  84. package/src/mpc/index.ts +142 -1
  85. package/src/provider/index.ts +25 -0
  86. package/src/shared/trace/index.ts +0 -1
  87. package/src/shared/types/README.md +6 -0
  88. package/src/shared/types/api.ts +12 -1
  89. package/src/shared/types/common.ts +332 -20
  90. package/src/shared/types/delegations.ts +10 -0
  91. package/src/shared/types/index.ts +1 -0
  92. package/src/shared/types/lifi.ts +82 -0
  93. package/src/shared/types/noah.ts +124 -33
  94. package/src/shared/types/yieldxyz.ts +186 -0
  95. package/src/shared/types/zero-x.ts +66 -0
  96. package/types.d.ts +6 -0
@@ -0,0 +1,436 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import LiFi, { normalizeChainToNetwork } from './index'
6
+ import Mpc from '../../../mpc'
7
+ import portalMock from '../../../__mocks/portal/portal'
8
+ import {
9
+ mockEip155Address,
10
+ mockHost,
11
+ mockLifiAction,
12
+ mockLifiGetRouteStepResponse,
13
+ mockLifiGetRoutesResponse,
14
+ mockLifiGetStatusResponse,
15
+ mockLifiRoute,
16
+ mockLifiStep,
17
+ } from '../../../__mocks/constants'
18
+
19
+ describe('normalizeChainToNetwork', () => {
20
+ it('maps hex chain ids to eip155', () => {
21
+ expect(normalizeChainToNetwork('0x1')).toBe('eip155:1')
22
+ expect(normalizeChainToNetwork('0xa')).toBe('eip155:10')
23
+ expect(normalizeChainToNetwork('0x89')).toBe('eip155:137')
24
+ })
25
+
26
+ it('preserves existing eip155 and numeric decimal strings', () => {
27
+ expect(normalizeChainToNetwork('eip155:42161')).toBe('eip155:42161')
28
+ expect(normalizeChainToNetwork('42161')).toBe('eip155:42161')
29
+ })
30
+ })
31
+
32
+ describe('LiFi tradeAsset', () => {
33
+ let mpc: Mpc
34
+ let lifi: LiFi
35
+
36
+ beforeEach(() => {
37
+ jest.clearAllMocks()
38
+ portalMock.host = mockHost
39
+ mpc = new Mpc({ portal: portalMock })
40
+ lifi = new LiFi({
41
+ mpc,
42
+ signAndSendTransaction: async () => '0xsent',
43
+ waitForConfirmation: async () => false,
44
+ })
45
+ })
46
+
47
+ it('throws when waitForConfirmation returns false and does not poll bridge status', async () => {
48
+ jest
49
+ .spyOn(mpc, 'getLifiRoutes')
50
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
51
+ jest
52
+ .spyOn(mpc, 'getLifiRouteStep')
53
+ .mockResolvedValue(mockLifiGetRouteStepResponse as never)
54
+ const pollSpy = jest
55
+ .spyOn(lifi, 'pollStatus')
56
+ .mockRejectedValue(new Error('pollStatus should not run'))
57
+
58
+ await expect(
59
+ lifi.tradeAsset({
60
+ fromChain: 'eip155:8453',
61
+ toChain: 'eip155:42161',
62
+ fromToken: 'ETH',
63
+ toToken: 'USDC',
64
+ amount: '1000000000000000',
65
+ fromAddress: mockEip155Address,
66
+ }),
67
+ ).rejects.toThrow(/on-chain confirmation did not complete/)
68
+
69
+ expect(pollSpy).not.toHaveBeenCalled()
70
+ })
71
+
72
+ it('strips nonce from EVM transaction request before sign', async () => {
73
+ const swapStep = {
74
+ ...mockLifiStep,
75
+ type: 'swap' as const,
76
+ action: {
77
+ ...mockLifiAction,
78
+ fromChainId: 'eip155:1',
79
+ toChainId: 'eip155:1',
80
+ },
81
+ }
82
+ const route = {
83
+ ...mockLifiRoute,
84
+ fromChainId: 'eip155:1',
85
+ toChainId: 'eip155:1',
86
+ steps: [swapStep],
87
+ }
88
+ const routesRes = {
89
+ data: {
90
+ rawResponse: {
91
+ routes: [route],
92
+ unavailableRoutes: { filteredOut: [], failed: [] },
93
+ },
94
+ },
95
+ }
96
+ const stepRes = {
97
+ data: {
98
+ rawResponse: {
99
+ ...swapStep,
100
+ transactionRequest: {
101
+ ...swapStep.transactionRequest,
102
+ nonce: '0xdeadbeef',
103
+ },
104
+ },
105
+ },
106
+ }
107
+
108
+ jest.spyOn(mpc, 'getLifiRoutes').mockResolvedValue(routesRes as never)
109
+ jest.spyOn(mpc, 'getLifiRouteStep').mockResolvedValue(stepRes as never)
110
+
111
+ const signer = jest.fn().mockResolvedValue('0xsigned')
112
+ const waiter = jest.fn().mockResolvedValue(true)
113
+ const lifiOk = new LiFi({
114
+ mpc,
115
+ signAndSendTransaction: signer,
116
+ waitForConfirmation: waiter,
117
+ })
118
+
119
+ await lifiOk.tradeAsset({
120
+ fromChain: 'eip155:1',
121
+ toChain: 'eip155:1',
122
+ fromToken: 'ETH',
123
+ toToken: 'USDC',
124
+ amount: '1000000000000000',
125
+ fromAddress: mockEip155Address,
126
+ })
127
+
128
+ expect(signer).toHaveBeenCalledTimes(1)
129
+ const sent = signer.mock.calls[0]![0] as Record<string, unknown>
130
+ expect(sent.nonce).toBeUndefined()
131
+ })
132
+
133
+ it('emits onProgress failed on unexpected errors (outer catch)', async () => {
134
+ const onProgress = jest.fn()
135
+ jest.spyOn(mpc, 'getLifiRoutes').mockRejectedValue(new Error('network down'))
136
+
137
+ await expect(
138
+ lifi.tradeAsset(
139
+ {
140
+ fromChain: 'eip155:8453',
141
+ toChain: 'eip155:42161',
142
+ fromToken: 'ETH',
143
+ toToken: 'USDC',
144
+ amount: '1000000000000000',
145
+ fromAddress: mockEip155Address,
146
+ onProgress,
147
+ },
148
+ {
149
+ signAndSendTransaction: async () => '0x',
150
+ waitForConfirmation: async () => true,
151
+ },
152
+ ),
153
+ ).rejects.toThrow('network down')
154
+
155
+ expect(onProgress).toHaveBeenCalledWith(
156
+ 'failed',
157
+ expect.objectContaining({ errorMessage: 'network down' }),
158
+ )
159
+ })
160
+
161
+ it('throws when routeIndex is out of bounds', async () => {
162
+ jest
163
+ .spyOn(mpc, 'getLifiRoutes')
164
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
165
+ const lifiOk = new LiFi({
166
+ mpc,
167
+ signAndSendTransaction: async () => '0x',
168
+ waitForConfirmation: async () => true,
169
+ })
170
+
171
+ await expect(
172
+ lifiOk.tradeAsset({
173
+ fromChain: 'eip155:8453',
174
+ toChain: 'eip155:42161',
175
+ fromToken: 'ETH',
176
+ toToken: 'USDC',
177
+ amount: '1000000000000000',
178
+ fromAddress: mockEip155Address,
179
+ routeIndex: 99,
180
+ }),
181
+ ).rejects.toThrow(/no route available at index 99/)
182
+ })
183
+
184
+ it('throws when getRouteStep returns error', async () => {
185
+ jest
186
+ .spyOn(mpc, 'getLifiRoutes')
187
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
188
+ jest.spyOn(mpc, 'getLifiRouteStep').mockResolvedValue({
189
+ error: 'step build failed',
190
+ } as never)
191
+ const lifiOk = new LiFi({
192
+ mpc,
193
+ signAndSendTransaction: async () => '0x',
194
+ waitForConfirmation: async () => true,
195
+ })
196
+
197
+ await expect(
198
+ lifiOk.tradeAsset({
199
+ fromChain: 'eip155:8453',
200
+ toChain: 'eip155:42161',
201
+ fromToken: 'ETH',
202
+ toToken: 'USDC',
203
+ amount: '1000000000000000',
204
+ fromAddress: mockEip155Address,
205
+ }),
206
+ ).rejects.toThrow('getRouteStep: step build failed')
207
+ })
208
+
209
+ it('cross-chain step calls pollStatus with fast statusPoll options', async () => {
210
+ jest
211
+ .spyOn(mpc, 'getLifiRoutes')
212
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
213
+ jest
214
+ .spyOn(mpc, 'getLifiRouteStep')
215
+ .mockResolvedValue(mockLifiGetRouteStepResponse as never)
216
+ jest
217
+ .spyOn(mpc, 'getLifiStatus')
218
+ .mockResolvedValue(mockLifiGetStatusResponse as never)
219
+
220
+ const pollSpy = jest.spyOn(LiFi.prototype, 'pollStatus')
221
+ const signer = jest.fn().mockResolvedValue('0xstep1')
222
+ const lifiOk = new LiFi({
223
+ mpc,
224
+ signAndSendTransaction: signer,
225
+ waitForConfirmation: async () => true,
226
+ })
227
+
228
+ await lifiOk.tradeAsset({
229
+ fromChain: 'eip155:8453',
230
+ toChain: 'eip155:42161',
231
+ fromToken: 'ETH',
232
+ toToken: 'USDC',
233
+ amount: '1000000000000000',
234
+ fromAddress: mockEip155Address,
235
+ statusPoll: {
236
+ initialDelayMs: 0,
237
+ everyMs: 5,
238
+ timeoutMs: 20000,
239
+ },
240
+ })
241
+
242
+ expect(signer).toHaveBeenCalled()
243
+ expect(pollSpy).toHaveBeenCalled()
244
+ pollSpy.mockRestore()
245
+ })
246
+
247
+ it('same-chain swap does not call pollStatus', async () => {
248
+ const swapStep = {
249
+ ...mockLifiStep,
250
+ type: 'swap' as const,
251
+ action: {
252
+ ...mockLifiAction,
253
+ fromChainId: 'eip155:1',
254
+ toChainId: 'eip155:1',
255
+ },
256
+ }
257
+ const route = {
258
+ ...mockLifiRoute,
259
+ fromChainId: 'eip155:1',
260
+ toChainId: 'eip155:1',
261
+ steps: [swapStep],
262
+ }
263
+ const routesRes = {
264
+ data: {
265
+ rawResponse: {
266
+ routes: [route],
267
+ unavailableRoutes: { filteredOut: [], failed: [] },
268
+ },
269
+ },
270
+ }
271
+ const stepRes = {
272
+ data: {
273
+ rawResponse: {
274
+ ...swapStep,
275
+ transactionRequest: { ...swapStep.transactionRequest, chainId: 'eip155:1' },
276
+ },
277
+ },
278
+ }
279
+ jest.spyOn(mpc, 'getLifiRoutes').mockResolvedValue(routesRes as never)
280
+ jest.spyOn(mpc, 'getLifiRouteStep').mockResolvedValue(stepRes as never)
281
+
282
+ const pollSpy = jest.spyOn(LiFi.prototype, 'pollStatus')
283
+ const lifiOk = new LiFi({
284
+ mpc,
285
+ signAndSendTransaction: async () => '0xswap',
286
+ waitForConfirmation: async () => true,
287
+ })
288
+
289
+ await lifiOk.tradeAsset({
290
+ fromChain: 'eip155:1',
291
+ toChain: 'eip155:1',
292
+ fromToken: 'ETH',
293
+ toToken: 'USDC',
294
+ amount: '1000000000000000',
295
+ fromAddress: mockEip155Address,
296
+ })
297
+
298
+ expect(pollSpy).not.toHaveBeenCalled()
299
+ pollSpy.mockRestore()
300
+ })
301
+
302
+ it('throws when signer returns empty hash', async () => {
303
+ jest
304
+ .spyOn(mpc, 'getLifiRoutes')
305
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
306
+ jest
307
+ .spyOn(mpc, 'getLifiRouteStep')
308
+ .mockResolvedValue(mockLifiGetRouteStepResponse as never)
309
+ const onProgress = jest.fn()
310
+ const lifiOk = new LiFi({
311
+ mpc,
312
+ signAndSendTransaction: async () => ' ',
313
+ waitForConfirmation: async () => true,
314
+ })
315
+
316
+ await expect(
317
+ lifiOk.tradeAsset({
318
+ fromChain: 'eip155:8453',
319
+ toChain: 'eip155:42161',
320
+ fromToken: 'ETH',
321
+ toToken: 'USDC',
322
+ amount: '1000000000000000',
323
+ fromAddress: mockEip155Address,
324
+ onProgress,
325
+ }),
326
+ ).rejects.toThrow(/Invalid transaction hash/)
327
+
328
+ expect(onProgress).toHaveBeenCalledWith(
329
+ 'failed',
330
+ expect.objectContaining({ errorMessage: expect.stringContaining('Invalid') }),
331
+ )
332
+ })
333
+
334
+ it('emits failed only ONCE when signer returns empty hash (no duplicate)', async () => {
335
+ jest
336
+ .spyOn(mpc, 'getLifiRoutes')
337
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
338
+ jest
339
+ .spyOn(mpc, 'getLifiRouteStep')
340
+ .mockResolvedValue(mockLifiGetRouteStepResponse as never)
341
+ const onProgress = jest.fn()
342
+ const lifiOk = new LiFi({
343
+ mpc,
344
+ signAndSendTransaction: async () => '',
345
+ waitForConfirmation: async () => true,
346
+ })
347
+
348
+ await expect(
349
+ lifiOk.tradeAsset({
350
+ fromChain: 'eip155:8453',
351
+ toChain: 'eip155:42161',
352
+ fromToken: 'ETH',
353
+ toToken: 'USDC',
354
+ amount: '1000000000000000',
355
+ fromAddress: mockEip155Address,
356
+ onProgress,
357
+ }),
358
+ ).rejects.toThrow(/Invalid transaction hash/)
359
+
360
+ const failedCalls = onProgress.mock.calls.filter(
361
+ (call) => call[0] === 'failed',
362
+ )
363
+ expect(failedCalls).toHaveLength(1)
364
+ expect(failedCalls[0]![1]).toMatchObject({
365
+ errorMessage: expect.stringContaining('Invalid transaction hash'),
366
+ stepIndex: 0,
367
+ totalSteps: 1,
368
+ })
369
+ })
370
+
371
+ it('emits failed only ONCE when waitForConfirmation returns false (no duplicate)', async () => {
372
+ jest
373
+ .spyOn(mpc, 'getLifiRoutes')
374
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
375
+ jest
376
+ .spyOn(mpc, 'getLifiRouteStep')
377
+ .mockResolvedValue(mockLifiGetRouteStepResponse as never)
378
+ const onProgress = jest.fn()
379
+ const lifiOk = new LiFi({
380
+ mpc,
381
+ signAndSendTransaction: async () => '0xhash',
382
+ waitForConfirmation: async () => false,
383
+ })
384
+
385
+ await expect(
386
+ lifiOk.tradeAsset({
387
+ fromChain: 'eip155:8453',
388
+ toChain: 'eip155:42161',
389
+ fromToken: 'ETH',
390
+ toToken: 'USDC',
391
+ amount: '1000000000000000',
392
+ fromAddress: mockEip155Address,
393
+ onProgress,
394
+ }),
395
+ ).rejects.toThrow(/on-chain confirmation did not complete/)
396
+
397
+ const failedCalls = onProgress.mock.calls.filter(
398
+ (call) => call[0] === 'failed',
399
+ )
400
+ expect(failedCalls).toHaveLength(1)
401
+ expect(failedCalls[0]![1]).toMatchObject({
402
+ errorMessage: expect.stringContaining('on-chain confirmation'),
403
+ txHash: '0xhash',
404
+ stepIndex: 0,
405
+ totalSteps: 1,
406
+ })
407
+ })
408
+
409
+ it('throws when waitForConfirmation throws', async () => {
410
+ jest
411
+ .spyOn(mpc, 'getLifiRoutes')
412
+ .mockResolvedValue(mockLifiGetRoutesResponse as never)
413
+ jest
414
+ .spyOn(mpc, 'getLifiRouteStep')
415
+ .mockResolvedValue(mockLifiGetRouteStepResponse as never)
416
+
417
+ const lifiOk = new LiFi({
418
+ mpc,
419
+ signAndSendTransaction: async () => '0xabc',
420
+ waitForConfirmation: async () => {
421
+ throw new Error('waiter boom')
422
+ },
423
+ })
424
+
425
+ await expect(
426
+ lifiOk.tradeAsset({
427
+ fromChain: 'eip155:8453',
428
+ toChain: 'eip155:42161',
429
+ fromToken: 'ETH',
430
+ toToken: 'USDC',
431
+ amount: '1000000000000000',
432
+ fromAddress: mockEip155Address,
433
+ }),
434
+ ).rejects.toThrow('waiter boom')
435
+ })
436
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import {
6
+ pollLifiStatusUntilTerminal,
7
+ isLikelyLifiIndexerNotReadyMessage,
8
+ } from './lifiStatusPoll'
9
+ import type { LifiStatusResponse } from '../../../shared/types'
10
+
11
+ describe('lifiStatusPoll', () => {
12
+ describe('isLikelyLifiIndexerNotReadyMessage', () => {
13
+ it('returns true for known indexer lag phrases', () => {
14
+ expect(
15
+ isLikelyLifiIndexerNotReadyMessage('No transfer information found'),
16
+ ).toBe(true)
17
+ expect(isLikelyLifiIndexerNotReadyMessage('transfer not found')).toBe(
18
+ true,
19
+ )
20
+ })
21
+
22
+ it('returns false for unrelated errors', () => {
23
+ expect(isLikelyLifiIndexerNotReadyMessage('Network error 400')).toBe(
24
+ false,
25
+ )
26
+ })
27
+ })
28
+
29
+ describe('pollLifiStatusUntilTerminal', () => {
30
+ it('treats response error field as hard error without resetting counter incorrectly', async () => {
31
+ const getStatus = jest
32
+ .fn()
33
+ .mockResolvedValueOnce({
34
+ error: 'temporary',
35
+ data: undefined,
36
+ } as LifiStatusResponse)
37
+ .mockResolvedValueOnce({
38
+ data: { rawResponse: { status: 'DONE' as const } },
39
+ } as LifiStatusResponse)
40
+
41
+ const result = await pollLifiStatusUntilTerminal({
42
+ getStatus,
43
+ request: { txHash: '0x1' },
44
+ everyMs: 5,
45
+ initialDelayMs: 0,
46
+ timeoutMs: 5000,
47
+ maxConsecutiveErrors: 10,
48
+ })
49
+
50
+ expect(result.status).toBe('DONE')
51
+ expect(getStatus).toHaveBeenCalledTimes(2)
52
+ })
53
+
54
+ it('retries on indexer lag message without counting as hard error', async () => {
55
+ const getStatus = jest
56
+ .fn()
57
+ .mockRejectedValueOnce(new Error('transfer not found'))
58
+ .mockResolvedValue({
59
+ data: { rawResponse: { status: 'DONE' as const } },
60
+ } as LifiStatusResponse)
61
+
62
+ const result = await pollLifiStatusUntilTerminal({
63
+ getStatus,
64
+ request: { txHash: '0x1' },
65
+ everyMs: 5,
66
+ initialDelayMs: 0,
67
+ timeoutMs: 5000,
68
+ maxConsecutiveErrors: 2,
69
+ })
70
+
71
+ expect(result.status).toBe('DONE')
72
+ })
73
+ })
74
+ })
@@ -0,0 +1,158 @@
1
+ /**
2
+ * LiFi status polling: domain tick (DONE / FAILED / indexer lag) over shared {@link pollLoop}.
3
+ */
4
+ import { sdkLogger } from '../../../logger'
5
+
6
+ import type {
7
+ LifiStatusRawResponse,
8
+ LifiStatusRequest,
9
+ LifiStatusResponse,
10
+ } from '../../../shared/types'
11
+
12
+ import {
13
+ pollLoop,
14
+ PollLoopTimeoutError,
15
+ type PollTickResult,
16
+ } from '../../../internal/pollLoop'
17
+
18
+ const LOG_PREFIX = '[LiFi.pollStatus]'
19
+
20
+ const INDEXER_LAG_MESSAGE_SNIPPETS = [
21
+ 'not an evm transaction',
22
+ 'no transfer information found',
23
+ 'transfer not found',
24
+ ] as const
25
+
26
+ export function isLikelyLifiIndexerNotReadyMessage(message: string): boolean {
27
+ const m = message.toLowerCase()
28
+ return INDEXER_LAG_MESSAGE_SNIPPETS.some((s) => m.includes(s))
29
+ }
30
+
31
+ export type LifiGetStatusFn = (
32
+ request: LifiStatusRequest,
33
+ ) => Promise<LifiStatusResponse>
34
+
35
+ export interface PollLifiUntilTerminalParams {
36
+ getStatus: LifiGetStatusFn
37
+ request: Pick<LifiStatusRequest, 'txHash' | 'fromChain' | 'toChain' | 'bridge'>
38
+ onUpdate?: (raw: LifiStatusRawResponse) => boolean | void
39
+ everyMs: number
40
+ initialDelayMs: number
41
+ timeoutMs: number
42
+ maxConsecutiveErrors: number
43
+ backoff?: { factor: number; maxIntervalMs: number }
44
+ }
45
+
46
+ export async function pollLifiStatusUntilTerminal(
47
+ params: PollLifiUntilTerminalParams,
48
+ ): Promise<LifiStatusRawResponse> {
49
+ let consecutiveHardErrors = 0
50
+
51
+ try {
52
+ return await pollLoop<LifiStatusRawResponse>({
53
+ tick: async (): Promise<PollTickResult<LifiStatusRawResponse>> => {
54
+ return lifiStatusPollTick(params, {
55
+ get: () => consecutiveHardErrors,
56
+ set: (n: number) => {
57
+ consecutiveHardErrors = n
58
+ },
59
+ })
60
+ },
61
+ intervalMs: params.everyMs,
62
+ initialDelayMs: params.initialDelayMs,
63
+ backoff: params.backoff,
64
+ timeoutMs: params.timeoutMs,
65
+ })
66
+ } catch (error) {
67
+ if (error instanceof PollLoopTimeoutError) {
68
+ throw new Error(
69
+ `${LOG_PREFIX} timed out after ${params.timeoutMs}ms waiting for LiFi terminal status`,
70
+ )
71
+ }
72
+ throw error
73
+ }
74
+ }
75
+
76
+ async function lifiStatusPollTick(
77
+ params: PollLifiUntilTerminalParams,
78
+ counter: { get: () => number; set: (n: number) => void },
79
+ ): Promise<PollTickResult<LifiStatusRawResponse>> {
80
+ try {
81
+ const res: LifiStatusResponse = await params.getStatus(
82
+ params.request as LifiStatusRequest,
83
+ )
84
+
85
+ if (res.error) {
86
+ sdkLogger.warn(`${LOG_PREFIX} response error field`, res.error)
87
+ return bumpHardError(counter, params.maxConsecutiveErrors)
88
+ }
89
+
90
+ counter.set(0)
91
+
92
+ const raw = res.data?.rawResponse
93
+ if (!raw) {
94
+ return { kind: 'continue' }
95
+ }
96
+
97
+ const { status } = raw
98
+
99
+ if (status === 'INVALID') {
100
+ return {
101
+ kind: 'throw',
102
+ error: new Error(
103
+ `${LOG_PREFIX} LiFi status INVALID for ${params.request.txHash}`,
104
+ ),
105
+ }
106
+ }
107
+
108
+ if (status === 'DONE') {
109
+ return { kind: 'stop', value: raw }
110
+ }
111
+
112
+ if (status === 'FAILED') {
113
+ const detail =
114
+ raw.substatusMessage ?? raw.substatus ?? 'LiFi transfer FAILED'
115
+ return {
116
+ kind: 'throw',
117
+ error: new Error(`${LOG_PREFIX} ${detail}`),
118
+ }
119
+ }
120
+
121
+ if (params.onUpdate) {
122
+ const carryOn = params.onUpdate(raw)
123
+ if (carryOn === false) {
124
+ return { kind: 'stop', value: raw }
125
+ }
126
+ }
127
+
128
+ return { kind: 'continue' }
129
+ } catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error)
131
+
132
+ if (isLikelyLifiIndexerNotReadyMessage(message)) {
133
+ sdkLogger.debug(`${LOG_PREFIX} indexer not ready, will retry`, message)
134
+ counter.set(0)
135
+ return { kind: 'continue' }
136
+ }
137
+
138
+ sdkLogger.warn(`${LOG_PREFIX} getStatus error`, error)
139
+ return bumpHardError(counter, params.maxConsecutiveErrors)
140
+ }
141
+ }
142
+
143
+ function bumpHardError(
144
+ counter: { get: () => number; set: (n: number) => void },
145
+ max: number,
146
+ ): PollTickResult<LifiStatusRawResponse> {
147
+ const next = counter.get() + 1
148
+ counter.set(next)
149
+ if (next >= max) {
150
+ return {
151
+ kind: 'throw',
152
+ error: new Error(
153
+ `${LOG_PREFIX} too many consecutive getStatus failures (${max})`,
154
+ ),
155
+ }
156
+ }
157
+ return { kind: 'continue' }
158
+ }