uvd-x402-sdk 2.71.0 → 2.72.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uvd-x402-sdk",
3
- "version": "2.71.0",
3
+ "version": "2.72.0",
4
4
  "description": "x402 Payment SDK - Gasless crypto payments across 25 blockchains via Ultravioleta facilitator. Supports EVM (including Scroll, SKALE Base, Robinhood Chain), Solana, Fogo, Stellar, NEAR, Algorand, Sui, and XRP Ledger. Features: ERC-8004 Trustless Agents, Escrow/Refunds, multi-stablecoin (USDC, EURC, AUSD, PYUSD, USDT, USDG).",
5
5
  "author": "Ultravioleta DAO <ultravioletadao@gmail.com>",
6
6
  "license": "MIT",
package/src/erc7702.ts ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * EIP-7702: make a DELEGATED EOA's EIP-3009 signature settle again.
3
+ *
4
+ * **THE PROBLEM.** A gasless-wallet provider's first sponsored operation on a
5
+ * chain may delegate the user's EOA (EIP-7702) to a smart-account
6
+ * implementation — Alchemy's `SemiModularAccount7702` (`0x69007702…`) is the one
7
+ * seen in the wild. From then on the address HAS code, so Circle's USDC (and any
8
+ * `SignatureChecker` consumer) verifies signatures via **ERC-1271 only**. A raw
9
+ * ECDSA authorization, however perfect, is rejected: `0x151d90fe`. The account is
10
+ * not broken and the signature is not wrong — they simply no longer speak the
11
+ * same dialect.
12
+ *
13
+ * Consequence for anything x402: a delegated payer's `transferWithAuthorization`
14
+ * / `receiveWithAuthorization` becomes **unsettleable on-chain**, so direct x402
15
+ * payments AND marketplace escrow locks both fail. Measured in production
16
+ * 2026-07-31: 14 of 14 delegated agents failed their escrow lock; the one
17
+ * non-delegated agent locked fine. The failure is silent in the worst way — the
18
+ * sellers looked broken and they were correct.
19
+ *
20
+ * **THE FIX** (verified on-chain; the wallet provider needs to change nothing).
21
+ * That account's `isValidSignature` DOES accept the EOA's own ECDSA — it just
22
+ * wants it in the account's envelope. Two steps, both provable against the
23
+ * verified source (`SemiModularAccountBase._exec1271Validation` +
24
+ * `SparseCalldataSegmentLib`):
25
+ *
26
+ * 1. The account does not check `hash` directly; it checks a REPLAY-SAFE hash =
27
+ * EIP-712 over the account's OWN domain
28
+ * `EIP712Domain(uint256 chainId, address verifyingContract=account)` with
29
+ * struct `ReplaySafeHash(bytes32 hash)`. Sign THAT, not the transfer digest.
30
+ * 2. Wrap the 65-byte signature with the account's fallback-validation locator:
31
+ * `0x00 00000000` (validation type 0, entity id 0 = FALLBACK_VALIDATION_ID)
32
+ * `FF` (RESERVED_VALIDATION_DATA_INDEX, the final segment) `00`
33
+ * (SignatureType.EOA).
34
+ *
35
+ * Because step 1 is still an ordinary typed-data signature, a REMOTE signer (a
36
+ * delegated agentic wallet) can produce it: the private key is never needed
37
+ * locally.
38
+ *
39
+ * **THE WRAP IS DELEGATE-SPECIFIC, NOT "delegated == wrap".** This is the part
40
+ * that bit us on 2026-08-25, and it is why this module exists in TypeScript at
41
+ * all: the moment a worker rates an agent through the facilitator's EIP-7702
42
+ * feedback rail, their EOA becomes delegated to Execution Market's
43
+ * `FeedbackDelegate` — which validates PLAIN ECDSA
44
+ * (`ECDSA.recover(hash, sig) == address(this)`). Wrapping that signature makes it
45
+ * invalid, so *the act of rating would break the rater's next payment*. Same
46
+ * silent-at-lock-time failure the wrap was built to fix, now caused by
47
+ * over-applying it.
48
+ *
49
+ * **DETECTION IS INJECTABLE, ON PURPOSE.** Knowing whether an address is
50
+ * delegated needs one `eth_getCode` — a chain read, and this SDK does not own an
51
+ * RPC policy. So detection is a callable you pass in ({@link DelegationResolver}).
52
+ * {@link rpcDelegationResolver} ships a default over `fetch`; a caller with its
53
+ * own endpoints, proxy or rotation passes its own.
54
+ *
55
+ * **THE THIRD STATE IS LOAD-BEARING.** A resolver returns `true` / `false` /
56
+ * **`null` = could not tell**. `null` must never collapse to "not delegated":
57
+ * that is exactly how the original bug survived eight days — the resolver failed,
58
+ * returned nothing, and the caller's `if (delegated)` read it as falsy and signed
59
+ * raw. **An unreadable answer is not a negative answer.**
60
+ *
61
+ * Port of `uvd_x402_sdk.erc7702` (Python). Keep the two in step: they are the
62
+ * same protocol seen from two languages, and a divergence here is a signature
63
+ * that cannot settle.
64
+ */
65
+
66
+ /** EIP-7702 delegation designator. */
67
+ export const DELEGATE_PREFIX = 'ef0100';
68
+
69
+ /**
70
+ * Delegate targets whose ERC-1271 needs the account-envelope wrap.
71
+ *
72
+ * Only a known Alchemy SMA implementation needs it. Every other delegate signs
73
+ * PLAIN (the standard 1271 "smart-EOA" pattern accepts the EOA's own ECDSA), and
74
+ * if some exotic future delegate needs a third dialect it fails VISIBLY at lock
75
+ * time — never silently.
76
+ *
77
+ * Notably NOT here: Execution Market's `FeedbackDelegate` (all nine deploys). An
78
+ * account that has rated through the facilitator's rail is delegated to it and
79
+ * must sign plain.
80
+ */
81
+ export const SMA_WRAP_TARGETS: readonly string[] = [
82
+ '0x69007702764179f14f51cdce752f4f775d74e139', // Alchemy SemiModularAccount7702
83
+ ];
84
+
85
+ /**
86
+ * `true` only when this delegate target needs the account-envelope wrap.
87
+ *
88
+ * A plain EOA (`target` null/undefined) and any other delegate — FeedbackDelegate,
89
+ * a standard 1271 smart-EOA — do NOT: they take the ordinary ECDSA signature.
90
+ */
91
+ export function needsAccountWrap(target: string | null | undefined): boolean {
92
+ return !!target && SMA_WRAP_TARGETS.includes(target.toLowerCase());
93
+ }
94
+
95
+ /**
96
+ * Account fallback-validation locator + final-segment marker + EOA signature type.
97
+ *
98
+ * - `0x00 00000000` — validation type 0, entity id 0 (FALLBACK_VALIDATION_LOOKUP_KEY)
99
+ * - `0xFF` — RESERVED_VALIDATION_DATA_INDEX (getFinalSegment)
100
+ * - `0x00` — SignatureType.EOA
101
+ */
102
+ const WRAP_PREFIX = '00000000' + '00' + 'ff' + '00';
103
+
104
+ /**
105
+ * The delegate target this EOA's EIP-7702 code points at, or `null`.
106
+ *
107
+ * `null` for a plain EOA, for non-7702 code, and for anything unparseable — the
108
+ * caller confirms the implementation before applying the wrap.
109
+ */
110
+ export function delegateTarget(code: string | null | undefined): string | null {
111
+ if (!code) return null;
112
+ const h = (code.startsWith('0x') ? code.slice(2) : code).toLowerCase();
113
+ if (!h.startsWith(DELEGATE_PREFIX) || h.length < 46) return null;
114
+ return `0x${h.slice(6, 46)}`;
115
+ }
116
+
117
+ /** Wrap a 65-byte ECDSA signature in the account's fallback-EOA envelope. */
118
+ export function wrapSignature(innerSignature: string): string {
119
+ const s = innerSignature.startsWith('0x') ? innerSignature.slice(2) : innerSignature;
120
+ return `0x${WRAP_PREFIX}${s}`;
121
+ }
122
+
123
+ /**
124
+ * The typed data whose signature the delegated account accepts.
125
+ *
126
+ * The domain is the ACCOUNT's own — `chainId` + `verifyingContract=account`, the
127
+ * two fields its typehash declares — and the struct is
128
+ * `ReplaySafeHash(bytes32 hash)` over the inner EIP-3009 digest. Signing THIS,
129
+ * not the digest, is what validates.
130
+ */
131
+ export function replaySafeTypedData(
132
+ innerDigest: string,
133
+ chainId: number,
134
+ account: string
135
+ ): { domain: Record<string, unknown>; types: Record<string, unknown>; message: Record<string, unknown> } {
136
+ return {
137
+ domain: { chainId, verifyingContract: account },
138
+ types: { ReplaySafeHash: [{ name: 'hash', type: 'bytes32' }] },
139
+ message: { hash: innerDigest },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * `(address, network) -> target | true | false | null`.
145
+ *
146
+ * Four answers, richer than a boolean on purpose: a resolver MAY return the
147
+ * delegate **target address** (a string) instead of just `true` — that is what
148
+ * lets the caller pick the right signing dialect (SMA wrap vs plain). `true` =
149
+ * delegated, target unknown; `false` = not delegated; `null` = UNKNOWN
150
+ * (unreadable chain), never "no".
151
+ */
152
+ export type DelegationResolver = (
153
+ address: string,
154
+ network: string
155
+ ) => Promise<string | boolean | null> | (string | boolean | null);
156
+
157
+ /**
158
+ * A default resolver over plain JSON-RPC `eth_getCode`, using `fetch`.
159
+ *
160
+ * Rotates through `urls` and returns `null` when **every** endpoint failed: an
161
+ * unreadable chain is not a "not delegated" verdict.
162
+ *
163
+ * A caller with its own RPC policy (a signed proxy, paid endpoints, per-chain
164
+ * routing) should pass its own resolver instead; that is the whole point of the
165
+ * injection.
166
+ */
167
+ export function rpcDelegationResolver(
168
+ urls: string | string[],
169
+ timeoutMs = 6000
170
+ ): DelegationResolver {
171
+ const endpoints = typeof urls === 'string' ? [urls] : [...urls];
172
+
173
+ return async (address: string): Promise<string | boolean | null> => {
174
+ const addr = (address || '').trim();
175
+ if (!addr || endpoints.length === 0) return null;
176
+
177
+ for (const url of endpoints) {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
180
+ try {
181
+ const res = await fetch(url, {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({
185
+ jsonrpc: '2.0',
186
+ id: 1,
187
+ method: 'eth_getCode',
188
+ params: [addr.toLowerCase(), 'latest'],
189
+ }),
190
+ signal: controller.signal,
191
+ });
192
+ clearTimeout(timer);
193
+ if (!res.ok) continue;
194
+ const code = (await res.json())?.result;
195
+ if (typeof code === 'string' && code.startsWith('0x')) {
196
+ // Return the TARGET when delegated, so the caller can pick the signing
197
+ // dialect; `false` for a plain EOA (readable, no delegation).
198
+ return delegateTarget(code) ?? false;
199
+ }
200
+ } catch {
201
+ clearTimeout(timer);
202
+ // Rotate to the next endpoint. Exhausting them all is UNKNOWN, below.
203
+ }
204
+ }
205
+ return null;
206
+ };
207
+ }
208
+
209
+ /** `{ delegated, target }` for one address on one network. */
210
+ export interface DelegationVerdict {
211
+ /** `null` = UNKNOWN. Never treat it as `false`. */
212
+ delegated: boolean | null;
213
+ /** The delegate implementation, when the resolver could name it. */
214
+ target: string | null;
215
+ }
216
+
217
+ /**
218
+ * Resolve one address's delegation on one network.
219
+ *
220
+ * - `{delegated: null, target: null}` — unknown (no resolver, or an unreadable
221
+ * chain). NEVER "no".
222
+ * - `{delegated: false, target: null}` — a plain EOA.
223
+ * - `{delegated: true, target: '0x…'}` — delegated, and we know to WHAT (pick the
224
+ * dialect from it).
225
+ * - `{delegated: true, target: null}` — delegated, target unknown (a legacy
226
+ * boolean-only resolver).
227
+ */
228
+ export async function resolveDelegation(
229
+ address: string,
230
+ network: string,
231
+ resolver?: DelegationResolver
232
+ ): Promise<DelegationVerdict> {
233
+ if (!resolver) return { delegated: null, target: null };
234
+
235
+ let v: string | boolean | null;
236
+ try {
237
+ v = await resolver(address, network);
238
+ } catch {
239
+ // A broken resolver is UNKNOWN, not a negative.
240
+ return { delegated: null, target: null };
241
+ }
242
+
243
+ if (typeof v === 'string') {
244
+ const t = v.trim();
245
+ // Only a real 20-byte address counts as a target. A non-address string is
246
+ // garbage, and garbage is UNKNOWN — never a verdict we sign on.
247
+ if (/^0x[0-9a-fA-F]{40}$/.test(t)) return { delegated: true, target: t };
248
+ return { delegated: null, target: null };
249
+ }
250
+ if (typeof v === 'boolean') return { delegated: v, target: null };
251
+ return { delegated: null, target: null };
252
+ }
253
+
254
+ /**
255
+ * `true` / `false` / `null` (**unknown**) for one address on one network.
256
+ *
257
+ * Without a resolver the answer is `null`, never `false`: "nobody asked" and "the
258
+ * address is a plain EOA" are different facts and only one of them is safe to
259
+ * sign on.
260
+ */
261
+ export async function isDelegated(
262
+ address: string,
263
+ network: string,
264
+ resolver?: DelegationResolver
265
+ ): Promise<boolean | null> {
266
+ return (await resolveDelegation(address, network, resolver)).delegated;
267
+ }
@@ -51,6 +51,13 @@
51
51
 
52
52
  import { ethers } from 'ethers';
53
53
  import { X402Error } from './types';
54
+ import {
55
+ needsAccountWrap,
56
+ replaySafeTypedData,
57
+ resolveDelegation,
58
+ wrapSignature,
59
+ type DelegationResolver,
60
+ } from './erc7702.js';
54
61
 
55
62
  const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
56
63
 
@@ -268,6 +275,20 @@ export interface EscrowPreAuthParams {
268
275
  * publishes a different limit.
269
276
  */
270
277
  depositLimitUsd?: number;
278
+ /**
279
+ * How to find out whether `payerWallet` is EIP-7702-delegated, and to what.
280
+ *
281
+ * Optional, and its absence is honest rather than convenient: with no resolver
282
+ * the verdict is UNKNOWN and this function signs the ordinary way, exactly as
283
+ * it did before. Pass one — {@link rpcDelegationResolver} or your own — as soon
284
+ * as your payers can be delegated accounts, because a delegated payer signing
285
+ * the wrong dialect produces an authorization that **cannot settle on-chain**
286
+ * and only fails at lock time.
287
+ *
288
+ * When a resolver IS supplied and it reaches no verdict, this throws instead of
289
+ * guessing: an unreadable chain is not a "not delegated" answer.
290
+ */
291
+ delegationResolver?: DelegationResolver;
271
292
  }
272
293
 
273
294
  /**
@@ -370,26 +391,86 @@ export async function buildEscrowPreAuth(
370
391
  // String-valued message fields: the typed data travels as JSON to the
371
392
  // adapter (SigningWalletAdapter.signTypedData contract), and ethers
372
393
  // accepts decimal strings for uint256 / 0x-hex for bytes32.
373
- const { signature } = await wallet.signTypedData(
374
- JSON.stringify({
375
- domain: {
376
- name: cfg.usdc_domain_name,
377
- version: cfg.usdc_domain_version,
378
- chainId: cfg.chain_id,
379
- verifyingContract: ethers.getAddress(cfg.usdc),
380
- },
381
- types: RECEIVE_WITH_AUTHORIZATION_TYPES,
382
- primaryType: 'ReceiveWithAuthorization',
383
- message: {
384
- from: payer,
385
- to: tokenCollector,
386
- value: maxAmount.toString(),
387
- validAfter: '0',
388
- validBefore: String(paymentInfo.preApprovalExpiry),
389
- nonce: nonce,
390
- },
391
- })
394
+ const typedData = {
395
+ domain: {
396
+ name: cfg.usdc_domain_name,
397
+ version: cfg.usdc_domain_version,
398
+ chainId: cfg.chain_id,
399
+ verifyingContract: ethers.getAddress(cfg.usdc),
400
+ },
401
+ types: RECEIVE_WITH_AUTHORIZATION_TYPES,
402
+ primaryType: 'ReceiveWithAuthorization',
403
+ message: {
404
+ from: payer,
405
+ to: tokenCollector,
406
+ value: maxAmount.toString(),
407
+ validAfter: '0',
408
+ validBefore: String(paymentInfo.preApprovalExpiry),
409
+ nonce: nonce,
410
+ },
411
+ };
412
+
413
+ // ── EIP-7702: a DELEGATED payer cannot settle a raw ECDSA authorization ──
414
+ //
415
+ // Once the payer's EOA carries code, USDC validates via ERC-1271 only and raw
416
+ // ECDSA reverts (0x151d90fe) — the lock fails on-chain. Measured in production
417
+ // 2026-07-31: 14 of 14 delegated payers failed; the one plain EOA locked fine.
418
+ //
419
+ // THREE STATES, and the third is the one that matters: an UNKNOWN delegation is
420
+ // NOT "not delegated". Collapsing it to false is exactly how the original bug
421
+ // survived eight days — signing raw for an account that can never settle it,
422
+ // silently.
423
+ //
424
+ // AND THE DIALECT DEPENDS ON THE TARGET. "Delegated" is not one signature
425
+ // scheme: the account-envelope wrap is Alchemy-SMA-specific. A delegate that
426
+ // validates plain ECDSA through ERC-1271 — Execution Market's FeedbackDelegate,
427
+ // which every account that has rated through the facilitator's rail is pointed
428
+ // at — needs the ORDINARY signature. Wrapping it is as unsettleable as signing
429
+ // raw for an SMA.
430
+ const { delegated, target } = await resolveDelegation(
431
+ payer,
432
+ `eip155:${cfg.chain_id}`,
433
+ params.delegationResolver
392
434
  );
435
+ if (delegated === null && params.delegationResolver) {
436
+ throw new X402Error(
437
+ `Could not determine whether ${payer} is EIP-7702-delegated on chain ` +
438
+ `${cfg.chain_id} (the resolver gave no verdict) — refusing to sign an ` +
439
+ 'escrow authorization blindly: the wrong dialect is unsettleable ' +
440
+ 'on-chain and the failure only shows up at lock time. Retry when the ' +
441
+ 'chain is readable.',
442
+ 'INVALID_CONFIG'
443
+ );
444
+ }
445
+
446
+ let signature: string;
447
+ if (delegated && (needsAccountWrap(target) || target === null)) {
448
+ // SMA-wrap: the known Alchemy account, OR a legacy boolean-only resolver that
449
+ // could not name the target — for which we keep the conservative behaviour
450
+ // rather than silently changing what such callers got. A resolver that
451
+ // returns the target lands any non-SMA delegate in the plain branch below.
452
+ const innerDigest = ethers.TypedDataEncoder.hash(
453
+ typedData.domain,
454
+ typedData.types,
455
+ typedData.message
456
+ );
457
+ const replaySafe = replaySafeTypedData(innerDigest, cfg.chain_id, payer);
458
+ const wrapped = await wallet.signTypedData(
459
+ JSON.stringify({ ...replaySafe, primaryType: 'ReplaySafeHash' })
460
+ );
461
+ if (!wrapped?.signature) {
462
+ throw new X402Error(
463
+ 'The wallet returned no signature for the replay-safe wrap — refusing ' +
464
+ 'to build an unsettleable authorization.',
465
+ 'INVALID_CONFIG'
466
+ );
467
+ }
468
+ signature = wrapSignature(wrapped.signature);
469
+ } else {
470
+ // Plain EOA, unknown-with-no-resolver, or a delegate that takes ordinary
471
+ // ECDSA (FeedbackDelegate and the standard 1271 smart-EOA pattern).
472
+ signature = (await wallet.signTypedData(JSON.stringify(typedData))).signature;
473
+ }
393
474
 
394
475
  // Raw JSON (NOT base64): the backend relays this verbatim to the
395
476
  // Facilitator /settle after validating payer/amount/receiver.
package/src/index.ts CHANGED
@@ -350,6 +350,20 @@ export type {
350
350
  EscrowTierWindows,
351
351
  } from './escrow-preauth';
352
352
 
353
+ // EIP-7702 delegated accounts (which dialect a delegated payer must sign in)
354
+ export {
355
+ DELEGATE_PREFIX,
356
+ SMA_WRAP_TARGETS,
357
+ delegateTarget,
358
+ isDelegated,
359
+ needsAccountWrap,
360
+ replaySafeTypedData,
361
+ resolveDelegation,
362
+ rpcDelegationResolver,
363
+ wrapSignature,
364
+ } from './erc7702.js';
365
+ export type { DelegationResolver, DelegationVerdict } from './erc7702.js';
366
+
353
367
  // Server-side middleware and facilitator client
354
368
  export {
355
369
  FacilitatorClient,