@x402r/refund 0.0.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.
@@ -0,0 +1,778 @@
1
+ /**
2
+ * Facilitator-side helpers for the Refund Helper Extension
3
+ *
4
+ * These helpers allow facilitator operators to validate refund info
5
+ * and handle refund settlements via X402DepositRelayProxy contracts.
6
+ */
7
+
8
+ import type { PaymentPayload, PaymentRequirements, SettleResponse } from "@x402/core/types";
9
+ import type { FacilitatorEvmSigner } from "@x402/evm";
10
+ import { getAddress, isAddress, parseErc6492Signature, parseSignature, zeroAddress } from "viem";
11
+ import { REFUND_EXTENSION_KEY, type RefundExtension } from "./types";
12
+
13
+ /**
14
+ * Checks if an error is a rate limit error (429)
15
+ */
16
+ function isRateLimitError(error: unknown): boolean {
17
+ if (error && typeof error === "object") {
18
+ // Check for viem error structure
19
+ const err = error as { status?: number; message?: string; details?: string; cause?: unknown };
20
+ if (err.status === 429) {
21
+ return true;
22
+ }
23
+ const message = err.message || err.details || "";
24
+ if (typeof message === "string" && message.toLowerCase().includes("rate limit")) {
25
+ return true;
26
+ }
27
+ // Check nested cause
28
+ if (err.cause && isRateLimitError(err.cause)) {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Wraps readContract calls with retry logic for rate limit errors
37
+ * Uses exponential backoff: 1s, 2s, 4s, 8s, 16s
38
+ */
39
+ async function readContractWithRetry<T>(
40
+ signer: FacilitatorEvmSigner,
41
+ args: {
42
+ address: `0x${string}`;
43
+ abi: readonly unknown[];
44
+ functionName: string;
45
+ args?: readonly unknown[];
46
+ },
47
+ maxRetries = 5,
48
+ ): Promise<T> {
49
+ let lastError: unknown;
50
+
51
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
52
+ try {
53
+ return (await signer.readContract(args)) as T;
54
+ } catch (error) {
55
+ lastError = error;
56
+
57
+ // Only retry on rate limit errors
58
+ if (!isRateLimitError(error)) {
59
+ throw error;
60
+ }
61
+
62
+ // Don't retry on last attempt
63
+ if (attempt >= maxRetries) {
64
+ break;
65
+ }
66
+
67
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s
68
+ const delayMs = Math.min(1000 * Math.pow(2, attempt), 16000);
69
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
70
+ }
71
+ }
72
+
73
+ throw lastError;
74
+ }
75
+
76
+ /**
77
+ * Factory ABI - any CREATE3-compatible factory that implements these methods can be used
78
+ * No interface required - duck typing at runtime!
79
+ *
80
+ * Note: Proxies store all data directly (merchantPayout, token, escrow), so factory
81
+ * only needs methods for deployment and address computation.
82
+ */
83
+ const FACTORY_ABI = [
84
+ {
85
+ name: "getMerchantFromRelay",
86
+ type: "function",
87
+ stateMutability: "view",
88
+ inputs: [{ name: "relayAddress", type: "address" }],
89
+ outputs: [{ name: "", type: "address" }],
90
+ },
91
+ {
92
+ name: "getRelayAddress",
93
+ type: "function",
94
+ stateMutability: "view",
95
+ inputs: [{ name: "merchantPayout", type: "address" }],
96
+ outputs: [{ name: "", type: "address" }],
97
+ },
98
+ {
99
+ name: "deployRelay",
100
+ type: "function",
101
+ stateMutability: "nonpayable",
102
+ inputs: [{ name: "merchantPayout", type: "address" }],
103
+ outputs: [{ name: "", type: "address" }],
104
+ },
105
+ {
106
+ name: "getCreateX",
107
+ type: "function",
108
+ stateMutability: "view",
109
+ inputs: [],
110
+ outputs: [{ name: "", type: "address" }],
111
+ },
112
+ ] as const;
113
+
114
+ /**
115
+ * Escrow ABI for shared escrow
116
+ */
117
+ const ESCROW_ABI = [
118
+ {
119
+ name: "registerMerchant",
120
+ type: "function",
121
+ stateMutability: "nonpayable",
122
+ inputs: [
123
+ { name: "merchantPayout", type: "address" },
124
+ { name: "arbiter", type: "address" },
125
+ ],
126
+ outputs: [],
127
+ },
128
+ {
129
+ name: "registeredMerchants",
130
+ type: "function",
131
+ stateMutability: "view",
132
+ inputs: [{ name: "merchantPayout", type: "address" }],
133
+ outputs: [{ name: "", type: "bool" }],
134
+ },
135
+ {
136
+ name: "merchantArbiters",
137
+ type: "function",
138
+ stateMutability: "view",
139
+ inputs: [{ name: "merchantPayout", type: "address" }],
140
+ outputs: [{ name: "", type: "address" }],
141
+ },
142
+ {
143
+ name: "getArbiter",
144
+ type: "function",
145
+ stateMutability: "view",
146
+ inputs: [{ name: "merchantPayout", type: "address" }],
147
+ outputs: [{ name: "", type: "address" }],
148
+ },
149
+ {
150
+ name: "noteDeposit",
151
+ type: "function",
152
+ stateMutability: "nonpayable",
153
+ inputs: [
154
+ { name: "user", type: "address" },
155
+ { name: "merchantPayout", type: "address" },
156
+ { name: "amount", type: "uint256" },
157
+ ],
158
+ outputs: [{ name: "", type: "uint256" }],
159
+ },
160
+ {
161
+ name: "release",
162
+ type: "function",
163
+ stateMutability: "nonpayable",
164
+ inputs: [
165
+ { name: "user", type: "address" },
166
+ { name: "depositNonce", type: "uint256" },
167
+ ],
168
+ outputs: [],
169
+ },
170
+ {
171
+ name: "refund",
172
+ type: "function",
173
+ stateMutability: "nonpayable",
174
+ inputs: [
175
+ { name: "user", type: "address" },
176
+ { name: "depositNonce", type: "uint256" },
177
+ ],
178
+ outputs: [],
179
+ },
180
+ {
181
+ name: "deposits",
182
+ type: "function",
183
+ stateMutability: "view",
184
+ inputs: [
185
+ { name: "user", type: "address" },
186
+ { name: "depositNonce", type: "uint256" },
187
+ ],
188
+ outputs: [
189
+ { name: "principal", type: "uint256" },
190
+ { name: "timestamp", type: "uint256" },
191
+ { name: "nonce", type: "uint256" },
192
+ { name: "merchantPayout", type: "address" },
193
+ ],
194
+ },
195
+ ] as const;
196
+
197
+ /**
198
+ * RelayProxy ABI - proxy stores all data directly
199
+ */
200
+ const RELAY_PROXY_ABI = [
201
+ {
202
+ name: "executeDeposit",
203
+ type: "function",
204
+ stateMutability: "nonpayable",
205
+ inputs: [
206
+ { name: "fromUser", type: "address" },
207
+ { name: "amount", type: "uint256" },
208
+ { name: "validAfter", type: "uint256" },
209
+ { name: "validBefore", type: "uint256" },
210
+ { name: "nonce", type: "bytes32" },
211
+ { name: "v", type: "uint8" },
212
+ { name: "r", type: "bytes32" },
213
+ { name: "s", type: "bytes32" },
214
+ ],
215
+ outputs: [],
216
+ },
217
+ {
218
+ name: "MERCHANT_PAYOUT",
219
+ type: "function",
220
+ stateMutability: "view",
221
+ inputs: [],
222
+ outputs: [{ name: "", type: "address" }],
223
+ },
224
+ {
225
+ name: "TOKEN",
226
+ type: "function",
227
+ stateMutability: "view",
228
+ inputs: [],
229
+ outputs: [{ name: "", type: "address" }],
230
+ },
231
+ {
232
+ name: "ESCROW",
233
+ type: "function",
234
+ stateMutability: "view",
235
+ inputs: [],
236
+ outputs: [{ name: "", type: "address" }],
237
+ },
238
+ ] as const;
239
+
240
+ /**
241
+ * Extracts refund extension info from payment payload or requirements
242
+ *
243
+ * @param paymentPayload - The payment payload (may contain extensions)
244
+ * @param _ - The payment requirements (currently unused, kept for API compatibility)
245
+ * @returns Refund extension info if valid, null otherwise
246
+ */
247
+ export function extractRefundInfo(
248
+ paymentPayload: PaymentPayload,
249
+ _: PaymentRequirements,
250
+ ): { factoryAddress: string; merchantPayouts: Record<string, string> } | null {
251
+ // Get extension from payload (extensions flow from PaymentRequired through PaymentPayload)
252
+ const extension = paymentPayload.extensions?.[REFUND_EXTENSION_KEY] as
253
+ | RefundExtension
254
+ | undefined;
255
+
256
+ if (!extension || !extension.info || !extension.info.factoryAddress) {
257
+ return null;
258
+ }
259
+
260
+ const factoryAddress = extension.info.factoryAddress;
261
+ const merchantPayouts = extension.info.merchantPayouts || {};
262
+
263
+ // Validate factory address format
264
+ if (!isAddress(factoryAddress)) {
265
+ return null;
266
+ }
267
+
268
+ return { factoryAddress, merchantPayouts };
269
+ }
270
+
271
+ /**
272
+ * Helper for facilitator operators to handle refund settlements via X402DepositRelayProxy.
273
+ *
274
+ * This function:
275
+ * 1. Extracts refund extension info (factory address)
276
+ * 2. Validates factory exists
277
+ * 3. Reads merchantPayout and escrow directly from proxy storage
278
+ * 4. Checks if merchant is registered
279
+ * 5. Deploys relay on-demand if needed (via factory)
280
+ * 6. Calls proxy.executeDeposit() to deposit funds into escrow
281
+ *
282
+ * Returns null if refund is not applicable (delegates to normal flow).
283
+ * Throws error on execution failure or if merchant not registered (facilitator should handle in hook).
284
+ *
285
+ * @param paymentPayload - The payment payload containing authorization and signature
286
+ * @param paymentRequirements - The payment requirements containing refund extension
287
+ * @param signer - The EVM signer for contract interactions
288
+ * @returns SettleResponse on success, null if not applicable
289
+ * @throws Error on execution failure or if merchant not registered
290
+ *
291
+ * @example
292
+ * ```typescript
293
+ * facilitator.onBeforeSettle(async (context) => {
294
+ * try {
295
+ * const result = await settleWithRefundHelper(
296
+ * context.paymentPayload,
297
+ * context.paymentRequirements,
298
+ * signer,
299
+ * );
300
+ *
301
+ * if (result) {
302
+ * return { abort: true, reason: 'handled_by_refund_helper' };
303
+ * }
304
+ * } catch (error) {
305
+ * // Log error but don't abort - let normal settlement proceed
306
+ * console.error('Refund helper settlement failed:', error);
307
+ * }
308
+ *
309
+ * return null; // Proceed with normal settlement
310
+ * });
311
+ * ```
312
+ */
313
+ export async function settleWithRefundHelper(
314
+ paymentPayload: PaymentPayload,
315
+ paymentRequirements: PaymentRequirements,
316
+ signer: FacilitatorEvmSigner,
317
+ ): Promise<SettleResponse | null> {
318
+ // Extract refund info from extension
319
+ const refundInfo = extractRefundInfo(paymentPayload, paymentRequirements);
320
+ if (!refundInfo) {
321
+ return null; // Not refundable, proceed with normal settlement
322
+ }
323
+
324
+ const factoryAddress = refundInfo.factoryAddress;
325
+ const merchantPayouts = refundInfo.merchantPayouts;
326
+
327
+ // Check if factory exists (via code check)
328
+ try {
329
+ const factoryCode = await signer.getCode({ address: getAddress(factoryAddress) });
330
+ if (!factoryCode || factoryCode === "0x" || factoryCode.length <= 2) {
331
+ throw new Error(
332
+ `Factory contract does not exist at ${factoryAddress}. Invalid refund extension.`,
333
+ );
334
+ }
335
+ } catch (error) {
336
+ throw new Error(
337
+ `Failed to check factory contract: ${error instanceof Error ? error.message : String(error)}`,
338
+ );
339
+ }
340
+
341
+ // Get proxy address from payTo
342
+ const proxyAddress = getAddress(paymentRequirements.payTo);
343
+
344
+ // Check if relay exists (via code check) - do this FIRST before trying to read from it
345
+ const relayCode = await signer.getCode({ address: proxyAddress });
346
+ const relayExists = relayCode && relayCode !== "0x" && relayCode.length > 2;
347
+
348
+ // Get merchantPayout - try from deployed proxy first, then from extension merchantPayouts map if not deployed
349
+ let merchantPayout: string;
350
+ let escrowAddress: string | undefined;
351
+
352
+ if (relayExists) {
353
+ // Relay is deployed - read merchantPayout and escrow directly from proxy
354
+ try {
355
+ merchantPayout = await readContractWithRetry<string>(signer, {
356
+ address: proxyAddress,
357
+ abi: RELAY_PROXY_ABI,
358
+ functionName: "MERCHANT_PAYOUT",
359
+ args: [],
360
+ });
361
+
362
+ escrowAddress = await readContractWithRetry<string>(signer, {
363
+ address: proxyAddress,
364
+ abi: RELAY_PROXY_ABI,
365
+ functionName: "ESCROW",
366
+ args: [],
367
+ });
368
+ } catch (error) {
369
+ // Proxy query failed even though code exists - might not be a refund proxy
370
+ return null;
371
+ }
372
+ } else {
373
+ // Relay not deployed - get merchantPayout from extension's merchantPayouts map
374
+ // The extension contains a map of proxyAddress -> merchantPayout
375
+
376
+ // Look up merchantPayout in the extension's merchantPayouts map
377
+ // Try both lowercase and original case for the proxy address
378
+ const proxyAddressLower = proxyAddress.toLowerCase();
379
+ merchantPayout = merchantPayouts[proxyAddress] || merchantPayouts[proxyAddressLower];
380
+
381
+ if (!merchantPayout || merchantPayout === zeroAddress) {
382
+ return null; // Not a refund payment, proceed with normal settlement
383
+ }
384
+
385
+ // escrowAddress will be set after deployment
386
+ }
387
+
388
+ // If merchantPayout is zero address, this is not a refund payment
389
+ if (!merchantPayout || merchantPayout === zeroAddress) {
390
+ return null; // Not a refund payment, proceed with normal settlement
391
+ }
392
+
393
+ // Deploy relay on-demand if needed
394
+ if (!relayExists) {
395
+ try {
396
+ // First, verify the expected address matches what the factory would compute
397
+ const expectedAddress = await readContractWithRetry<string>(signer, {
398
+ address: getAddress(factoryAddress),
399
+ abi: FACTORY_ABI,
400
+ functionName: "getRelayAddress",
401
+ args: [getAddress(merchantPayout)],
402
+ });
403
+
404
+ // Verify addresses match (case-insensitive comparison)
405
+ if (expectedAddress.toLowerCase() !== proxyAddress.toLowerCase()) {
406
+ throw new Error(
407
+ `Address mismatch: Factory computed ${expectedAddress} but expected ${proxyAddress}. ` +
408
+ `This may indicate a version or CreateX address mismatch.`,
409
+ );
410
+ }
411
+
412
+ // Deploy the relay via factory
413
+ const txHash = await signer.writeContract({
414
+ address: getAddress(factoryAddress),
415
+ abi: FACTORY_ABI,
416
+ functionName: "deployRelay",
417
+ args: [getAddress(merchantPayout)],
418
+ });
419
+
420
+ // Wait for deployment transaction to be mined
421
+ const receipt = await signer.waitForTransactionReceipt({ hash: txHash });
422
+
423
+ // Verify transaction succeeded
424
+ if (receipt.status !== "success") {
425
+ throw new Error(`Relay deployment transaction failed: ${txHash}. Transaction reverted.`);
426
+ }
427
+
428
+ // Wait a bit for the code to be available (CREATE3 deployments can take a moment)
429
+ // Retry checking for code up to 5 times with increasing delays
430
+ let deployedCode: string | undefined;
431
+ for (let i = 0; i < 5; i++) {
432
+ if (i > 0) {
433
+ const delay = 1000 * i; // 1s, 2s, 3s, 4s
434
+ await new Promise(resolve => setTimeout(resolve, delay));
435
+ }
436
+ deployedCode = await signer.getCode({ address: proxyAddress });
437
+ if (deployedCode && deployedCode !== "0x" && deployedCode.length > 2) {
438
+ break;
439
+ }
440
+ }
441
+
442
+ // Verify the contract was actually deployed at the expected address
443
+ // This is critical for CREATE3 deployments - the address is deterministic
444
+ // but we need to ensure the deployment actually happened
445
+ if (!deployedCode || deployedCode === "0x" || deployedCode.length <= 2) {
446
+ // Double-check the factory's computed address
447
+ const actualAddress = await readContractWithRetry<string>(signer, {
448
+ address: getAddress(factoryAddress),
449
+ abi: FACTORY_ABI,
450
+ functionName: "getRelayAddress",
451
+ args: [getAddress(merchantPayout)],
452
+ });
453
+
454
+ throw new Error(
455
+ `Relay deployment completed but contract code not found at ${proxyAddress}. ` +
456
+ `Transaction hash: ${txHash}. Factory computed address: ${actualAddress}. ` +
457
+ `Expected address: ${proxyAddress}. ` +
458
+ `This may indicate a CREATE3 deployment issue, timing problem, or address computation mismatch.`,
459
+ );
460
+ }
461
+
462
+ // Now read escrow address from the newly deployed proxy
463
+ escrowAddress = await readContractWithRetry<string>(signer, {
464
+ address: proxyAddress,
465
+ abi: RELAY_PROXY_ABI,
466
+ functionName: "ESCROW",
467
+ args: [],
468
+ });
469
+ } catch (error) {
470
+ // Check if this is an insufficient funds error
471
+ const errorMessage = error instanceof Error ? error.message : String(error);
472
+ const errorString = errorMessage.toLowerCase();
473
+
474
+ if (
475
+ errorString.includes("insufficient funds") ||
476
+ errorString.includes("exceeds the balance") ||
477
+ errorString.includes("insufficient balance") ||
478
+ errorString.includes("the total cost") ||
479
+ errorString.includes("exceeds the balance of the account")
480
+ ) {
481
+ const facilitatorAddress = signer.getAddresses()[0];
482
+ throw new Error(
483
+ `Failed to deploy relay: Insufficient funds in facilitator account.\n` +
484
+ `The facilitator account (${facilitatorAddress}) does not have enough ETH to pay for gas to deploy the relay contract.\n` +
485
+ `Please fund the facilitator account with ETH to cover gas costs.\n` +
486
+ `Original error: ${errorMessage}`,
487
+ );
488
+ }
489
+
490
+ throw new Error(
491
+ `Failed to deploy relay: ${errorMessage}`,
492
+ );
493
+ }
494
+ }
495
+
496
+ // At this point, escrowAddress must be set (either from reading deployed proxy or after deployment)
497
+ if (!escrowAddress) {
498
+ throw new Error("Internal error: escrowAddress not set after deployment check");
499
+ }
500
+
501
+ // Check if merchant is registered
502
+ let isRegistered: boolean;
503
+ try {
504
+ isRegistered = await readContractWithRetry<boolean>(signer, {
505
+ address: getAddress(escrowAddress),
506
+ abi: ESCROW_ABI,
507
+ functionName: "registeredMerchants",
508
+ args: [getAddress(merchantPayout)],
509
+ });
510
+ } catch (error) {
511
+ throw new Error(
512
+ `Failed to check merchant registration: ${error instanceof Error ? error.message : String(error)}`,
513
+ );
514
+ }
515
+
516
+ if (!isRegistered) {
517
+ throw new Error(
518
+ `Merchant ${merchantPayout} is not registered. Please register at https://app.402r.org to enable refund functionality.`,
519
+ );
520
+ }
521
+
522
+ // Extract payment parameters from payload
523
+ const payload = paymentPayload.payload as {
524
+ authorization?: {
525
+ from: string;
526
+ to: string;
527
+ value: string;
528
+ validAfter: string;
529
+ validBefore: string;
530
+ nonce: string;
531
+ };
532
+ signature?: string;
533
+ };
534
+
535
+ if (!payload.authorization || !payload.signature) {
536
+ // Invalid payload structure, delegate to normal flow
537
+ return null;
538
+ }
539
+
540
+ const { authorization, signature } = payload;
541
+
542
+ // Verify that authorization.to matches the proxy address
543
+ // The ERC3009 signature must have been signed with to=proxyAddress
544
+ const authTo = getAddress(authorization.to);
545
+ if (authTo !== proxyAddress) {
546
+ throw new Error(
547
+ `Authorization 'to' address (${authTo}) does not match proxy address (${proxyAddress}). ` +
548
+ `The ERC3009 signature must be signed with to=proxyAddress.`,
549
+ );
550
+ }
551
+
552
+ // Verify proxy can read its own immutables (this tests the _readImmutable function)
553
+ // This helps catch issues before attempting executeDeposit
554
+ try {
555
+ const proxyToken = await readContractWithRetry<string>(signer, {
556
+ address: proxyAddress,
557
+ abi: RELAY_PROXY_ABI,
558
+ functionName: "TOKEN",
559
+ args: [],
560
+ });
561
+
562
+ // CRITICAL: Verify proxy TOKEN matches payment requirements asset
563
+ // If they don't match, transferWithAuthorization will fail
564
+ const proxyTokenNormalized = getAddress(proxyToken);
565
+ const paymentAssetNormalized = getAddress(paymentRequirements.asset);
566
+
567
+ if (proxyTokenNormalized !== paymentAssetNormalized) {
568
+ const errorMsg =
569
+ `❌ ADDRESS MISMATCH: Proxy has ${proxyTokenNormalized} but payment requires ${paymentAssetNormalized}. ` +
570
+ `The proxy was deployed with the wrong address. ` +
571
+ `This causes transferWithAuthorization to fail. ` +
572
+ `Solution: Redeploy the proxy with the correct address: ${paymentAssetNormalized}`;
573
+ throw new Error(errorMsg);
574
+ }
575
+
576
+ const proxyEscrow = await readContractWithRetry<string>(signer, {
577
+ address: proxyAddress,
578
+ abi: RELAY_PROXY_ABI,
579
+ functionName: "ESCROW",
580
+ args: [],
581
+ });
582
+
583
+ // Verify these match what we read earlier
584
+ if (proxyEscrow.toLowerCase() !== escrowAddress.toLowerCase()) {
585
+ throw new Error(
586
+ `Proxy ESCROW mismatch: proxy reports ${proxyEscrow} but we read ${escrowAddress} earlier`,
587
+ );
588
+ }
589
+ } catch (error) {
590
+ throw new Error(
591
+ `Failed to read proxy immutables. This may indicate a proxy deployment issue. ` +
592
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
593
+ );
594
+ }
595
+
596
+ // Parse signature - handle ERC-6492 if needed
597
+ let parsedSignature: string;
598
+ try {
599
+ const erc6492Result = parseErc6492Signature(signature as `0x${string}`);
600
+ parsedSignature = erc6492Result.signature;
601
+ } catch {
602
+ // Not ERC-6492, use signature as-is
603
+ parsedSignature = signature;
604
+ }
605
+
606
+ // Extract signature components (v, r, s)
607
+ const signatureLength = parsedSignature.startsWith("0x")
608
+ ? parsedSignature.length - 2
609
+ : parsedSignature.length;
610
+ const isECDSA = signatureLength === 130;
611
+
612
+ if (!isECDSA) {
613
+ // Non-ECDSA signatures not supported
614
+ return null;
615
+ }
616
+
617
+ // Parse signature into v, r, s
618
+ const parsedSig = parseSignature(parsedSignature as `0x${string}`);
619
+ const v = (parsedSig.v as number | undefined) ?? parsedSig.yParity ?? 0;
620
+ const r = parsedSig.r;
621
+ const s = parsedSig.s;
622
+
623
+ // Check if nonce has already been used (ERC3009 tracks this)
624
+ // This helps catch state issues before attempting executeDeposit
625
+ try {
626
+ const tokenAddress = getAddress(paymentRequirements.asset);
627
+ const nonceUsed = await readContractWithRetry<boolean>(signer, {
628
+ address: tokenAddress,
629
+ abi: [
630
+ {
631
+ inputs: [
632
+ { name: "authorizer", type: "address" },
633
+ { name: "nonce", type: "bytes32" },
634
+ ],
635
+ name: "authorizationState",
636
+ outputs: [{ name: "", type: "bool" }],
637
+ stateMutability: "view",
638
+ type: "function",
639
+ },
640
+ ],
641
+ functionName: "authorizationState",
642
+ args: [getAddress(authorization.from), authorization.nonce as `0x${string}`],
643
+ });
644
+
645
+ if (nonceUsed) {
646
+ throw new Error(
647
+ `ERC3009 nonce ${authorization.nonce} has already been used. ` +
648
+ `This authorization cannot be reused.`,
649
+ );
650
+ }
651
+ } catch (error) {
652
+ // If authorizationState doesn't exist or fails, continue
653
+ // Some contracts might not implement this function
654
+ }
655
+
656
+ // Call proxy.executeDeposit() with retry logic
657
+ let lastError: unknown;
658
+ const maxRetries = 5;
659
+
660
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
661
+ try {
662
+ const txHash = await signer.writeContract({
663
+ address: proxyAddress,
664
+ abi: RELAY_PROXY_ABI,
665
+ functionName: "executeDeposit",
666
+ args: [
667
+ getAddress(authorization.from),
668
+ BigInt(authorization.value),
669
+ BigInt(authorization.validAfter),
670
+ BigInt(authorization.validBefore),
671
+ authorization.nonce as `0x${string}`,
672
+ v,
673
+ r,
674
+ s,
675
+ ],
676
+ });
677
+
678
+ // Wait for transaction confirmation
679
+ const receipt = await signer.waitForTransactionReceipt({ hash: txHash });
680
+
681
+ if (receipt.status !== "success") {
682
+ throw new Error(`Proxy.executeDeposit transaction failed: ${txHash}`);
683
+ }
684
+
685
+ // Return SettleResponse on success
686
+ return {
687
+ success: true,
688
+ transaction: txHash,
689
+ network: paymentRequirements.network,
690
+ payer: authorization.from,
691
+ };
692
+ } catch (error) {
693
+ lastError = error;
694
+
695
+ // Don't retry on last attempt
696
+ if (attempt >= maxRetries) {
697
+ break;
698
+ }
699
+
700
+ // Retry with delays: 1s, 2s, 3s, 4s, 5s
701
+ const delayMs = (attempt + 1) * 1000;
702
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
703
+ }
704
+ }
705
+
706
+ // All retries exhausted, handle error
707
+ const error = lastError;
708
+
709
+ // Execution failure - throw error (don't return null)
710
+ // Extract more detailed error information from nested error objects
711
+ let errorMessage = error instanceof Error ? error.message : String(error);
712
+ let revertReason: string | undefined;
713
+
714
+ // Try to extract revert reason from nested error structure (viem error format)
715
+ if (error && typeof error === "object") {
716
+ const errorObj = error as Record<string, unknown>;
717
+
718
+ // Check for cause.reason (viem ContractFunctionRevertedError)
719
+ if (errorObj.cause && typeof errorObj.cause === "object") {
720
+ const cause = errorObj.cause as Record<string, unknown>;
721
+ if (typeof cause.reason === "string") {
722
+ revertReason = cause.reason;
723
+ }
724
+ // Check for cause.data which might contain encoded revert reason
725
+ if (cause.data && typeof cause.data === "string") {
726
+ // Try to decode as a string if it's a revert with reason string
727
+ // Revert with reason string starts with 0x08c379a0 (Error(string) selector) + offset + length + string
728
+ if (cause.data.startsWith("0x08c379a0")) {
729
+ try {
730
+ // Skip selector (4 bytes) + offset (32 bytes) + length (32 bytes) = 68 chars
731
+ const lengthHex = cause.data.slice(138, 202); // Length is at offset 68
732
+ const length = parseInt(lengthHex, 16);
733
+ const stringHex = cause.data.slice(202, 202 + length * 2);
734
+ const decodedReason = Buffer.from(stringHex, "hex")
735
+ .toString("utf8")
736
+ .replace(/\0/g, "");
737
+ if (decodedReason) {
738
+ revertReason = decodedReason;
739
+ }
740
+ } catch (decodeErr) {
741
+ // Failed to decode revert reason
742
+ }
743
+ }
744
+ }
745
+ }
746
+
747
+ // Check for shortMessage (viem error format)
748
+ if (typeof errorObj.shortMessage === "string") {
749
+ if (!revertReason && errorObj.shortMessage.includes("reverted")) {
750
+ // Try to extract from shortMessage
751
+ const match = errorObj.shortMessage.match(/reverted(?:[: ]+)?(.+)/i);
752
+ if (match && match[1]) {
753
+ revertReason = match[1].trim();
754
+ }
755
+ }
756
+ }
757
+ }
758
+
759
+ // Build detailed error message
760
+ if (revertReason && revertReason !== "execution reverted") {
761
+ errorMessage = `Contract reverted: ${revertReason}`;
762
+ } else {
763
+ errorMessage =
764
+ `Contract execution reverted (no specific revert reason available). ` +
765
+ `All pre-checks passed: merchant registered, nonce unused, proxy immutables readable. ` +
766
+ `Possible failure points in executeDeposit: ` +
767
+ `1) _readImmutable staticcall failing (unlikely - we can read immutables directly), ` +
768
+ `2) ERC3009 transferWithAuthorization failing when called through proxy (most likely), ` +
769
+ `3) Transfer to escrow failing, ` +
770
+ `4) Escrow.noteDeposit failing - POOL.supply() may be paused, asset not configured in Aave pool, or pool has restrictions. ` +
771
+ `Check Aave pool status and asset configuration on Base Sepolia. ` +
772
+ `Debugging: Use a transaction trace/debugger on the failed transaction to see exact revert point. ` +
773
+ `The simulation succeeds, so the contract logic is correct - this is likely an execution context issue. ` +
774
+ `Original error: ${errorMessage}`;
775
+ }
776
+
777
+ throw new Error(`Failed to execute proxy.executeDeposit: ${errorMessage}`);
778
+ }