@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.
- package/README.md +139 -0
- package/dist/cjs/index.d.ts +253 -0
- package/dist/cjs/index.js +713 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.d.mts +253 -0
- package/dist/esm/index.mjs +678 -0
- package/dist/esm/index.mjs.map +1 -0
- package/package.json +68 -0
- package/src/facilitator.ts +778 -0
- package/src/index.ts +119 -0
- package/src/server/computeRelayAddress.ts +73 -0
- package/src/server.ts +334 -0
- package/src/types.ts +74 -0
|
@@ -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
|
+
}
|