@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.
- package/package.json +9 -6
- package/src/client.ts +261 -0
- package/src/constants.ts +18 -0
- package/src/errors.ts +78 -0
- package/src/facilitator.ts +211 -0
- package/src/index.ts +59 -0
- package/src/protocol.ts +154 -0
- package/src/server.ts +313 -0
- package/src/signing.ts +176 -0
- package/src/types.ts +102 -0
- package/tsconfig.json +9 -0
- package/dist/cjs/client.d.ts.map +0 -1
- package/dist/cjs/client.js.map +0 -1
- package/dist/cjs/constants.d.ts.map +0 -1
- package/dist/cjs/constants.js.map +0 -1
- package/dist/cjs/errors.d.ts.map +0 -1
- package/dist/cjs/errors.js.map +0 -1
- package/dist/cjs/facilitator.d.ts.map +0 -1
- package/dist/cjs/facilitator.js.map +0 -1
- package/dist/cjs/index.d.ts.map +0 -1
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/protocol.d.ts.map +0 -1
- package/dist/cjs/protocol.js.map +0 -1
- package/dist/cjs/server.d.ts.map +0 -1
- package/dist/cjs/server.js.map +0 -1
- package/dist/cjs/signing.d.ts.map +0 -1
- package/dist/cjs/signing.js.map +0 -1
- package/dist/cjs/types.d.ts.map +0 -1
- package/dist/cjs/types.js.map +0 -1
- package/dist/esm/client.d.ts.map +0 -1
- package/dist/esm/client.js.map +0 -1
- package/dist/esm/constants.d.ts.map +0 -1
- package/dist/esm/constants.js.map +0 -1
- package/dist/esm/errors.d.ts.map +0 -1
- package/dist/esm/errors.js.map +0 -1
- package/dist/esm/facilitator.d.ts.map +0 -1
- package/dist/esm/facilitator.js.map +0 -1
- package/dist/esm/index.d.ts.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/protocol.d.ts.map +0 -1
- package/dist/esm/protocol.js.map +0 -1
- package/dist/esm/server.d.ts.map +0 -1
- package/dist/esm/server.js.map +0 -1
- package/dist/esm/signing.d.ts.map +0 -1
- package/dist/esm/signing.js.map +0 -1
- package/dist/esm/types.d.ts.map +0 -1
- package/dist/esm/types.js.map +0 -1
- package/dist/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.esm.tsbuildinfo +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
package/src/protocol.ts
ADDED
|
@@ -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
|
+
}
|