@shroud-fi/x402 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +9 -6
  2. package/src/client.ts +261 -0
  3. package/src/constants.ts +18 -0
  4. package/src/errors.ts +78 -0
  5. package/src/facilitator.ts +211 -0
  6. package/src/index.ts +59 -0
  7. package/src/protocol.ts +154 -0
  8. package/src/server.ts +313 -0
  9. package/src/signing.ts +176 -0
  10. package/src/types.ts +102 -0
  11. package/tsconfig.json +9 -0
  12. package/dist/cjs/client.d.ts.map +0 -1
  13. package/dist/cjs/client.js.map +0 -1
  14. package/dist/cjs/constants.d.ts.map +0 -1
  15. package/dist/cjs/constants.js.map +0 -1
  16. package/dist/cjs/errors.d.ts.map +0 -1
  17. package/dist/cjs/errors.js.map +0 -1
  18. package/dist/cjs/facilitator.d.ts.map +0 -1
  19. package/dist/cjs/facilitator.js.map +0 -1
  20. package/dist/cjs/index.d.ts.map +0 -1
  21. package/dist/cjs/index.js.map +0 -1
  22. package/dist/cjs/protocol.d.ts.map +0 -1
  23. package/dist/cjs/protocol.js.map +0 -1
  24. package/dist/cjs/server.d.ts.map +0 -1
  25. package/dist/cjs/server.js.map +0 -1
  26. package/dist/cjs/signing.d.ts.map +0 -1
  27. package/dist/cjs/signing.js.map +0 -1
  28. package/dist/cjs/types.d.ts.map +0 -1
  29. package/dist/cjs/types.js.map +0 -1
  30. package/dist/esm/client.d.ts.map +0 -1
  31. package/dist/esm/client.js.map +0 -1
  32. package/dist/esm/constants.d.ts.map +0 -1
  33. package/dist/esm/constants.js.map +0 -1
  34. package/dist/esm/errors.d.ts.map +0 -1
  35. package/dist/esm/errors.js.map +0 -1
  36. package/dist/esm/facilitator.d.ts.map +0 -1
  37. package/dist/esm/facilitator.js.map +0 -1
  38. package/dist/esm/index.d.ts.map +0 -1
  39. package/dist/esm/index.js.map +0 -1
  40. package/dist/esm/protocol.d.ts.map +0 -1
  41. package/dist/esm/protocol.js.map +0 -1
  42. package/dist/esm/server.d.ts.map +0 -1
  43. package/dist/esm/server.js.map +0 -1
  44. package/dist/esm/signing.d.ts.map +0 -1
  45. package/dist/esm/signing.js.map +0 -1
  46. package/dist/esm/types.d.ts.map +0 -1
  47. package/dist/esm/types.js.map +0 -1
  48. package/dist/tsconfig.cjs.tsbuildinfo +0 -1
  49. package/dist/tsconfig.esm.tsbuildinfo +0 -1
  50. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,154 @@
1
+ /**
2
+ * x402 protocol constants + wire types.
3
+ *
4
+ * v2 spec only. See docs/research/competitive/x402-spec-and-ecosystem.md §1
5
+ * for the canonical field names. v1 (whitepaper, deprecated) names are not
6
+ * accepted by this package.
7
+ */
8
+
9
+ import type { Address, Hex } from 'viem';
10
+
11
+ // ─── HTTP header names ───────────────────────────────────────────────────────
12
+
13
+ /** Server → client: 402 challenge envelope (raw JSON, no base64 in v2). */
14
+ export const X402_PAYMENT_REQUIRED_HEADER = 'X-PAYMENT-REQUIRED' as const;
15
+
16
+ /**
17
+ * Client → server: signed PaymentPayload (base64-encoded JSON).
18
+ *
19
+ * Canonical name per the v2 spec is `X-PAYMENT`. Older drafts and some
20
+ * SDKs use `X-PAYMENT-SIGNATURE`; we use `X-PAYMENT` to stay aligned with
21
+ * the foundation SDKs (`@x402/fetch`, `@x402/axios`, `@x402/express` all
22
+ * read `X-PAYMENT`).
23
+ */
24
+ export const X402_PAYMENT_SIGNATURE_HEADER = 'X-PAYMENT' as const;
25
+
26
+ /** Server → client: settlement confirmation (base64-encoded JSON, on 2xx). */
27
+ export const X402_PAYMENT_RESPONSE_HEADER = 'X-PAYMENT-RESPONSE' as const;
28
+
29
+ // ─── Scheme identifiers ──────────────────────────────────────────────────────
30
+
31
+ /** "Exact" scheme — seller knows price up front. Only scheme we support in v1. */
32
+ export const X402_SCHEME_EXACT = 'exact' as const;
33
+
34
+ export type X402SchemeId = typeof X402_SCHEME_EXACT;
35
+
36
+ // ─── Facilitator URLs ────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * PayAI Network facilitator — free tier (≤ 10k settlements/mo) requires no
40
+ * authentication at all. Our default.
41
+ */
42
+ export const PAYAI_FACILITATOR_URL =
43
+ 'https://facilitator.payai.network' as const;
44
+
45
+ /**
46
+ * Coinbase CDP facilitator — secondary. Requires CDP API key for use.
47
+ * URL verified against research doc §3 (cdp.coinbase.com/platform/v2/x402).
48
+ */
49
+ export const COINBASE_FACILITATOR_URL =
50
+ 'https://api.cdp.coinbase.com/platform/v2/x402' as const;
51
+
52
+ /**
53
+ * Map an EVM chain id to the x402 v1 legacy network name that facilitators
54
+ * (PayAI, Coinbase CDP, etc.) expect on the wire. The facilitator ecosystem
55
+ * standardized on these short names, NOT CAIP-2 (`eip155:8453`). Our SDK uses
56
+ * CAIP-2 internally and translates only at the facilitator boundary.
57
+ *
58
+ * Verified against PayAI `/supported` (2026-06-04) — Base mainnet = "base",
59
+ * Base Sepolia = "base-sepolia". Returns undefined for chains we don't map.
60
+ */
61
+ export function facilitatorNetworkName(chainId: number): string | undefined {
62
+ const NAMES: Readonly<Record<number, string>> = {
63
+ 8453: 'base',
64
+ 84532: 'base-sepolia',
65
+ };
66
+ return NAMES[chainId];
67
+ }
68
+
69
+ // ─── Wire types ──────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * The `accepts[N]` entry from a v2 `PaymentRequired` object — the exact shape
73
+ * a client receives over the wire.
74
+ *
75
+ * Field names match the spec verbatim (NOT the v1 whitepaper names — see
76
+ * research doc §10.1 for the drift table).
77
+ */
78
+ export interface X402PaymentRequirements {
79
+ /** Scheme id; only `'exact'` supported in v1. */
80
+ readonly scheme: X402SchemeId;
81
+ /** CAIP-2 network id, e.g. `'eip155:8453'`. */
82
+ readonly network: string;
83
+ /**
84
+ * Maximum payment amount in token base units (string-encoded to survive
85
+ * uint256). For scheme `exact`, this is the exact required amount.
86
+ *
87
+ * Spec field name: `maxAmountRequired`. We mirror it verbatim.
88
+ */
89
+ readonly maxAmountRequired: string;
90
+ /** ERC-20 contract address of the asset. */
91
+ readonly asset: Address;
92
+ /**
93
+ * Recipient address. In ShroudFi servers this is ALWAYS a freshly derived
94
+ * stealth address — never the server's main wallet.
95
+ */
96
+ readonly payTo: Address;
97
+ /** Authorization validity window (seconds). */
98
+ readonly maxTimeoutSeconds: number;
99
+ /** Resource being paid for (URL or pseudo-URL). */
100
+ readonly resource: string;
101
+ /** Optional human-readable description. */
102
+ readonly description?: string;
103
+ /**
104
+ * Scheme-specific extra data. For `exact` on EVM, contains the EIP-712
105
+ * domain `name` + `version` for the asset (so the client can rebuild the
106
+ * domain separator without having to look the token up).
107
+ */
108
+ readonly extra: {
109
+ readonly name: string;
110
+ readonly version: string;
111
+ /**
112
+ * ShroudFi-specific extension: emitted so a ShroudFi-aware client can
113
+ * relay the Announcement event after settlement. Forward-compatible —
114
+ * non-ShroudFi clients ignore unknown keys.
115
+ */
116
+ readonly shroudfiAnnouncement?: {
117
+ readonly ephemeralPubKey: Hex;
118
+ readonly viewTag: number;
119
+ };
120
+ };
121
+ }
122
+
123
+ /**
124
+ * The signed payload the client sends back in `X-PAYMENT`. EIP-3009
125
+ * `transferWithAuthorization` shape.
126
+ */
127
+ export interface X402PaymentPayload {
128
+ readonly x402Version: 2;
129
+ readonly scheme: X402SchemeId;
130
+ readonly network: string;
131
+ readonly payload: {
132
+ readonly signature: Hex;
133
+ readonly authorization: {
134
+ readonly from: Address;
135
+ readonly to: Address;
136
+ /** uint256 as string. */
137
+ readonly value: string;
138
+ readonly validAfter: string;
139
+ readonly validBefore: string;
140
+ readonly nonce: Hex;
141
+ };
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Optional facilitator config. When unset, the default is PayAI free-tier
147
+ * (no auth). When `ed25519AuthToken` is provided, the facilitator client
148
+ * attaches `Authorization: Bearer <token>` to every request — caller is
149
+ * responsible for minting the JWT per PayAI's spec (research doc §10.5).
150
+ */
151
+ export interface X402FacilitatorConfig {
152
+ readonly url: string;
153
+ readonly ed25519AuthToken?: string;
154
+ }
package/src/server.ts ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * x402 server — challenge generation + signed-payment verification.
3
+ *
4
+ * Privacy invariants enforced here:
5
+ * - Every challenge derives a FRESH stealth address from the recipient's
6
+ * meta-address. The recipient's main wallet NEVER appears in the
7
+ * PaymentRequired body.
8
+ * - The facilitator is only ever shown the stealth `payTo` — same property.
9
+ * - Error paths never include amount values, signature bytes, or the
10
+ * server's spend key.
11
+ */
12
+
13
+ import { decodeMetaAddress } from '@shroud-fi/core';
14
+ import { prepareStealthPayment } from '@shroud-fi/payments';
15
+ import { getEip3009Token } from '@shroud-fi/transport';
16
+ import {
17
+ X402_PAYMENT_REQUIRED_HEADER,
18
+ X402_SCHEME_EXACT,
19
+ type X402PaymentPayload,
20
+ type X402PaymentRequirements,
21
+ } from './protocol.js';
22
+ import { X402_VERSION, DEFAULT_MAX_TIMEOUT_SECS } from './constants.js';
23
+ import {
24
+ X402AssetNotSupportedError,
25
+ X402InvalidChallengeError,
26
+ } from './errors.js';
27
+ import type {
28
+ X402Challenge,
29
+ X402PaymentVerification,
30
+ X402ServerConfig,
31
+ } from './types.js';
32
+ import {
33
+ facilitatorSettle,
34
+ facilitatorVerify,
35
+ resolveFacilitator,
36
+ toFacilitatorRequest,
37
+ } from './facilitator.js';
38
+ import {
39
+ verifyTransferWithAuthorizationSignature,
40
+ type TransferWithAuthorizationInput,
41
+ } from './signing.js';
42
+
43
+ /**
44
+ * Encode a CAIP-2 network id from a chain id. EVM only.
45
+ */
46
+ function caip2(chainId: number): string {
47
+ return `eip155:${chainId}`;
48
+ }
49
+
50
+ /**
51
+ * Robust bigint coercion that accepts decimal strings, hex strings,
52
+ * numbers, and bigints. Throws X402InvalidChallengeError on anything else.
53
+ */
54
+ function toBigInt(v: unknown): bigint {
55
+ if (typeof v === 'bigint') return v;
56
+ if (typeof v === 'number') return BigInt(v);
57
+ if (typeof v === 'string') {
58
+ try {
59
+ return BigInt(v);
60
+ } catch {
61
+ throw new X402InvalidChallengeError();
62
+ }
63
+ }
64
+ throw new X402InvalidChallengeError();
65
+ }
66
+
67
+ export interface X402Server {
68
+ /**
69
+ * Build a 402 challenge for a given resource. Each call derives a fresh
70
+ * stealth address — calls are deliberately non-idempotent.
71
+ */
72
+ challenge(args: {
73
+ resource: string;
74
+ description?: string;
75
+ priceAtomic?: bigint;
76
+ }): Promise<X402Challenge>;
77
+ /**
78
+ * Verify a signed payload submitted by the client. Optionally settles via
79
+ * the configured facilitator.
80
+ *
81
+ * When `skipFacilitator: true` is passed, verify returns `valid: true` after
82
+ * signature recovery succeeds and does NOT contact the facilitator. The
83
+ * caller is then responsible for settling the EIP-3009 authorization on-chain
84
+ * itself (e.g. by calling `USDC.transferWithAuthorization` directly through
85
+ * its own walletClient). Use this when the operator runs its own settler EOA
86
+ * and does not want a facilitator in the trust chain — e.g. headless
87
+ * agent-to-agent demos, anon-stack deployments.
88
+ */
89
+ verify(args: {
90
+ signedPayload: string | X402PaymentPayload;
91
+ challenge: X402Challenge;
92
+ skipFacilitator?: boolean;
93
+ }): Promise<X402PaymentVerification>;
94
+ }
95
+
96
+ /**
97
+ * Build an X402Server bound to a single recipient meta-address + asset.
98
+ */
99
+ export function createX402Server(config: X402ServerConfig): X402Server {
100
+ // v0.1.1: validate the asset is an EIP-3009 token registered for the chain.
101
+ // Previously hardcoded to USDC; the EIP-3009 registry now also lists EURC on
102
+ // Base mainnet, with room for future Circle-issued assets without changing
103
+ // this file. Lookup is case-insensitive on the asset address.
104
+ const token = getEip3009Token(config.chainId, config.asset);
105
+ if (token === undefined) {
106
+ throw new X402AssetNotSupportedError();
107
+ }
108
+ const assetDomain = token.domain;
109
+
110
+ // Pre-decode the meta-address ONCE — repeated decodes would waste cycles.
111
+ // The decode validates curve membership; downstream errors become
112
+ // X402InvalidChallengeError.
113
+ let decoded;
114
+ try {
115
+ decoded = decodeMetaAddress(config.recipientMetaAddress);
116
+ } catch {
117
+ throw new X402InvalidChallengeError();
118
+ }
119
+
120
+ const facilitator = resolveFacilitator(config.facilitator);
121
+
122
+ return {
123
+ async challenge({ resource, description, priceAtomic }) {
124
+ const price = priceAtomic ?? config.defaultPriceAtomic;
125
+
126
+ // Derive a fresh stealth address per call. prepareStealthPayment hashes
127
+ // a fresh ephemeral key internally — uniqueness is built in.
128
+ const prepared = prepareStealthPayment(decoded);
129
+
130
+ const requirements: X402PaymentRequirements = {
131
+ scheme: X402_SCHEME_EXACT,
132
+ network: caip2(config.chainId),
133
+ maxAmountRequired: price.toString(),
134
+ asset: config.asset,
135
+ payTo: prepared.stealthAddress,
136
+ maxTimeoutSeconds: DEFAULT_MAX_TIMEOUT_SECS,
137
+ resource,
138
+ ...(description !== undefined ? { description } : {}),
139
+ extra: {
140
+ name: assetDomain.name,
141
+ version: assetDomain.version,
142
+ shroudfiAnnouncement: {
143
+ ephemeralPubKey: prepared.ephemeralPubKey,
144
+ viewTag: prepared.viewTag,
145
+ },
146
+ },
147
+ };
148
+
149
+ const body = {
150
+ x402Version: X402_VERSION,
151
+ error: 'Payment required',
152
+ accepts: [requirements] as const,
153
+ } as const;
154
+
155
+ return {
156
+ status: 402,
157
+ headers: {
158
+ [X402_PAYMENT_REQUIRED_HEADER]: JSON.stringify(body),
159
+ 'content-type': 'application/json',
160
+ },
161
+ body,
162
+ announcement: {
163
+ ephemeralPubKey: prepared.ephemeralPubKey,
164
+ viewTag: prepared.viewTag,
165
+ stealthAddress: prepared.stealthAddress,
166
+ },
167
+ };
168
+ },
169
+
170
+ async verify({ signedPayload, challenge, skipFacilitator }) {
171
+ // 1. Decode incoming payload.
172
+ let payload: X402PaymentPayload;
173
+ if (typeof signedPayload === 'string') {
174
+ try {
175
+ // Spec: header value is base64-encoded JSON.
176
+ const decodedJson = Buffer.from(signedPayload, 'base64').toString(
177
+ 'utf-8',
178
+ );
179
+ payload = JSON.parse(decodedJson) as X402PaymentPayload;
180
+ } catch {
181
+ return { valid: false, error: 'malformed_payload' };
182
+ }
183
+ } else {
184
+ payload = signedPayload;
185
+ }
186
+
187
+ // 2. Structural checks. Tagged failures only — no field values in errors.
188
+ if (
189
+ payload.x402Version !== 2 ||
190
+ payload.scheme !== X402_SCHEME_EXACT ||
191
+ payload.network !== caip2(config.chainId)
192
+ ) {
193
+ return { valid: false, error: 'scheme_mismatch' };
194
+ }
195
+ const auth = payload.payload?.authorization;
196
+ const sig = payload.payload?.signature;
197
+ if (auth === undefined || sig === undefined) {
198
+ return { valid: false, error: 'malformed_payload' };
199
+ }
200
+
201
+ const accepted = challenge.body.accepts[0];
202
+ if (accepted === undefined) {
203
+ return { valid: false, error: 'malformed_challenge' };
204
+ }
205
+
206
+ // 3. Authorization fields must address the same stealth `payTo` and the
207
+ // exact required amount.
208
+ let value: bigint;
209
+ let validAfter: bigint;
210
+ let validBefore: bigint;
211
+ try {
212
+ value = toBigInt(auth.value);
213
+ validAfter = toBigInt(auth.validAfter);
214
+ validBefore = toBigInt(auth.validBefore);
215
+ } catch {
216
+ return { valid: false, error: 'malformed_payload' };
217
+ }
218
+
219
+ if (
220
+ auth.to.toLowerCase() !== accepted.payTo.toLowerCase()
221
+ ) {
222
+ return { valid: false, error: 'recipient_mismatch' };
223
+ }
224
+ let requiredAmount: bigint;
225
+ try {
226
+ requiredAmount = toBigInt(accepted.maxAmountRequired);
227
+ } catch {
228
+ return { valid: false, error: 'malformed_challenge' };
229
+ }
230
+ if (value !== requiredAmount) {
231
+ return { valid: false, error: 'amount_mismatch' };
232
+ }
233
+
234
+ // 4. Time window.
235
+ const nowSecs = BigInt(Math.floor(Date.now() / 1000));
236
+ if (validBefore <= nowSecs) {
237
+ return { valid: false, error: 'expired' };
238
+ }
239
+ if (validAfter > nowSecs) {
240
+ return { valid: false, error: 'not_yet_valid' };
241
+ }
242
+
243
+ // 5. Signature recovery.
244
+ const authInput: TransferWithAuthorizationInput = {
245
+ from: auth.from,
246
+ to: auth.to,
247
+ value,
248
+ validAfter,
249
+ validBefore,
250
+ nonce: auth.nonce,
251
+ };
252
+ const recovered = await verifyTransferWithAuthorizationSignature({
253
+ chainId: config.chainId,
254
+ verifyingContract: config.asset,
255
+ authorization: authInput,
256
+ signature: sig,
257
+ });
258
+ if (
259
+ recovered === null ||
260
+ recovered.toLowerCase() !== auth.from.toLowerCase()
261
+ ) {
262
+ return { valid: false, error: 'signature_invalid' };
263
+ }
264
+
265
+ // 6a. Self-settle mode: caller handles on-chain settlement, we return
266
+ // valid:true after signature recovery. No facilitator contact.
267
+ if (skipFacilitator === true) {
268
+ return { valid: true };
269
+ }
270
+
271
+ // 6b. Facilitator submission. Caller gets the settled tx hash.
272
+ // We send the facilitator only what it needs: the requirements + the
273
+ // signed payload. The recipient's main wallet is never in this body
274
+ // because `accepted.payTo` is the freshly derived stealth address.
275
+ try {
276
+ // Translate our v2/CAIP-2 body into the v1/legacy-network shape the
277
+ // facilitator expects. Proven against PayAI on Base mainnet
278
+ // (tx 0x57d84d53…). Same body drives both /verify and /settle.
279
+ const facilitatorBody = toFacilitatorRequest({
280
+ chainId: config.chainId,
281
+ paymentRequirements: accepted as unknown as Record<string, unknown>,
282
+ paymentPayload: payload as unknown as Record<string, unknown>,
283
+ });
284
+ const verifyResult = await facilitatorVerify(
285
+ facilitator,
286
+ facilitatorBody,
287
+ );
288
+ if (!verifyResult.isValid) {
289
+ return { valid: false, error: 'facilitator_rejected' };
290
+ }
291
+ const settle = await facilitatorSettle(facilitator, facilitatorBody);
292
+ if (!settle.success || settle.txHash === undefined) {
293
+ return { valid: false, error: 'facilitator_settle_failed' };
294
+ }
295
+ return { valid: true, settledTxHash: settle.txHash };
296
+ } catch {
297
+ // Facilitator unreachable / non-2xx. Return signature-verified=true
298
+ // so the caller can decide whether to retry or fall back. The signed
299
+ // payload remains cryptographically valid even when the facilitator
300
+ // path failed.
301
+ return { valid: false, error: 'facilitator_unreachable' };
302
+ }
303
+ },
304
+ };
305
+ }
306
+
307
+ /** Re-export so consumers don't have to dig into types.ts. */
308
+ export type {
309
+ X402Challenge,
310
+ X402PaymentVerification,
311
+ X402ServerConfig,
312
+ } from './types.js';
313
+ export type { X402PaymentRequirements } from './protocol.js';
package/src/signing.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * EIP-3009 `TransferWithAuthorization` typed-data signing for x402.
3
+ *
4
+ * Privacy invariants:
5
+ * - The private key never leaves the viem `account` object. We do not
6
+ * extract it, log it, or attach it to errors.
7
+ * - The signed payload (`signature`, `nonce`, `value`) is never logged,
8
+ * stringified into errors, or stored. Caller is responsible for sending
9
+ * it directly to the server in the `X-PAYMENT` header.
10
+ * - `nonce` is a fresh 32 bytes per call via `crypto.getRandomValues`,
11
+ * never reused.
12
+ */
13
+
14
+ import type { Account, Address, Chain, Hex } from 'viem';
15
+ import { getEip3009Token } from '@shroud-fi/transport';
16
+ import { X402AssetNotSupportedError } from './errors.js';
17
+
18
+ /**
19
+ * EIP-3009 `TransferWithAuthorization` typed-data types.
20
+ * Exported for tests; the runtime signer constructs the same object inline.
21
+ */
22
+ export const TransferWithAuthorizationTypes = {
23
+ TransferWithAuthorization: [
24
+ { name: 'from', type: 'address' },
25
+ { name: 'to', type: 'address' },
26
+ { name: 'value', type: 'uint256' },
27
+ { name: 'validAfter', type: 'uint256' },
28
+ { name: 'validBefore', type: 'uint256' },
29
+ { name: 'nonce', type: 'bytes32' },
30
+ ],
31
+ } as const;
32
+
33
+ /**
34
+ * The cleartext authorization fields. Caller passes them in; signer hashes,
35
+ * signs, and returns both signature + the authorization (so the caller can
36
+ * forward the unsigned data alongside the signature).
37
+ */
38
+ export interface TransferWithAuthorizationInput {
39
+ readonly from: Address;
40
+ readonly to: Address;
41
+ readonly value: bigint;
42
+ readonly validAfter: bigint;
43
+ readonly validBefore: bigint;
44
+ readonly nonce: Hex;
45
+ }
46
+
47
+ export interface SignedTransferWithAuthorization {
48
+ readonly signature: Hex;
49
+ readonly nonce: Hex;
50
+ readonly from: Address;
51
+ readonly to: Address;
52
+ readonly value: bigint;
53
+ readonly validAfter: bigint;
54
+ readonly validBefore: bigint;
55
+ }
56
+
57
+ /**
58
+ * Generate a fresh 32-byte nonce for EIP-3009. Uses Web Crypto (available in
59
+ * Node 20+ and all browsers). Never reuse — each authorization needs a
60
+ * unique nonce to avoid replay.
61
+ */
62
+ export function generateAuthorizationNonce(): Hex {
63
+ const bytes = new Uint8Array(32);
64
+ // Web Crypto is globally available on Node 20+; deliberately not using
65
+ // `node:crypto.randomBytes` so the module stays runtime-agnostic.
66
+ globalThis.crypto.getRandomValues(bytes);
67
+ let hex = '0x';
68
+ for (const b of bytes) {
69
+ hex += b.toString(16).padStart(2, '0');
70
+ }
71
+ return hex as Hex;
72
+ }
73
+
74
+ /**
75
+ * Sign an EIP-3009 `TransferWithAuthorization` over any registered EIP-3009
76
+ * asset (USDC, EURC). The EIP-712 domain is derived from the asset's entry in
77
+ * the EIP-3009 registry — using the wrong domain would silently produce an
78
+ * invalid signature.
79
+ *
80
+ * @throws X402AssetNotSupportedError if the verifyingContract address is not
81
+ * registered as an EIP-3009 token on the chain.
82
+ */
83
+ export async function signTransferWithAuthorization(args: {
84
+ readonly account: Account;
85
+ readonly chain: Chain;
86
+ readonly verifyingContract: Address;
87
+ readonly input: TransferWithAuthorizationInput;
88
+ }): Promise<SignedTransferWithAuthorization> {
89
+ const { account, chain, verifyingContract, input } = args;
90
+ const token = getEip3009Token(chain.id, verifyingContract);
91
+ if (token === undefined) {
92
+ throw new X402AssetNotSupportedError();
93
+ }
94
+
95
+ const domain = {
96
+ name: token.domain.name,
97
+ version: token.domain.version,
98
+ chainId: chain.id,
99
+ verifyingContract,
100
+ } as const;
101
+
102
+ if (account.signTypedData === undefined) {
103
+ // Local account; viem provides .signTypedData for privateKeyToAccount.
104
+ // For JSON-RPC accounts the caller must provide a wrapper. Treat as
105
+ // unsupported configuration rather than leaking the underlying error.
106
+ throw new X402AssetNotSupportedError();
107
+ }
108
+
109
+ const signature = await account.signTypedData({
110
+ domain,
111
+ types: TransferWithAuthorizationTypes,
112
+ primaryType: 'TransferWithAuthorization',
113
+ message: {
114
+ from: input.from,
115
+ to: input.to,
116
+ value: input.value,
117
+ validAfter: input.validAfter,
118
+ validBefore: input.validBefore,
119
+ nonce: input.nonce,
120
+ },
121
+ });
122
+
123
+ return {
124
+ signature,
125
+ nonce: input.nonce,
126
+ from: input.from,
127
+ to: input.to,
128
+ value: input.value,
129
+ validAfter: input.validAfter,
130
+ validBefore: input.validBefore,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Verify a `TransferWithAuthorization` signature on the server side.
136
+ * Returns the recovered signer address (`from`) or `null` on failure.
137
+ *
138
+ * NOTE: we do NOT throw on failure — caller maps `null` → verify.valid=false
139
+ * with an error tag. Throwing here would risk leaking signature bytes via
140
+ * thrown error stacks.
141
+ */
142
+ export async function verifyTransferWithAuthorizationSignature(args: {
143
+ readonly chainId: number;
144
+ readonly verifyingContract: Address;
145
+ readonly authorization: TransferWithAuthorizationInput;
146
+ readonly signature: Hex;
147
+ }): Promise<Address | null> {
148
+ const { chainId, verifyingContract, authorization, signature } = args;
149
+ const token = getEip3009Token(chainId, verifyingContract);
150
+ if (token === undefined) return null;
151
+
152
+ const { recoverTypedDataAddress } = await import('viem');
153
+ try {
154
+ return await recoverTypedDataAddress({
155
+ domain: {
156
+ name: token.domain.name,
157
+ version: token.domain.version,
158
+ chainId,
159
+ verifyingContract,
160
+ },
161
+ types: TransferWithAuthorizationTypes,
162
+ primaryType: 'TransferWithAuthorization',
163
+ message: {
164
+ from: authorization.from,
165
+ to: authorization.to,
166
+ value: authorization.value,
167
+ validAfter: authorization.validAfter,
168
+ validBefore: authorization.validBefore,
169
+ nonce: authorization.nonce,
170
+ },
171
+ signature,
172
+ });
173
+ } catch {
174
+ return null;
175
+ }
176
+ }