0xtrails 0.6.0 → 0.6.2

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 (88) hide show
  1. package/dist/{ccip-Dw5AN7oU.js → ccip-CZfykYU7.js} +4 -4
  2. package/dist/chains.d.ts +9 -2
  3. package/dist/chains.d.ts.map +1 -1
  4. package/dist/constants.d.ts +1 -0
  5. package/dist/constants.d.ts.map +1 -1
  6. package/dist/contractUtils.d.ts +2 -1
  7. package/dist/contractUtils.d.ts.map +1 -1
  8. package/dist/{index-BtVUTbEZ.js → index-S9pphnT9.js} +29732 -36767
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +300 -242
  12. package/dist/prepareSend.d.ts.map +1 -1
  13. package/dist/transactionIntent/handlers/crossChain.d.ts.map +1 -1
  14. package/dist/transactionIntent/handlers/sameChainSameToken.d.ts.map +1 -1
  15. package/dist/transactionIntent/types.d.ts +3 -1
  16. package/dist/transactionIntent/types.d.ts.map +1 -1
  17. package/dist/widget/components/ChainImage.d.ts.map +1 -1
  18. package/dist/widget/components/ClassicSwap.d.ts.map +1 -1
  19. package/dist/widget/components/DepositTracker.d.ts +12 -0
  20. package/dist/widget/components/DepositTracker.d.ts.map +1 -0
  21. package/dist/widget/components/Disconnect.d.ts.map +1 -1
  22. package/dist/widget/components/FeeBreakdown.d.ts.map +1 -1
  23. package/dist/widget/components/QRCodeDeposit.d.ts.map +1 -1
  24. package/dist/widget/components/QRCodeOptions.d.ts +9 -0
  25. package/dist/widget/components/QRCodeOptions.d.ts.map +1 -0
  26. package/dist/widget/components/QuoteDetails.d.ts.map +1 -1
  27. package/dist/widget/components/Receipt.d.ts.map +1 -1
  28. package/dist/widget/components/ThemeProvider.d.ts.map +1 -1
  29. package/dist/widget/components/Toast.d.ts.map +1 -1
  30. package/dist/widget/components/TransferPendingVertical.d.ts.map +1 -1
  31. package/dist/widget/components/WalletConnectionPending.d.ts.map +1 -1
  32. package/dist/widget/css/compiled.css +1 -1
  33. package/dist/widget/css/index.css +103 -38
  34. package/dist/widget/hooks/useCheckout.d.ts.map +1 -1
  35. package/dist/widget/hooks/useCurrentScreen.d.ts +1 -1
  36. package/dist/widget/hooks/useCurrentScreen.d.ts.map +1 -1
  37. package/dist/widget/hooks/useDebugScreens.d.ts +1 -1
  38. package/dist/widget/hooks/useDebugScreens.d.ts.map +1 -1
  39. package/dist/widget/hooks/useIntentTransactionHistory.d.ts.map +1 -1
  40. package/dist/widget/hooks/useQuote.d.ts.map +1 -1
  41. package/dist/widget/hooks/useSelectedFeeOption.d.ts.map +1 -1
  42. package/dist/widget/hooks/useSendForm.d.ts.map +1 -1
  43. package/dist/widget/index.js +1 -1
  44. package/dist/widget/widget.d.ts.map +1 -1
  45. package/package.json +6 -9
  46. package/src/chains.ts +37 -4
  47. package/src/constants.ts +1 -0
  48. package/src/contractUtils.ts +8 -7
  49. package/src/estimate.ts +2 -2
  50. package/src/index.ts +2 -0
  51. package/src/intents.ts +2 -2
  52. package/src/paymasterSend.ts +2 -2
  53. package/src/prepareSend.ts +34 -3
  54. package/src/sendUserOp.ts +2 -2
  55. package/src/tokens.ts +2 -2
  56. package/src/transactionIntent/deposits/gaslessDeposit.ts +2 -2
  57. package/src/transactionIntent/handlers/crossChain.ts +51 -2
  58. package/src/transactionIntent/handlers/sameChainSameToken.ts +52 -2
  59. package/src/transactionIntent/quote/normalizeQuote.ts +2 -2
  60. package/src/transactionIntent/types.ts +9 -1
  61. package/src/widget/compiled.css +1 -1
  62. package/src/widget/components/ChainImage.tsx +10 -7
  63. package/src/widget/components/ChainList.tsx +1 -1
  64. package/src/widget/components/ClassicSwap.tsx +8 -4
  65. package/src/widget/components/ConnectedWallets.tsx +1 -1
  66. package/src/widget/components/DepositTracker.tsx +298 -0
  67. package/src/widget/components/Disconnect.tsx +24 -3
  68. package/src/widget/components/FeeBreakdown.tsx +3 -3
  69. package/src/widget/components/QRCodeDeposit.tsx +29 -19
  70. package/src/widget/components/QRCodeOptions.tsx +65 -0
  71. package/src/widget/components/QuoteDetails.tsx +694 -803
  72. package/src/widget/components/Receipt.tsx +76 -40
  73. package/src/widget/components/ThemeProvider.tsx +7 -12
  74. package/src/widget/components/Toast.tsx +3 -2
  75. package/src/widget/components/TokenSelector.tsx +1 -1
  76. package/src/widget/components/Tooltip.tsx +1 -1
  77. package/src/widget/components/TransferPendingVertical.tsx +11 -2
  78. package/src/widget/components/WalletConnectionPending.tsx +28 -5
  79. package/src/widget/hooks/useCheckout.ts +10 -2
  80. package/src/widget/hooks/useCurrentScreen.tsx +1 -0
  81. package/src/widget/hooks/useDebugScreens.ts +1 -0
  82. package/src/widget/hooks/useIntentTransactionHistory.ts +114 -143
  83. package/src/widget/hooks/useQuote.ts +92 -6
  84. package/src/widget/hooks/useSelectedFeeOption.tsx +86 -29
  85. package/src/widget/hooks/useSendForm.ts +43 -7
  86. package/src/widget/index.css +103 -38
  87. package/src/widget/widget.tsx +48 -5
  88. package/dist/0xtrails.css +0 -1
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useCallback } from "react"
1
+ import { useEffect, useCallback, useState } from "react"
2
+ import { useQuery } from "@tanstack/react-query"
2
3
  import {
3
4
  getAccountTransactionHistory,
4
5
  type IntentTransaction,
@@ -9,12 +10,12 @@ import { logger } from "../../logger.js"
9
10
  import { useTrails } from "../providers/TrailsProvider.js"
10
11
  import { commonTokenImages } from "../../tokens.js"
11
12
  import { getTrailsClient } from "../../trailsClient.js"
12
- import { SortOrder, type IntentSummary } from "@0xsequence/trails-api"
13
+ import type { IntentSummary } from "@0xsequence/trails-api"
13
14
  import {
14
15
  decodeTrailsTokenSweeperEvents,
15
16
  decodeGuestModuleEvents,
16
17
  } from "../../decoders.js"
17
- import { getPublicRpcClient } from "../../chains.js"
18
+ import { getChainRpcClient } from "../../chains.js"
18
19
  import { getChainInfo } from "../../chains.js"
19
20
  import { isNativeToken } from "../../utils.js"
20
21
 
@@ -443,7 +444,7 @@ async function decodeTransactionEvents(
443
444
  continue
444
445
  }
445
446
 
446
- const publicClient = getPublicRpcClient(chainInfo)
447
+ const publicClient = getChainRpcClient(chainInfo.id)
447
448
  const receipt = await publicClient.getTransactionReceipt({
448
449
  hash: hash as `0x${string}`,
449
450
  })
@@ -724,46 +725,57 @@ export type UseIntentTransactionHistoryReturn = {
724
725
  totalPages?: number
725
726
  }
726
727
 
728
+ // ============================================================================
729
+ // Main Hook
730
+ // ============================================================================
731
+
727
732
  export function useIntentTransactionHistory({
728
733
  accountAddress,
729
734
  pageSize = 10,
730
735
  enabled = true,
731
736
  }: UseIntentTransactionHistoryParams): UseIntentTransactionHistoryReturn {
732
737
  const trailsConfig = useTrails()
733
- const [transactions, setTransactions] = useState<IntentTransaction[]>([])
734
- const [loading, setLoading] = useState(false)
735
- const [error, setError] = useState<string | null>(null)
738
+
739
+ // Page state - store the full page object from API
736
740
  const [page, setPage] = useState(0)
737
- const [hasMore, setHasMore] = useState(false)
738
-
739
- /**
740
- * Fetches intent transaction history from the API
741
- */
742
- const fetchIntentTransactionHistoryFromApi = useCallback(
743
- async (
744
- accountAddress: string,
745
- pageSize: number,
746
- pageNum: number,
747
- apiKey: string,
748
- apiUrl: string,
749
- ) => {
750
- if (!apiKey) {
751
- throw new Error("Trails api key is required")
741
+ const [pageHistory, setPageHistory] = useState<any[]>([undefined]) // undefined = first page (no page param needed)
742
+
743
+ // Get current page object
744
+ const currentPageObject = pageHistory[page]
745
+
746
+ // TanStack Query for fetching paginated data
747
+ const { data, isLoading, error, refetch, isFetching } = useQuery({
748
+ queryKey: [
749
+ "intentTransactionHistory",
750
+ accountAddress,
751
+ pageSize,
752
+ page, // Include page index in key for proper caching
753
+ enabled,
754
+ ],
755
+ queryFn: async () => {
756
+ if (!accountAddress || !trailsConfig.trailsApiKey) {
757
+ throw new Error("Account address and API key required")
752
758
  }
753
759
 
754
- const trailsClient = getTrailsClient({ apiKey, hostname: apiUrl })
760
+ const trailsClient = getTrailsClient({
761
+ apiKey: trailsConfig.trailsApiKey,
762
+ hostname: trailsConfig.trailsApiUrl!,
763
+ })
755
764
 
756
- const requestParams = {
765
+ const requestParams: any = {
757
766
  byOwnerAddress: accountAddress,
758
- page: {
767
+ }
768
+
769
+ // Build page object with pageSize for first request
770
+ // For subsequent requests, pass the opaque nextPage object which includes pageSize
771
+ if (currentPageObject) {
772
+ // Subsequent pages - use the opaque nextPage object as-is
773
+ requestParams.page = currentPageObject
774
+ } else {
775
+ // First page - set initial pageSize
776
+ requestParams.page = {
759
777
  pageSize,
760
- sort: [
761
- {
762
- column: "created_at",
763
- order: SortOrder.DESC,
764
- },
765
- ],
766
- },
778
+ }
767
779
  }
768
780
 
769
781
  logger.console.log(
@@ -776,142 +788,101 @@ export function useIntentTransactionHistory({
776
788
 
777
789
  logger.console.log("[trails-sdk] getIntentTransactionHistory response:", {
778
790
  intentCount: apiResult.intents?.length || 0,
779
- hasNextPage: !!apiResult.nextPage,
780
- firstIntent: apiResult.intents?.[0],
791
+ hasNextPage: apiResult.nextPage?.more ?? false,
792
+ pageObject: apiResult.nextPage,
781
793
  })
782
794
 
783
- // Map API response to IntentTransaction
795
+ // Map and enrich transactions
784
796
  const mappedTransactions: IntentTransaction[] = (
785
797
  apiResult.intents || []
786
798
  ).map(mapIntentSummaryToTransaction)
787
799
 
800
+ const supportedTokens = await getSupportedTokens()
801
+
802
+ const enrichedTransactions = await Promise.all(
803
+ mappedTransactions.map(async (transaction) =>
804
+ enrichTransaction(transaction, supportedTokens, trailsConfig),
805
+ ),
806
+ )
807
+
788
808
  return {
789
- transactions: mappedTransactions,
790
- page: apiResult.nextPage
791
- ? {
792
- page: pageNum,
793
- pageSize,
794
- more: apiResult.nextPage.more || false,
795
- }
796
- : {
797
- page: pageNum,
798
- pageSize,
799
- more: false,
800
- },
809
+ transactions: enrichedTransactions,
810
+ nextPage: apiResult.nextPage,
811
+ hasMore: apiResult.nextPage?.more ?? false,
801
812
  }
802
813
  },
803
- [],
804
- )
805
-
806
- const fetchTransactions = useCallback(
807
- async (pageNum: number = 0, append: boolean = false) => {
808
- if (!accountAddress || !enabled) return
809
-
810
- setLoading(true)
811
- setError(null)
812
-
813
- try {
814
- const apiKey = trailsConfig.trailsApiKey || ""
815
- const apiUrl = trailsConfig.trailsApiUrl!
814
+ enabled: enabled && !!accountAddress && !!trailsConfig.trailsApiKey,
815
+ staleTime: 1000 * 60 * 5, // 5 minutes
816
+ placeholderData: (previousData) => previousData, // Keep previous data while fetching
817
+ })
816
818
 
817
- // Fetch intent transaction history from API
818
- const result = await fetchIntentTransactionHistoryFromApi(
819
- accountAddress,
820
- pageSize,
821
- pageNum,
822
- apiKey,
823
- apiUrl,
824
- )
819
+ const transactions = data?.transactions ?? []
820
+ const hasMore = data?.hasMore ?? false
821
+ const nextPageObject = data?.nextPage
825
822
 
826
- // Get supported tokens once for all transactions
827
- const supportedTokens = await getSupportedTokens()
823
+ const refetchPage = useCallback(() => {
824
+ refetch()
825
+ }, [refetch])
828
826
 
829
- // Enrich transactions with token information, transaction hashes, and status
830
- const enrichedTransactions = await Promise.all(
831
- (result.transactions || []).map(async (transaction) =>
832
- enrichTransaction(transaction, supportedTokens, trailsConfig),
833
- ),
834
- )
827
+ const nextPageHandler = useCallback(() => {
828
+ if (!hasMore || isLoading) return
835
829
 
836
- if (append) {
837
- setTransactions((prev) => [...prev, ...enrichedTransactions])
838
- } else {
839
- setTransactions(enrichedTransactions)
840
- }
830
+ // Check if next page is already cached
831
+ if (pageHistory[page + 1]) {
832
+ logger.console.log("[trails-sdk] Using cached page", page + 1)
833
+ setPage((p) => p + 1)
834
+ return
835
+ }
841
836
 
842
- setHasMore(result.page?.more || false)
843
- setPage(pageNum)
844
- } catch (err) {
845
- const errorMessage =
846
- err instanceof Error
847
- ? err.message
848
- : "Failed to fetch intent transaction history"
849
- setError(errorMessage)
850
- logger.console.error(
851
- "[trails-sdk] Error in useIntentTransactionHistory:",
852
- err,
853
- )
854
- } finally {
855
- setLoading(false)
856
- }
857
- },
858
- [
859
- accountAddress,
860
- enabled,
861
- pageSize,
862
- trailsConfig,
863
- fetchIntentTransactionHistoryFromApi,
864
- ],
865
- )
837
+ // Fetch next page by storing the nextPage object and incrementing page index
838
+ if (nextPageObject) {
839
+ setPageHistory((prev) => {
840
+ const newHistory = [...prev]
841
+ newHistory[page + 1] = nextPageObject
842
+ return newHistory
843
+ })
844
+ setPage((p) => p + 1)
845
+ }
846
+ }, [hasMore, isLoading, nextPageObject, page, pageHistory])
866
847
 
867
- const refetch = () => {
868
- fetchTransactions(page, false)
869
- }
848
+ const prevPageHandler = useCallback(() => {
849
+ if (page <= 0 || isLoading) return
870
850
 
871
- const loadMore = () => {
872
- if (hasMore && !loading) {
873
- const nextPage = page + 1
874
- fetchTransactions(nextPage, true)
875
- }
876
- }
851
+ // Go back to previous page
852
+ logger.console.log("[trails-sdk] Going to previous page", page - 1)
853
+ setPage((p) => p - 1)
854
+ }, [page, isLoading])
877
855
 
878
- const nextPage = () => {
879
- if (hasMore && !loading) {
880
- const next = page + 1
881
- fetchTransactions(next, false)
882
- }
883
- }
856
+ const loadMoreHandler = useCallback(() => {
857
+ if (!hasMore || isFetching) return
884
858
 
885
- const prevPage = () => {
886
- if (page > 0 && !loading) {
887
- const prev = page - 1
888
- fetchTransactions(prev, false)
859
+ if (nextPageObject) {
860
+ setPageHistory((prev) => {
861
+ const newHistory = [...prev]
862
+ newHistory[page + 1] = nextPageObject
863
+ return newHistory
864
+ })
865
+ setPage((p) => p + 1)
889
866
  }
890
- }
891
-
892
- const hasPrev = page > 0
867
+ }, [hasMore, isFetching, nextPageObject, page])
893
868
 
869
+ // Reset on account change
870
+ // biome-ignore lint/correctness/useExhaustiveDependencies: accountAddress is an external dependency
894
871
  useEffect(() => {
895
- if (accountAddress && enabled) {
896
- fetchTransactions(0, false)
897
- } else {
898
- setTransactions([])
899
- setError(null)
900
- setHasMore(false)
901
- setPage(0)
902
- }
903
- }, [accountAddress, enabled, fetchTransactions])
872
+ setPage(0)
873
+ setPageHistory([undefined])
874
+ }, [accountAddress])
904
875
 
905
876
  return {
906
877
  transactions,
907
- loading,
908
- error,
909
- refetch,
910
- loadMore,
878
+ loading: isLoading || isFetching,
879
+ error: error instanceof Error ? error.message : null,
880
+ refetch: refetchPage,
881
+ loadMore: loadMoreHandler,
911
882
  hasMore,
912
883
  page,
913
- nextPage,
914
- prevPage,
915
- hasPrev,
884
+ nextPage: nextPageHandler,
885
+ prevPage: prevPageHandler,
886
+ hasPrev: page > 0,
916
887
  }
917
888
  }
@@ -1,7 +1,7 @@
1
1
  import { useQuery } from "@tanstack/react-query"
2
2
  import { useRef } from "react"
3
3
  import type { TransactionReceipt } from "viem"
4
- import { zeroAddress } from "viem"
4
+ import { zeroAddress, erc20Abi } from "viem"
5
5
  import { useIndexerGatewayClient } from "../../indexerClient.js"
6
6
  import { getTokenBalancesWithPrices } from "../../tokenBalances.js"
7
7
  import { logger } from "../../logger.js"
@@ -13,7 +13,7 @@ import { getFullErrorMessage, getPrettifiedErrorMessage } from "../../error.js"
13
13
  import { prepareSend } from "../../prepareSend.js"
14
14
  import { abortControllerRegistry } from "../../abortController.js"
15
15
  import { TradeType } from "../../prepareSend.js"
16
- import { getChainInfo } from "../../chains.js"
16
+ import { getChainInfo, useChainRpcClient } from "../../chains.js"
17
17
  import type { IntentTransaction } from "@0xsequence/trails-api"
18
18
  import type { PrepareSendOptions } from "../../transactionIntent/types.js"
19
19
  import type { Chain } from "../../chains.js"
@@ -188,6 +188,10 @@ export function useQuote({
188
188
  })
189
189
  const indexerGatewayClient = useIndexerGatewayClient()
190
190
 
191
+ // Get RPC clients for known chainIds using hooks (reads from TrailsProvider context)
192
+ const originPublicClient = useChainRpcClient(fromChainId ?? undefined)
193
+ const destinationPublicClient = useChainRpcClient(toChainId ?? undefined)
194
+
191
195
  const { supportedTokens } = useSupportedTokens()
192
196
 
193
197
  // Get mutation hooks for passing to prepareSend
@@ -312,6 +316,52 @@ export function useQuote({
312
316
  )
313
317
  }
314
318
 
319
+ // Helper function to fetch decimals on-chain if not found in token list
320
+ const fetchDecimalsOnChain = async (
321
+ tokenAddress: string,
322
+ chainId: number,
323
+ ): Promise<number | null> => {
324
+ try {
325
+ // Use the hook-based RPC clients that are already available
326
+ const publicClient =
327
+ chainId === fromChainId
328
+ ? originPublicClient
329
+ : chainId === toChainId
330
+ ? destinationPublicClient
331
+ : null
332
+
333
+ if (!publicClient) {
334
+ logger.console.warn(
335
+ `[trails-sdk] No RPC client available for chain ${chainId}`,
336
+ )
337
+ return null
338
+ }
339
+
340
+ // For native tokens, return 18 decimals
341
+ if (tokenAddress.toLowerCase() === zeroAddress.toLowerCase()) {
342
+ const chainInfo = getChainInfo(chainId)
343
+ return chainInfo?.nativeCurrency.decimals ?? 18
344
+ }
345
+
346
+ const decimals = await publicClient.readContract({
347
+ address: tokenAddress as `0x${string}`,
348
+ abi: erc20Abi,
349
+ functionName: "decimals",
350
+ })
351
+
352
+ logger.console.log(
353
+ `[trails-sdk] Fetched decimals on-chain for token ${tokenAddress} on chain ${chainId}: ${decimals}`,
354
+ )
355
+ return decimals
356
+ } catch (error) {
357
+ logger.console.error(
358
+ `[trails-sdk] Error fetching decimals on-chain for token ${tokenAddress} on chain ${chainId}:`,
359
+ error,
360
+ )
361
+ return null
362
+ }
363
+ }
364
+
315
365
  const originToken = supportedTokens?.find(
316
366
  (token) =>
317
367
  token.contractAddress?.toLowerCase() ===
@@ -323,10 +373,27 @@ export function useQuote({
323
373
  toTokenAddress?.toLowerCase() && token.chainId === toChainId,
324
374
  )
325
375
 
326
- const sourceTokenDecimals = originToken?.decimals
376
+ let sourceTokenDecimals = originToken?.decimals
377
+ if (!sourceTokenDecimals && fromTokenAddress && fromChainId) {
378
+ logger.console.warn(
379
+ "[trails-sdk] [useQuote] Source token decimals not found in token list, fetching on-chain:",
380
+ {
381
+ originToken,
382
+ fromTokenAddress,
383
+ fromChainId,
384
+ },
385
+ )
386
+ const onChainDecimals = await fetchDecimalsOnChain(
387
+ fromTokenAddress,
388
+ fromChainId,
389
+ )
390
+ if (onChainDecimals !== null) {
391
+ sourceTokenDecimals = onChainDecimals
392
+ }
393
+ }
327
394
  if (!sourceTokenDecimals) {
328
395
  logger.console.error(
329
- "[trails-sdk] [useQuote] Missing source token decimals:",
396
+ "[trails-sdk] [useQuote] Source token decimals not found:",
330
397
  {
331
398
  originToken,
332
399
  fromTokenAddress,
@@ -335,10 +402,27 @@ export function useQuote({
335
402
  )
336
403
  throw new Error("Source token decimals not found")
337
404
  }
338
- const destinationTokenDecimals = destinationToken?.decimals
405
+ let destinationTokenDecimals = destinationToken?.decimals
406
+ if (!destinationTokenDecimals && toTokenAddress && toChainId) {
407
+ logger.console.warn(
408
+ "[trails-sdk] Destination token decimals not found in token list, fetching on-chain:",
409
+ {
410
+ destinationToken,
411
+ toTokenAddress,
412
+ toChainId,
413
+ },
414
+ )
415
+ const onChainDecimals = await fetchDecimalsOnChain(
416
+ toTokenAddress,
417
+ toChainId,
418
+ )
419
+ if (onChainDecimals !== null) {
420
+ destinationTokenDecimals = onChainDecimals
421
+ }
422
+ }
339
423
  if (!destinationTokenDecimals) {
340
424
  logger.console.error(
341
- "[trails-sdk] Missing destination token decimals:",
425
+ "[trails-sdk] Destination token decimals not found:",
342
426
  {
343
427
  destinationToken,
344
428
  toTokenAddress,
@@ -381,6 +465,8 @@ export function useQuote({
381
465
  checkoutOnHandlers,
382
466
  sequenceIndexerUrl,
383
467
  sequenceProjectAccessKey,
468
+ originPublicClient: originPublicClient ?? undefined,
469
+ destinationPublicClient: destinationPublicClient ?? undefined,
384
470
  }
385
471
 
386
472
  logger.console.log("[trails-sdk] options", options)
@@ -13,6 +13,8 @@ import { logger } from "../../index.js"
13
13
  import { isNativeToken, normalizeAddress } from "../../utils.js"
14
14
  import { isAddressEqual } from "viem"
15
15
 
16
+ const FEE_OPTION_PREFERENCE_KEY = "trails-fee-option-preference"
17
+
16
18
  const createBalanceKey = (
17
19
  chainId?: number,
18
20
  tokenAddress?: string | null,
@@ -101,8 +103,7 @@ export const SelectedFeeOptionProvider: React.FC<
101
103
  > = ({ children, initialToken = null }) => {
102
104
  const [selectedFeeOption, setSelectedFeeOptionInternalRaw] =
103
105
  useState<FeeOption | null>(initialToken)
104
- const [hasUserSelectedFeeOption, setHasUserSelectedFeeOption] =
105
- useState(false)
106
+ const [hasUserSelected, setHasUserSelected] = useState(false)
106
107
  const [feeOptions, setFeeOptionsInternal] = useState<FeeOption[]>([])
107
108
  const [originTokenChainId, setOriginTokenChainId] = useState<
108
109
  number | undefined
@@ -123,12 +124,23 @@ export const SelectedFeeOptionProvider: React.FC<
123
124
  // Wrapper to track when user makes an explicit selection
124
125
  const setSelectedFeeOption = useCallback((token: FeeOption | null) => {
125
126
  setSelectedFeeOptionInternalRaw(token)
126
- setHasUserSelectedFeeOption(true)
127
+ setHasUserSelected(true)
128
+
129
+ // Save preference to localStorage: "native" or "erc20"
130
+ const preference = token === null ? "native" : "erc20"
131
+ try {
132
+ localStorage.setItem(FEE_OPTION_PREFERENCE_KEY, preference)
133
+ } catch (error) {
134
+ logger.console.warn(
135
+ "[trails-sdk] Failed to save fee option preference to localStorage",
136
+ error,
137
+ )
138
+ }
127
139
  }, [])
128
140
 
129
141
  const clearSelectedFeeOption = useCallback(() => {
130
142
  setSelectedFeeOptionInternalRaw(null)
131
- setHasUserSelectedFeeOption(false)
143
+ setHasUserSelected(false)
132
144
  }, [])
133
145
 
134
146
  // Process raw fee options with balance checks
@@ -169,44 +181,89 @@ export const SelectedFeeOptionProvider: React.FC<
169
181
  )
170
182
  }, [feeOptions, originTokenChainId, tokenBalanceLookup])
171
183
 
172
- // Auto-select first fee option (ERC20 or native gas) with sufficient balance
173
- // NOTE: If sufficient balance, it will usually resolve to the native gas option because native gas is the first option in the list returned by the API
184
+ // Auto-select fee option: check preference, then fallback to first option
174
185
  useEffect(() => {
186
+ // Clear selection if no options available
175
187
  if (processedFeeOptions.length === 0) {
176
188
  if (selectedFeeOption !== null) {
177
189
  setSelectedFeeOptionInternalRaw(null)
178
- setHasUserSelectedFeeOption(false)
190
+ setHasUserSelected(false)
179
191
  }
180
192
  return
181
193
  }
182
194
 
183
- const selectionStillValid = selectedFeeOption
184
- ? processedFeeOptions.some((option) =>
185
- isAddressEqual(
186
- option.tokenAddress as `0x${string}`,
187
- selectedFeeOption.tokenAddress as `0x${string}`,
188
- ),
189
- )
190
- : false
195
+ // If user has made a selection, validate and update it
196
+ if (hasUserSelected) {
197
+ // Check if current selection is still valid
198
+ const isSelectionValid =
199
+ selectedFeeOption === null
200
+ ? processedFeeOptions.some((option) =>
201
+ isNativeToken(option.tokenAddress),
202
+ )
203
+ : processedFeeOptions.some((option) =>
204
+ isAddressEqual(
205
+ option.tokenAddress as `0x${string}`,
206
+ selectedFeeOption.tokenAddress as `0x${string}`,
207
+ ),
208
+ )
191
209
 
192
- if (selectedFeeOption && !selectionStillValid) {
193
- setSelectedFeeOptionInternalRaw(null)
194
- setHasUserSelectedFeeOption(false)
195
- return
210
+ if (isSelectionValid) {
211
+ // Update to latest object if it's an ERC20 token
212
+ if (selectedFeeOption !== null) {
213
+ const updatedOption = processedFeeOptions.find((option) =>
214
+ isAddressEqual(
215
+ option.tokenAddress as `0x${string}`,
216
+ selectedFeeOption.tokenAddress as `0x${string}`,
217
+ ),
218
+ )
219
+ if (updatedOption && updatedOption !== selectedFeeOption) {
220
+ setSelectedFeeOptionInternalRaw(updatedOption)
221
+ }
222
+ }
223
+ return // Keep user's selection
224
+ } else {
225
+ // Selection is invalid, clear it and allow auto-selection below
226
+ setSelectedFeeOptionInternalRaw(null)
227
+ setHasUserSelected(false)
228
+ }
196
229
  }
197
230
 
198
- if (!hasUserSelectedFeeOption && !selectedFeeOption) {
199
- const fallbackOption =
200
- processedFeeOptions.find((option) => !option.notEnoughBalance) ??
201
- processedFeeOptions[0] ??
202
- null
231
+ // No user selection - auto-select based on preference or fallback
203
232
 
204
- // Only fallback if the fallback option is erc20
205
- if (fallbackOption && !isNativeToken(fallbackOption.tokenAddress)) {
206
- setSelectedFeeOptionInternalRaw(fallbackOption)
233
+ // Load preference from localStorage
234
+ let preferredType: "native" | "erc20" | null = null
235
+ try {
236
+ const stored = localStorage.getItem(FEE_OPTION_PREFERENCE_KEY)
237
+ if (stored === "native" || stored === "erc20") {
238
+ preferredType = stored
239
+ }
240
+ } catch {
241
+ // Ignore localStorage errors
242
+ }
243
+
244
+ // Try to select based on preference
245
+ if (preferredType) {
246
+ const isNative = preferredType === "native"
247
+ const preferredOption = processedFeeOptions.find(
248
+ (option) => isNativeToken(option.tokenAddress) === isNative,
249
+ )
250
+
251
+ if (preferredOption) {
252
+ setSelectedFeeOptionInternalRaw(isNative ? null : preferredOption)
253
+ return
254
+ }
255
+ }
256
+
257
+ // Fallback: select first available option
258
+ if (processedFeeOptions.length > 0) {
259
+ const firstOption = processedFeeOptions[0]
260
+ if (firstOption) {
261
+ setSelectedFeeOptionInternalRaw(
262
+ isNativeToken(firstOption.tokenAddress) ? null : firstOption,
263
+ )
207
264
  }
208
265
  }
209
- }, [processedFeeOptions, selectedFeeOption, hasUserSelectedFeeOption])
266
+ }, [processedFeeOptions, selectedFeeOption, hasUserSelected])
210
267
 
211
268
  // Function to set raw fee options and process them
212
269
  const setFeeOptions = useCallback(
@@ -221,9 +278,9 @@ export const SelectedFeeOptionProvider: React.FC<
221
278
  setOriginTokenChainId(originChainId)
222
279
  setAvailableTokens(tokens)
223
280
 
281
+ // Only clear selection if fee options are empty
224
282
  if (normalized.length === 0) {
225
283
  setSelectedFeeOptionInternalRaw(null)
226
- setHasUserSelectedFeeOption(false)
227
284
  }
228
285
  },
229
286
  [],