@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shroud-fi/x402",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Stealth-addressed HTTP 402 payments for AI agents. EIP-3009 settlement into one-time stealth addresses.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"shroudfi",
|
|
@@ -27,13 +27,16 @@
|
|
|
27
27
|
}
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
|
-
"dist"
|
|
30
|
+
"dist",
|
|
31
|
+
"src",
|
|
32
|
+
"tsconfig.json",
|
|
33
|
+
"README.md"
|
|
31
34
|
],
|
|
32
35
|
"dependencies": {
|
|
33
36
|
"@noble/hashes": "^1.5.0",
|
|
34
|
-
"@shroud-fi/
|
|
35
|
-
"@shroud-fi/
|
|
36
|
-
"@shroud-fi/transport": "0.1.
|
|
37
|
+
"@shroud-fi/payments": "0.1.3",
|
|
38
|
+
"@shroud-fi/core": "0.1.3",
|
|
39
|
+
"@shroud-fi/transport": "0.1.4"
|
|
37
40
|
},
|
|
38
41
|
"peerDependencies": {
|
|
39
42
|
"viem": "^2.21.0"
|
|
@@ -42,7 +45,7 @@
|
|
|
42
45
|
"viem": "^2.21.0",
|
|
43
46
|
"vitest": "^2.0.0",
|
|
44
47
|
"typescript": "^5.6.0",
|
|
45
|
-
"@shroud-fi/scanning": "0.1.
|
|
48
|
+
"@shroud-fi/scanning": "0.1.3"
|
|
46
49
|
},
|
|
47
50
|
"publishConfig": {
|
|
48
51
|
"access": "public"
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 client — fetch wrapper that auto-handles 402 challenges.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Issue initial fetch.
|
|
6
|
+
* 2. If status !== 402, return as-is.
|
|
7
|
+
* 3. Parse X-PAYMENT-REQUIRED, validate scheme + asset + safety cap.
|
|
8
|
+
* 4. Sign EIP-3009 TransferWithAuthorization for USDC.
|
|
9
|
+
* 5. Re-issue request with `X-PAYMENT: base64(JSON(payload))`.
|
|
10
|
+
* 6. On 2xx, surface `x402Settlement` on the returned Response if the
|
|
11
|
+
* server attached X-PAYMENT-RESPONSE.
|
|
12
|
+
*
|
|
13
|
+
* Privacy invariants:
|
|
14
|
+
* - The signed payload is constructed in memory and forwarded directly to
|
|
15
|
+
* the server. We never log or serialize the signature, nonce, or value.
|
|
16
|
+
* - The client wallet address is unavoidably visible (it IS the `from`).
|
|
17
|
+
* This is the agent's stealth-payer EOA, not a persistent identity if
|
|
18
|
+
* used by ShroudAgent.
|
|
19
|
+
* - Errors carry no amount, no signature, no nonce bytes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Hex } from 'viem';
|
|
23
|
+
import { getEip3009Token } from '@shroud-fi/transport';
|
|
24
|
+
import {
|
|
25
|
+
X402_PAYMENT_REQUIRED_HEADER,
|
|
26
|
+
X402_PAYMENT_RESPONSE_HEADER,
|
|
27
|
+
X402_PAYMENT_SIGNATURE_HEADER,
|
|
28
|
+
X402_SCHEME_EXACT,
|
|
29
|
+
type X402PaymentPayload,
|
|
30
|
+
type X402PaymentRequirements,
|
|
31
|
+
} from './protocol.js';
|
|
32
|
+
import { X402_VERSION, DEFAULT_AUTHORIZATION_TTL_SECS } from './constants.js';
|
|
33
|
+
import {
|
|
34
|
+
X402AmountMismatchError,
|
|
35
|
+
X402AssetNotSupportedError,
|
|
36
|
+
X402InvalidChallengeError,
|
|
37
|
+
} from './errors.js';
|
|
38
|
+
import type { X402ClientConfig, X402PaymentResult } from './types.js';
|
|
39
|
+
import {
|
|
40
|
+
generateAuthorizationNonce,
|
|
41
|
+
signTransferWithAuthorization,
|
|
42
|
+
} from './signing.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The augmented Response surface. We can't subclass `Response` in a portable
|
|
46
|
+
* way, so we attach `x402Settlement` as a non-standard own-property.
|
|
47
|
+
*/
|
|
48
|
+
export interface X402Response extends Response {
|
|
49
|
+
x402Settlement?: X402PaymentResult;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface X402Client {
|
|
53
|
+
/**
|
|
54
|
+
* Wrap `fetch` with 402 auto-pay. The returned Response is augmented with
|
|
55
|
+
* `x402Settlement` when a payment was made.
|
|
56
|
+
*
|
|
57
|
+
* @param maxPriceAtomic — safety cap. If the server demands more, the
|
|
58
|
+
* client throws X402AmountMismatchError without signing anything.
|
|
59
|
+
*/
|
|
60
|
+
fetch(
|
|
61
|
+
input: string | URL | Request,
|
|
62
|
+
init?: RequestInit & { maxPriceAtomic?: bigint },
|
|
63
|
+
): Promise<X402Response>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Decode a 402 challenge body from the response. Tries header, then body. */
|
|
67
|
+
async function readChallenge(
|
|
68
|
+
res: Response,
|
|
69
|
+
): Promise<{ accepts: readonly X402PaymentRequirements[] }> {
|
|
70
|
+
// Header path (spec-canonical) first.
|
|
71
|
+
const header = res.headers.get(X402_PAYMENT_REQUIRED_HEADER);
|
|
72
|
+
if (header !== null && header.length > 0) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(header) as {
|
|
75
|
+
accepts: readonly X402PaymentRequirements[];
|
|
76
|
+
};
|
|
77
|
+
if (Array.isArray(parsed.accepts) && parsed.accepts.length > 0) {
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// fall through to body parse
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Body fallback — some servers put the envelope in the body only.
|
|
85
|
+
try {
|
|
86
|
+
const text = await res.clone().text();
|
|
87
|
+
const parsed = JSON.parse(text) as {
|
|
88
|
+
accepts: readonly X402PaymentRequirements[];
|
|
89
|
+
};
|
|
90
|
+
if (Array.isArray(parsed.accepts) && parsed.accepts.length > 0) {
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// fall through
|
|
95
|
+
}
|
|
96
|
+
throw new X402InvalidChallengeError();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Decode the X-PAYMENT-RESPONSE header into a settlement record. */
|
|
100
|
+
function readSettlement(res: Response): X402PaymentResult | undefined {
|
|
101
|
+
const header = res.headers.get(X402_PAYMENT_RESPONSE_HEADER);
|
|
102
|
+
if (header === null || header.length === 0) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
// Header is base64-encoded JSON per the spec.
|
|
105
|
+
const decoded = Buffer.from(header, 'base64').toString('utf-8');
|
|
106
|
+
const parsed = JSON.parse(decoded) as {
|
|
107
|
+
transaction?: string;
|
|
108
|
+
txHash?: string;
|
|
109
|
+
settledAt?: number;
|
|
110
|
+
};
|
|
111
|
+
const txHash =
|
|
112
|
+
(parsed.transaction ?? parsed.txHash) as Hex | undefined;
|
|
113
|
+
if (txHash === undefined) return undefined;
|
|
114
|
+
return {
|
|
115
|
+
txHash,
|
|
116
|
+
settledAt:
|
|
117
|
+
typeof parsed.settledAt === 'number'
|
|
118
|
+
? parsed.settledAt
|
|
119
|
+
: Math.floor(Date.now() / 1000),
|
|
120
|
+
};
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Convert a value into a bigint safely. */
|
|
127
|
+
function safeBigInt(v: unknown): bigint | null {
|
|
128
|
+
try {
|
|
129
|
+
if (typeof v === 'bigint') return v;
|
|
130
|
+
if (typeof v === 'string' || typeof v === 'number') return BigInt(v);
|
|
131
|
+
return null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createX402Client(config: X402ClientConfig): X402Client {
|
|
138
|
+
const transport = config.transport;
|
|
139
|
+
const walletClient = transport.walletClient;
|
|
140
|
+
if (walletClient === undefined || walletClient.account === undefined) {
|
|
141
|
+
// We deliberately throw at construction time so a misconfigured client
|
|
142
|
+
// can't ship to production without a wallet.
|
|
143
|
+
throw new X402AssetNotSupportedError();
|
|
144
|
+
}
|
|
145
|
+
const account = walletClient.account;
|
|
146
|
+
const chain = transport.chain;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
async fetch(input, init) {
|
|
150
|
+
const fetchImpl = globalThis.fetch;
|
|
151
|
+
const maxPriceAtomic = init?.maxPriceAtomic;
|
|
152
|
+
|
|
153
|
+
// Strip our non-standard option before passing init to fetch.
|
|
154
|
+
const passThroughInit: RequestInit = { ...(init ?? {}) };
|
|
155
|
+
delete (passThroughInit as { maxPriceAtomic?: bigint }).maxPriceAtomic;
|
|
156
|
+
|
|
157
|
+
// 1. First attempt.
|
|
158
|
+
const first = (await fetchImpl(input, passThroughInit)) as X402Response;
|
|
159
|
+
if (first.status !== 402) {
|
|
160
|
+
return first;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Decode challenge.
|
|
164
|
+
const challenge = await readChallenge(first);
|
|
165
|
+
const requirements = challenge.accepts[0];
|
|
166
|
+
if (requirements === undefined) {
|
|
167
|
+
throw new X402InvalidChallengeError();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 3. Scheme + network + asset checks.
|
|
171
|
+
if (requirements.scheme !== X402_SCHEME_EXACT) {
|
|
172
|
+
throw new X402InvalidChallengeError();
|
|
173
|
+
}
|
|
174
|
+
// v0.1.1: accept any EIP-3009 asset registered for this chain (USDC, EURC).
|
|
175
|
+
// The exact asset is challenge-driven — the server picks; we validate
|
|
176
|
+
// membership in the registry rather than pinning to one symbol.
|
|
177
|
+
const challengedAsset = getEip3009Token(chain.id, requirements.asset);
|
|
178
|
+
if (challengedAsset === undefined) {
|
|
179
|
+
throw new X402AssetNotSupportedError();
|
|
180
|
+
}
|
|
181
|
+
const expectedNetwork = `eip155:${chain.id}`;
|
|
182
|
+
if (requirements.network !== expectedNetwork) {
|
|
183
|
+
throw new X402AssetNotSupportedError();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 4. Safety cap.
|
|
187
|
+
const required = safeBigInt(requirements.maxAmountRequired);
|
|
188
|
+
if (required === null) {
|
|
189
|
+
throw new X402InvalidChallengeError();
|
|
190
|
+
}
|
|
191
|
+
if (maxPriceAtomic !== undefined && required > maxPriceAtomic) {
|
|
192
|
+
throw new X402AmountMismatchError();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 5. Build authorization. validAfter=0n, validBefore=now+ttl.
|
|
196
|
+
const nowSecs = BigInt(Math.floor(Date.now() / 1000));
|
|
197
|
+
const ttl = BigInt(
|
|
198
|
+
Math.min(
|
|
199
|
+
requirements.maxTimeoutSeconds || DEFAULT_AUTHORIZATION_TTL_SECS,
|
|
200
|
+
DEFAULT_AUTHORIZATION_TTL_SECS,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
const validBefore = nowSecs + ttl;
|
|
204
|
+
const nonce = generateAuthorizationNonce();
|
|
205
|
+
|
|
206
|
+
const signed = await signTransferWithAuthorization({
|
|
207
|
+
account,
|
|
208
|
+
chain,
|
|
209
|
+
verifyingContract: requirements.asset,
|
|
210
|
+
input: {
|
|
211
|
+
from: account.address,
|
|
212
|
+
to: requirements.payTo,
|
|
213
|
+
value: required,
|
|
214
|
+
validAfter: 0n,
|
|
215
|
+
validBefore,
|
|
216
|
+
nonce,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// 6. Build the PaymentPayload + base64 the header.
|
|
221
|
+
const payload: X402PaymentPayload = {
|
|
222
|
+
x402Version: X402_VERSION,
|
|
223
|
+
scheme: X402_SCHEME_EXACT,
|
|
224
|
+
network: requirements.network,
|
|
225
|
+
payload: {
|
|
226
|
+
signature: signed.signature,
|
|
227
|
+
authorization: {
|
|
228
|
+
from: signed.from,
|
|
229
|
+
to: signed.to,
|
|
230
|
+
value: signed.value.toString(),
|
|
231
|
+
validAfter: signed.validAfter.toString(),
|
|
232
|
+
validBefore: signed.validBefore.toString(),
|
|
233
|
+
nonce: signed.nonce,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const encoded = Buffer.from(JSON.stringify(payload), 'utf-8').toString(
|
|
238
|
+
'base64',
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// 7. Re-issue. Mutating the headers via a new object so the caller's
|
|
242
|
+
// original init stays untouched.
|
|
243
|
+
const retryHeaders = new Headers(passThroughInit.headers ?? undefined);
|
|
244
|
+
retryHeaders.set(X402_PAYMENT_SIGNATURE_HEADER, encoded);
|
|
245
|
+
const retryInit: RequestInit = {
|
|
246
|
+
...passThroughInit,
|
|
247
|
+
headers: retryHeaders,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const second = (await fetchImpl(input, retryInit)) as X402Response;
|
|
251
|
+
const settlement = readSettlement(second);
|
|
252
|
+
if (settlement !== undefined) {
|
|
253
|
+
// Attach as a non-standard own-property.
|
|
254
|
+
second.x402Settlement = settlement;
|
|
255
|
+
}
|
|
256
|
+
return second;
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export type { X402ClientConfig, X402PaymentResult } from './types.js';
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defaults for the @shroud-fi/x402 package.
|
|
3
|
+
*
|
|
4
|
+
* Tight defaults reduce blast radius if a signed payload leaks:
|
|
5
|
+
* - 5-minute validity window matches Coinbase CDP recommendation and the
|
|
6
|
+
* PayAI free tier facilitator's default maxTimeoutSeconds (300).
|
|
7
|
+
* - x402Version pinned to 2 (current shipping spec). We deliberately do NOT
|
|
8
|
+
* implement v1 backward-compat per LOCKED-DECISIONS (whitepaper field
|
|
9
|
+
* names are deprecated).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const X402_VERSION = 2 as const;
|
|
13
|
+
|
|
14
|
+
/** Default EIP-3009 authorization validity window (seconds). */
|
|
15
|
+
export const DEFAULT_AUTHORIZATION_TTL_SECS = 300;
|
|
16
|
+
|
|
17
|
+
/** Default facilitator `maxTimeoutSeconds` echoed back in PaymentRequired. */
|
|
18
|
+
export const DEFAULT_MAX_TIMEOUT_SECS = 300;
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 error hierarchy.
|
|
3
|
+
*
|
|
4
|
+
* Privacy invariants:
|
|
5
|
+
* - No amount, value, or balance numbers in any .message string.
|
|
6
|
+
* - No private keys, signature bytes, or nonce bytes in any .message string.
|
|
7
|
+
* - No recipient main wallet addresses in any .message string (stealth
|
|
8
|
+
* addresses are derivable on-chain so leaking them post-payment is fine,
|
|
9
|
+
* but the registrant wallet is identity-bearing — never include it).
|
|
10
|
+
* - All errors extend the same base class so callers can `instanceof X402Error`
|
|
11
|
+
* once at the boundary.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class X402Error extends Error {
|
|
15
|
+
override readonly name: string = 'X402Error';
|
|
16
|
+
constructor(message: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The 402 challenge body (or its header) could not be parsed / is malformed.
|
|
23
|
+
* Catch-all for "we received something the spec disallows".
|
|
24
|
+
*/
|
|
25
|
+
export class X402InvalidChallengeError extends X402Error {
|
|
26
|
+
override readonly name: string = 'X402InvalidChallengeError';
|
|
27
|
+
constructor() {
|
|
28
|
+
super('Invalid x402 challenge payload');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The signed `PAYMENT-SIGNATURE` payload submitted by the client could not be
|
|
34
|
+
* verified against the challenge (signer mismatch, malformed JSON, expired
|
|
35
|
+
* authorization, etc).
|
|
36
|
+
*/
|
|
37
|
+
export class X402SignatureVerificationError extends X402Error {
|
|
38
|
+
override readonly name: string = 'X402SignatureVerificationError';
|
|
39
|
+
constructor() {
|
|
40
|
+
super('x402 signed payment payload failed verification');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Facilitator HTTP call (verify or settle) failed. Tag identifies the
|
|
46
|
+
* facilitator stage but never carries response body, token amount, or signed
|
|
47
|
+
* payload.
|
|
48
|
+
*/
|
|
49
|
+
export class X402FacilitatorError extends X402Error {
|
|
50
|
+
override readonly name: string = 'X402FacilitatorError';
|
|
51
|
+
constructor(stage: 'verify' | 'settle' | 'network') {
|
|
52
|
+
super(`x402 facilitator request failed (stage=${stage})`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The server's configured asset is not the canonical USDC on the configured
|
|
58
|
+
* chain (or USDC is not deployed on the chain). Hard-fail to avoid signing
|
|
59
|
+
* authorizations against the wrong token.
|
|
60
|
+
*/
|
|
61
|
+
export class X402AssetNotSupportedError extends X402Error {
|
|
62
|
+
override readonly name: string = 'X402AssetNotSupportedError';
|
|
63
|
+
constructor() {
|
|
64
|
+
super('x402: requested asset is not supported on the configured chain');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The server's quoted price exceeded the client's `maxPriceAtomic` safety cap,
|
|
70
|
+
* or the verified payload's value did not equal the challenge requirement.
|
|
71
|
+
* The error carries no amount bytes — caller can read inputs separately.
|
|
72
|
+
*/
|
|
73
|
+
export class X402AmountMismatchError extends X402Error {
|
|
74
|
+
override readonly name: string = 'X402AmountMismatchError';
|
|
75
|
+
constructor() {
|
|
76
|
+
super('x402: payment amount exceeds caller cap or does not match challenge');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facilitator HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* Two endpoints per the x402 spec:
|
|
5
|
+
* - POST /verify — facilitator validates signature + balance + simulation.
|
|
6
|
+
* - POST /settle — facilitator broadcasts the on-chain settlement tx.
|
|
7
|
+
*
|
|
8
|
+
* Privacy invariants:
|
|
9
|
+
* - We forward only the signed PaymentPayload + the PaymentRequirements. The
|
|
10
|
+
* facilitator never receives the recipient's main wallet — only the
|
|
11
|
+
* freshly derived stealth address (the `payTo` field of the requirements).
|
|
12
|
+
* - We never log request/response bodies. Tags are short strings only.
|
|
13
|
+
* - Auth token (if any) is attached as a Bearer header. We never log the
|
|
14
|
+
* full token; tests verify the header is set but redaction is the
|
|
15
|
+
* operator's responsibility upstream.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Hex } from 'viem';
|
|
19
|
+
import {
|
|
20
|
+
PAYAI_FACILITATOR_URL,
|
|
21
|
+
facilitatorNetworkName,
|
|
22
|
+
type X402FacilitatorConfig,
|
|
23
|
+
} from './protocol.js';
|
|
24
|
+
import { X402FacilitatorError } from './errors.js';
|
|
25
|
+
|
|
26
|
+
export interface FacilitatorVerifyResult {
|
|
27
|
+
readonly isValid: boolean;
|
|
28
|
+
readonly invalidReason?: string;
|
|
29
|
+
readonly payer?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FacilitatorSettleResult {
|
|
33
|
+
readonly success: boolean;
|
|
34
|
+
readonly txHash?: Hex;
|
|
35
|
+
readonly errorReason?: string;
|
|
36
|
+
readonly payer?: string;
|
|
37
|
+
readonly network?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve the facilitator config, defaulting to PayAI free tier. */
|
|
41
|
+
export function resolveFacilitator(
|
|
42
|
+
config: X402FacilitatorConfig | undefined,
|
|
43
|
+
): X402FacilitatorConfig {
|
|
44
|
+
if (config === undefined) {
|
|
45
|
+
return { url: PAYAI_FACILITATOR_URL };
|
|
46
|
+
}
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Translate our internal x402 v2 / CAIP-2 verify+settle body into the v1 /
|
|
52
|
+
* legacy-network shape that PayAI (and the wider facilitator ecosystem)
|
|
53
|
+
* accepts on the wire. Proven end-to-end on Base mainnet (tx 0x57d84d53…)
|
|
54
|
+
* before this shipped.
|
|
55
|
+
*
|
|
56
|
+
* Transformations:
|
|
57
|
+
* - x402Version 2 → 1 (top-level and on the payload)
|
|
58
|
+
* - network CAIP-2 (`eip155:8453`) → legacy name (`base`) on both the
|
|
59
|
+
* payload and the requirements
|
|
60
|
+
* - requirements.extra trimmed to `{ name, version }` — the facilitator
|
|
61
|
+
* does not consume our `shroudfiAnnouncement` extension and some reject
|
|
62
|
+
* unknown extra keys
|
|
63
|
+
* - requirements gains `mimeType: ''` (present in the canonical v1 shape)
|
|
64
|
+
*
|
|
65
|
+
* Pure — never mutates its inputs. Throws X402FacilitatorError('network') for
|
|
66
|
+
* a chain with no mapped facilitator network name.
|
|
67
|
+
*/
|
|
68
|
+
export function toFacilitatorRequest(args: {
|
|
69
|
+
readonly chainId: number;
|
|
70
|
+
readonly paymentRequirements: Record<string, unknown> & {
|
|
71
|
+
readonly extra?: Record<string, unknown>;
|
|
72
|
+
};
|
|
73
|
+
readonly paymentPayload: Record<string, unknown>;
|
|
74
|
+
}): {
|
|
75
|
+
x402Version: 1;
|
|
76
|
+
paymentPayload: Record<string, unknown> & { x402Version: 1; network: string };
|
|
77
|
+
paymentRequirements: Record<string, unknown> & {
|
|
78
|
+
network: string;
|
|
79
|
+
mimeType: string;
|
|
80
|
+
extra: { name: string; version: string };
|
|
81
|
+
};
|
|
82
|
+
} {
|
|
83
|
+
const network = facilitatorNetworkName(args.chainId);
|
|
84
|
+
if (network === undefined) {
|
|
85
|
+
throw new X402FacilitatorError('network');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const srcExtra = (args.paymentRequirements.extra ?? {}) as Record<
|
|
89
|
+
string,
|
|
90
|
+
unknown
|
|
91
|
+
>;
|
|
92
|
+
const extra = {
|
|
93
|
+
name: String(srcExtra.name ?? ''),
|
|
94
|
+
version: String(srcExtra.version ?? ''),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const paymentRequirements = {
|
|
98
|
+
...args.paymentRequirements,
|
|
99
|
+
network,
|
|
100
|
+
mimeType:
|
|
101
|
+
typeof args.paymentRequirements.mimeType === 'string'
|
|
102
|
+
? (args.paymentRequirements.mimeType as string)
|
|
103
|
+
: '',
|
|
104
|
+
extra,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const paymentPayload = {
|
|
108
|
+
...args.paymentPayload,
|
|
109
|
+
x402Version: 1 as const,
|
|
110
|
+
network,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return { x402Version: 1, paymentPayload, paymentRequirements };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build standard headers for a facilitator request. When an Ed25519 auth
|
|
118
|
+
* token is configured, attach `Authorization: Bearer <token>`. Otherwise
|
|
119
|
+
* the request hits the PayAI free-tier endpoint (no auth required).
|
|
120
|
+
*/
|
|
121
|
+
export function buildFacilitatorHeaders(
|
|
122
|
+
config: X402FacilitatorConfig,
|
|
123
|
+
): Record<string, string> {
|
|
124
|
+
const headers: Record<string, string> = {
|
|
125
|
+
'content-type': 'application/json',
|
|
126
|
+
accept: 'application/json',
|
|
127
|
+
};
|
|
128
|
+
if (config.ed25519AuthToken !== undefined) {
|
|
129
|
+
headers['authorization'] = `Bearer ${config.ed25519AuthToken}`;
|
|
130
|
+
}
|
|
131
|
+
return headers;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Call the facilitator's `/verify` endpoint. Returns the parsed verification
|
|
136
|
+
* result. Re-wraps every failure path as a tagged X402FacilitatorError so
|
|
137
|
+
* no upstream response body leaks through.
|
|
138
|
+
*/
|
|
139
|
+
export async function facilitatorVerify(
|
|
140
|
+
config: X402FacilitatorConfig,
|
|
141
|
+
body: unknown,
|
|
142
|
+
fetchImpl: typeof fetch = fetch,
|
|
143
|
+
): Promise<FacilitatorVerifyResult> {
|
|
144
|
+
const url = `${config.url.replace(/\/+$/, '')}/verify`;
|
|
145
|
+
let res: Response;
|
|
146
|
+
try {
|
|
147
|
+
res = await fetchImpl(url, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: buildFacilitatorHeaders(config),
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
throw new X402FacilitatorError('network');
|
|
154
|
+
}
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
throw new X402FacilitatorError('verify');
|
|
157
|
+
}
|
|
158
|
+
let parsed: FacilitatorVerifyResult;
|
|
159
|
+
try {
|
|
160
|
+
parsed = (await res.json()) as FacilitatorVerifyResult;
|
|
161
|
+
} catch {
|
|
162
|
+
throw new X402FacilitatorError('verify');
|
|
163
|
+
}
|
|
164
|
+
return parsed;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Call the facilitator's `/settle` endpoint. Returns the parsed settle
|
|
169
|
+
* result (including the on-chain tx hash on success).
|
|
170
|
+
*/
|
|
171
|
+
export async function facilitatorSettle(
|
|
172
|
+
config: X402FacilitatorConfig,
|
|
173
|
+
body: unknown,
|
|
174
|
+
fetchImpl: typeof fetch = fetch,
|
|
175
|
+
): Promise<FacilitatorSettleResult> {
|
|
176
|
+
const url = `${config.url.replace(/\/+$/, '')}/settle`;
|
|
177
|
+
let res: Response;
|
|
178
|
+
try {
|
|
179
|
+
res = await fetchImpl(url, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: buildFacilitatorHeaders(config),
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
throw new X402FacilitatorError('network');
|
|
186
|
+
}
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
throw new X402FacilitatorError('settle');
|
|
189
|
+
}
|
|
190
|
+
let raw: Record<string, unknown>;
|
|
191
|
+
try {
|
|
192
|
+
raw = (await res.json()) as Record<string, unknown>;
|
|
193
|
+
} catch {
|
|
194
|
+
throw new X402FacilitatorError('settle');
|
|
195
|
+
}
|
|
196
|
+
// Normalize the tx-hash field. The x402 facilitator spec drifted: PayAI
|
|
197
|
+
// returns `transaction`, some return `transactionHash`, our own type uses
|
|
198
|
+
// `txHash`. Accept all three so callers get a populated txHash on success.
|
|
199
|
+
const txHash = (raw.txHash ?? raw.transaction ?? raw.transactionHash) as
|
|
200
|
+
| Hex
|
|
201
|
+
| undefined;
|
|
202
|
+
return {
|
|
203
|
+
success: raw.success === true,
|
|
204
|
+
...(txHash !== undefined ? { txHash } : {}),
|
|
205
|
+
...(typeof raw.errorReason === 'string'
|
|
206
|
+
? { errorReason: raw.errorReason }
|
|
207
|
+
: {}),
|
|
208
|
+
...(typeof raw.payer === 'string' ? { payer: raw.payer } : {}),
|
|
209
|
+
...(typeof raw.network === 'string' ? { network: raw.network } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Public surface — @shroud-fi/x402
|
|
2
|
+
// HTTP 402 + stealth-address payment routing for AI agents on Base.
|
|
3
|
+
// See packages/x402/README and docs/research/competitive/x402-spec-and-ecosystem.md.
|
|
4
|
+
|
|
5
|
+
// Server side
|
|
6
|
+
export { createX402Server } from './server.js';
|
|
7
|
+
export type {
|
|
8
|
+
X402Server,
|
|
9
|
+
X402ServerConfig,
|
|
10
|
+
X402Challenge,
|
|
11
|
+
X402PaymentRequirements,
|
|
12
|
+
X402PaymentVerification,
|
|
13
|
+
} from './server.js';
|
|
14
|
+
|
|
15
|
+
// Client side
|
|
16
|
+
export { createX402Client } from './client.js';
|
|
17
|
+
export type {
|
|
18
|
+
X402Client,
|
|
19
|
+
X402ClientConfig,
|
|
20
|
+
X402PaymentResult,
|
|
21
|
+
} from './client.js';
|
|
22
|
+
|
|
23
|
+
// Protocol constants + types
|
|
24
|
+
export {
|
|
25
|
+
X402_PAYMENT_REQUIRED_HEADER,
|
|
26
|
+
X402_PAYMENT_SIGNATURE_HEADER,
|
|
27
|
+
X402_PAYMENT_RESPONSE_HEADER,
|
|
28
|
+
X402_SCHEME_EXACT,
|
|
29
|
+
PAYAI_FACILITATOR_URL,
|
|
30
|
+
COINBASE_FACILITATOR_URL,
|
|
31
|
+
facilitatorNetworkName,
|
|
32
|
+
} from './protocol.js';
|
|
33
|
+
|
|
34
|
+
// Facilitator client + adapter (v0.1.2)
|
|
35
|
+
export {
|
|
36
|
+
resolveFacilitator,
|
|
37
|
+
toFacilitatorRequest,
|
|
38
|
+
facilitatorVerify,
|
|
39
|
+
facilitatorSettle,
|
|
40
|
+
} from './facilitator.js';
|
|
41
|
+
export type {
|
|
42
|
+
FacilitatorVerifyResult,
|
|
43
|
+
FacilitatorSettleResult,
|
|
44
|
+
} from './facilitator.js';
|
|
45
|
+
export type {
|
|
46
|
+
X402PaymentPayload,
|
|
47
|
+
X402SchemeId,
|
|
48
|
+
X402FacilitatorConfig,
|
|
49
|
+
} from './protocol.js';
|
|
50
|
+
|
|
51
|
+
// Errors
|
|
52
|
+
export {
|
|
53
|
+
X402Error,
|
|
54
|
+
X402InvalidChallengeError,
|
|
55
|
+
X402SignatureVerificationError,
|
|
56
|
+
X402FacilitatorError,
|
|
57
|
+
X402AssetNotSupportedError,
|
|
58
|
+
X402AmountMismatchError,
|
|
59
|
+
} from './errors.js';
|