@lifi/widget 4.9.0 → 4.10.1

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 (70) hide show
  1. package/README.md +28 -10
  2. package/dist/esm/components/TokenList/TokenDetailsSheet.js +5 -8
  3. package/dist/esm/components/TokenList/TokenDetailsSheet.js.map +1 -1
  4. package/dist/esm/components/TokenList/TokenDetailsSheetContent.js +30 -2
  5. package/dist/esm/components/TokenList/TokenDetailsSheetContent.js.map +1 -1
  6. package/dist/esm/components/TokenList/TokenListItem.js +36 -47
  7. package/dist/esm/components/TokenList/TokenListItem.js.map +1 -1
  8. package/dist/esm/components/TokenList/VirtualizedTokenList.d.ts.map +1 -1
  9. package/dist/esm/components/TokenList/VirtualizedTokenList.js +2 -2
  10. package/dist/esm/components/TokenList/VirtualizedTokenList.js.map +1 -1
  11. package/dist/esm/components/TokenList/tokenVerificationIcons.js +29 -0
  12. package/dist/esm/components/TokenList/tokenVerificationIcons.js.map +1 -0
  13. package/dist/esm/config/version.d.ts +1 -1
  14. package/dist/esm/config/version.js +1 -1
  15. package/dist/esm/config/version.js.map +1 -1
  16. package/dist/esm/hooks/usePacedValue.js +1 -1
  17. package/dist/esm/hooks/usePacedValue.js.map +1 -1
  18. package/dist/esm/hooks/useRouteExecutionMessage.d.ts.map +1 -1
  19. package/dist/esm/hooks/useRouteExecutionMessage.js +4 -8
  20. package/dist/esm/hooks/useRouteExecutionMessage.js.map +1 -1
  21. package/dist/esm/i18n/bn.json +567 -266
  22. package/dist/esm/i18n/de.json +552 -251
  23. package/dist/esm/i18n/en.json +12 -62
  24. package/dist/esm/i18n/es.json +522 -221
  25. package/dist/esm/i18n/fr.json +541 -240
  26. package/dist/esm/i18n/hi.json +592 -291
  27. package/dist/esm/i18n/id.json +485 -184
  28. package/dist/esm/i18n/it.json +546 -245
  29. package/dist/esm/i18n/ja.json +512 -211
  30. package/dist/esm/i18n/ko.json +553 -252
  31. package/dist/esm/i18n/pl.json +598 -297
  32. package/dist/esm/i18n/pt.json +513 -212
  33. package/dist/esm/i18n/th.json +554 -253
  34. package/dist/esm/i18n/tr.json +538 -237
  35. package/dist/esm/i18n/uk.json +514 -213
  36. package/dist/esm/i18n/vi.json +550 -249
  37. package/dist/esm/i18n/zh.json +528 -227
  38. package/dist/esm/pages/TransactionPage/TransactionFailedButtons.js +70 -18
  39. package/dist/esm/pages/TransactionPage/TransactionFailedButtons.js.map +1 -1
  40. package/dist/esm/pages/TransactionPage/utils.d.ts +3 -0
  41. package/dist/esm/pages/TransactionPage/utils.d.ts.map +1 -1
  42. package/dist/esm/pages/TransactionPage/utils.js +27 -1
  43. package/dist/esm/pages/TransactionPage/utils.js.map +1 -1
  44. package/dist/esm/shared.d.ts +2 -2
  45. package/dist/esm/shared.js +2 -2
  46. package/dist/esm/stores/routes/utils.d.ts +1 -1
  47. package/dist/esm/stores/routes/utils.d.ts.map +1 -1
  48. package/dist/esm/stores/routes/utils.js +13 -1
  49. package/dist/esm/stores/routes/utils.js.map +1 -1
  50. package/dist/esm/utils/getErrorMessage.js +4 -0
  51. package/dist/esm/utils/getErrorMessage.js.map +1 -1
  52. package/dist/esm/utils/token.js +13 -1
  53. package/dist/esm/utils/token.js.map +1 -1
  54. package/package.json +4 -4
  55. package/src/components/TokenList/TokenDetailsSheet.tsx +5 -9
  56. package/src/components/TokenList/TokenDetailsSheetContent.tsx +46 -4
  57. package/src/components/TokenList/TokenListItem.tsx +44 -68
  58. package/src/components/TokenList/VirtualizedTokenList.tsx +3 -6
  59. package/src/components/TokenList/tokenVerificationIcons.ts +15 -0
  60. package/src/components/TokenList/types.ts +3 -11
  61. package/src/config/version.ts +1 -1
  62. package/src/hooks/usePacedValue.ts +1 -1
  63. package/src/hooks/useRouteExecutionMessage.ts +10 -13
  64. package/src/pages/TransactionPage/TransactionFailedButtons.test.tsx +497 -0
  65. package/src/pages/TransactionPage/TransactionFailedButtons.tsx +78 -9
  66. package/src/pages/TransactionPage/utils.ts +81 -0
  67. package/src/shared.ts +1 -0
  68. package/src/stores/routes/utils.ts +24 -1
  69. package/src/utils/getErrorMessage.ts +5 -0
  70. package/src/utils/token.ts +48 -0
@@ -0,0 +1,497 @@
1
+ /** @vitest-environment happy-dom */
2
+
3
+ import {
4
+ LiFiErrorCode,
5
+ type LiFiStepExtended,
6
+ type RouteExtended,
7
+ } from '@lifi/sdk'
8
+ import {
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ Outlet,
14
+ RouterProvider,
15
+ } from '@tanstack/react-router'
16
+ import type { ReactNode } from 'react'
17
+ import { createRoot, type Root } from 'react-dom/client'
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
19
+ import { create, type StoreApi, type UseBoundStore } from 'zustand'
20
+ import { DISPLAY_INTERVAL } from '../../hooks/usePacedValue.js'
21
+ import type { WidgetContextProps } from '../../providers/WidgetProvider/types.js'
22
+ import { WidgetContext } from '../../providers/WidgetProvider/WidgetProvider.js'
23
+ import { BookmarkStoreProvider } from '../../stores/bookmarks/BookmarkStore.js'
24
+ import type { Bookmark } from '../../stores/bookmarks/types.js'
25
+ import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js'
26
+ import { useBookmarks } from '../../stores/bookmarks/useBookmarks.js'
27
+ import { createFormStore } from '../../stores/form/createFormStore.js'
28
+ import { FormStoreContext } from '../../stores/form/FormStoreContext.js'
29
+ import type { FormStoreStore } from '../../stores/form/types.js'
30
+ import { RouteExecutionStatus } from '../../stores/routes/types.js'
31
+
32
+ // Real page, buttons, router, form store and pacing; only wallet, SDK and theme leaves are stubbed.
33
+
34
+ const SENDER = '0x1111111111111111111111111111111111111111'
35
+
36
+ interface TestRouteState {
37
+ route: RouteExtended
38
+ status: RouteExecutionStatus | undefined
39
+ setStatus: (status: RouteExecutionStatus) => void
40
+ deleteRoute: () => void
41
+ }
42
+
43
+ type TestRouteStore = UseBoundStore<StoreApi<TestRouteState>>
44
+
45
+ const { routeState } = vi.hoisted(() => ({
46
+ routeState: { store: undefined as unknown },
47
+ }))
48
+ const testRouteStore = () => routeState.store as TestRouteStore
49
+ const useTestRoute = <T,>(selector: (state: TestRouteState) => T): T =>
50
+ testRouteStore()(selector)
51
+
52
+ vi.mock('../../hooks/useRouteExecution.js', () => ({
53
+ useRouteExecution: () => {
54
+ const { route, status, deleteRoute } = useTestRoute((state) => state)
55
+ return {
56
+ route: status ? route : undefined,
57
+ status,
58
+ deleteRoute,
59
+ executeRoute: () => {},
60
+ restartRoute: () => {},
61
+ }
62
+ },
63
+ }))
64
+ vi.mock('react-i18next', () => ({
65
+ useTranslation: () => ({ t: (key: string) => key }),
66
+ }))
67
+ vi.mock('../../hooks/useHeader.js', () => ({ useHeader: () => {} }))
68
+ vi.mock('../../hooks/useAddressActivity.js', () => ({
69
+ useAddressActivity: () => ({
70
+ toAddress: undefined,
71
+ hasActivity: true,
72
+ isLoading: false,
73
+ isFetched: true,
74
+ }),
75
+ }))
76
+ vi.mock('../../hooks/useSwapOnly.js', () => ({ useSwapOnly: () => false }))
77
+ vi.mock('../../components/PageContainer.js', () => ({
78
+ PageContainer: ({ children }: { children?: ReactNode }) => children,
79
+ }))
80
+ vi.mock('./RouteTracker.js', () => ({ RouteTracker: () => null }))
81
+ vi.mock('./ExchangeRateBottomSheet.js', () => ({
82
+ ExchangeRateBottomSheet: () => null,
83
+ }))
84
+ vi.mock('./StartTransactionButton.js', () => ({
85
+ StartTransactionButton: ({ text }: { text?: string }) => (
86
+ <button type="button">{text}</button>
87
+ ),
88
+ }))
89
+ vi.mock('./TokenValueBottomSheet.js', () => ({
90
+ TokenValueBottomSheet: () => null,
91
+ }))
92
+ vi.mock('./ConfirmToAddressSheet.js', () => ({
93
+ ConfirmToAddressSheet: () => null,
94
+ }))
95
+ vi.mock('./TransactionContent.js', async () => {
96
+ const { TransactionFailedButtons } = await import(
97
+ './TransactionFailedButtons.js'
98
+ )
99
+ return {
100
+ TransactionContent: ({
101
+ route,
102
+ status,
103
+ restartRoute,
104
+ deleteRoute,
105
+ }: {
106
+ route: RouteExtended
107
+ status: RouteExecutionStatus
108
+ restartRoute: () => void
109
+ deleteRoute: () => void
110
+ }) =>
111
+ status === RouteExecutionStatus.Failed ? (
112
+ <TransactionFailedButtons
113
+ route={route}
114
+ restartRoute={restartRoute}
115
+ deleteRoute={deleteRoute}
116
+ />
117
+ ) : null,
118
+ }
119
+ })
120
+
121
+ const { TransactionPage } = await import('./TransactionPage.js')
122
+
123
+ const failedExecution = (code: LiFiErrorCode) => ({
124
+ status: 'FAILED',
125
+ startedAt: 0,
126
+ actions: [{ type: 'SWAP', status: 'FAILED', error: { code, message: '' } }],
127
+ })
128
+
129
+ const step = {
130
+ id: 'step-0',
131
+ action: {
132
+ fromChainId: 1,
133
+ fromAmount: '1500000',
134
+ fromToken: { address: '0xfrom', decimals: 6, chainId: 1 },
135
+ fromAddress: SENDER,
136
+ toChainId: 8453,
137
+ toToken: { address: '0xto', decimals: 6, chainId: 8453 },
138
+ toAddress: SENDER,
139
+ },
140
+ estimate: { gasCosts: [], feeCosts: [] },
141
+ execution: failedExecution(LiFiErrorCode.CallBundleNotFound),
142
+ } as unknown as LiFiStepExtended
143
+
144
+ const route = {
145
+ id: 'r1',
146
+ fromChainId: 1,
147
+ fromToken: step.action.fromToken,
148
+ fromAmount: step.action.fromAmount,
149
+ fromAddress: SENDER,
150
+ toChainId: 8453,
151
+ toToken: step.action.toToken,
152
+ toAddress: SENDER,
153
+ fromAmountUSD: '1',
154
+ toAmountUSD: '1',
155
+ steps: [step],
156
+ } as unknown as RouteExtended
157
+
158
+ const SOLANA_RECEIVER = 'So1anaReceiver11111111111111111111111111111'
159
+ const routeToSolana = {
160
+ ...route,
161
+ toChainId: 1151111081099710,
162
+ toAddress: SOLANA_RECEIVER,
163
+ steps: [
164
+ {
165
+ ...step,
166
+ action: {
167
+ ...step.action,
168
+ toChainId: 1151111081099710,
169
+ toAddress: SOLANA_RECEIVER,
170
+ },
171
+ },
172
+ ],
173
+ } as unknown as RouteExtended
174
+
175
+ const routeFailedWith = (code: LiFiErrorCode) =>
176
+ ({
177
+ ...route,
178
+ steps: [{ ...step, execution: failedExecution(code) }],
179
+ }) as unknown as RouteExtended
180
+
181
+ // The first step is done, so a new swap would send its funds again.
182
+ const routeFailedInLaterStep = {
183
+ ...route,
184
+ steps: [
185
+ {
186
+ ...step,
187
+ execution: {
188
+ status: 'DONE',
189
+ startedAt: 0,
190
+ actions: [{ type: 'SWAP', status: 'DONE', txHash: '0xhash' }],
191
+ },
192
+ },
193
+ { ...step, id: 'step-1' },
194
+ ],
195
+ } as unknown as RouteExtended
196
+
197
+ let formStore: FormStoreStore
198
+ let bookmarks: {
199
+ selected?: Bookmark
200
+ select?: (bookmark?: Bookmark) => void
201
+ }
202
+
203
+ const BookmarkProbe = (): null => {
204
+ const { selectedBookmark } = useBookmarks()
205
+ const { setSelectedBookmark } = useBookmarkActions()
206
+ bookmarks.selected = selectedBookmark
207
+ bookmarks.select = setSelectedBookmark
208
+ return null
209
+ }
210
+
211
+ const makeRouter = () => {
212
+ const rootRoute = createRootRoute({
213
+ component: () => (
214
+ <>
215
+ <BookmarkProbe />
216
+ <Outlet />
217
+ </>
218
+ ),
219
+ })
220
+ const home = createRoute({
221
+ getParentRoute: () => rootRoute,
222
+ path: '/',
223
+ component: () => <div id="home" />,
224
+ })
225
+ const transaction = createRoute({
226
+ getParentRoute: () => rootRoute,
227
+ path: '/transaction-execution',
228
+ component: TransactionPage,
229
+ })
230
+ return createRouter({
231
+ routeTree: rootRoute.addChildren([home, transaction]),
232
+ history: createMemoryHistory({
233
+ initialEntries: ['/', '/transaction-execution?routeId=r1'],
234
+ }),
235
+ })
236
+ }
237
+
238
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
239
+
240
+ // Longer than one pacing window, so the paced status has caught up.
241
+ const PAST_PACING = DISPLAY_INTERVAL + 300
242
+
243
+ const waitFor = async (check: () => boolean) => {
244
+ for (let i = 0; i < 100 && !check(); i++) {
245
+ await sleep(20)
246
+ }
247
+ expect(check()).toBe(true)
248
+ }
249
+
250
+ let container: HTMLDivElement
251
+ let root: Root
252
+ let router: ReturnType<typeof makeRouter>
253
+
254
+ const render = async (
255
+ status: RouteExecutionStatus,
256
+ config: Partial<WidgetContextProps> = {},
257
+ testRoute: RouteExtended = route
258
+ ) => {
259
+ routeState.store = create<TestRouteState>((set) => ({
260
+ route: testRoute,
261
+ status,
262
+ setStatus: (status) => set({ status }),
263
+ deleteRoute: () => set({ status: undefined }),
264
+ }))
265
+ router = makeRouter()
266
+ root.render(
267
+ <WidgetContext
268
+ value={
269
+ { elementId: '', integrator: '', mode: 'default', ...config } as never
270
+ }
271
+ >
272
+ <FormStoreContext value={formStore}>
273
+ <BookmarkStoreProvider namePrefix="test">
274
+ <RouterProvider router={router} />
275
+ </BookmarkStoreProvider>
276
+ </FormStoreContext>
277
+ </WidgetContext>
278
+ )
279
+ await waitFor(
280
+ () =>
281
+ router.state.status === 'idle' &&
282
+ router.state.location.pathname === '/transaction-execution'
283
+ )
284
+ }
285
+
286
+ const newSwapButton = () =>
287
+ [...container.querySelectorAll('button')].find(
288
+ (button) => button.textContent === 'button.startNewSwap'
289
+ )
290
+
291
+ const deleteButton = () =>
292
+ [...container.querySelectorAll('button')].find(
293
+ (button) => button.textContent === 'button.delete'
294
+ )
295
+
296
+ // The mocks leave only the failed-route buttons on the page.
297
+ const buttonTexts = () =>
298
+ [...container.querySelectorAll('button')].map((button) => button.textContent)
299
+
300
+ const field = (name: 'fromAmount' | 'toAmount' | 'toAddress' | 'fromToken') =>
301
+ formStore.getState().userValues[name]?.value
302
+
303
+ beforeEach(() => {
304
+ ;(
305
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
306
+ ).IS_REACT_ACT_ENVIRONMENT = false
307
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
308
+ container = document.createElement('div')
309
+ document.body.appendChild(container)
310
+ root = createRoot(container)
311
+ formStore = createFormStore()
312
+ bookmarks = {}
313
+ })
314
+
315
+ afterEach(() => {
316
+ root.unmount()
317
+ container.remove()
318
+ localStorage.clear()
319
+ vi.restoreAllMocks()
320
+ })
321
+
322
+ describe('The buttons of a failed route', () => {
323
+ it('should offer Delete and a new swap, and no retry, when the wallet has no record of the bundle', async () => {
324
+ await render(RouteExecutionStatus.Failed)
325
+ await waitFor(() => buttonTexts().length > 0)
326
+
327
+ expect(buttonTexts()).toEqual(['button.delete', 'button.startNewSwap'])
328
+ })
329
+
330
+ it('should offer only Delete when the wallet has no record of the bundle and a new swap is not possible', async () => {
331
+ await render(RouteExecutionStatus.Failed, {}, routeFailedInLaterStep)
332
+ await waitFor(() => buttonTexts().length > 0)
333
+
334
+ expect(buttonTexts()).toEqual(['button.delete'])
335
+ })
336
+
337
+ it('should offer Delete and a retry for other errors', async () => {
338
+ await render(
339
+ RouteExecutionStatus.Failed,
340
+ {},
341
+ routeFailedWith(LiFiErrorCode.SignatureRejected)
342
+ )
343
+ await waitFor(() => buttonTexts().length > 0)
344
+
345
+ expect(buttonTexts()).toEqual(['button.delete', 'button.tryAgain'])
346
+ })
347
+
348
+ it('should delete the route and not fill the form when Delete is clicked', async () => {
349
+ await render(RouteExecutionStatus.Failed)
350
+ await waitFor(() => !!newSwapButton())
351
+
352
+ deleteButton()!.click()
353
+
354
+ await waitFor(() => !!container.querySelector('#home'))
355
+ await sleep(300)
356
+ expect(testRouteState().status).toBeUndefined()
357
+ expect(field('fromAmount')).toBeFalsy()
358
+ })
359
+ })
360
+
361
+ describe('Start a new swap and the transaction page cleanup', () => {
362
+ // Within one pacing window the page still holds the failed status when it unmounts.
363
+ it('should keep the filled amount when the route failed just before the click', async () => {
364
+ await render(RouteExecutionStatus.Pending)
365
+ formStore.getState().setFieldValue('toAmount', '7')
366
+ testRouteState().setStatus(RouteExecutionStatus.Failed)
367
+ await waitFor(() => !!newSwapButton())
368
+
369
+ newSwapButton()!.click()
370
+
371
+ await waitFor(() => !!container.querySelector('#home'))
372
+ await sleep(PAST_PACING)
373
+ expect(router.state.location.pathname).toBe('/')
374
+ expect(field('fromAmount')).toBe('1.5')
375
+ expect(field('fromToken')).toBe('0xfrom')
376
+ expect(field('toAmount')).toBe('')
377
+ })
378
+
379
+ it('should fill the form for a route that had already failed', async () => {
380
+ await render(RouteExecutionStatus.Failed)
381
+ await waitFor(() => !!newSwapButton())
382
+
383
+ newSwapButton()!.click()
384
+
385
+ await waitFor(() => !!container.querySelector('#home'))
386
+ await sleep(PAST_PACING)
387
+ expect(field('fromAmount')).toBe('1.5')
388
+ expect(field('toAddress')).toBe('')
389
+ })
390
+
391
+ it('should clear the receiver and the bookmark when the receiver is open to change', async () => {
392
+ await render(RouteExecutionStatus.Failed)
393
+ await waitFor(() => !!newSwapButton() && !!bookmarks.select)
394
+ formStore.getState().setFieldValue('toAddress', '0xold')
395
+ bookmarks.select?.({
396
+ name: 'Old',
397
+ address: '0xold',
398
+ chainType: 'EVM',
399
+ } as Bookmark)
400
+
401
+ newSwapButton()!.click()
402
+
403
+ await waitFor(() => !!container.querySelector('#home'))
404
+ await sleep(300)
405
+ expect(field('toAddress')).toBe('')
406
+ expect(bookmarks.selected).toBeUndefined()
407
+ })
408
+
409
+ it('should keep the bookmark that labels the same receiver', async () => {
410
+ await render(RouteExecutionStatus.Failed, {}, routeToSolana)
411
+ await waitFor(() => !!newSwapButton() && !!bookmarks.select)
412
+ const solana = {
413
+ name: 'Solana',
414
+ address: SOLANA_RECEIVER,
415
+ chainType: 'SVM',
416
+ } as Bookmark
417
+ bookmarks.select?.(solana)
418
+
419
+ newSwapButton()!.click()
420
+
421
+ await waitFor(() => !!container.querySelector('#home'))
422
+ await sleep(300)
423
+ expect(field('toAddress')).toBe(SOLANA_RECEIVER)
424
+ expect(bookmarks.selected).toEqual(solana)
425
+ })
426
+
427
+ it('should keep a required receiver that is the sender', async () => {
428
+ // A route to the sender has no top-level receiver.
429
+ await render(
430
+ RouteExecutionStatus.Failed,
431
+ { requiredUI: { toAddress: true } },
432
+ { ...route, toAddress: undefined }
433
+ )
434
+ await waitFor(() => !!newSwapButton())
435
+
436
+ newSwapButton()!.click()
437
+
438
+ await waitFor(() => !!container.querySelector('#home'))
439
+ await sleep(300)
440
+ expect(field('toAddress')).toBe(SENDER)
441
+ })
442
+
443
+ it("should keep the integrator's receiver and bookmark when the receiver is hidden", async () => {
444
+ await render(RouteExecutionStatus.Failed, { hiddenUI: { toAddress: true } })
445
+ await waitFor(() => !!newSwapButton() && !!bookmarks.select)
446
+ formStore.getState().setFieldValue('toAddress', '0xlocked')
447
+ const locked = {
448
+ name: 'Locked',
449
+ address: '0xlocked',
450
+ chainType: 'EVM',
451
+ } as Bookmark
452
+ bookmarks.select?.(locked)
453
+
454
+ newSwapButton()!.click()
455
+
456
+ await waitFor(() => !!container.querySelector('#home'))
457
+ await sleep(300)
458
+ expect(field('fromAmount')).toBe('1.5')
459
+ expect(field('toAddress')).toBe('0xlocked')
460
+ expect(bookmarks.selected).toEqual(locked)
461
+ })
462
+
463
+ it("should keep the integrator's receiver when the receiver is locked", async () => {
464
+ await render(RouteExecutionStatus.Failed, {
465
+ disabledUI: { toAddress: true },
466
+ })
467
+ await waitFor(() => !!newSwapButton())
468
+ formStore.getState().setFieldValue('toAddress', '0xlocked')
469
+
470
+ newSwapButton()!.click()
471
+
472
+ await waitFor(() => !!container.querySelector('#home'))
473
+ await sleep(300)
474
+ expect(field('fromAmount')).toBe('1.5')
475
+ expect(field('toAddress')).toBe('0xlocked')
476
+ })
477
+
478
+ it("should fill the route's receiver when a locked receiver is empty", async () => {
479
+ await render(
480
+ RouteExecutionStatus.Failed,
481
+ { disabledUI: { toAddress: true } },
482
+ routeToSolana
483
+ )
484
+ await waitFor(() => !!newSwapButton())
485
+
486
+ newSwapButton()!.click()
487
+
488
+ await waitFor(() => !!container.querySelector('#home'))
489
+ await sleep(300)
490
+ expect(field('fromAmount')).toBe('1.5')
491
+ expect(field('toAddress')).toBe(SOLANA_RECEIVER)
492
+ })
493
+ })
494
+
495
+ function testRouteState(): TestRouteState {
496
+ return testRouteStore().getState()
497
+ }
@@ -1,22 +1,30 @@
1
1
  import type { RouteExtended } from '@lifi/sdk'
2
2
  import { Box, Button } from '@mui/material'
3
+ import { useNavigate } from '@tanstack/react-router'
3
4
  import { useRef } from 'react'
4
5
  import { useTranslation } from 'react-i18next'
5
6
  import type { BottomSheetBase } from '../../components/BottomSheet/types.js'
6
7
  import { useAddressActivity } from '../../hooks/useAddressActivity.js'
7
8
  import { useNavigateBack } from '../../hooks/useNavigateBack.js'
9
+ import { useSwapOnly } from '../../hooks/useSwapOnly.js'
8
10
  import { useWidgetEvents } from '../../hooks/useWidgetEvents.js'
9
11
  import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js'
12
+ import { useBookmarkActions } from '../../stores/bookmarks/useBookmarkActions.js'
13
+ import { useFieldActions } from '../../stores/form/useFieldActions.js'
10
14
  import { WidgetEvent } from '../../types/events.js'
11
15
  import { getAccumulatedFeeCostsBreakdown } from '../../utils/fees.js'
16
+ import { navigationRoutes } from '../../utils/navigationRoutes.js'
12
17
  import { ConfirmToAddressSheet } from './ConfirmToAddressSheet.js'
13
18
  import { StartTransactionButton } from './StartTransactionButton.js'
14
19
  import { TokenValueBottomSheet } from './TokenValueBottomSheet.js'
15
20
  import type { RetryGate } from './utils.js'
16
21
  import {
17
22
  calculateValueLossPercentage,
23
+ canStartNewSwap,
24
+ getNewSwapFormValues,
18
25
  getRetryGates,
19
26
  getTokenValueLossThreshold,
27
+ isCallBundleNotFound,
20
28
  openNextGate,
21
29
  } from './utils.js'
22
30
 
@@ -32,7 +40,11 @@ export const TransactionFailedButtons: React.FC<
32
40
  const { t } = useTranslation()
33
41
  const emitter = useWidgetEvents()
34
42
  const navigateBack = useNavigateBack()
35
- const { mode, hiddenUI } = useWidgetConfig()
43
+ const navigate = useNavigate()
44
+ const { mode, hiddenUI, disabledUI, requiredUI } = useWidgetConfig()
45
+ const swapOnly = useSwapOnly()
46
+ const { setFieldValue, getFieldValues } = useFieldActions()
47
+ const { setSelectedBookmark, getSelectedBookmark } = useBookmarkActions()
36
48
 
37
49
  const tokenValueBottomSheetRef = useRef<BottomSheetBase>(null)
38
50
  const confirmToAddressSheetRef = useRef<BottomSheetBase>(null)
@@ -49,6 +61,49 @@ export const TransactionFailedButtons: React.FC<
49
61
  deleteRoute()
50
62
  }
51
63
 
64
+ const callBundleNotFound = isCallBundleNotFound(route)
65
+ const showStartNewSwap =
66
+ callBundleNotFound && canStartNewSwap({ route, mode, swapOnly })
67
+ // Try again would only wait for the same bundle again.
68
+ const showRetry = !callBundleNotFound
69
+
70
+ // Home, not back: after a reload, the route is opened from Activities.
71
+ const handleStartNewSwap = () => {
72
+ // An empty hidden or locked field still needs the route's receiver.
73
+ const [formReceiver] = getFieldValues('toAddress')
74
+ const keepReceiver = Boolean(
75
+ (disabledUI?.toAddress || hiddenUI?.toAddress) && formReceiver
76
+ )
77
+ const values = getNewSwapFormValues(route, {
78
+ keepReceiver,
79
+ receiverRequired: requiredUI?.toAddress,
80
+ })
81
+ const fieldNames = Object.keys(values) as (keyof typeof values)[]
82
+ const receiver = keepReceiver ? formReceiver : values.toAddress
83
+ // Fill after navigate resolves: this page's unmount cleanup has run by then,
84
+ // as TransactionFailedButtons.test.tsx checks.
85
+ navigate({ to: navigationRoutes.home, replace: true })
86
+ .then(() => {
87
+ for (const fieldName of fieldNames) {
88
+ setFieldValue(fieldName, values[fieldName], {
89
+ isDirty: true,
90
+ isTouched: true,
91
+ })
92
+ }
93
+ // A bookmark name from an earlier receiver must not label this one.
94
+ if (
95
+ getSelectedBookmark()?.address.toLowerCase() !==
96
+ receiver?.toLowerCase()
97
+ ) {
98
+ setSelectedBookmark()
99
+ }
100
+ })
101
+ .catch((error) => {
102
+ console.warn('Filling the new swap failed.', error)
103
+ })
104
+ deleteRoute()
105
+ }
106
+
52
107
  const retryGates = (() => {
53
108
  const { gasCostUSD, feeCostUSD } = getAccumulatedFeeCostsBreakdown(route)
54
109
  return getRetryGates({
@@ -112,14 +167,28 @@ export const TransactionFailedButtons: React.FC<
112
167
  {t('button.delete')}
113
168
  </Button>
114
169
  </Box>
115
- <Box sx={{ flex: 1 }}>
116
- <StartTransactionButton
117
- text={t('button.tryAgain')}
118
- onClick={handleRetryClick}
119
- route={route}
120
- loading={isLoadingAddressActivity}
121
- />
122
- </Box>
170
+ {showStartNewSwap ? (
171
+ <Box sx={{ flex: 1 }}>
172
+ <Button
173
+ variant="contained"
174
+ color="primary"
175
+ onClick={handleStartNewSwap}
176
+ fullWidth
177
+ >
178
+ {t('button.startNewSwap')}
179
+ </Button>
180
+ </Box>
181
+ ) : null}
182
+ {showRetry ? (
183
+ <Box sx={{ flex: 1 }}>
184
+ <StartTransactionButton
185
+ text={t('button.tryAgain')}
186
+ onClick={handleRetryClick}
187
+ route={route}
188
+ loading={isLoadingAddressActivity}
189
+ />
190
+ </Box>
191
+ ) : null}
123
192
  </Box>
124
193
  {mode !== 'custom' ? (
125
194
  <TokenValueBottomSheet