@x402r/evm 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,88 +1,161 @@
1
1
  /**
2
2
  * Escrow Scheme - Client
3
- * Creates payment payloads for escrow payments
3
+ * Creates payment payloads for escrow payments.
4
+ *
5
+ * Implements x402's SchemeNetworkClient interface so it can be registered
6
+ * on an x402Client via client.register('eip155:84532', new EscrowEvmScheme(signer)).
4
7
  */
5
8
 
6
- import type { WalletClient } from "viem";
9
+ import type {
10
+ Network,
11
+ PaymentPayload,
12
+ PaymentRequirements,
13
+ SchemeNetworkClient,
14
+ } from "@x402/core/types";
15
+ import type { ClientEvmSigner } from "@x402/evm";
16
+ import { x402Client } from "@x402/core/client";
7
17
  import {
8
18
  computeEscrowNonce,
9
19
  signERC3009,
10
20
  generateSalt,
11
21
  } from "../../shared/nonce.js";
12
22
  import { MAX_UINT48 } from "../../shared/constants.js";
13
- import type { EscrowExtra, EscrowPayload } from "../../shared/types.js";
14
-
15
- export interface PaymentRequirements {
16
- scheme: string;
17
- network: string;
18
- amount: string;
19
- asset: `0x${string}`;
20
- payTo: `0x${string}`;
21
- extra: EscrowExtra;
23
+ import type { EscrowExtra } from "../../shared/types.js";
24
+
25
+ /**
26
+ * Parse chainId from CAIP-2 network identifier
27
+ */
28
+ function parseChainId(network: string): number {
29
+ const parts = network.split(":");
30
+ if (parts.length !== 2 || parts[0] !== "eip155") {
31
+ throw new Error(
32
+ `Invalid network format: ${network}. Expected 'eip155:<chainId>'`,
33
+ );
34
+ }
35
+ const chainId = parseInt(parts[1], 10);
36
+ if (isNaN(chainId)) {
37
+ throw new Error(`Invalid chainId in network: ${network}`);
38
+ }
39
+ return chainId;
40
+ }
41
+
42
+ /**
43
+ * Escrow Client Scheme - implements x402's SchemeNetworkClient
44
+ */
45
+ export class EscrowEvmScheme implements SchemeNetworkClient {
46
+ readonly scheme = "escrow";
47
+
48
+ constructor(private readonly signer: ClientEvmSigner) {}
49
+
50
+ async createPaymentPayload(
51
+ x402Version: number,
52
+ requirements: PaymentRequirements,
53
+ ): Promise<Pick<PaymentPayload, "x402Version" | "payload">> {
54
+ if (x402Version !== 2) {
55
+ throw new Error(
56
+ `Unsupported x402Version: ${x402Version}. Only version 2 is supported.`,
57
+ );
58
+ }
59
+
60
+ const extra = requirements.extra as unknown as EscrowExtra;
61
+
62
+ // Validate required EIP-712 domain parameters (M3, M10)
63
+ if (!extra.name) {
64
+ throw new Error(
65
+ `EIP-712 domain parameter 'name' is required in payment requirements for asset ${requirements.asset}`,
66
+ );
67
+ }
68
+ if (!extra.version) {
69
+ throw new Error(
70
+ `EIP-712 domain parameter 'version' is required in payment requirements for asset ${requirements.asset}`,
71
+ );
72
+ }
73
+
74
+ const {
75
+ escrowAddress,
76
+ operatorAddress,
77
+ tokenCollector,
78
+ minFeeBps = 0,
79
+ maxFeeBps = 0,
80
+ feeReceiver,
81
+ preApprovalExpirySeconds,
82
+ refundExpirySeconds,
83
+ authorizationExpirySeconds,
84
+ } = extra;
85
+
86
+ const chainId = parseChainId(requirements.network);
87
+ const maxAmount = requirements.amount;
88
+
89
+ const paymentInfo = {
90
+ operator: operatorAddress,
91
+ receiver: requirements.payTo as `0x${string}`,
92
+ token: requirements.asset as `0x${string}`,
93
+ maxAmount,
94
+ preApprovalExpiry: preApprovalExpirySeconds ?? MAX_UINT48,
95
+ authorizationExpiry: authorizationExpirySeconds ?? MAX_UINT48,
96
+ refundExpiry: refundExpirySeconds ?? MAX_UINT48,
97
+ minFeeBps,
98
+ maxFeeBps,
99
+ feeReceiver: feeReceiver ?? operatorAddress,
100
+ salt: generateSalt(),
101
+ };
102
+
103
+ const nonce = computeEscrowNonce(chainId, escrowAddress, paymentInfo);
104
+
105
+ // ERC-3009 authorization - validBefore MUST match what contract passes to receiveWithAuthorization
106
+ // The contract uses paymentInfo.preApprovalExpiry as validBefore
107
+ const authorization = {
108
+ from: this.signer.address,
109
+ to: tokenCollector,
110
+ value: maxAmount,
111
+ validAfter: "0",
112
+ validBefore: String(paymentInfo.preApprovalExpiry),
113
+ nonce,
114
+ };
115
+
116
+ const signature = await signERC3009(
117
+ this.signer,
118
+ authorization,
119
+ extra,
120
+ requirements.asset as `0x${string}`,
121
+ chainId,
122
+ );
123
+
124
+ return {
125
+ x402Version,
126
+ payload: { authorization, signature, paymentInfo },
127
+ };
128
+ }
22
129
  }
23
130
 
24
131
  /**
25
- * Create an escrow payment payload from payment requirements
132
+ * Register escrow client scheme with x402Client
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * const client = new x402Client();
137
+ * registerEscrowScheme(client, { signer, networks: "eip155:84532" });
138
+ * ```
26
139
  */
27
- export async function createPaymentPayload(
28
- requirements: PaymentRequirements,
29
- wallet: WalletClient,
30
- ): Promise<EscrowPayload> {
31
- const {
32
- escrowAddress,
33
- operatorAddress,
34
- tokenCollector,
35
- minFeeBps = 0,
36
- maxFeeBps = 0,
37
- feeReceiver,
38
- preApprovalExpirySeconds,
39
- refundExpirySeconds,
40
- authorizationExpirySeconds,
41
- } = requirements.extra;
42
-
43
- const chainId = await wallet.getChainId();
44
- const maxAmount = requirements.amount;
45
-
46
- const paymentInfo = {
47
- operator: operatorAddress,
48
- receiver: requirements.payTo,
49
- token: requirements.asset,
50
- maxAmount,
51
- preApprovalExpiry: preApprovalExpirySeconds ?? MAX_UINT48,
52
- authorizationExpiry: authorizationExpirySeconds ?? MAX_UINT48,
53
- refundExpiry: refundExpirySeconds ?? MAX_UINT48,
54
- minFeeBps,
55
- maxFeeBps,
56
- feeReceiver: feeReceiver ?? operatorAddress,
57
- salt: generateSalt(),
58
- };
59
-
60
- const nonce = computeEscrowNonce(chainId, escrowAddress, paymentInfo);
61
-
62
- // ERC-3009 authorization - validBefore MUST match what contract passes to receiveWithAuthorization
63
- // The contract uses paymentInfo.preApprovalExpiry as validBefore
64
- const authorization = {
65
- from: wallet.account!.address,
66
- to: tokenCollector,
67
- value: maxAmount,
68
- validAfter: "0",
69
- validBefore: String(paymentInfo.preApprovalExpiry),
70
- nonce,
71
- };
72
-
73
- const signature = await signERC3009(
74
- wallet,
75
- authorization,
76
- requirements.extra,
77
- requirements.asset,
78
- );
79
-
80
- return { authorization, signature, paymentInfo };
140
+ export function registerEscrowScheme(
141
+ client: x402Client,
142
+ config: { signer: ClientEvmSigner; networks: Network | Network[] },
143
+ ): x402Client {
144
+ const scheme = new EscrowEvmScheme(config.signer);
145
+ const networks = Array.isArray(config.networks)
146
+ ? config.networks
147
+ : [config.networks];
148
+ for (const network of networks) {
149
+ client.register(network, scheme);
150
+ }
151
+ return client;
81
152
  }
82
153
 
154
+ /**
155
+ * @deprecated Use `new EscrowEvmScheme(signer)` directly
156
+ */
83
157
  export const EscrowScheme = {
84
158
  scheme: "escrow" as const,
85
- createPaymentPayload,
86
159
  };
87
160
 
88
161
  export type { EscrowExtra, EscrowPayload } from "../../shared/types.js";
@@ -16,8 +16,16 @@ import type {
16
16
  } from "@x402/core/types";
17
17
  import type { FacilitatorEvmSigner } from "@x402/evm";
18
18
  import { x402Facilitator } from "@x402/core/facilitator";
19
- import { OPERATOR_ABI } from "../../shared/constants.js";
19
+ import {
20
+ OPERATOR_ABI,
21
+ ERC20_BALANCE_OF_ABI,
22
+ ERC6492_MAGIC_VALUE,
23
+ } from "../../shared/constants.js";
20
24
  import { verifyERC3009Signature } from "../../shared/nonce.js";
25
+ import {
26
+ isEscrowPayload,
27
+ isEscrowExtra,
28
+ } from "../../shared/types.js";
21
29
  import type { EscrowExtra, EscrowPayload } from "../../shared/types.js";
22
30
 
23
31
  /**
@@ -39,6 +47,46 @@ function parseChainId(network: string): number {
39
47
  return chainId;
40
48
  }
41
49
 
50
+ /**
51
+ * Extract inner signature from an EIP-6492 wrapped signature.
52
+ * If the signature is not EIP-6492 wrapped, returns it unchanged.
53
+ *
54
+ * EIP-6492 format: abi.encode(address, bytes, bytes) ++ MAGIC_VALUE
55
+ * The inner signature is the third ABI-encoded bytes field.
56
+ */
57
+ function unwrapERC6492Signature(signature: `0x${string}`): `0x${string}` {
58
+ // EIP-6492 magic is 32 bytes (64 hex chars) at the end
59
+ if (signature.length <= 66) return signature; // Too short to be wrapped
60
+
61
+ const magicSuffix = `0x${signature.slice(-64)}`;
62
+ if (magicSuffix !== ERC6492_MAGIC_VALUE) return signature; // Not wrapped
63
+
64
+ // Strip the magic suffix and ABI-decode: (address prepareTarget, bytes prepareData, bytes innerSignature)
65
+ // The wrapped data (without magic) is: 0x + ABI-encoded (address, bytes, bytes)
66
+ const wrappedHex = signature.slice(2, -64); // hex without 0x prefix and magic
67
+
68
+ // ABI layout for (address, bytes, bytes):
69
+ // word 0 (0-64): address (padded to 32 bytes)
70
+ // word 1 (64-128): offset to prepareData bytes
71
+ // word 2 (128-192): offset to innerSignature bytes
72
+ // Then the dynamic data follows
73
+
74
+ if (wrappedHex.length < 192) return signature; // Malformed
75
+
76
+ const innerSigOffset = parseInt(wrappedHex.slice(128, 192), 16) * 2; // byte offset → hex offset
77
+ if (innerSigOffset + 64 > wrappedHex.length) return signature; // Malformed
78
+
79
+ const innerSigLength = parseInt(
80
+ wrappedHex.slice(innerSigOffset, innerSigOffset + 64),
81
+ 16,
82
+ ) * 2; // bytes → hex chars
83
+ const innerSigStart = innerSigOffset + 64;
84
+
85
+ if (innerSigStart + innerSigLength > wrappedHex.length) return signature; // Malformed
86
+
87
+ return `0x${wrappedHex.slice(innerSigStart, innerSigStart + innerSigLength)}` as `0x${string}`;
88
+ }
89
+
42
90
  /**
43
91
  * Escrow Facilitator Scheme - implements x402's SchemeNetworkFacilitator
44
92
  *
@@ -56,29 +104,97 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
56
104
  return [...this.signer.getAddresses()];
57
105
  }
58
106
 
59
- getExtra(_network: string): Record<string, unknown> {
60
- return { name: "USDC", version: "2" };
107
+ // C4: name/version now come from server's parsePrice() via AssetAmount.extra.
108
+ // The facilitator should not hardcode token-specific metadata.
109
+ getExtra(_network: string): Record<string, unknown> | undefined {
110
+ return undefined;
61
111
  }
62
112
 
63
113
  async verify(
64
114
  payload: PaymentPayload,
65
115
  requirements: PaymentRequirements,
66
116
  ): Promise<VerifyResponse> {
67
- const escrowPayload = payload.payload as unknown as EscrowPayload;
68
- const extra = requirements.extra as unknown as EscrowExtra;
117
+ // M5: Type guard instead of double cast
118
+ if (!isEscrowPayload(payload.payload)) {
119
+ return {
120
+ isValid: false,
121
+ invalidReason: "invalid_payload_format",
122
+ };
123
+ }
124
+ const escrowPayload = payload.payload as EscrowPayload;
125
+ const payer = escrowPayload.authorization.from;
126
+
127
+ // Validate scheme
128
+ if (requirements.scheme !== "escrow") {
129
+ return {
130
+ isValid: false,
131
+ invalidReason: "unsupported_scheme",
132
+ payer,
133
+ };
134
+ }
135
+
136
+ // Validate network format
137
+ const networkParts = requirements.network.split(":");
138
+ if (networkParts.length !== 2 || networkParts[0] !== "eip155") {
139
+ return {
140
+ isValid: false,
141
+ invalidReason: "invalid_network",
142
+ payer,
143
+ };
144
+ }
145
+
146
+ // M5: Type guard for extra
147
+ if (!isEscrowExtra(requirements.extra)) {
148
+ return {
149
+ isValid: false,
150
+ invalidReason: "invalid_escrow_extra",
151
+ payer,
152
+ };
153
+ }
154
+ const extra = requirements.extra as EscrowExtra;
69
155
  const chainId = parseChainId(requirements.network);
70
156
 
157
+ // Time window validation
158
+ const now = Math.floor(Date.now() / 1000);
159
+ const validBefore = Number(escrowPayload.authorization.validBefore);
160
+ const validAfter = Number(escrowPayload.authorization.validAfter);
161
+
162
+ if (validBefore <= now + 6) {
163
+ return {
164
+ isValid: false,
165
+ invalidReason: "authorization_expired",
166
+ payer,
167
+ };
168
+ }
169
+
170
+ if (validAfter > now) {
171
+ return {
172
+ isValid: false,
173
+ invalidReason: "authorization_not_yet_valid",
174
+ payer,
175
+ };
176
+ }
177
+
178
+ // M4: Extract inner signature for verification if EIP-6492 wrapped.
179
+ // The contract's ERC6492SignatureHandler handles deployment; the facilitator
180
+ // only needs the inner ECDSA signature for ecrecover verification.
181
+ const signatureForVerify = unwrapERC6492Signature(escrowPayload.signature);
182
+
71
183
  // Verify ERC-3009 signature
72
184
  const isValidSignature = await verifyERC3009Signature(
73
185
  this.signer,
74
186
  escrowPayload.authorization,
75
- escrowPayload.signature,
187
+ signatureForVerify,
76
188
  { ...extra, chainId },
77
189
  requirements.asset as `0x${string}`,
78
190
  );
79
191
 
80
192
  if (!isValidSignature) {
81
- return { isValid: false, invalidReason: "Invalid ERC-3009 signature" };
193
+ return {
194
+ isValid: false,
195
+ invalidReason: "invalid_escrow_signature",
196
+ payer,
197
+ };
82
198
  }
83
199
 
84
200
  // Verify amount meets requirements
@@ -86,7 +202,11 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
86
202
  BigInt(escrowPayload.authorization.value) <
87
203
  BigInt(requirements.amount)
88
204
  ) {
89
- return { isValid: false, invalidReason: "Insufficient payment amount" };
205
+ return {
206
+ isValid: false,
207
+ invalidReason: "insufficient_amount",
208
+ payer,
209
+ };
90
210
  }
91
211
 
92
212
  // Verify token matches
@@ -94,7 +214,11 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
94
214
  escrowPayload.paymentInfo.token.toLowerCase() !==
95
215
  requirements.asset.toLowerCase()
96
216
  ) {
97
- return { isValid: false, invalidReason: "Token mismatch" };
217
+ return {
218
+ isValid: false,
219
+ invalidReason: "token_mismatch",
220
+ payer,
221
+ };
98
222
  }
99
223
 
100
224
  // Verify receiver matches
@@ -102,12 +226,37 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
102
226
  escrowPayload.paymentInfo.receiver.toLowerCase() !==
103
227
  requirements.payTo.toLowerCase()
104
228
  ) {
105
- return { isValid: false, invalidReason: "Receiver mismatch" };
229
+ return {
230
+ isValid: false,
231
+ invalidReason: "receiver_mismatch",
232
+ payer,
233
+ };
234
+ }
235
+
236
+ // H4: Balance check — verify payer has sufficient token balance
237
+ try {
238
+ const balance = await this.signer.readContract({
239
+ address: requirements.asset as `0x${string}`,
240
+ abi: ERC20_BALANCE_OF_ABI,
241
+ functionName: "balanceOf",
242
+ args: [payer],
243
+ });
244
+
245
+ if (BigInt(balance as string) < BigInt(requirements.amount)) {
246
+ return {
247
+ isValid: false,
248
+ invalidReason: "insufficient_balance",
249
+ payer,
250
+ };
251
+ }
252
+ } catch {
253
+ // If balance check fails (e.g., non-standard token), skip it.
254
+ // The on-chain transaction will fail anyway if balance is insufficient.
106
255
  }
107
256
 
108
257
  return {
109
258
  isValid: true,
110
- payer: escrowPayload.authorization.from,
259
+ payer,
111
260
  };
112
261
  }
113
262
 
@@ -115,6 +264,18 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
115
264
  payload: PaymentPayload,
116
265
  requirements: PaymentRequirements,
117
266
  ): Promise<SettleResponse> {
267
+ // H2: Re-verify before settling to catch expired/invalid payloads
268
+ const verification = await this.verify(payload, requirements);
269
+ if (!verification.isValid) {
270
+ return {
271
+ success: false,
272
+ errorReason: verification.invalidReason ?? "verification_failed",
273
+ transaction: "",
274
+ network: requirements.network,
275
+ payer: verification.payer,
276
+ };
277
+ }
278
+
118
279
  const escrowPayload = payload.payload as unknown as EscrowPayload;
119
280
  const extra = requirements.extra as unknown as EscrowExtra;
120
281
  const { authorizeAddress, operatorAddress, tokenCollector } = extra;
@@ -134,7 +295,8 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
134
295
  salt: BigInt(escrowPayload.paymentInfo.salt),
135
296
  };
136
297
 
137
- // Pass raw signature - ERC3009PaymentCollector expects raw bytes, not ABI-encoded
298
+ // Pass raw signature ERC3009PaymentCollector/ERC6492SignatureHandler
299
+ // handles EIP-6492 unwrapping and wallet deployment on-chain
138
300
  const collectorData = escrowPayload.signature;
139
301
 
140
302
  const target = authorizeAddress ?? operatorAddress;
@@ -152,6 +314,28 @@ export class EscrowFacilitatorScheme implements SchemeNetworkFacilitator {
152
314
  ],
153
315
  });
154
316
 
317
+ // Wait for transaction confirmation with 60s timeout to avoid hanging on stuck txs
318
+ const receiptPromise = this.signer.waitForTransactionReceipt({
319
+ hash: txHash,
320
+ });
321
+ const timeoutPromise = new Promise<never>((_, reject) =>
322
+ setTimeout(
323
+ () => reject(new Error("Transaction receipt timeout after 60s")),
324
+ 60_000,
325
+ ),
326
+ );
327
+ const receipt = await Promise.race([receiptPromise, timeoutPromise]);
328
+
329
+ if (receipt.status !== "success") {
330
+ return {
331
+ success: false,
332
+ errorReason: "transaction_reverted",
333
+ transaction: txHash,
334
+ network: requirements.network,
335
+ payer: escrowPayload.authorization.from,
336
+ };
337
+ }
338
+
155
339
  return {
156
340
  success: true,
157
341
  transaction: txHash,