@jaw.id/ui 0.0.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 (170) hide show
  1. package/.babelrc +12 -0
  2. package/README.md +55 -0
  3. package/components.json +22 -0
  4. package/dist/ccip-Cdkx99iZ.js +147 -0
  5. package/dist/components/ConnectDialog/index.d.ts +4 -0
  6. package/dist/components/ConnectDialog/index.d.ts.map +1 -0
  7. package/dist/components/ConnectDialog/types.d.ts +20 -0
  8. package/dist/components/ConnectDialog/types.d.ts.map +1 -0
  9. package/dist/components/DefaultDialog/index.d.ts +14 -0
  10. package/dist/components/DefaultDialog/index.d.ts.map +1 -0
  11. package/dist/components/Eip712Dialog/index.d.ts +4 -0
  12. package/dist/components/Eip712Dialog/index.d.ts.map +1 -0
  13. package/dist/components/Eip712Dialog/types.d.ts +19 -0
  14. package/dist/components/Eip712Dialog/types.d.ts.map +1 -0
  15. package/dist/components/FeeTokenSelector/index.d.ts +26 -0
  16. package/dist/components/FeeTokenSelector/index.d.ts.map +1 -0
  17. package/dist/components/OnboardingDialog/index.d.ts +4 -0
  18. package/dist/components/OnboardingDialog/index.d.ts.map +1 -0
  19. package/dist/components/OnboardingDialog/types.d.ts +36 -0
  20. package/dist/components/OnboardingDialog/types.d.ts.map +1 -0
  21. package/dist/components/OrSeparator/index.d.ts +2 -0
  22. package/dist/components/OrSeparator/index.d.ts.map +1 -0
  23. package/dist/components/PermissionDialog/index.d.ts +4 -0
  24. package/dist/components/PermissionDialog/index.d.ts.map +1 -0
  25. package/dist/components/PermissionDialog/types.d.ts +49 -0
  26. package/dist/components/PermissionDialog/types.d.ts.map +1 -0
  27. package/dist/components/SignatureDialog/index.d.ts +4 -0
  28. package/dist/components/SignatureDialog/index.d.ts.map +1 -0
  29. package/dist/components/SignatureDialog/types.d.ts +19 -0
  30. package/dist/components/SignatureDialog/types.d.ts.map +1 -0
  31. package/dist/components/SiweDialog/index.d.ts +4 -0
  32. package/dist/components/SiweDialog/index.d.ts.map +1 -0
  33. package/dist/components/SiweDialog/types.d.ts +22 -0
  34. package/dist/components/SiweDialog/types.d.ts.map +1 -0
  35. package/dist/components/TransactionDialog/DecodedCalldata.d.ts +9 -0
  36. package/dist/components/TransactionDialog/DecodedCalldata.d.ts.map +1 -0
  37. package/dist/components/TransactionDialog/index.d.ts +4 -0
  38. package/dist/components/TransactionDialog/index.d.ts.map +1 -0
  39. package/dist/components/TransactionDialog/types.d.ts +34 -0
  40. package/dist/components/TransactionDialog/types.d.ts.map +1 -0
  41. package/dist/components/ui/accordion.d.ts +8 -0
  42. package/dist/components/ui/accordion.d.ts.map +1 -0
  43. package/dist/components/ui/avatar.d.ts +7 -0
  44. package/dist/components/ui/avatar.d.ts.map +1 -0
  45. package/dist/components/ui/button.d.ts +11 -0
  46. package/dist/components/ui/button.d.ts.map +1 -0
  47. package/dist/components/ui/card.d.ts +10 -0
  48. package/dist/components/ui/card.d.ts.map +1 -0
  49. package/dist/components/ui/checkbox.d.ts +5 -0
  50. package/dist/components/ui/checkbox.d.ts.map +1 -0
  51. package/dist/components/ui/dialog.d.ts +18 -0
  52. package/dist/components/ui/dialog.d.ts.map +1 -0
  53. package/dist/components/ui/dropdown-menu.d.ts +26 -0
  54. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  55. package/dist/components/ui/form.d.ts +25 -0
  56. package/dist/components/ui/form.d.ts.map +1 -0
  57. package/dist/components/ui/input.d.ts +8 -0
  58. package/dist/components/ui/input.d.ts.map +1 -0
  59. package/dist/components/ui/label.d.ts +5 -0
  60. package/dist/components/ui/label.d.ts.map +1 -0
  61. package/dist/components/ui/popover.d.ts +8 -0
  62. package/dist/components/ui/popover.d.ts.map +1 -0
  63. package/dist/components/ui/scroll-area.d.ts +6 -0
  64. package/dist/components/ui/scroll-area.d.ts.map +1 -0
  65. package/dist/components/ui/select.d.ts +14 -0
  66. package/dist/components/ui/select.d.ts.map +1 -0
  67. package/dist/components/ui/separator.d.ts +5 -0
  68. package/dist/components/ui/separator.d.ts.map +1 -0
  69. package/dist/components/ui/sheet.d.ts +14 -0
  70. package/dist/components/ui/sheet.d.ts.map +1 -0
  71. package/dist/components/ui/spinner.d.ts +3 -0
  72. package/dist/components/ui/spinner.d.ts.map +1 -0
  73. package/dist/components/ui/tabs.d.ts +8 -0
  74. package/dist/components/ui/tabs.d.ts.map +1 -0
  75. package/dist/components/ui/tooltip.d.ts +8 -0
  76. package/dist/components/ui/tooltip.d.ts.map +1 -0
  77. package/dist/hooks/index.d.ts +6 -0
  78. package/dist/hooks/index.d.ts.map +1 -0
  79. package/dist/hooks/useChainIconURI.d.ts +12 -0
  80. package/dist/hooks/useChainIconURI.d.ts.map +1 -0
  81. package/dist/hooks/useDecodedCalldata.d.ts +15 -0
  82. package/dist/hooks/useDecodedCalldata.d.ts.map +1 -0
  83. package/dist/hooks/useFeeTokenPrice.d.ts +8 -0
  84. package/dist/hooks/useFeeTokenPrice.d.ts.map +1 -0
  85. package/dist/hooks/useGasEstimation.d.ts +77 -0
  86. package/dist/hooks/useGasEstimation.d.ts.map +1 -0
  87. package/dist/hooks/useIsMobile.d.ts +2 -0
  88. package/dist/hooks/useIsMobile.d.ts.map +1 -0
  89. package/dist/icons/index.d.ts +12 -0
  90. package/dist/icons/index.d.ts.map +1 -0
  91. package/dist/index-DlfjdYW4.js +83119 -0
  92. package/dist/index.css +1 -0
  93. package/dist/index.d.ts +16 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +38 -0
  96. package/dist/lib/utils.d.ts +3 -0
  97. package/dist/lib/utils.d.ts.map +1 -0
  98. package/dist/react/ReactUIHandler.d.ts +35 -0
  99. package/dist/react/ReactUIHandler.d.ts.map +1 -0
  100. package/dist/react/index.d.ts +2 -0
  101. package/dist/react/index.d.ts.map +1 -0
  102. package/dist/utils/formatAddress.d.ts +18 -0
  103. package/dist/utils/formatAddress.d.ts.map +1 -0
  104. package/dist/utils/index.d.ts +5 -0
  105. package/dist/utils/index.d.ts.map +1 -0
  106. package/dist/utils/justaNameInstance.d.ts +7 -0
  107. package/dist/utils/justaNameInstance.d.ts.map +1 -0
  108. package/dist/utils/tokenBalance.d.ts +10 -0
  109. package/dist/utils/tokenBalance.d.ts.map +1 -0
  110. package/dist/utils/tokenPrice.d.ts +12 -0
  111. package/dist/utils/tokenPrice.d.ts.map +1 -0
  112. package/package.json +67 -0
  113. package/postcss.config.cjs +6 -0
  114. package/src/components/ConnectDialog/index.tsx +270 -0
  115. package/src/components/ConnectDialog/types.ts +34 -0
  116. package/src/components/DefaultDialog/index.tsx +99 -0
  117. package/src/components/Eip712Dialog/index.tsx +525 -0
  118. package/src/components/Eip712Dialog/types.ts +27 -0
  119. package/src/components/FeeTokenSelector/index.tsx +308 -0
  120. package/src/components/OnboardingDialog/index.tsx +317 -0
  121. package/src/components/OnboardingDialog/types.ts +43 -0
  122. package/src/components/OrSeparator/index.tsx +13 -0
  123. package/src/components/PermissionDialog/index.tsx +598 -0
  124. package/src/components/PermissionDialog/types.ts +77 -0
  125. package/src/components/SignatureDialog/index.tsx +180 -0
  126. package/src/components/SignatureDialog/types.ts +27 -0
  127. package/src/components/SiweDialog/index.tsx +231 -0
  128. package/src/components/SiweDialog/types.ts +34 -0
  129. package/src/components/TransactionDialog/DecodedCalldata.tsx +79 -0
  130. package/src/components/TransactionDialog/index.tsx +663 -0
  131. package/src/components/TransactionDialog/types.ts +54 -0
  132. package/src/components/ui/accordion.tsx +66 -0
  133. package/src/components/ui/avatar.tsx +53 -0
  134. package/src/components/ui/button.tsx +59 -0
  135. package/src/components/ui/card.tsx +92 -0
  136. package/src/components/ui/checkbox.tsx +32 -0
  137. package/src/components/ui/dialog.tsx +183 -0
  138. package/src/components/ui/dropdown-menu.tsx +258 -0
  139. package/src/components/ui/form.tsx +167 -0
  140. package/src/components/ui/input.tsx +60 -0
  141. package/src/components/ui/label.tsx +24 -0
  142. package/src/components/ui/popover.tsx +48 -0
  143. package/src/components/ui/scroll-area.tsx +58 -0
  144. package/src/components/ui/select.tsx +160 -0
  145. package/src/components/ui/separator.tsx +28 -0
  146. package/src/components/ui/sheet.tsx +169 -0
  147. package/src/components/ui/spinner.tsx +18 -0
  148. package/src/components/ui/tabs.tsx +69 -0
  149. package/src/components/ui/tooltip.tsx +61 -0
  150. package/src/hooks/index.ts +5 -0
  151. package/src/hooks/useChainIconURI.tsx +117 -0
  152. package/src/hooks/useDecodedCalldata.ts +128 -0
  153. package/src/hooks/useFeeTokenPrice.tsx +36 -0
  154. package/src/hooks/useGasEstimation.ts +474 -0
  155. package/src/hooks/useIsMobile.ts +36 -0
  156. package/src/icons/index.tsx +114 -0
  157. package/src/index.ts +19 -0
  158. package/src/lib/utils.ts +6 -0
  159. package/src/react/ReactUIHandler.tsx +3004 -0
  160. package/src/react/index.ts +2 -0
  161. package/src/styles.css +210 -0
  162. package/src/utils/formatAddress.ts +24 -0
  163. package/src/utils/index.ts +4 -0
  164. package/src/utils/justaNameInstance.ts +25 -0
  165. package/src/utils/tokenBalance.ts +41 -0
  166. package/src/utils/tokenPrice.ts +58 -0
  167. package/tailwind.config.js +130 -0
  168. package/tsconfig.json +19 -0
  169. package/tsconfig.lib.json +43 -0
  170. package/vite.config.ts +45 -0
@@ -0,0 +1,128 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { createPublicClient, http, decodeFunctionData, type Abi, type Hex } from 'viem';
3
+ import { whatsabi } from '@shazow/whatsabi';
4
+ import { JAW_RPC_URL } from '@jaw.id/core';
5
+
6
+ export interface DecodedParam {
7
+ name: string;
8
+ type: string;
9
+ value: string;
10
+ }
11
+
12
+ export interface DecodedCalldata {
13
+ functionName: string;
14
+ signature: string;
15
+ params: DecodedParam[];
16
+ }
17
+
18
+ // Module-level ABI cache keyed by lowercase address
19
+ const abiCache = new Map<string, Abi>();
20
+
21
+ // Deduplicates concurrent fetches for the same contract
22
+ const inflightRequests = new Map<string, Promise<Abi>>();
23
+
24
+ async function fetchAbi(address: string, rpcUrl: string): Promise<Abi> {
25
+ const key = address.toLowerCase();
26
+
27
+ const cached = abiCache.get(key);
28
+ if (cached) return cached;
29
+
30
+ const inflight = inflightRequests.get(key);
31
+ if (inflight) return inflight;
32
+
33
+ const promise = (async () => {
34
+ const client = createPublicClient({ transport: http(rpcUrl) });
35
+ const result = await whatsabi.autoload(address, {
36
+ provider: client,
37
+ followProxies: true,
38
+ abiLoader: false,
39
+ signatureLookup: new whatsabi.loaders.OpenChainSignatureLookup(),
40
+ });
41
+ const abi = result.abi as Abi;
42
+ abiCache.set(key, abi);
43
+ return abi;
44
+ })();
45
+
46
+ inflightRequests.set(key, promise);
47
+ try {
48
+ return await promise;
49
+ } finally {
50
+ inflightRequests.delete(key);
51
+ }
52
+ }
53
+
54
+ function formatParamValue(value: unknown): string {
55
+ if (typeof value === 'bigint') return value.toString();
56
+ if (Array.isArray(value)) return `[${value.map(formatParamValue).join(', ')}]`;
57
+ if (typeof value === 'object' && value !== null) return JSON.stringify(value);
58
+ return String(value);
59
+ }
60
+
61
+ export function useDecodedCalldata(
62
+ to: string | undefined,
63
+ data: string | undefined,
64
+ chainId: number,
65
+ apiKey?: string
66
+ ): { decoded: DecodedCalldata | null; isLoading: boolean } {
67
+ const [decoded, setDecoded] = useState<DecodedCalldata | null>(null);
68
+ const [isLoading, setIsLoading] = useState(false);
69
+
70
+ // Early return conditions checked inside useEffect to keep hook call order stable
71
+ useEffect(() => {
72
+ if (!to || !data || data === '0x' || data.length < 10) {
73
+ setDecoded(null);
74
+ setIsLoading(false);
75
+ return;
76
+ }
77
+
78
+ let isMounted = true;
79
+
80
+ const decode = async () => {
81
+ setIsLoading(true);
82
+ try {
83
+ const rpcUrl = apiKey
84
+ ? `${JAW_RPC_URL}?chainId=${chainId}&api-key=${apiKey}`
85
+ : `${JAW_RPC_URL}?chainId=${chainId}`;
86
+
87
+ const abi = await fetchAbi(to, rpcUrl);
88
+ if (!isMounted) return;
89
+
90
+ const { functionName, args } = decodeFunctionData({ abi, data: data as Hex });
91
+
92
+ // Find the matching ABI entry to get parameter names/types
93
+ const abiItem = abi.find(
94
+ (item) => 'name' in item && item.name === functionName && item.type === 'function'
95
+ );
96
+
97
+ const inputs = abiItem && 'inputs' in abiItem ? abiItem.inputs ?? [] : [];
98
+
99
+ const params: DecodedParam[] = (args ?? []).map((arg, i) => ({
100
+ name: inputs[i]?.name || `param${i}`,
101
+ type: inputs[i]?.type || 'unknown',
102
+ value: formatParamValue(arg),
103
+ }));
104
+
105
+ const signature = `${functionName}(${inputs.map((inp) => inp.type).join(', ')})`;
106
+
107
+ if (isMounted) {
108
+ setDecoded({ functionName, signature, params });
109
+ setIsLoading(false);
110
+ }
111
+ } catch (err) {
112
+ console.debug('[useDecodedCalldata] Failed to decode:', err);
113
+ if (isMounted) {
114
+ setDecoded(null);
115
+ setIsLoading(false);
116
+ }
117
+ }
118
+ };
119
+
120
+ decode();
121
+
122
+ return () => {
123
+ isMounted = false;
124
+ };
125
+ }, [to, data, chainId, apiKey]);
126
+
127
+ return { decoded, isLoading };
128
+ }
@@ -0,0 +1,36 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { fetchTokenPrice } from '../utils/tokenPrice';
3
+
4
+ /**
5
+ * Hook to fetch and cache the current price in USD for a token by its symbol
6
+ * Uses CryptoCompare API with a 5-minute cache
7
+ * @param symbol - The token symbol (ETH, AVAX, BNB, USDC, etc.)
8
+ * @returns The current token price in USD, or 0 if not yet fetched/failed
9
+ */
10
+ export function useFeeTokenPrice(symbol?: string): number {
11
+ const [price, setPrice] = useState<number>(0);
12
+
13
+ useEffect(() => {
14
+ if (!symbol) {
15
+ setPrice(0);
16
+ return;
17
+ }
18
+
19
+ let isMounted = true;
20
+
21
+ const getPrice = async () => {
22
+ const fetchedPrice = await fetchTokenPrice(symbol);
23
+ if (isMounted) {
24
+ setPrice(fetchedPrice);
25
+ }
26
+ };
27
+
28
+ getPrice();
29
+
30
+ return () => {
31
+ isMounted = false;
32
+ };
33
+ }, [symbol]);
34
+
35
+ return price;
36
+ }
@@ -0,0 +1,474 @@
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
+ import type { Address, Hex } from 'viem';
3
+ import type { Account, TokenEstimate, TransactionCall } from '@jaw.id/core';
4
+ import { estimateErc20PaymasterCosts, JAW_PAYMASTER_URL } from '@jaw.id/core';
5
+ import type { FeeTokenOption } from '../components/FeeTokenSelector';
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ // Re-export TransactionCall from core for consumers
12
+ export type { TransactionCall } from '@jaw.id/core';
13
+
14
+ /**
15
+ * Configuration options for gas estimation
16
+ */
17
+ export interface UseGasEstimationConfig {
18
+ /** The Account instance to estimate gas for */
19
+ account: Account | null;
20
+ /** Array of transaction calls to estimate */
21
+ transactionCalls: TransactionCall[];
22
+ /** Chain ID for the estimation */
23
+ chainId: number;
24
+ /** API key for paymaster access */
25
+ apiKey?: string;
26
+ /** Available fee tokens (ETH + ERC-20) */
27
+ feeTokens: FeeTokenOption[];
28
+ /** Whether the transaction is sponsored (gas covered by paymaster) */
29
+ isSponsored?: boolean;
30
+ /** Optional permission ID for permission-based execution */
31
+ permissionId?: Hex;
32
+ /** Callback when fee tokens are updated with estimates */
33
+ onFeeTokensUpdate?: (tokens: FeeTokenOption[]) => void;
34
+ }
35
+
36
+ /**
37
+ * Result returned by the useGasEstimation hook
38
+ */
39
+ export interface UseGasEstimationResult {
40
+ /** Estimated gas fee in ETH (as string) or 'sponsored' */
41
+ gasFee: string;
42
+ /** Whether gas estimation is in progress */
43
+ gasFeeLoading: boolean;
44
+ /** Error message if estimation failed */
45
+ gasEstimationError: string;
46
+ /** Token cost estimates for ERC-20 payment options */
47
+ tokenEstimates: TokenEstimate[];
48
+ /** Whether ERC-20 token cost estimation is in progress */
49
+ estimatingTokenCosts: boolean;
50
+ /** Currently selected fee token */
51
+ selectedFeeToken: FeeTokenOption | null;
52
+ /** Function to manually select a fee token */
53
+ setSelectedFeeToken: (token: FeeTokenOption | null) => void;
54
+ /** Whether user is paying with ERC-20 (not ETH, not sponsored) */
55
+ isPayingWithErc20: boolean;
56
+ /** Re-estimate gas (useful after transaction changes) */
57
+ refetch: () => void;
58
+ }
59
+
60
+ // ============================================================================
61
+ // Constants
62
+ // ============================================================================
63
+
64
+ /** Fallback gas estimate in ETH for L2 chains when ETH estimation fails */
65
+ const FALLBACK_GAS_ESTIMATE_ETH = '0.00005';
66
+
67
+ /** Error messages that indicate insufficient funds */
68
+ const INSUFFICIENT_FUNDS_ERRORS = [
69
+ 'AA21',
70
+ "didn't pay prefund",
71
+ 'insufficient',
72
+ 'AA50', // PostOp reverted (e.g., paymaster insufficient balance)
73
+ 'paymasterValidationGasLimit is required', // ERC-20 paymaster can't validate with 0 balance
74
+ ];
75
+
76
+ // ============================================================================
77
+ // Helper Functions
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Check if an error indicates insufficient funds
82
+ */
83
+ function isInsufficientFundsError(error: unknown): boolean {
84
+ if (!(error instanceof Error)) return false;
85
+ return INSUFFICIENT_FUNDS_ERRORS.some(msg =>
86
+ error.message.toLowerCase().includes(msg.toLowerCase())
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Build paymaster URL with chain ID and API key
92
+ */
93
+ function buildPaymasterUrl(chainId: number, apiKey?: string): string {
94
+ const baseUrl = `${JAW_PAYMASTER_URL}?chainId=${chainId}`;
95
+ return apiKey ? `${baseUrl}&api-key=${apiKey}` : baseUrl;
96
+ }
97
+
98
+ // ============================================================================
99
+ // Hook Implementation
100
+ // ============================================================================
101
+
102
+ /**
103
+ * Hook for estimating gas costs in both ETH and ERC-20 tokens.
104
+ *
105
+ * Features:
106
+ * - Parallel estimation of ETH and ERC-20 costs using Promise.allSettled
107
+ * - Automatic fallback to ERC-20 when ETH balance is insufficient
108
+ * - Smart token selection based on available balances
109
+ * - Loading states for smooth UI transitions
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * const {
114
+ * gasFee,
115
+ * gasFeeLoading,
116
+ * selectedFeeToken,
117
+ * setSelectedFeeToken,
118
+ * isPayingWithErc20,
119
+ * } = useGasEstimation({
120
+ * account,
121
+ * transactionCalls,
122
+ * chainId: 84532,
123
+ * apiKey: 'your-api-key',
124
+ * feeTokens,
125
+ * onFeeTokensUpdate: setFeeTokens,
126
+ * });
127
+ * ```
128
+ */
129
+ export function useGasEstimation({
130
+ account,
131
+ transactionCalls,
132
+ chainId,
133
+ apiKey,
134
+ feeTokens,
135
+ isSponsored = false,
136
+ permissionId,
137
+ onFeeTokensUpdate,
138
+ }: UseGasEstimationConfig): UseGasEstimationResult {
139
+ // -------------------------------------------------------------------------
140
+ // State
141
+ // -------------------------------------------------------------------------
142
+
143
+ const [gasFee, setGasFee] = useState<string>('');
144
+ const [gasFeeLoading, setGasFeeLoading] = useState<boolean>(true);
145
+ const [gasEstimationError, setGasEstimationError] = useState<string>('');
146
+ const [tokenEstimates, setTokenEstimates] = useState<TokenEstimate[]>([]);
147
+ const [estimatingTokenCosts, setEstimatingTokenCosts] = useState<boolean>(false);
148
+ const [selectedFeeToken, setSelectedFeeToken] = useState<FeeTokenOption | null>(null);
149
+
150
+ // Track estimation version to handle race conditions
151
+ const estimationVersionRef = useRef<number>(0);
152
+
153
+ // Use refs for values that shouldn't trigger re-estimation
154
+ const feeTokensRef = useRef(feeTokens);
155
+ feeTokensRef.current = feeTokens;
156
+
157
+ const onFeeTokensUpdateRef = useRef(onFeeTokensUpdate);
158
+ onFeeTokensUpdateRef.current = onFeeTokensUpdate;
159
+
160
+ // Track ERC-20 token addresses to re-run estimation when new tokens are added
161
+ // This is stable when only gasCostFormatted/isSelectable change (preventing infinite loops)
162
+ const erc20TokenAddresses = useMemo(
163
+ () => feeTokens
164
+ .filter(t => !t.isNative)
165
+ .map(t => t.address.toLowerCase())
166
+ .sort()
167
+ .join(','),
168
+ [feeTokens]
169
+ );
170
+
171
+ // -------------------------------------------------------------------------
172
+ // Derived State
173
+ // -------------------------------------------------------------------------
174
+
175
+ const isPayingWithErc20 = !isSponsored && !!selectedFeeToken && !selectedFeeToken.isNative;
176
+
177
+ // -------------------------------------------------------------------------
178
+ // Sync selected token when feeTokens update
179
+ // -------------------------------------------------------------------------
180
+
181
+ useEffect(() => {
182
+ if (!selectedFeeToken || selectedFeeToken.isNative) return;
183
+
184
+ const updatedToken = feeTokens.find(
185
+ t => t.address.toLowerCase() === selectedFeeToken.address.toLowerCase()
186
+ );
187
+
188
+ // Update if gasCostFormatted changed (new estimate came in)
189
+ if (updatedToken && updatedToken.gasCostFormatted !== selectedFeeToken.gasCostFormatted) {
190
+ setSelectedFeeToken(updatedToken);
191
+ }
192
+ }, [feeTokens, selectedFeeToken]);
193
+
194
+ // -------------------------------------------------------------------------
195
+ // Main Estimation Logic
196
+ // -------------------------------------------------------------------------
197
+
198
+ const estimateGas = useCallback(async () => {
199
+ // Validation - keep loading state true while waiting for account
200
+ if (!account) {
201
+ // Don't set loading to false here - we're still waiting for prerequisites
202
+ return;
203
+ }
204
+
205
+ // Handle sponsored transactions
206
+ if (isSponsored) {
207
+ setGasFee('sponsored');
208
+ setGasFeeLoading(false);
209
+ setGasEstimationError('');
210
+ return;
211
+ }
212
+
213
+ // Handle empty transactionCalls (e.g., for permission grants)
214
+ // Use a fallback gas estimate and still run ERC-20 estimation with a dummy call
215
+ // Using zero address as dummy target for estimation purposes
216
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address;
217
+ const effectiveCalls = transactionCalls.length > 0
218
+ ? transactionCalls
219
+ : [{ to: ZERO_ADDRESS, value: 0n }]; // Dummy call for estimation
220
+
221
+ // Increment version to track this estimation
222
+ const currentVersion = ++estimationVersionRef.current;
223
+
224
+ // Start loading
225
+ setGasFeeLoading(true);
226
+ setEstimatingTokenCosts(true);
227
+ setGasEstimationError('');
228
+
229
+ try {
230
+ // Use refs to avoid infinite loops (these values shouldn't trigger re-estimation)
231
+ const currentFeeTokens = feeTokensRef.current;
232
+ const erc20Tokens = currentFeeTokens.filter(t => !t.isNative);
233
+ // Filter tokens with balance > 0 for estimation (paymaster can't validate with 0 balance)
234
+ const erc20TokensWithBalance = erc20Tokens.filter(t => t.balance > 0n);
235
+ const paymasterUrl = buildPaymasterUrl(chainId, apiKey);
236
+
237
+ // Convert effectiveCalls to ensure value is bigint for estimateErc20PaymasterCosts
238
+ const callsWithBigIntValue = effectiveCalls.map(call => ({
239
+ to: call.to as Address,
240
+ value: call.value !== undefined
241
+ ? (typeof call.value === 'string' ? BigInt(call.value) : call.value)
242
+ : undefined,
243
+ data: call.data as Hex | undefined,
244
+ }));
245
+
246
+ // Run ETH and ERC-20 estimation in parallel
247
+ const [ethResult, erc20Result] = await Promise.allSettled([
248
+ // ETH gas estimation
249
+ account.calculateGasCost(
250
+ effectiveCalls,
251
+ permissionId ? { permissionId } : undefined
252
+ ),
253
+ // ERC-20 gas estimation (only for tokens with balance > 0)
254
+ // Tokens with 0 balance will be marked as not selectable below
255
+ erc20TokensWithBalance.length > 0
256
+ ? estimateErc20PaymasterCosts(
257
+ account.getSmartAccount(),
258
+ callsWithBigIntValue,
259
+ account.getChain(),
260
+ paymasterUrl,
261
+ erc20TokensWithBalance.map(t => ({
262
+ address: t.address as Address,
263
+ symbol: t.symbol,
264
+ decimals: t.decimals,
265
+ balance: t.balance,
266
+ }))
267
+ )
268
+ : Promise.resolve([])
269
+ ]);
270
+
271
+ // Check if this estimation is still current (handle race conditions)
272
+ if (currentVersion !== estimationVersionRef.current) {
273
+ return;
274
+ }
275
+
276
+ // Process ERC-20 results first (so we have updated feeTokens for decision making)
277
+ let updatedFeeTokens = [...currentFeeTokens];
278
+ let erc20Estimates: TokenEstimate[] = [];
279
+
280
+ if (erc20Result.status === 'fulfilled') {
281
+ erc20Estimates = erc20Result.value;
282
+ setTokenEstimates(erc20Estimates);
283
+
284
+ // Update feeTokens with the estimated costs and selectability
285
+ updatedFeeTokens = currentFeeTokens.map(token => {
286
+ if (token.isNative) return token;
287
+
288
+ // Tokens with 0 balance are not selectable (can't pay gas fees)
289
+ if (token.balance === 0n) {
290
+ return {
291
+ ...token,
292
+ gasCostFormatted: undefined, // No estimate available
293
+ isSelectable: false,
294
+ };
295
+ }
296
+
297
+ const estimate = erc20Estimates.find(
298
+ e => e.tokenAddress.toLowerCase() === token.address.toLowerCase()
299
+ );
300
+
301
+ if (estimate) {
302
+ return {
303
+ ...token,
304
+ gasCostFormatted: estimate.tokenCostFormatted,
305
+ isSelectable: estimate.hasSufficientBalance,
306
+ };
307
+ }
308
+ return token;
309
+ });
310
+
311
+ // Notify parent of updated tokens (use ref to avoid infinite loop)
312
+ onFeeTokensUpdateRef.current?.(updatedFeeTokens);
313
+ } else if (erc20Result.status === 'rejected') {
314
+ // Check if this is an insufficient balance error (expected case, not a real error)
315
+ const isInsufficientBalance = isInsufficientFundsError(erc20Result.reason);
316
+
317
+ if (isInsufficientBalance) {
318
+ // This is an expected case - user doesn't have enough ERC-20 tokens
319
+ // Don't log as error, just mark tokens as insufficient
320
+ updatedFeeTokens = currentFeeTokens.map(token => {
321
+ if (token.isNative) return token;
322
+
323
+ // Tokens with 0 balance are not selectable
324
+ if (token.balance === 0n) {
325
+ return {
326
+ ...token,
327
+ gasCostFormatted: undefined,
328
+ isSelectable: false,
329
+ };
330
+ }
331
+
332
+ // Try to extract the required amount from the error message
333
+ // Format: "X.XXX USDC required but sender has Y USDC"
334
+ let gasCostFormatted = 'Insufficient';
335
+ const errorMsg = erc20Result.reason instanceof Error ? erc20Result.reason.message : '';
336
+ const match = errorMsg.match(/([\d.]+)\s*(\w+)\s*required/i);
337
+ if (match) {
338
+ gasCostFormatted = match[1]; // Just the amount, symbol is already shown
339
+ }
340
+
341
+ return {
342
+ ...token,
343
+ gasCostFormatted,
344
+ isSelectable: false,
345
+ };
346
+ });
347
+ } else {
348
+ // Unexpected error - log it and show estimation failed
349
+ console.error('[useGasEstimation] ERC-20 estimation failed:', erc20Result.reason);
350
+ updatedFeeTokens = currentFeeTokens.map(token => {
351
+ if (token.isNative) return token;
352
+
353
+ // Tokens with 0 balance - just mark as not selectable without error message
354
+ if (token.balance === 0n) {
355
+ return {
356
+ ...token,
357
+ gasCostFormatted: undefined,
358
+ isSelectable: false,
359
+ };
360
+ }
361
+
362
+ return {
363
+ ...token,
364
+ gasCostFormatted: 'Estimation failed',
365
+ isSelectable: false,
366
+ };
367
+ });
368
+ }
369
+ onFeeTokensUpdateRef.current?.(updatedFeeTokens);
370
+ }
371
+
372
+ // Process ETH result
373
+ const ethSuccess = ethResult.status === 'fulfilled';
374
+ const ethInsufficientFunds = ethResult.status === 'rejected' &&
375
+ isInsufficientFundsError(ethResult.reason);
376
+
377
+ if (ethSuccess) {
378
+ handleEthSuccess(ethResult.value, updatedFeeTokens);
379
+ } else if (ethInsufficientFunds) {
380
+ handleEthInsufficientFunds(updatedFeeTokens);
381
+ } else {
382
+ handleEstimationError(ethResult.status === 'rejected' ? ethResult.reason : null);
383
+ }
384
+ } catch (error) {
385
+ // Check if this estimation is still current
386
+ if (currentVersion !== estimationVersionRef.current) {
387
+ return;
388
+ }
389
+ handleEstimationError(error);
390
+ } finally {
391
+ // Only update loading states if this is still the current estimation
392
+ if (currentVersion === estimationVersionRef.current) {
393
+ setGasFeeLoading(false);
394
+ setEstimatingTokenCosts(false);
395
+ }
396
+ }
397
+ // Note: feeTokens and onFeeTokensUpdate accessed via refs to prevent infinite loops
398
+ // erc20TokenAddresses triggers re-estimation when new ERC-20 tokens are added (but not when estimates update)
399
+ }, [account, transactionCalls, chainId, apiKey, isSponsored, permissionId, erc20TokenAddresses]);
400
+
401
+ // -------------------------------------------------------------------------
402
+ // Result Handlers
403
+ // -------------------------------------------------------------------------
404
+
405
+ /**
406
+ * Handle successful ETH gas estimation
407
+ */
408
+ const handleEthSuccess = useCallback((gasPrice: string, updatedFeeTokens: FeeTokenOption[]) => {
409
+ setGasFee(gasPrice);
410
+ setGasEstimationError('');
411
+
412
+ // Auto-select native token if not already selected
413
+ if (!selectedFeeToken) {
414
+ const nativeToken = updatedFeeTokens.find(t => t.isNative && t.isSelectable);
415
+ if (nativeToken) {
416
+ setSelectedFeeToken(nativeToken);
417
+ }
418
+ }
419
+ }, [selectedFeeToken]);
420
+
421
+ /**
422
+ * Handle ETH insufficient funds - try to fallback to ERC-20
423
+ */
424
+ const handleEthInsufficientFunds = useCallback((
425
+ updatedFeeTokens: FeeTokenOption[],
426
+ ) => {
427
+ const selectableErc20 = updatedFeeTokens.find(t => !t.isNative && t.isSelectable);
428
+
429
+ if (selectableErc20) {
430
+ // Auto-select first selectable ERC-20 token
431
+ setSelectedFeeToken(selectableErc20);
432
+ setGasFee(FALLBACK_GAS_ESTIMATE_ETH);
433
+ setGasEstimationError('');
434
+ } else {
435
+ // No selectable payment options (neither ETH nor ERC-20 have sufficient balance)
436
+ setGasFee('');
437
+ setGasEstimationError('Insufficient funds');
438
+ }
439
+ }, []);
440
+
441
+ /**
442
+ * Handle estimation error (not insufficient funds)
443
+ */
444
+ const handleEstimationError = useCallback((error: unknown) => {
445
+ console.error('[useGasEstimation] Error:', error);
446
+ setGasFee('');
447
+ setGasEstimationError('Failed to estimate gas');
448
+ }, []);
449
+
450
+ // -------------------------------------------------------------------------
451
+ // Effects
452
+ // -------------------------------------------------------------------------
453
+
454
+ // Run estimation when dependencies change
455
+ useEffect(() => {
456
+ estimateGas();
457
+ }, [estimateGas]);
458
+
459
+ // -------------------------------------------------------------------------
460
+ // Return
461
+ // -------------------------------------------------------------------------
462
+
463
+ return {
464
+ gasFee,
465
+ gasFeeLoading,
466
+ gasEstimationError,
467
+ tokenEstimates,
468
+ estimatingTokenCosts,
469
+ selectedFeeToken,
470
+ setSelectedFeeToken,
471
+ isPayingWithErc20,
472
+ refetch: estimateGas,
473
+ };
474
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ export function useIsMobile(breakpoint = 768) {
4
+ const [isMobile, setIsMobile] = useState(false)
5
+
6
+ useEffect(() => {
7
+ // Check if window is defined (client-side)
8
+ if (typeof window === 'undefined') return undefined
9
+
10
+ // Media query for mobile detection
11
+ const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
12
+
13
+ // Set initial value
14
+ setIsMobile(mediaQuery.matches)
15
+
16
+ // Handler for media query changes
17
+ const handleMediaQueryChange = (e: MediaQueryListEvent) => {
18
+ setIsMobile(e.matches)
19
+ }
20
+
21
+ // Modern browsers
22
+ if (mediaQuery.addEventListener) {
23
+ mediaQuery.addEventListener('change', handleMediaQueryChange)
24
+ return () => mediaQuery.removeEventListener('change', handleMediaQueryChange)
25
+ }
26
+ // Legacy browsers
27
+ else if (mediaQuery.addListener) {
28
+ mediaQuery.addListener(handleMediaQueryChange)
29
+ return () => mediaQuery.removeListener(handleMediaQueryChange)
30
+ }
31
+
32
+ return undefined
33
+ }, [breakpoint])
34
+
35
+ return isMobile
36
+ }