0xtrails 0.7.0 → 0.8.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 (102) hide show
  1. package/dist/{ccip-fConRNoG.js → ccip-uMWNlvmJ.js} +34 -34
  2. package/dist/fees.d.ts.map +1 -1
  3. package/dist/{index-BbajxCG_.js → index-BiPwqVkZ.js} +31527 -28874
  4. package/dist/index.d.ts +8 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +478 -456
  7. package/dist/intents.d.ts +10 -4
  8. package/dist/intents.d.ts.map +1 -1
  9. package/dist/prepareSend.d.ts +1 -1
  10. package/dist/prepareSend.d.ts.map +1 -1
  11. package/dist/prices.d.ts +2 -2
  12. package/dist/prices.d.ts.map +1 -1
  13. package/dist/refund.d.ts +116 -0
  14. package/dist/refund.d.ts.map +1 -0
  15. package/dist/tokenBalances.d.ts +1 -1
  16. package/dist/tokenBalances.d.ts.map +1 -1
  17. package/dist/transactionIntent/handlers/crossChain.d.ts +4 -3
  18. package/dist/transactionIntent/handlers/crossChain.d.ts.map +1 -1
  19. package/dist/transactionIntent/handlers/sameChainSameToken.d.ts +3 -3
  20. package/dist/transactionIntent/handlers/sameChainSameToken.d.ts.map +1 -1
  21. package/dist/transactionIntent/quote/normalizeQuote.d.ts +1 -2
  22. package/dist/transactionIntent/quote/normalizeQuote.d.ts.map +1 -1
  23. package/dist/transactionIntent/quote/quoteHelpers.d.ts +3 -3
  24. package/dist/transactionIntent/quote/quoteHelpers.d.ts.map +1 -1
  25. package/dist/transactionIntent/types.d.ts +5 -4
  26. package/dist/transactionIntent/types.d.ts.map +1 -1
  27. package/dist/transactions.d.ts +4 -0
  28. package/dist/transactions.d.ts.map +1 -1
  29. package/dist/widget/components/AccountIntentTransactionHistory.d.ts.map +1 -1
  30. package/dist/widget/components/ClassicSwap.d.ts +2 -1
  31. package/dist/widget/components/ClassicSwap.d.ts.map +1 -1
  32. package/dist/widget/components/Earn.d.ts +2 -1
  33. package/dist/widget/components/Earn.d.ts.map +1 -1
  34. package/dist/widget/components/ErrorDisplay.d.ts.map +1 -1
  35. package/dist/widget/components/Fund.d.ts +2 -1
  36. package/dist/widget/components/Fund.d.ts.map +1 -1
  37. package/dist/widget/components/FundSwap.d.ts +2 -1
  38. package/dist/widget/components/FundSwap.d.ts.map +1 -1
  39. package/dist/widget/components/Pay.d.ts +2 -1
  40. package/dist/widget/components/Pay.d.ts.map +1 -1
  41. package/dist/widget/components/PoolDeposit.d.ts +2 -1
  42. package/dist/widget/components/PoolDeposit.d.ts.map +1 -1
  43. package/dist/widget/components/QuoteDetails.d.ts +1 -0
  44. package/dist/widget/components/QuoteDetails.d.ts.map +1 -1
  45. package/dist/widget/components/Swap.d.ts +2 -1
  46. package/dist/widget/components/Swap.d.ts.map +1 -1
  47. package/dist/widget/components/TokenImage.d.ts.map +1 -1
  48. package/dist/widget/components/TransactionDetails.d.ts.map +1 -1
  49. package/dist/widget/css/compiled.css +1 -1
  50. package/dist/widget/hooks/useAmountUsd.d.ts.map +1 -1
  51. package/dist/widget/hooks/useDefaultTokenSelection.d.ts.map +1 -1
  52. package/dist/widget/hooks/useGetIntent.d.ts +18 -0
  53. package/dist/widget/hooks/useGetIntent.d.ts.map +1 -0
  54. package/dist/widget/hooks/useIntentTransactionHistory.d.ts.map +1 -1
  55. package/dist/widget/hooks/useQuote.d.ts +10 -7
  56. package/dist/widget/hooks/useQuote.d.ts.map +1 -1
  57. package/dist/widget/hooks/useSendForm.d.ts +3 -2
  58. package/dist/widget/hooks/useSendForm.d.ts.map +1 -1
  59. package/dist/widget/hooks/useTokenList.d.ts.map +1 -1
  60. package/dist/widget/hooks/useTrailsSendTransaction.d.ts.map +1 -1
  61. package/dist/widget/index.js +3 -3
  62. package/dist/widget/widget.d.ts +2 -1
  63. package/dist/widget/widget.d.ts.map +1 -1
  64. package/package.json +5 -12
  65. package/src/fees.ts +8 -2
  66. package/src/index.ts +33 -1
  67. package/src/intents.ts +34 -7
  68. package/src/prepareSend.ts +6 -4
  69. package/src/prices.ts +6 -6
  70. package/src/refund.ts +914 -0
  71. package/src/tokenBalances.ts +4 -14
  72. package/src/transactionIntent/handlers/crossChain.ts +21 -10
  73. package/src/transactionIntent/handlers/sameChainSameToken.ts +12 -8
  74. package/src/transactionIntent/quote/normalizeQuote.ts +29 -27
  75. package/src/transactionIntent/quote/quoteHelpers.ts +5 -9
  76. package/src/transactionIntent/types.ts +5 -3
  77. package/src/transactions.ts +5 -0
  78. package/src/widget/compiled.css +1 -1
  79. package/src/widget/components/AccountIntentTransactionHistory.tsx +197 -5
  80. package/src/widget/components/ClassicSwap.tsx +6 -3
  81. package/src/widget/components/Earn.tsx +6 -3
  82. package/src/widget/components/ErrorDisplay.tsx +6 -4
  83. package/src/widget/components/Fund.tsx +6 -3
  84. package/src/widget/components/FundSwap.tsx +2 -1
  85. package/src/widget/components/Pay.tsx +15 -7
  86. package/src/widget/components/PoolDeposit.tsx +6 -3
  87. package/src/widget/components/QuoteDetails.tsx +34 -38
  88. package/src/widget/components/Swap.tsx +2 -1
  89. package/src/widget/components/TokenImage.tsx +3 -1
  90. package/src/widget/components/TransactionDetails.tsx +108 -0
  91. package/src/widget/hooks/useAmountUsd.ts +0 -3
  92. package/src/widget/hooks/useDefaultTokenSelection.tsx +0 -3
  93. package/src/widget/hooks/useGetIntent.ts +53 -0
  94. package/src/widget/hooks/useIntentTransactionHistory.ts +85 -3
  95. package/src/widget/hooks/useQuote.ts +16 -10
  96. package/src/widget/hooks/useSendForm.ts +30 -15
  97. package/src/widget/hooks/useTokenList.ts +2 -4
  98. package/src/widget/hooks/useTrailsSendTransaction.ts +2 -1
  99. package/src/widget/widget.tsx +12 -6
  100. package/dist/sequenceWallet.d.ts +0 -67
  101. package/dist/sequenceWallet.d.ts.map +0 -1
  102. package/src/sequenceWallet.ts +0 -532
package/src/refund.ts ADDED
@@ -0,0 +1,914 @@
1
+ import type { IntentCalls } from "@0xtrails/api"
2
+ import {
3
+ Config,
4
+ Constants,
5
+ Erc6492,
6
+ Payload,
7
+ Signature,
8
+ } from "@0xsequence/wallet-primitives"
9
+ import { Address, Bytes, Hex } from "ox"
10
+ import { AbiFunction } from "ox"
11
+ import { SEQUENCE_V3_CONTRACT_ADDRESSES } from "./constants.js"
12
+ import { calculateIntentAddress, createIntentConfiguration } from "./intents.js"
13
+ import { getChainRpcClient, getChainInfo } from "./chains.js"
14
+ import { logger } from "./logger.js"
15
+ import type { WalletClient, Chain } from "viem"
16
+ import { splitSignature } from "./gasless.js"
17
+ import { useMemo, useCallback } from "react"
18
+ import { useGetIntent } from "./widget/hooks/useGetIntent.js"
19
+ import { useTokenBalances } from "./tokenBalances.js"
20
+ import { getERC20TransferData } from "./encoders.js"
21
+ import { zeroAddress } from "viem"
22
+ import type { Payload as WalletPayload } from "@0xsequence/wallet-primitives"
23
+ import { attemptSwitchChain } from "./chainSwitch.js"
24
+
25
+ // Constants for execute function
26
+ type ChainId = number
27
+
28
+ /**
29
+ * Checks if an address is deployed by checking for bytecode
30
+ * @param address - The address to check
31
+ * @param chainId - The chain ID where the address exists
32
+ * @returns Promise resolving to true if the address has bytecode, false otherwise
33
+ */
34
+ export async function isAddressDeployed(
35
+ address: Address.Address,
36
+ chainId: ChainId,
37
+ ): Promise<boolean> {
38
+ const publicClient = getChainRpcClient(chainId)
39
+ const code = await publicClient.getCode({ address })
40
+ const isDeployed = code !== undefined && code !== "0x"
41
+ logger.console.log("[refund] Address deployment check:", {
42
+ address,
43
+ chainId,
44
+ codeLength: code?.length ?? 0,
45
+ isDeployed,
46
+ })
47
+ return isDeployed
48
+ }
49
+
50
+ /**
51
+ * Generates a random space value for Sequence wallet payloads.
52
+ * Space must be in the range [0, 2^160) (20 bytes).
53
+ */
54
+ function generateRandomSpace(): bigint {
55
+ const bytes = new Uint8Array(20) // 20 bytes = 160 bits
56
+ crypto.getRandomValues(bytes)
57
+ // Convert bytes to BigInt, ensuring it's within [0, 2^160)
58
+ return BigInt(
59
+ `0x${Array.from(bytes)
60
+ .map((b) => b.toString(16).padStart(2, "0"))
61
+ .join("")}`,
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Creates a refund payload with random space
67
+ * @param refundCall - The refund call to include in the payload
68
+ * @param nonce - The nonce value to use
69
+ * @returns The created payload
70
+ */
71
+ function createRefundPayload(
72
+ refundCall: Payload.Call,
73
+ nonce: bigint,
74
+ ): Payload.Calls {
75
+ const refundSpace = generateRandomSpace()
76
+ return {
77
+ type: "call",
78
+ calls: [refundCall],
79
+ space: refundSpace,
80
+ nonce,
81
+ }
82
+ }
83
+
84
+ export interface BuildRefundTransactionParams {
85
+ mainSigner: Address.Address
86
+ calls: Array<IntentCalls>
87
+ refundCall: Payload.Call
88
+ chainId: ChainId
89
+ intentAddress: Address.Address
90
+ walletClient: WalletClient
91
+ }
92
+
93
+ export interface BuildRefundTransactionWithSignatureParams {
94
+ mainSigner: Address.Address
95
+ calls: Array<IntentCalls>
96
+ payload: Payload.Calls // The payload that was signed
97
+ chainId: ChainId
98
+ intentAddress: Address.Address
99
+ signature: string // Hex string signature
100
+ }
101
+
102
+ /**
103
+ * Signs the payload hash for a refund transaction
104
+ */
105
+ export async function signPayload(
106
+ walletClient: WalletClient,
107
+ intentAddress: Address.Address,
108
+ chainId: ChainId,
109
+ payload: Payload.Calls,
110
+ ): Promise<string> {
111
+ if (!walletClient.account) {
112
+ throw new Error("Wallet client account is required for signing")
113
+ }
114
+
115
+ const typedData = Payload.toTyped(intentAddress, chainId, payload)
116
+
117
+ const signature = await walletClient.signTypedData({
118
+ account: walletClient.account,
119
+ ...typedData,
120
+ })
121
+
122
+ logger.console.log("[refund] Signature received:", signature)
123
+ return signature
124
+ }
125
+
126
+ /**
127
+ * Builds a refund transaction using a pre-signed hash
128
+ */
129
+ export async function buildRefundTransactionWithSignature(
130
+ params: BuildRefundTransactionWithSignatureParams,
131
+ ): Promise<{ to: `0x${string}`; data: Hex.Hex }> {
132
+ const { mainSigner, calls, payload, chainId, intentAddress, signature } =
133
+ params
134
+
135
+ // Validate refund call "to" address is not zeroAddress
136
+ const refundCall = payload.calls[0]
137
+ if (!refundCall) {
138
+ throw new Error("Refund call not found in payload")
139
+ }
140
+ if (!refundCall.to || Address.isEqual(refundCall.to, zeroAddress)) {
141
+ throw new Error("Refund call 'to' address cannot be zero address")
142
+ }
143
+
144
+ logger.console.log(
145
+ "[refund] buildRefundTransactionWithSignature called with params:",
146
+ {
147
+ mainSigner,
148
+ callsCount: calls.length,
149
+ calls: calls.map((c) => ({
150
+ chainId: c.chainId,
151
+ space: c.space?.toString(),
152
+ nonce: c.nonce?.toString(),
153
+ calls: c.calls.map((c) => ({
154
+ to: c.to,
155
+ value: c.value?.toString(),
156
+ data: c.data,
157
+ gasLimit: c.gasLimit?.toString(),
158
+ delegateCall: c.delegateCall,
159
+ onlyFallback: c.onlyFallback,
160
+ behaviorOnError: c.behaviorOnError,
161
+ })),
162
+ })),
163
+ refundCall: {
164
+ to: refundCall.to,
165
+ value: refundCall.value.toString(),
166
+ data: refundCall.data,
167
+ gasLimit: refundCall.gasLimit.toString(),
168
+ },
169
+ chainId,
170
+ intentAddress,
171
+ signatureLength: signature.length,
172
+ space: payload.space.toString(),
173
+ nonce: payload.nonce.toString(),
174
+ },
175
+ )
176
+
177
+ // Filter calls to only include origin calls (matching chainId)
178
+ // The intentAddress is the originIntentAddress, so we should only use origin calls
179
+ // when calculating the intent configuration and address
180
+ const originCalls = calls.filter((c) => {
181
+ const callChainId =
182
+ typeof c.chainId === "bigint" ? c.chainId : BigInt(String(c.chainId))
183
+ return callChainId === BigInt(chainId)
184
+ })
185
+ logger.console.log("[refund] Filtered origin calls:", {
186
+ originalCallsCount: calls.length,
187
+ originCallsCount: originCalls.length,
188
+ chainId,
189
+ })
190
+
191
+ const intentConfig = createIntentConfiguration(mainSigner, originCalls)
192
+ logger.console.log("[refund] Intent configuration created:", {
193
+ threshold: intentConfig.threshold.toString(),
194
+ checkpoint: intentConfig.checkpoint.toString(),
195
+ topology: Array.isArray(intentConfig.topology)
196
+ ? `Node with ${intentConfig.topology.length} items`
197
+ : "SignerLeaf",
198
+ })
199
+
200
+ const context = SEQUENCE_V3_CONTRACT_ADDRESSES
201
+ const imageHash = Config.hashConfiguration(intentConfig)
202
+ logger.console.log("[refund] Image hash:", Hex.fromBytes(imageHash))
203
+ // See if the derived intent address is correct
204
+ const derivedConfigurationAddress = calculateIntentAddress(
205
+ mainSigner,
206
+ originCalls,
207
+ )
208
+ logger.console.log(
209
+ "[refund] Derived configuration address:",
210
+ derivedConfigurationAddress,
211
+ )
212
+ if (!Address.isEqual(derivedConfigurationAddress, intentAddress)) {
213
+ throw new Error(
214
+ "Derived configuration address does not match intent address. Probably config is wrong",
215
+ )
216
+ }
217
+
218
+ // Use the payload that was passed in (already contains space, nonce, and refundCall)
219
+ logger.console.log("[refund] Payload created:", {
220
+ type: payload.type,
221
+ callsCount: payload.calls.length,
222
+ space: payload.space.toString(),
223
+ nonce: payload.nonce.toString(),
224
+ calls: payload.calls.map((c) => ({
225
+ to: c.to,
226
+ value: c.value.toString(),
227
+ data: c.data,
228
+ gasLimit: c.gasLimit.toString(),
229
+ })),
230
+ })
231
+
232
+ const encodedPayload = Bytes.toHex(Payload.encode(payload))
233
+ logger.console.log("[refund] Encoded payload:", encodedPayload)
234
+
235
+ const toSignatureLeaf = async (
236
+ signer: Address.Address,
237
+ signature: `0x${string}`,
238
+ ): Promise<
239
+ Signature.SignatureOfSignerLeaf | Signature.SignatureOfSapientSignerLeaf
240
+ > => {
241
+ const signerIsContract = await isAddressDeployed(signer, chainId)
242
+ if (signerIsContract) {
243
+ return {
244
+ address: signer,
245
+ type: "erc1271",
246
+ data: signature as `0x${string}`,
247
+ }
248
+ } else {
249
+ const { r, s, v } = splitSignature(signature)
250
+ const yParity = v - 27 // Convert v to yParity (0 or 1)
251
+ logger.console.log("[refund] Signature parsed:", { r, s, v, yParity })
252
+ const rBigInt = BigInt(r)
253
+ const sBigInt = BigInt(s)
254
+ return {
255
+ type: "hash",
256
+ r: rBigInt,
257
+ s: sBigInt,
258
+ yParity,
259
+ }
260
+ }
261
+ }
262
+ const signerSignatureLeaf = await toSignatureLeaf(
263
+ mainSigner,
264
+ signature as `0x${string}`,
265
+ )
266
+ const topology = Signature.fillLeaves(intentConfig.topology, (leaf) =>
267
+ Address.isEqual(leaf.address, mainSigner) ? signerSignatureLeaf : undefined,
268
+ )
269
+ logger.console.log("[refund] Topology filled with signature leaves")
270
+
271
+ const filledConfig = {
272
+ noChainId: false,
273
+ configuration: { ...intentConfig, topology },
274
+ }
275
+ const encodedSignature = Signature.encodeSignature(filledConfig, true, true)
276
+ logger.console.log(
277
+ "[refund] Signature encoded:",
278
+ Bytes.toHex(encodedSignature),
279
+ )
280
+
281
+ // Check if intent address has bytecode deployed
282
+ const intentAddressDeployed = await isAddressDeployed(intentAddress, chainId)
283
+
284
+ const executeData = AbiFunction.encodeData(Constants.EXECUTE, [
285
+ encodedPayload,
286
+ Bytes.toHex(encodedSignature),
287
+ ])
288
+ logger.console.log("[refund] Execute data encoded:", executeData)
289
+
290
+ // Validate intent address is not zeroAddress
291
+ if (!intentAddress || Address.isEqual(intentAddress, zeroAddress)) {
292
+ throw new Error("Intent address cannot be zero address")
293
+ }
294
+
295
+ if (intentAddressDeployed) {
296
+ logger.console.log(
297
+ "[refund] Intent address is deployed, returning direct transaction:",
298
+ {
299
+ to: intentAddress,
300
+ data: executeData,
301
+ },
302
+ )
303
+ return { to: intentAddress, data: executeData }
304
+ } else {
305
+ logger.console.log(
306
+ "[refund] Intent address not deployed, using ERC-6492 deploy",
307
+ )
308
+ const deploy = Erc6492.deploy(imageHash, context)
309
+ logger.console.log("[refund] ERC-6492 deploy created:", {
310
+ to: deploy.to,
311
+ dataLength: deploy.data.length,
312
+ })
313
+
314
+ // Validate deploy.to and intentAddress are not zeroAddress
315
+ if (!deploy.to || Address.isEqual(deploy.to, zeroAddress)) {
316
+ throw new Error("Deploy 'to' address cannot be zero address")
317
+ }
318
+
319
+ const deployPayload = {
320
+ type: "call" as const,
321
+ space: 0n,
322
+ nonce: 0n,
323
+ calls: [
324
+ {
325
+ to: deploy.to,
326
+ value: 0n,
327
+ data: Hex.fromBytes(deploy.data),
328
+ gasLimit: 0n,
329
+ delegateCall: false,
330
+ onlyFallback: false,
331
+ behaviorOnError: "revert" as const,
332
+ },
333
+ {
334
+ to: intentAddress,
335
+ value: 0n,
336
+ data: executeData,
337
+ gasLimit: 0n,
338
+ delegateCall: false,
339
+ onlyFallback: false,
340
+ behaviorOnError: "revert" as const,
341
+ },
342
+ ],
343
+ }
344
+ logger.console.log("[refund] Deploy payload created:", {
345
+ callsCount: deployPayload.calls.length,
346
+ calls: deployPayload.calls.map((c) => ({
347
+ to: c.to,
348
+ value: c.value.toString(),
349
+ dataLength: c.data.length,
350
+ gasLimit: c.gasLimit.toString(),
351
+ })),
352
+ })
353
+
354
+ const encodedDeployPayload = Bytes.toHex(Payload.encode(deployPayload))
355
+ logger.console.log("[refund] Encoded deploy payload:", encodedDeployPayload)
356
+
357
+ const result = {
358
+ to: context.guestModule as `0x${string}`,
359
+ data: encodedDeployPayload,
360
+ }
361
+
362
+ // Validate final transaction "to" address is not zeroAddress
363
+ if (!result.to || Address.isEqual(result.to, zeroAddress)) {
364
+ throw new Error("Transaction 'to' address cannot be zero address")
365
+ }
366
+
367
+ logger.console.log("[refund] Returning ERC-6492 transaction:", {
368
+ to: result.to,
369
+ dataLength: result.data.length,
370
+ })
371
+
372
+ return result
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Builds a refund transaction for a given intent configuration
378
+ * @param params - Object containing:
379
+ * - mainSigner: The main signer address
380
+ * - calls: Array of intent calls used to create the original intent configuration
381
+ * - refundCall: The refund call to execute
382
+ * - chainId: The chain ID where the refund transaction will be executed
383
+ * - intentAddress: The intent address from the API response
384
+ * - walletClient: The wallet client to sign the payload hash
385
+ * @returns Promise resolving to transaction object with `to` address and `data` hex string
386
+ */
387
+ export async function buildRefundTransaction(
388
+ params: BuildRefundTransactionParams,
389
+ ): Promise<{ to: `0x${string}`; data: Hex.Hex }> {
390
+ const {
391
+ mainSigner,
392
+ calls,
393
+ refundCall,
394
+ chainId,
395
+ intentAddress,
396
+ walletClient,
397
+ } = params
398
+
399
+ logger.console.log("[refund] buildRefundTransaction called with params:", {
400
+ mainSigner,
401
+ callsCount: calls.length,
402
+ refundCall: {
403
+ to: refundCall.to,
404
+ value: refundCall.value.toString(),
405
+ data: refundCall.data,
406
+ gasLimit: refundCall.gasLimit.toString(),
407
+ },
408
+ chainId,
409
+ intentAddress,
410
+ })
411
+
412
+ // Create payload with random space and calculate hash for signing
413
+ const payload = createRefundPayload(refundCall, BigInt(calls[0]?.nonce ?? 0))
414
+
415
+ // Sign the payload hash
416
+ const signature = await signPayload(
417
+ walletClient,
418
+ intentAddress,
419
+ chainId,
420
+ payload,
421
+ )
422
+
423
+ // Build transaction with signature using the payload that was signed
424
+ return buildRefundTransactionWithSignature({
425
+ mainSigner,
426
+ calls,
427
+ payload,
428
+ chainId,
429
+ intentAddress,
430
+ signature,
431
+ })
432
+ }
433
+
434
+ /**
435
+ * Determines the refund call based on token balances at the intent address
436
+ */
437
+ export function determineRefundCall(
438
+ tokenBalancesData:
439
+ | {
440
+ nativeBalances?: Array<{ balance?: string; chainId?: number }>
441
+ balances?: Array<{
442
+ balance?: string
443
+ contractAddress?: string
444
+ contractInfo?: { decimals?: number; chainId?: number }
445
+ }>
446
+ }
447
+ | undefined,
448
+ refundToAddress: `0x${string}`,
449
+ ): WalletPayload.Call {
450
+ // Validate refund address is not zeroAddress
451
+ if (!refundToAddress || Address.isEqual(refundToAddress, zeroAddress)) {
452
+ throw new Error("Refund address cannot be zero address")
453
+ }
454
+ // Find the token with the highest balance
455
+ let highestBalanceToken: {
456
+ type: "native" | "erc20"
457
+ balance: bigint
458
+ tokenAddress?: string
459
+ decimals?: number
460
+ chainId?: number
461
+ } | null = null
462
+
463
+ // Check native token balances
464
+ if (tokenBalancesData?.nativeBalances) {
465
+ for (const nativeBalance of tokenBalancesData.nativeBalances) {
466
+ const balance = BigInt(nativeBalance.balance || "0")
467
+ if (balance > 0n) {
468
+ if (!highestBalanceToken || balance > highestBalanceToken.balance) {
469
+ highestBalanceToken = {
470
+ type: "native",
471
+ balance,
472
+ chainId: nativeBalance.chainId,
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+
479
+ // Check ERC20 token balances
480
+ if (tokenBalancesData?.balances) {
481
+ for (const tokenBalance of tokenBalancesData.balances) {
482
+ const balance = BigInt(tokenBalance.balance || "0")
483
+ if (balance > 0n) {
484
+ if (!highestBalanceToken || balance > highestBalanceToken.balance) {
485
+ highestBalanceToken = {
486
+ type: "erc20",
487
+ balance,
488
+ tokenAddress: tokenBalance.contractAddress,
489
+ decimals: tokenBalance.contractInfo?.decimals,
490
+ chainId: tokenBalance.contractInfo?.chainId,
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ if (!highestBalanceToken) {
498
+ logger.console.log(
499
+ "[refund] No tokens found at intent address, creating empty refund call",
500
+ )
501
+ return {
502
+ to: refundToAddress,
503
+ value: 0n,
504
+ data: "0x" as `0x${string}`,
505
+ gasLimit: 0n,
506
+ delegateCall: false,
507
+ onlyFallback: false,
508
+ behaviorOnError: "revert",
509
+ }
510
+ } else if (highestBalanceToken.type === "native") {
511
+ logger.console.log("[refund] Creating native token refund call:", {
512
+ to: refundToAddress,
513
+ value: highestBalanceToken.balance.toString(),
514
+ balance: highestBalanceToken.balance.toString(),
515
+ chainId: highestBalanceToken.chainId,
516
+ })
517
+ return {
518
+ to: refundToAddress,
519
+ value: highestBalanceToken.balance,
520
+ data: "0x" as `0x${string}`,
521
+ gasLimit: 0n,
522
+ delegateCall: false,
523
+ onlyFallback: false,
524
+ behaviorOnError: "revert",
525
+ }
526
+ } else {
527
+ // ERC20 token refund
528
+ const transferData = getERC20TransferData({
529
+ recipient: refundToAddress,
530
+ amount: highestBalanceToken.balance,
531
+ })
532
+ logger.console.log("[refund] Creating ERC20 token refund call:", {
533
+ to: highestBalanceToken.tokenAddress,
534
+ value: "0",
535
+ data: transferData,
536
+ balance: highestBalanceToken.balance.toString(),
537
+ decimals: highestBalanceToken.decimals,
538
+ chainId: highestBalanceToken.chainId,
539
+ recipient: refundToAddress,
540
+ })
541
+ // Validate token address is not zeroAddress
542
+ if (
543
+ !highestBalanceToken.tokenAddress ||
544
+ Address.isEqual(
545
+ Address.from(highestBalanceToken.tokenAddress),
546
+ zeroAddress,
547
+ )
548
+ ) {
549
+ throw new Error("ERC20 token address cannot be zero address")
550
+ }
551
+
552
+ return {
553
+ to: Address.from(highestBalanceToken.tokenAddress),
554
+ value: 0n,
555
+ data: transferData as `0x${string}`,
556
+ gasLimit: 0n,
557
+ delegateCall: false,
558
+ onlyFallback: false,
559
+ behaviorOnError: "revert",
560
+ }
561
+ }
562
+ }
563
+
564
+ export interface UseTrailsRefundParams {
565
+ intentId: string | undefined
566
+ walletClient: WalletClient | undefined
567
+ refundToAddress?: `0x${string}`
568
+ }
569
+
570
+ export interface UseTrailsRefundReturn {
571
+ intent: any | null
572
+ isLoadingIntent: boolean
573
+ isLoadingBalances: boolean
574
+ intentError: Error | null
575
+ balancesError: Error | null
576
+ hasIntentBalance: boolean
577
+ refetchIntent: () => void
578
+ signPayload: () => Promise<{
579
+ signature: string
580
+ payload: Payload.Calls
581
+ refundCall: Payload.Call
582
+ }>
583
+ getRefundTx: (params: {
584
+ signedHash: string
585
+ payload: Payload.Calls
586
+ }) => Promise<{
587
+ to: `0x${string}`
588
+ data: `0x${string}`
589
+ chainId: number
590
+ chain: Chain
591
+ }>
592
+ }
593
+
594
+ /**
595
+ * Hook for building and signing refund transactions
596
+ * @example
597
+ * ```tsx
598
+ * const { signPayload, getRefundTx } = useTrailsRefund({
599
+ * intentId: "0x...",
600
+ * walletClient: walletClient,
601
+ * })
602
+ *
603
+ * const signedHash = await signRefund()
604
+ * const refundTx = await getRefundTx({ signedHash })
605
+ * await walletClient.sendTransaction(refundTx)
606
+ * ```
607
+ */
608
+ export function useTrailsRefund({
609
+ intentId,
610
+ walletClient,
611
+ refundToAddress,
612
+ }: UseTrailsRefundParams): UseTrailsRefundReturn {
613
+ const {
614
+ intent: intentData,
615
+ isLoading: isLoadingIntent,
616
+ error: intentError,
617
+ refetch: refetchIntent,
618
+ } = useGetIntent({
619
+ intentId,
620
+ enabled: !!intentId,
621
+ })
622
+
623
+ // Get token balances for the intent address
624
+ const intentAddressForBalances = useMemo<Address.Address | null>(() => {
625
+ if (!intentData?.originIntentAddress) return null
626
+ return Address.from(intentData.originIntentAddress)
627
+ }, [intentData?.originIntentAddress])
628
+
629
+ const {
630
+ tokenBalancesData,
631
+ isLoadingBalances,
632
+ balanceError: balancesError,
633
+ } = useTokenBalances(intentAddressForBalances)
634
+
635
+ // Check if intent address has any balance (native or ERC20)
636
+ const hasIntentBalance = useMemo<boolean>(() => {
637
+ if (!tokenBalancesData) return false
638
+
639
+ // Check native token balances
640
+ if (tokenBalancesData.nativeBalances) {
641
+ for (const nativeBalance of tokenBalancesData.nativeBalances) {
642
+ const balance = BigInt(nativeBalance.balance || "0")
643
+ if (balance > 0n) {
644
+ return true
645
+ }
646
+ }
647
+ }
648
+
649
+ // Check ERC20 token balances
650
+ if (tokenBalancesData.balances) {
651
+ for (const tokenBalance of tokenBalancesData.balances) {
652
+ const balance = BigInt(tokenBalance.balance || "0")
653
+ if (balance > 0n) {
654
+ return true
655
+ }
656
+ }
657
+ }
658
+
659
+ return false
660
+ }, [tokenBalancesData])
661
+
662
+ // Get refund address (use walletClient account or provided address)
663
+ // Don't throw during render - validate when methods are called
664
+ const effectiveRefundAddress = useMemo<`0x${string}` | null>(() => {
665
+ const address =
666
+ refundToAddress || (walletClient?.account?.address as `0x${string}`)
667
+ if (!address || Address.isEqual(address, zeroAddress)) {
668
+ return null // Will be validated when used
669
+ }
670
+ return address
671
+ }, [refundToAddress, walletClient?.account?.address])
672
+
673
+ const signPayloadCallback = useCallback(async (): Promise<{
674
+ signature: string
675
+ payload: Payload.Calls
676
+ refundCall: Payload.Call
677
+ }> => {
678
+ if (!intentData || !walletClient || !walletClient.account) {
679
+ throw new Error("Intent data and wallet client with account required")
680
+ }
681
+
682
+ // Validate refund address
683
+ if (!effectiveRefundAddress) {
684
+ throw new Error(
685
+ "Refund address is required. Provide refundToAddress or connect a wallet with a valid address",
686
+ )
687
+ }
688
+ if (Address.isEqual(effectiveRefundAddress, zeroAddress)) {
689
+ throw new Error("Refund address cannot be zero address")
690
+ }
691
+
692
+ // Build calls array
693
+ const calls: IntentCalls[] = []
694
+ if (intentData.originCalls) {
695
+ calls.push({
696
+ chainId: intentData.originCalls.chainId,
697
+ space: intentData.originCalls.space,
698
+ nonce: intentData.originCalls.nonce,
699
+ calls: intentData.originCalls.calls.map((call: any) => ({
700
+ to: call.to,
701
+ value: call.value,
702
+ data: call.data,
703
+ gasLimit: call.gasLimit,
704
+ delegateCall: call.delegateCall,
705
+ onlyFallback: call.onlyFallback,
706
+ behaviorOnError: call.behaviorOnError,
707
+ })),
708
+ })
709
+ }
710
+ if (intentData.destinationCalls) {
711
+ calls.push({
712
+ chainId: intentData.destinationCalls.chainId,
713
+ space: intentData.destinationCalls.space,
714
+ nonce: intentData.destinationCalls.nonce,
715
+ calls: intentData.destinationCalls.calls.map((call: any) => ({
716
+ to: call.to,
717
+ value: call.value,
718
+ data: call.data,
719
+ gasLimit: call.gasLimit,
720
+ delegateCall: call.delegateCall,
721
+ onlyFallback: call.onlyFallback,
722
+ behaviorOnError: call.behaviorOnError,
723
+ })),
724
+ })
725
+ }
726
+
727
+ // Determine refund call from token balances
728
+ const refundCall = determineRefundCall(
729
+ tokenBalancesData,
730
+ effectiveRefundAddress,
731
+ )
732
+
733
+ // Get chain ID from origin calls (this is the chain where the refund should execute)
734
+ const chainId = intentData.originCalls?.chainId
735
+ if (!chainId) {
736
+ throw new Error("Chain ID not found in origin calls")
737
+ }
738
+ logger.console.log("[refund] Using chain ID from origin calls:", chainId)
739
+
740
+ // Switch to the correct chain before signing
741
+ if (walletClient) {
742
+ try {
743
+ await attemptSwitchChain({
744
+ walletClient,
745
+ desiredChainId: Number(chainId),
746
+ })
747
+ logger.console.log("[refund] Successfully switched to chain:", chainId)
748
+ } catch (error) {
749
+ logger.console.error(
750
+ "[refund] Failed to switch chain before signing:",
751
+ error,
752
+ )
753
+ throw new Error(
754
+ `Failed to switch to chain ${chainId}: ${error instanceof Error ? error.message : "Unknown error"}`,
755
+ )
756
+ }
757
+ }
758
+
759
+ // Get intent address (needed for buildRefundTransactionWithSignature)
760
+ if (!intentData.originIntentAddress) {
761
+ throw new Error("Intent data missing originIntentAddress")
762
+ }
763
+ const intentAddress = Address.from(intentData.originIntentAddress)
764
+
765
+ // Create payload with random space and calculate hash for signing
766
+ const payload = createRefundPayload(
767
+ refundCall,
768
+ BigInt(calls[0]?.nonce ?? 0),
769
+ )
770
+
771
+ // Sign the hash - call the function directly to avoid circular import
772
+ if (!walletClient.account) {
773
+ throw new Error("Wallet client account is required for signing")
774
+ }
775
+ const typedData = Payload.toTyped(intentAddress, chainId, payload)
776
+ const signature = await walletClient.signTypedData({
777
+ account: walletClient.account,
778
+ ...typedData,
779
+ })
780
+ logger.console.log("[refund] Signature received:", signature)
781
+ return {
782
+ signature,
783
+ payload,
784
+ refundCall,
785
+ }
786
+ }, [intentData, walletClient, tokenBalancesData, effectiveRefundAddress])
787
+
788
+ // Export as signPayload for the hook return
789
+ const signPayload = signPayloadCallback
790
+
791
+ const getRefundTx = useCallback(
792
+ async (params: {
793
+ signedHash: string
794
+ payload: Payload.Calls
795
+ }): Promise<{
796
+ to: `0x${string}`
797
+ data: `0x${string}`
798
+ chainId: number
799
+ chain: Chain
800
+ }> => {
801
+ if (!intentData || !walletClient?.account) {
802
+ throw new Error("Intent data and wallet client with account required")
803
+ }
804
+
805
+ // Validate refund address
806
+ if (!effectiveRefundAddress) {
807
+ throw new Error(
808
+ "Refund address is required. Provide refundToAddress or connect a wallet with a valid address",
809
+ )
810
+ }
811
+ if (Address.isEqual(effectiveRefundAddress, zeroAddress)) {
812
+ throw new Error("Refund address cannot be zero address")
813
+ }
814
+
815
+ // Build calls array (needed for intent configuration)
816
+ const calls: IntentCalls[] = []
817
+ if (intentData.originCalls) {
818
+ calls.push({
819
+ chainId: intentData.originCalls.chainId,
820
+ space: intentData.originCalls.space,
821
+ nonce: intentData.originCalls.nonce,
822
+ calls: intentData.originCalls.calls.map((call: any) => ({
823
+ to: call.to,
824
+ value: call.value,
825
+ data: call.data,
826
+ gasLimit: call.gasLimit,
827
+ delegateCall: call.delegateCall,
828
+ onlyFallback: call.onlyFallback,
829
+ behaviorOnError: call.behaviorOnError,
830
+ })),
831
+ })
832
+ }
833
+ if (intentData.destinationCalls) {
834
+ calls.push({
835
+ chainId: intentData.destinationCalls.chainId,
836
+ space: intentData.destinationCalls.space,
837
+ nonce: intentData.destinationCalls.nonce,
838
+ calls: intentData.destinationCalls.calls.map((call: any) => ({
839
+ to: call.to,
840
+ value: call.value,
841
+ data: call.data,
842
+ gasLimit: call.gasLimit,
843
+ delegateCall: call.delegateCall,
844
+ onlyFallback: call.onlyFallback,
845
+ behaviorOnError: call.behaviorOnError,
846
+ })),
847
+ })
848
+ }
849
+
850
+ // Use the refund call from the payload (passed from signPayload)
851
+ const refundCall = params.payload.calls[0]
852
+ if (!refundCall) {
853
+ throw new Error("Refund call not found in payload")
854
+ }
855
+
856
+ // Get chain ID from origin calls (this is the chain where the refund should execute)
857
+ const chainId = intentData.originCalls?.chainId
858
+ if (!chainId) {
859
+ throw new Error("Chain ID not found in origin calls")
860
+ }
861
+ logger.console.log("[refund] Using chain ID from origin calls:", chainId)
862
+
863
+ // Get intent address
864
+ if (!intentData.originIntentAddress) {
865
+ throw new Error("Intent data missing originIntentAddress")
866
+ }
867
+ const intentAddress = Address.from(intentData.originIntentAddress)
868
+
869
+ // Build transaction with signature using the payload passed from signPayload
870
+ const mainSigner = walletClient.account.address as Address.Address
871
+ const result = await buildRefundTransactionWithSignature({
872
+ mainSigner,
873
+ calls,
874
+ payload: params.payload,
875
+ chainId: Number(chainId),
876
+ intentAddress,
877
+ signature: params.signedHash,
878
+ })
879
+
880
+ // Final validation: ensure result "to" address is not zeroAddress
881
+ if (!result.to || Address.isEqual(result.to, zeroAddress)) {
882
+ throw new Error(
883
+ "Refund transaction 'to' address cannot be zero address",
884
+ )
885
+ }
886
+
887
+ // Get chain info for the transaction
888
+ const chainInfo = getChainInfo(Number(chainId))
889
+ if (!chainInfo) {
890
+ throw new Error(`Chain info not found for chain ID: ${chainId}`)
891
+ }
892
+
893
+ return {
894
+ to: result.to,
895
+ data: result.data,
896
+ chainId: Number(chainId),
897
+ chain: chainInfo as Chain,
898
+ }
899
+ },
900
+ [intentData, walletClient, effectiveRefundAddress],
901
+ )
902
+
903
+ return {
904
+ intent: intentData,
905
+ isLoadingIntent,
906
+ isLoadingBalances,
907
+ intentError: intentError as Error | null,
908
+ balancesError: balancesError as Error | null,
909
+ hasIntentBalance,
910
+ refetchIntent,
911
+ signPayload,
912
+ getRefundTx,
913
+ }
914
+ }