402-trinity-gaming 0.1.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/INTEGRATION.md +188 -0
- package/LICENSE +110 -0
- package/README.md +105 -0
- package/dist/batch-manager.d.ts +201 -0
- package/dist/batch-manager.js +291 -0
- package/dist/batch-manager.min.js +1 -0
- package/dist/budget-file.d.ts +106 -0
- package/dist/budget-file.js +270 -0
- package/dist/budget-file.min.js +1 -0
- package/dist/evm-tx.d.ts +55 -0
- package/dist/evm-tx.js +195 -0
- package/dist/evm-tx.min.js +1 -0
- package/dist/proceeds-fee.d.ts +87 -0
- package/dist/proceeds-fee.js +158 -0
- package/dist/proceeds-fee.min.js +1 -0
- package/dist/seller.d.ts +121 -0
- package/dist/seller.js +136 -0
- package/dist/seller.min.js +1 -0
- package/dist/signer.d.ts +64 -0
- package/dist/signer.js +61 -0
- package/dist/signer.min.js +1 -0
- package/dist/storefront.d.ts +143 -0
- package/dist/storefront.js +173 -0
- package/dist/storefront.min.js +1 -0
- package/dist/x402.d.ts +391 -0
- package/dist/x402.js +930 -0
- package/dist/x402.min.js +1 -0
- package/package.json +120 -0
- package/src/batch-manager.ts +351 -0
- package/src/budget-file.ts +314 -0
- package/src/evm-tx.ts +244 -0
- package/src/proceeds-fee.ts +202 -0
- package/src/seller.ts +252 -0
- package/src/signer.ts +129 -0
- package/src/storefront.ts +286 -0
- package/src/x402.ts +1255 -0
package/src/x402.ts
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402-trinity - zero-dependency x402 payment interceptor for edge runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Native primitives only: BigInt, Uint32Array, crypto.getRandomValues, fetch, btoa.
|
|
5
|
+
* secp256k1 + keccak256 + EIP-712/EIP-3009 are implemented inline because WebCrypto
|
|
6
|
+
* (crypto.subtle) exposes neither the secp256k1 curve nor a keccak256 digest.
|
|
7
|
+
*
|
|
8
|
+
* Live-path cost once warm: 3 keccak permutations + 2 modmuls. No key generation,
|
|
9
|
+
* no curve multiplication, no scalar tables built at request time.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/* ============================ keccak-256 ============================ */
|
|
13
|
+
|
|
14
|
+
const RC_LO = new Uint32Array([
|
|
15
|
+
0x00000001, 0x00008082, 0x0000808a, 0x80008000, 0x0000808b, 0x80000001,
|
|
16
|
+
0x80008081, 0x00008009, 0x0000008a, 0x00000088, 0x80008009, 0x8000000a,
|
|
17
|
+
0x8000808b, 0x0000008b, 0x00008089, 0x00008003, 0x00008002, 0x00000080,
|
|
18
|
+
0x0000800a, 0x8000000a, 0x80008081, 0x00008080, 0x80000001, 0x80008008,
|
|
19
|
+
]);
|
|
20
|
+
const RC_HI = new Uint32Array([
|
|
21
|
+
0x00000000, 0x00000000, 0x80000000, 0x80000000, 0x00000000, 0x00000000,
|
|
22
|
+
0x80000000, 0x80000000, 0x00000000, 0x00000000, 0x00000000, 0x00000000,
|
|
23
|
+
0x00000000, 0x80000000, 0x80000000, 0x80000000, 0x80000000, 0x80000000,
|
|
24
|
+
0x00000000, 0x80000000, 0x80000000, 0x80000000, 0x00000000, 0x80000000,
|
|
25
|
+
]);
|
|
26
|
+
// rho rotation offsets, lane index = x + 5y
|
|
27
|
+
const RHO = new Uint8Array([
|
|
28
|
+
0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39,
|
|
29
|
+
41, 45, 15, 21, 8, 18, 2, 61, 56, 14,
|
|
30
|
+
]);
|
|
31
|
+
// pi permutation: PI[src] = dst, where dst = y + 5*((2x+3y) mod 5)
|
|
32
|
+
const PI = (() => {
|
|
33
|
+
const p = new Uint8Array(25);
|
|
34
|
+
for (let y = 0; y < 5; y++) for (let x = 0; x < 5; x++) p[x + 5 * y] = y + 5 * ((2 * x + 3 * y) % 5);
|
|
35
|
+
return p;
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
const _S = new Uint32Array(50);
|
|
39
|
+
const _B = new Uint32Array(50);
|
|
40
|
+
const _C = new Uint32Array(10);
|
|
41
|
+
|
|
42
|
+
function keccakF(S: Uint32Array): void {
|
|
43
|
+
const B = _B, C = _C;
|
|
44
|
+
for (let rnd = 0; rnd < 24; rnd++) {
|
|
45
|
+
// theta
|
|
46
|
+
for (let x = 0; x < 5; x++) {
|
|
47
|
+
let lo = 0, hi = 0;
|
|
48
|
+
for (let y = 0; y < 25; y += 5) { const i = (x + y) << 1; lo ^= S[i]; hi ^= S[i + 1]; }
|
|
49
|
+
C[x << 1] = lo; C[(x << 1) + 1] = hi;
|
|
50
|
+
}
|
|
51
|
+
for (let x = 0; x < 5; x++) {
|
|
52
|
+
const a = ((x + 1) % 5) << 1, b = ((x + 4) % 5) << 1;
|
|
53
|
+
const lo1 = C[a], hi1 = C[a + 1];
|
|
54
|
+
const dlo = C[b] ^ ((lo1 << 1) | (hi1 >>> 31));
|
|
55
|
+
const dhi = C[b + 1] ^ ((hi1 << 1) | (lo1 >>> 31));
|
|
56
|
+
for (let y = 0; y < 25; y += 5) { const i = (x + y) << 1; S[i] ^= dlo; S[i + 1] ^= dhi; }
|
|
57
|
+
}
|
|
58
|
+
// rho + pi
|
|
59
|
+
for (let i = 0; i < 25; i++) {
|
|
60
|
+
const n = RHO[i], lo = S[i << 1], hi = S[(i << 1) + 1], d = PI[i] << 1;
|
|
61
|
+
if (n === 0) { B[d] = lo; B[d + 1] = hi; }
|
|
62
|
+
else if (n < 32) { B[d] = (lo << n) | (hi >>> (32 - n)); B[d + 1] = (hi << n) | (lo >>> (32 - n)); }
|
|
63
|
+
else { const m = n - 32; B[d] = (hi << m) | (lo >>> (32 - m)); B[d + 1] = (lo << m) | (hi >>> (32 - m)); }
|
|
64
|
+
}
|
|
65
|
+
// chi
|
|
66
|
+
for (let y = 0; y < 25; y += 5) for (let x = 0; x < 5; x++) {
|
|
67
|
+
const i = (x + y) << 1, i1 = (((x + 1) % 5) + y) << 1, i2 = (((x + 2) % 5) + y) << 1;
|
|
68
|
+
S[i] = B[i] ^ (~B[i1] & B[i2]);
|
|
69
|
+
S[i + 1] = B[i + 1] ^ (~B[i1 + 1] & B[i2 + 1]);
|
|
70
|
+
}
|
|
71
|
+
// iota
|
|
72
|
+
S[0] ^= RC_LO[rnd]; S[1] ^= RC_HI[rnd];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** keccak256 over one or more byte runs, absorbed as if concatenated. */
|
|
77
|
+
export function keccak256(...parts: Uint8Array[]): Uint8Array {
|
|
78
|
+
const S = _S; S.fill(0);
|
|
79
|
+
let p = 0; // byte offset inside the 136-byte rate block
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
for (let j = 0; j < part.length; j++) {
|
|
82
|
+
S[p >> 2] ^= part[j] << ((p & 3) << 3);
|
|
83
|
+
if (++p === 136) { keccakF(S); p = 0; }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
S[p >> 2] ^= 0x01 << ((p & 3) << 3); // keccak (not SHA3) padding
|
|
87
|
+
S[33] ^= 0x80000000; // final bit at byte 135
|
|
88
|
+
keccakF(S);
|
|
89
|
+
const out = new Uint8Array(32);
|
|
90
|
+
for (let i = 0; i < 32; i++) out[i] = (S[i >> 2] >>> ((i & 3) << 3)) & 0xff;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ======================= bytes / hex / abi words ======================= */
|
|
95
|
+
|
|
96
|
+
const HEXC = '0123456789abcdef';
|
|
97
|
+
export const toHex = (b: Uint8Array): string => {
|
|
98
|
+
let s = '0x';
|
|
99
|
+
for (let i = 0; i < b.length; i++) s += HEXC[b[i] >> 4] + HEXC[b[i] & 15];
|
|
100
|
+
return s;
|
|
101
|
+
};
|
|
102
|
+
export const fromHex = (h: string): Uint8Array => {
|
|
103
|
+
const s = h.slice(0, 2) === '0x' ? h.slice(2) : h;
|
|
104
|
+
const b = new Uint8Array(s.length >> 1);
|
|
105
|
+
for (let i = 0; i < b.length; i++) b[i] = parseInt(s.slice(i << 1, (i << 1) + 2), 16);
|
|
106
|
+
return b;
|
|
107
|
+
};
|
|
108
|
+
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s);
|
|
109
|
+
const beBytes = (v: bigint, len: number): Uint8Array => {
|
|
110
|
+
const b = new Uint8Array(len);
|
|
111
|
+
for (let i = len - 1; i >= 0 && v > 0n; i--) { b[i] = Number(v & 0xffn); v >>= 8n; }
|
|
112
|
+
return b;
|
|
113
|
+
};
|
|
114
|
+
const toBig = (b: Uint8Array): bigint => {
|
|
115
|
+
let v = 0n;
|
|
116
|
+
for (let i = 0; i < b.length; i++) v = (v << 8n) | BigInt(b[i]);
|
|
117
|
+
return v;
|
|
118
|
+
};
|
|
119
|
+
/** One abi.encode word: uint256, address or bytes32. */
|
|
120
|
+
const word = (v: bigint | string | Uint8Array): Uint8Array => {
|
|
121
|
+
if (typeof v === 'bigint') return beBytes(v, 32);
|
|
122
|
+
const b = typeof v === 'string' ? fromHex(v) : v;
|
|
123
|
+
const w = new Uint8Array(32);
|
|
124
|
+
w.set(b, 32 - b.length);
|
|
125
|
+
return w;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/* ============================= secp256k1 ============================= */
|
|
129
|
+
|
|
130
|
+
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
|
|
131
|
+
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
|
|
132
|
+
const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
|
|
133
|
+
const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
|
|
134
|
+
const HALF_N = N >> 1n;
|
|
135
|
+
|
|
136
|
+
type J = [bigint, bigint, bigint]; // jacobian point
|
|
137
|
+
const mod = (a: bigint, m: bigint): bigint => { const r = a % m; return r < 0n ? r + m : r; };
|
|
138
|
+
|
|
139
|
+
function inv(a: bigint, m: bigint): bigint {
|
|
140
|
+
let r = m, nr = mod(a, m), s = 0n, ns = 1n;
|
|
141
|
+
while (nr !== 0n) {
|
|
142
|
+
const q = r / nr;
|
|
143
|
+
const tr = r - q * nr; r = nr; nr = tr;
|
|
144
|
+
const ts = s - q * ns; s = ns; ns = ts;
|
|
145
|
+
}
|
|
146
|
+
return mod(s, m);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function jDbl(p: J): J {
|
|
150
|
+
const X = p[0], Y = p[1], Z = p[2];
|
|
151
|
+
if (Y === 0n || Z === 0n) return [0n, 1n, 0n];
|
|
152
|
+
const A = mod(X * X, P), B = mod(Y * Y, P), C = mod(B * B, P);
|
|
153
|
+
const D = mod(2n * (mod((X + B) * (X + B), P) - A - C), P);
|
|
154
|
+
const E = mod(3n * A, P), F = mod(E * E, P);
|
|
155
|
+
const X3 = mod(F - 2n * D, P);
|
|
156
|
+
return [X3, mod(E * (D - X3) - 8n * C, P), mod(2n * Y * Z, P)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function jAdd(p: J, q: J): J {
|
|
160
|
+
const X1 = p[0], Y1 = p[1], Z1 = p[2], X2 = q[0], Y2 = q[1], Z2 = q[2];
|
|
161
|
+
if (Z1 === 0n) return q;
|
|
162
|
+
if (Z2 === 0n) return p;
|
|
163
|
+
const ZZ1 = mod(Z1 * Z1, P), ZZ2 = mod(Z2 * Z2, P);
|
|
164
|
+
const U1 = mod(X1 * ZZ2, P), U2 = mod(X2 * ZZ1, P);
|
|
165
|
+
const S1 = mod(Y1 * Z2 * ZZ2, P), S2 = mod(Y2 * Z1 * ZZ1, P);
|
|
166
|
+
const H = mod(U2 - U1, P), R = mod(S2 - S1, P);
|
|
167
|
+
if (H === 0n) return R === 0n ? jDbl(p) : [0n, 1n, 0n];
|
|
168
|
+
const HH = mod(H * H, P), HHH = mod(H * HH, P), V = mod(U1 * HH, P);
|
|
169
|
+
const X3 = mod(R * R - HHH - 2n * V, P);
|
|
170
|
+
return [X3, mod(R * (V - X3) - S1 * HHH, P), mod(Z1 * Z2 * H, P)];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Scalar multiply. Runs only off the hot path (key setup + idle nonce fill), so it is
|
|
175
|
+
* a plain double-and-add: variable-time, but never executed while a request is in flight.
|
|
176
|
+
* See README "Known tradeoffs" before using this in a shared-tenant process.
|
|
177
|
+
*/
|
|
178
|
+
function jMul(k: bigint, p: J): J {
|
|
179
|
+
let r: J = [0n, 1n, 0n], a = p;
|
|
180
|
+
while (k > 0n) {
|
|
181
|
+
if (k & 1n) r = jAdd(r, a);
|
|
182
|
+
a = jDbl(a);
|
|
183
|
+
k >>= 1n;
|
|
184
|
+
}
|
|
185
|
+
return r;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Constant-time-hardened scalar multiply, for scalars that ARE secret: the private key
|
|
190
|
+
* (addressOf) and the ECDSA nonce k (makeNonce - leaking k leaks the key outright).
|
|
191
|
+
*
|
|
192
|
+
* Two countermeasures:
|
|
193
|
+
* 1. Scalar blinding - compute (k + r*n)*G instead of k*G. Identical result because
|
|
194
|
+
* n*G is the point at infinity, but the bit pattern is re-randomised every call,
|
|
195
|
+
* so repeated signings never expose the same operation sequence twice.
|
|
196
|
+
* 2. Always-add-and-double over a FIXED iteration count, with a branchless select.
|
|
197
|
+
* The addition is performed on every bit and the result chosen by bit-mask, so the
|
|
198
|
+
* add/no-add pattern no longer tracks the key bits, and the loop count no longer
|
|
199
|
+
* reveals the scalar's bit length.
|
|
200
|
+
*
|
|
201
|
+
* HONEST SCOPE - this is hardening, not a constant-time proof. JavaScript BigInt
|
|
202
|
+
* arithmetic is itself variable-time (V8 short-circuits on operand size and allocates
|
|
203
|
+
* per operation), and no pure-JS implementation can remove that. What is removed is the
|
|
204
|
+
* large, directly key-correlated leak: the data-dependent branch on each key bit.
|
|
205
|
+
* If the threat model genuinely requires constant-time signing, use `remoteSign` and an
|
|
206
|
+
* HSM. Cost: about 1.7x the work of the variable-time path - see the benchmark.
|
|
207
|
+
*/
|
|
208
|
+
function jMulCT(k: bigint, p: J): J {
|
|
209
|
+
const rb = new Uint32Array(1);
|
|
210
|
+
crypto.getRandomValues(rb);
|
|
211
|
+
const kb = k + BigInt(rb[0]) * N; // blinded scalar, same result
|
|
212
|
+
let R: J = [0n, 1n, 0n];
|
|
213
|
+
for (let i = 287; i >= 0; i--) {
|
|
214
|
+
R = jDbl(R);
|
|
215
|
+
const T = jAdd(R, p);
|
|
216
|
+
const m = -((kb >> BigInt(i)) & 1n); // 0n when the bit is 0, -1n when 1
|
|
217
|
+
R = [
|
|
218
|
+
(R[0] & ~m) | (T[0] & m),
|
|
219
|
+
(R[1] & ~m) | (T[1] & m),
|
|
220
|
+
(R[2] & ~m) | (T[2] & m),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
return R;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const affine = (p: J): [bigint, bigint] => {
|
|
227
|
+
const zi = inv(p[2], P), z2 = mod(zi * zi, P);
|
|
228
|
+
return [mod(p[0] * z2, P), mod(p[1] * mod(z2 * zi, P), P)];
|
|
229
|
+
};
|
|
230
|
+
const G: J = [GX, GY, 1n];
|
|
231
|
+
|
|
232
|
+
function randScalar(): bigint {
|
|
233
|
+
const b = new Uint8Array(32);
|
|
234
|
+
for (;;) {
|
|
235
|
+
crypto.getRandomValues(b);
|
|
236
|
+
const v = toBig(b);
|
|
237
|
+
if (v > 0n && v < N) return v;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Lowercase 20-byte address for a private scalar. */
|
|
242
|
+
function addressOf(d: bigint): string {
|
|
243
|
+
const xy = affine(jMulCT(d, G)); // d is the private key
|
|
244
|
+
return toHex(keccak256(beBytes(xy[0], 32), beBytes(xy[1], 32)).slice(12));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* ============== anticipatory ECDSA nonce = the pipeline ============== */
|
|
248
|
+
|
|
249
|
+
interface Nonce { kInv: bigint; r: bigint; rec: number }
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Precompute k*G, r and k^-1. This is the entire expensive half of ECDSA and it does
|
|
253
|
+
* not depend on the message, so it is fully computable before the 402 ever arrives.
|
|
254
|
+
* A Nonce is single-use: reusing k across two signatures leaks the private key.
|
|
255
|
+
*/
|
|
256
|
+
function makeNonce(): Nonce {
|
|
257
|
+
for (;;) {
|
|
258
|
+
const k = randScalar();
|
|
259
|
+
const xy = affine(jMulCT(k, G)); // k is secret: leaking it leaks the private key
|
|
260
|
+
const r = mod(xy[0], N);
|
|
261
|
+
if (r === 0n) continue;
|
|
262
|
+
return { kInv: inv(k, N), r, rec: Number(xy[1] & 1n) | (xy[0] >= N ? 2 : 0) };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Live path: two modmuls plus low-s normalization (EIP-2). No curve operations. */
|
|
267
|
+
function signWith(nc: Nonce, z: bigint, d: bigint): string {
|
|
268
|
+
let s = mod(nc.kInv * (z + nc.r * d), N);
|
|
269
|
+
let rec = nc.rec;
|
|
270
|
+
if (s > HALF_N) { s = N - s; rec ^= 1; }
|
|
271
|
+
const sig = new Uint8Array(65);
|
|
272
|
+
sig.set(beBytes(nc.r, 32), 0);
|
|
273
|
+
sig.set(beBytes(s, 32), 32);
|
|
274
|
+
sig[64] = 27 + rec;
|
|
275
|
+
return toHex(sig);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ========================= EIP-712 / EIP-3009 ========================= */
|
|
279
|
+
|
|
280
|
+
const DOMAIN_TH = keccak256(utf8(
|
|
281
|
+
'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'
|
|
282
|
+
));
|
|
283
|
+
const XFER_TH = keccak256(utf8(
|
|
284
|
+
'TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)'
|
|
285
|
+
));
|
|
286
|
+
|
|
287
|
+
export interface Authorization {
|
|
288
|
+
from: string; to: string; value: string;
|
|
289
|
+
validAfter: string; validBefore: string; nonce: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const domainSep = (name: string, version: string, chainId: number, verifying: string): Uint8Array =>
|
|
293
|
+
keccak256(DOMAIN_TH, keccak256(utf8(name)), keccak256(utf8(version)), word(BigInt(chainId)), word(verifying));
|
|
294
|
+
|
|
295
|
+
function digest(dsep: Uint8Array, a: Authorization): bigint {
|
|
296
|
+
const structHash = keccak256(
|
|
297
|
+
XFER_TH, word(a.from), word(a.to), word(BigInt(a.value)),
|
|
298
|
+
word(BigInt(a.validAfter)), word(BigInt(a.validBefore)), word(a.nonce)
|
|
299
|
+
);
|
|
300
|
+
return toBig(keccak256(new Uint8Array([0x19, 0x01]), dsep, structHash));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ============================ x402 protocol ============================ */
|
|
304
|
+
|
|
305
|
+
export interface Requirement {
|
|
306
|
+
scheme: string; network: string; payTo: string; asset: string;
|
|
307
|
+
maxAmountRequired: string; maxTimeoutSeconds?: number;
|
|
308
|
+
extra?: { name?: string; version?: string } | null;
|
|
309
|
+
resource?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* MAINNET ONLY. chainId + USDC defaults, used to fill gaps the server left.
|
|
314
|
+
*
|
|
315
|
+
* Every field here was read off the deployed contract - `name()`, `version()`, `eth_chainId`
|
|
316
|
+
* and `DOMAIN_SEPARATOR()` - not copied from documentation. A wrong `name` produces a wrong
|
|
317
|
+
* domain separator and every payment on that chain is silently rejected on-chain.
|
|
318
|
+
*
|
|
319
|
+
* No testnets ship. Add any chain you need - including a testnet - via `customChains`.
|
|
320
|
+
*/
|
|
321
|
+
export interface ChainSpec { id: number; asset: string; name: string; version: string }
|
|
322
|
+
const CHAINS: Record<string, ChainSpec> = {
|
|
323
|
+
// Base + USDC only. This is the pair that has actually moved money - every other chain
|
|
324
|
+
// had a verified domain separator and zero real transactions, and shipping a default
|
|
325
|
+
// nobody has sent a payment on is a claim, not a feature. Add others with `customChains`
|
|
326
|
+
// once you have tested them: the machinery is chain-agnostic, the confidence is not.
|
|
327
|
+
'base': { id: 8453, asset: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', name: 'USD Coin', version: '2' },
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/* =========================== THE PROTOCOL FEE ===========================
|
|
331
|
+
* x402-trinity adds 0.1% ON TOP of every payment, plus a flat $0.01 on every hundredth
|
|
332
|
+
* one, and it is ON BY DEFAULT.
|
|
333
|
+
*
|
|
334
|
+
* - it is ADDED, never deducted: the seller always receives their full asking
|
|
335
|
+
* price, and the extra comes out of the payer's wallet
|
|
336
|
+
* - a notice is printed the first time a client is constructed; you can send it
|
|
337
|
+
* somewhere else, but it always fires
|
|
338
|
+
* - it is settled by a facilitator, so neither you nor the payer spends gas moving it
|
|
339
|
+
* - turn it off in one line: createX402Fetch({ surcharge: false, ... })
|
|
340
|
+
*
|
|
341
|
+
* Both are owed on the payments they land on, but they are SETTLED TOGETHER in a single
|
|
342
|
+
* authorization on the hundredth payment. Settling costs about $0.0015 of gas on Base, and
|
|
343
|
+
* 0.1% of a two-cent payment is $0.00002 - moving that on its own would cost seventy-five
|
|
344
|
+
* times what it collects. Batching makes gas roughly an eighth of what is swept.
|
|
345
|
+
*
|
|
346
|
+
* The tally MUST be durable for this to work - see `surcharge.store`. Without one it resets
|
|
347
|
+
* with the process and the hundredth payment never arrives.
|
|
348
|
+
*
|
|
349
|
+
* If you would rather not pay it, the opt-out above is supported, deliberately easy,
|
|
350
|
+
* and will not be removed.
|
|
351
|
+
* ======================================================================== */
|
|
352
|
+
const FEE_VAULT = '0x2f011f21D6Ec758Bc18f0f9142EeD01Ce2d8a0d3';
|
|
353
|
+
const FEE_PPM = 1000n; // 1000 parts per million = 0.1%, on every payment
|
|
354
|
+
const FEE_EVERY = 100n; // plus a flat charge every hundredth payment
|
|
355
|
+
const FEE_AMOUNT = 10_000n; // $0.01, flat
|
|
356
|
+
const FEE_SCALE = 1_000_000n; // tally precision, so sub-unit fees are not lost
|
|
357
|
+
/**
|
|
358
|
+
* Where a signed fee authorization is sent. Self-hosted, so collection does not depend on
|
|
359
|
+
* a third party's rate limit or free tier - the previous default stopped settling the
|
|
360
|
+
* moment its quota ran out, and every fee after that was simply lost.
|
|
361
|
+
*
|
|
362
|
+
* It speaks the facilitator /settle shape, so `surcharge.collector` can be pointed at any
|
|
363
|
+
* x402 facilitator instead and the client does not need to know the difference.
|
|
364
|
+
*
|
|
365
|
+
* A facilitator has no concept of "seller". It checks that an authorization matches the
|
|
366
|
+
* requirements it was handed, so presenting requirements whose payTo is the vault makes
|
|
367
|
+
* the fee an ordinary x402 payment as far as it can tell.
|
|
368
|
+
*/
|
|
369
|
+
const FEE_COLLECTOR = 'https://x402-trinity-collector.x402trinity.workers.dev/submit';
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The fee disclosure, as a value rather than a side effect. Nothing prints it: a library
|
|
373
|
+
* running inside a studio's process has no business writing to their console. Surface it
|
|
374
|
+
* wherever disclosure belongs for you - store terms, a settings screen, your own logger -
|
|
375
|
+
* or pass `surcharge.onNotice` to receive it at construction.
|
|
376
|
+
*/
|
|
377
|
+
export const NOTICE =
|
|
378
|
+
'A convenience fee is added ON TOP of each payment: 0.1% per transaction and 1 cent ' +
|
|
379
|
+
'every 100 transactions. Sellers always receive their full asking price.';
|
|
380
|
+
|
|
381
|
+
/** CAIP-2 ids, as used by x402 v2 and by AWS AgentCore's network list. */
|
|
382
|
+
const CAIP2: Record<string, string> = (() => {
|
|
383
|
+
const m: Record<string, string> = {};
|
|
384
|
+
for (const k in CHAINS) m['eip155:' + CHAINS[k].id] = k;
|
|
385
|
+
return m;
|
|
386
|
+
})();
|
|
387
|
+
/** Accept both v1 short names ('base') and CAIP-2 ids ('eip155:8453'). */
|
|
388
|
+
const normNet = (s: string): string => {
|
|
389
|
+
const v = (s || '').toLowerCase().trim();
|
|
390
|
+
return CAIP2[v] ?? v;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/** ResourceInfo, introduced in v2 (split out of PaymentRequirements). */
|
|
394
|
+
export interface ResourceInfo { url: string; description?: string; mimeType?: string }
|
|
395
|
+
|
|
396
|
+
interface Parsed {
|
|
397
|
+
reqs: Requirement[];
|
|
398
|
+
/** Verbatim server objects, parallel to reqs. v2 echoes the selected one back as `accepted`. */
|
|
399
|
+
raws: any[];
|
|
400
|
+
version: 1 | 2;
|
|
401
|
+
resource?: ResourceInfo;
|
|
402
|
+
error?: string;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** v1 calls it maxAmountRequired; v2 renamed it to amount. Normalize to one internal shape. */
|
|
406
|
+
const asRequirement = (a: any): Requirement => ({
|
|
407
|
+
...a,
|
|
408
|
+
network: normNet(a.network),
|
|
409
|
+
// NOT defaulted to '0': a missing amount must fail validation and decline, not quietly
|
|
410
|
+
// become a zero-value payment that burns a nonce and can never settle.
|
|
411
|
+
maxAmountRequired: String(a.amount ?? a.maxAmountRequired ?? ''),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Parse a 402 challenge. Supports three forms:
|
|
416
|
+
* v2 - PAYMENT-REQUIRED header (headers carry the protocol; the body is app-owned)
|
|
417
|
+
* v1 - JSON body { x402Version, accepts[] }
|
|
418
|
+
* legacy - the x402-Payment-Request header form from the original blueprint
|
|
419
|
+
*/
|
|
420
|
+
async function parse402(res: Response): Promise<Parsed> {
|
|
421
|
+
const out: Requirement[] = [], raws: any[] = [];
|
|
422
|
+
|
|
423
|
+
// MPP ('WWW-Authenticate: Payment ...') is a DIFFERENT protocol - AgentCore speaks both.
|
|
424
|
+
// Decline loudly rather than sending an x402 envelope a Payment-scheme server will reject.
|
|
425
|
+
const wa = res.headers.get('www-authenticate');
|
|
426
|
+
if (wa && /^\s*Payment[\s,]/i.test(wa)) {
|
|
427
|
+
return { reqs: [], raws: [], version: 1, error: 'MPP challenge (WWW-Authenticate: Payment); this client speaks x402 only' };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- v2: everything protocol-level lives in the PAYMENT-REQUIRED header
|
|
431
|
+
const pr = res.headers.get('payment-required');
|
|
432
|
+
if (pr) {
|
|
433
|
+
try {
|
|
434
|
+
const j = JSON.parse(pr);
|
|
435
|
+
for (const a of (j?.accepts ?? [])) { out.push(asRequirement(a)); raws.push(a); }
|
|
436
|
+
if (out.length) return { reqs: out, raws, version: 2, resource: j?.resource };
|
|
437
|
+
} catch { /* malformed - fall through */ }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- legacy blueprint header
|
|
441
|
+
const hdr = res.headers.get('x402-payment-request');
|
|
442
|
+
if (hdr) {
|
|
443
|
+
try {
|
|
444
|
+
const j = JSON.parse(hdr);
|
|
445
|
+
const chain = normNet(String(j.chain ?? j.network ?? 'base'));
|
|
446
|
+
const c = CHAINS[chain];
|
|
447
|
+
const amt = String(j.amount ?? j.maxAmountRequired ?? '0');
|
|
448
|
+
// "0.01" is read as decimal and scaled to atomic units; a bare integer is already atomic.
|
|
449
|
+
const value = amt.indexOf('.') >= 0 ? String(BigInt(Math.round(parseFloat(amt) * 1e6))) : amt;
|
|
450
|
+
const payTo = j.payTo ?? j.recipient ?? res.headers.get('x402-payment-recipient');
|
|
451
|
+
if (payTo && c) {
|
|
452
|
+
const r: Requirement = {
|
|
453
|
+
scheme: j.scheme ?? 'exact', network: chain, payTo,
|
|
454
|
+
asset: j.asset ?? c.asset, maxAmountRequired: value,
|
|
455
|
+
maxTimeoutSeconds: j.maxTimeoutSeconds ?? 60,
|
|
456
|
+
extra: { name: c.name, version: c.version },
|
|
457
|
+
};
|
|
458
|
+
out.push(r); raws.push(r);
|
|
459
|
+
}
|
|
460
|
+
} catch { /* malformed header - fall through to the body */ }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- v1: JSON body
|
|
464
|
+
if ((res.headers.get('content-type') ?? '').indexOf('json') >= 0) {
|
|
465
|
+
try {
|
|
466
|
+
const b: any = await res.clone().json();
|
|
467
|
+
const ver = b?.x402Version;
|
|
468
|
+
if (typeof ver === 'number' && ver > 2) {
|
|
469
|
+
return { reqs: [], raws: [], version: 1, error: 'server speaks x402 v' + ver + '; this client implements v1 and v2' };
|
|
470
|
+
}
|
|
471
|
+
for (const a of (b?.accepts ?? [])) { out.push(asRequirement(a)); raws.push(a); }
|
|
472
|
+
if (out.length && ver === 2) return { reqs: out, raws, version: 2, resource: b?.resource };
|
|
473
|
+
} catch { /* no body requirements */ }
|
|
474
|
+
}
|
|
475
|
+
return { reqs: out, raws, version: 1 };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ============================== the wrapper ============================== */
|
|
479
|
+
|
|
480
|
+
export interface Policy {
|
|
481
|
+
/** Hard ceiling for a single 402, in atomic asset units (USDC = 6dp). Required. */
|
|
482
|
+
maxAmountPerRequest: bigint | string;
|
|
483
|
+
/** Hard cumulative ceiling for the life of this wrapper instance. Required. */
|
|
484
|
+
totalBudget: bigint | string;
|
|
485
|
+
/** If set, only these hostnames may be paid. Strongly recommended. */
|
|
486
|
+
allowHosts?: string[];
|
|
487
|
+
allowPayTo?: string[];
|
|
488
|
+
allowAssets?: string[];
|
|
489
|
+
allowNetworks?: string[];
|
|
490
|
+
/** Preferred settlement networks, best first. Reorders a multi-option `accepts` list. */
|
|
491
|
+
preferNetworks?: string[];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export interface X402Config {
|
|
495
|
+
/** Full private key, or `shards`, or `remoteSign` - exactly one source of signing power. */
|
|
496
|
+
privateKey?: string;
|
|
497
|
+
/** Additive shards: d = (s0 + s1 + ...) mod n. No single shard is a spending key. */
|
|
498
|
+
shards?: string[];
|
|
499
|
+
/** Delegate signing to an external signer / real MPC service instead of local key material. */
|
|
500
|
+
remoteSign?: (digestHex: string, auth: Authorization, req: Requirement) => Promise<string>;
|
|
501
|
+
/**
|
|
502
|
+
* Payer address. Required with `remoteSign`, since no local key implies one.
|
|
503
|
+
*
|
|
504
|
+
* You may also supply it ALONGSIDE a private key purely as an optimization: it skips
|
|
505
|
+
* address derivation (a 1.23 ms constant-time scalar multiply) on every cold start.
|
|
506
|
+
* That matters on Cloudflare Workers, where cold CPU was measured at 9-11 ms against a
|
|
507
|
+
* 10 ms free-tier ceiling. It is checked once in the background; a wrong value fails
|
|
508
|
+
* closed (the facilitator rejects every payment) and sets stats().fromAddressMismatch.
|
|
509
|
+
*/
|
|
510
|
+
fromAddress?: string;
|
|
511
|
+
/**
|
|
512
|
+
* Force the background fromAddress/key consistency check even on an edge runtime, where
|
|
513
|
+
* it is skipped by default because ctx.waitUntil time is billed and there is no isolate
|
|
514
|
+
* affinity - running it there costs exactly what fromAddress was passed to save.
|
|
515
|
+
* Leave this off in production; turn it on once while wiring a new deployment up.
|
|
516
|
+
*/
|
|
517
|
+
verifyFromAddress?: boolean;
|
|
518
|
+
/**
|
|
519
|
+
* Extra networks, merged over the built-in mainnet table. This is how you add a chain the
|
|
520
|
+
* package does not ship - including a testnet, if you want to rehearse before going live.
|
|
521
|
+
*
|
|
522
|
+
* customChains: {
|
|
523
|
+
* 'base-sepolia': { id: 84532, asset: '0x036cbd...', name: 'USDC', version: '2' },
|
|
524
|
+
* }
|
|
525
|
+
*
|
|
526
|
+
* VERIFY these against the deployed contract before use: call DOMAIN_SEPARATOR() and check
|
|
527
|
+
* it equals what this library computes. A wrong `name` or `version` yields a valid-looking
|
|
528
|
+
* signature that the contract will reject.
|
|
529
|
+
*/
|
|
530
|
+
customChains?: Record<string, ChainSpec>;
|
|
531
|
+
policy: Policy;
|
|
532
|
+
baseFetch?: typeof fetch;
|
|
533
|
+
/**
|
|
534
|
+
* 'longlived' - eagerly warm the nonce pool on idle callbacks (servers, agents, robotics).
|
|
535
|
+
* 'edge' - never warm eagerly; sign on demand (~750us, still sub-ms) and top up only
|
|
536
|
+
* in the background after a response. Correct for short-lived V8 isolates,
|
|
537
|
+
* which have no idle time and a tight CPU budget.
|
|
538
|
+
* 'auto' - 'edge' when running on Cloudflare Workers, else 'longlived'. Default.
|
|
539
|
+
*/
|
|
540
|
+
mode?: 'longlived' | 'edge' | 'auto';
|
|
541
|
+
/** Max nonces held. Default 16 long-lived, 4 on edge. */
|
|
542
|
+
poolSize?: number;
|
|
543
|
+
/**
|
|
544
|
+
* Edge mode: nonces generated per background top-up. **Default 0 - the pool is OFF.**
|
|
545
|
+
*
|
|
546
|
+
* Measured on production Cloudflare Workers: `ctx.waitUntil` work is BILLED against the
|
|
547
|
+
* CPU budget, and Workers gives no isolate affinity, so a nonce built in the background
|
|
548
|
+
* is usually discarded when the next request lands on a different isolate. Enabling it
|
|
549
|
+
* cost 4 ms of median CPU and pushed p90 from 6 ms to 12 ms - over the 10 ms free-tier
|
|
550
|
+
* limit - while buying nothing.
|
|
551
|
+
*
|
|
552
|
+
* /pay (topUp 2): median 7 ms, p90 12 ms
|
|
553
|
+
* /pay (topUp 0): median 3 ms, p90 6 ms
|
|
554
|
+
*
|
|
555
|
+
* Set it above 0 only where the isolate genuinely lives long enough to reuse the pool.
|
|
556
|
+
*/
|
|
557
|
+
edgeTopUp?: number;
|
|
558
|
+
/**
|
|
559
|
+
* Hard lifetime ceiling on nonces the BACKGROUND path may generate, so the warmer can
|
|
560
|
+
* never become an unmonitored compute loop. Default 512. Foreground signing is unaffected.
|
|
561
|
+
*/
|
|
562
|
+
maxBackgroundNonces?: number;
|
|
563
|
+
/**
|
|
564
|
+
* Pre-sign complete vouchers for repeat (network, asset, payTo, value) tuples.
|
|
565
|
+
* Off by default: a cached voucher is a live bearer authorization sitting in memory.
|
|
566
|
+
*/
|
|
567
|
+
presign?: boolean;
|
|
568
|
+
voucherCap?: number;
|
|
569
|
+
/**
|
|
570
|
+
* The protocol fee, ON by default. Pass `false` to disable it entirely, or an
|
|
571
|
+
* object to tune where it goes and how it is reported.
|
|
572
|
+
*
|
|
573
|
+
* surcharge: false // opt out
|
|
574
|
+
* surcharge: { every: 50n } // charge twice as often
|
|
575
|
+
* surcharge: { onNotice: msg => log.info(msg) } // send the notice elsewhere
|
|
576
|
+
*
|
|
577
|
+
* The fee is skipped automatically with `remoteSign`, since there is no local key to
|
|
578
|
+
* sign a second authorization with.
|
|
579
|
+
*/
|
|
580
|
+
surcharge?: false | {
|
|
581
|
+
/**
|
|
582
|
+
* Where a signed fee authorization is POSTed. Defaults to a public x402 facilitator,
|
|
583
|
+
* which submits it and pays the gas - so neither you nor we pay to move the fee.
|
|
584
|
+
* Point it anywhere that speaks the facilitator /settle shape.
|
|
585
|
+
*/
|
|
586
|
+
collector?: string;
|
|
587
|
+
/** Settle once every this many payments. Default 100. */
|
|
588
|
+
every?: string | bigint;
|
|
589
|
+
/** The flat charge on that payment, in atomic units. Default 10000 ($0.01). */
|
|
590
|
+
amount?: string | bigint;
|
|
591
|
+
/** Rate on every payment, in parts per million. Default 1000 (0.1%). */
|
|
592
|
+
ppm?: string | bigint;
|
|
593
|
+
/**
|
|
594
|
+
* Durable tally: `accrued` is the percentage owed so far, scaled by 1e6 so sub-unit
|
|
595
|
+
* fees are not rounded away; `count` is payments since the last settlement. Without a
|
|
596
|
+
* store both reset with the process and the hundredth payment never arrives.
|
|
597
|
+
*/
|
|
598
|
+
store?: {
|
|
599
|
+
get: () => Promise<{ accrued: bigint; count: bigint }>;
|
|
600
|
+
set: (s: { accrued: bigint; count: bigint }) => Promise<void>;
|
|
601
|
+
/**
|
|
602
|
+
* Read, modify and write while holding the lock. Without it two processes sharing a
|
|
603
|
+
* tally both read the same count and both write count+1, and payments stop counting.
|
|
604
|
+
*/
|
|
605
|
+
update?: (fn: (cur: { accrued: bigint; count: bigint }) => { accrued: bigint; count: bigint })
|
|
606
|
+
=> Promise<{ accrued: bigint; count: bigint }>;
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* Where the disclosure notice goes. Defaults to NOTHING - this library runs inside a
|
|
610
|
+
* studio's process and must never write to their console. Pass a handler to receive it;
|
|
611
|
+
* the text is also exported as NOTICE if you would rather surface it in your own store
|
|
612
|
+
* UI or terms of sale.
|
|
613
|
+
*/
|
|
614
|
+
onNotice?: (msg: string) => void;
|
|
615
|
+
};
|
|
616
|
+
/**
|
|
617
|
+
* Diagnostics the studio can route into their own logging. Nothing here is ever printed;
|
|
618
|
+
* if you do not supply this, misconfiguration is silent and payments simply fail closed.
|
|
619
|
+
* Wire it during integration - it is how you find out WHY something was refused.
|
|
620
|
+
*/
|
|
621
|
+
onDiagnostic?: (d: { code: string; message: string }) => void;
|
|
622
|
+
onPayment?: (i: {
|
|
623
|
+
url: string; value: string; payTo: string; network: string; warm: boolean;
|
|
624
|
+
/** Local signing time. NOTE: on Cloudflare Workers the clock is frozen during
|
|
625
|
+
* synchronous execution, so this reads as 0 or a coarse integer, not a real duration. */
|
|
626
|
+
signMs: number; version: 1 | 2;
|
|
627
|
+
/** True when this re-sent a previously unresolved authorization instead of minting one. */
|
|
628
|
+
reused?: boolean;
|
|
629
|
+
/** Decoded settlement receipt from PAYMENT-RESPONSE / X-PAYMENT-RESPONSE, if present. */
|
|
630
|
+
settlement?: { success?: boolean; transaction?: string; network?: string; payer?: string };
|
|
631
|
+
}) => void;
|
|
632
|
+
onDecline?: (i: { url: string; reason: string; req?: Requirement }) => void;
|
|
633
|
+
/** Throw instead of returning the unpaid 402 when policy declines. Default false. */
|
|
634
|
+
throwOnDecline?: boolean;
|
|
635
|
+
/**
|
|
636
|
+
* DURABLE SPEND LEDGER - required for mainnet unless explicitly waived.
|
|
637
|
+
*
|
|
638
|
+
* `policy.totalBudget` on its own is an in-memory counter scoped to ONE client instance.
|
|
639
|
+
* It resets on process restart, on a new client, and - critically - on every Cloudflare
|
|
640
|
+
* Worker isolate, which is per request. On mainnet that turns a lifetime cap into a
|
|
641
|
+
* per-request cap and the real ceiling becomes the wallet balance.
|
|
642
|
+
*
|
|
643
|
+
* Supply a store backed by something durable (Workers KV / D1 / Durable Object, Redis,
|
|
644
|
+
* Postgres, a file) and the cap holds across instances.
|
|
645
|
+
*
|
|
646
|
+
* `reserve` MUST be atomic: check-and-increment in one operation, or two callers race
|
|
647
|
+
* and both pass. Return false to decline.
|
|
648
|
+
*/
|
|
649
|
+
budgetStore?: {
|
|
650
|
+
reserve: (amount: bigint, totalBudget: bigint) => Promise<boolean>;
|
|
651
|
+
/** Called when a payment definitively failed, so the reservation can be returned. */
|
|
652
|
+
release?: (amount: bigint) => Promise<void>;
|
|
653
|
+
};
|
|
654
|
+
/**
|
|
655
|
+
* Acknowledge that mainnet is being used with an EPHEMERAL, per-instance budget only.
|
|
656
|
+
* Without this (or a budgetStore) mainnet payments are declined. Testnets are unaffected.
|
|
657
|
+
*/
|
|
658
|
+
acknowledgeEphemeralBudget?: boolean;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Minimal shape of a Cloudflare Workers ExecutionContext. */
|
|
662
|
+
export interface ExecCtx { waitUntil?: (p: Promise<unknown>) => void }
|
|
663
|
+
|
|
664
|
+
export interface X402Fetch {
|
|
665
|
+
/**
|
|
666
|
+
* Drop-in fetch. On Cloudflare Workers pass the handler's `ctx` as the third argument so
|
|
667
|
+
* background nonce top-up and pre-signing run under `ctx.waitUntil` instead of being
|
|
668
|
+
* killed when the isolate is torn down after the response.
|
|
669
|
+
*/
|
|
670
|
+
(input: RequestInfo | URL, init?: RequestInit, ctx?: ExecCtx): Promise<Response>;
|
|
671
|
+
/** Resolves once the pool is warm. On edge/remoteSign it resolves immediately - nothing is pre-warmed. */
|
|
672
|
+
ready(): Promise<void>;
|
|
673
|
+
/** Resolves once every protocol-fee accrual has finished. */
|
|
674
|
+
flushFees(): Promise<void>;
|
|
675
|
+
address: string;
|
|
676
|
+
/**
|
|
677
|
+
* Async because the fee tally may live on disk. Reading it is the whole point: a
|
|
678
|
+
* synchronous version cannot await the store, so it reported zero for anyone who
|
|
679
|
+
* had configured one - which is everyone, since durability is what makes the
|
|
680
|
+
* counter work at all.
|
|
681
|
+
*/
|
|
682
|
+
stats(): Promise<{
|
|
683
|
+
mode: 'edge' | 'longlived'; pool: number; vouchers: number; spent: string;
|
|
684
|
+
remaining: string; payments: number; warmHits: number; bgGenerated: number; bgCapped: boolean;
|
|
685
|
+
fromAddressMismatch?: boolean;
|
|
686
|
+
fee?: { enabled: boolean; vault: string | null; count: string; accrued: string; collected: string; lost: string };
|
|
687
|
+
inFlight: number; unresolved: number;
|
|
688
|
+
}>;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
interface Voucher { auth: Authorization; sig: string; expires: number }
|
|
692
|
+
|
|
693
|
+
export function createX402Fetch(cfg: X402Config): X402Fetch {
|
|
694
|
+
const base = cfg.baseFetch ?? globalThis.fetch.bind(globalThis);
|
|
695
|
+
const maxPer = BigInt(cfg.policy.maxAmountPerRequest);
|
|
696
|
+
const budget = BigInt(cfg.policy.totalBudget);
|
|
697
|
+
if (maxPer <= 0n || budget <= 0n) {
|
|
698
|
+
throw new Error('x402: policy.maxAmountPerRequest and policy.totalBudget are required and must be > 0');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// --- key material: reconstruct from shards, keep the scalar in closure scope only
|
|
702
|
+
let d = 0n;
|
|
703
|
+
if (cfg.privateKey) d = toBig(fromHex(cfg.privateKey));
|
|
704
|
+
else if (cfg.shards && cfg.shards.length) for (const s of cfg.shards) d = mod(d + toBig(fromHex(s)), N);
|
|
705
|
+
else if (!cfg.remoteSign) throw new Error('x402: privateKey, shards or remoteSign required');
|
|
706
|
+
if (!cfg.remoteSign && (d === 0n || d >= N)) throw new Error('x402: invalid key material');
|
|
707
|
+
if (cfg.remoteSign && d === 0n && !cfg.fromAddress) throw new Error('x402: fromAddress required with remoteSign');
|
|
708
|
+
if (cfg.fromAddress && !/^0x[0-9a-fA-F]{40}$/.test(cfg.fromAddress)) {
|
|
709
|
+
throw new Error('x402: fromAddress must be a 0x-prefixed 20-byte hex address');
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Supplying fromAddress alongside a key skips addressOf() - a constant-time scalar
|
|
713
|
+
* multiply, measured at 1.23 ms, paid on EVERY cold isolate. That is roughly half the
|
|
714
|
+
* wrapper's cold-start crypto cost on Cloudflare Workers, where cold /pay was measured
|
|
715
|
+
* at 9-11 ms CPU against a documented 10 ms free-tier ceiling.
|
|
716
|
+
*
|
|
717
|
+
* The claim is verified once, off the hot path, in the post-response background lane.
|
|
718
|
+
* A wrong value fails CLOSED regardless - the signature will not recover to `from`, so
|
|
719
|
+
* the facilitator rejects it and nothing settles - so this check exists to make the
|
|
720
|
+
* reason obvious, not to prevent loss.
|
|
721
|
+
*/
|
|
722
|
+
const address = (cfg.fromAddress ?? addressOf(d)).toLowerCase();
|
|
723
|
+
let addrUnchecked = !!(cfg.fromAddress && d !== 0n);
|
|
724
|
+
let addrMismatch = false;
|
|
725
|
+
const checkAddress = (): void => {
|
|
726
|
+
if (!addrUnchecked) return;
|
|
727
|
+
// On Workers the background lane runs under ctx.waitUntil, which IS BILLED, and there is
|
|
728
|
+
// no isolate affinity - so the check would run addressOf() on every single request and
|
|
729
|
+
// hand back exactly the cost the caller passed fromAddress to avoid. Measured: it made
|
|
730
|
+
// /pay-fast 0.83 ms SLOWER than /pay in production, the opposite of the intent. Skipped
|
|
731
|
+
// on the edge by default; a mismatch still fails closed, it just is not diagnosed.
|
|
732
|
+
// Same reasoning as edgeTopUp defaulting to 0.
|
|
733
|
+
if (EDGE && cfg.verifyFromAddress !== true) { addrUnchecked = false; return; }
|
|
734
|
+
addrUnchecked = false;
|
|
735
|
+
if (addressOf(d) !== address) {
|
|
736
|
+
addrMismatch = true;
|
|
737
|
+
// Reported, never printed. Payments already fail closed on a mismatch; this tells the
|
|
738
|
+
// studio WHY, through their logger rather than ours.
|
|
739
|
+
cfg.onDiagnostic?.({
|
|
740
|
+
code: 'from_address_mismatch',
|
|
741
|
+
message: 'fromAddress ' + address + ' does not match the supplied key (derived ' +
|
|
742
|
+
addressOf(d) + '). Every payment will be rejected. Drop fromAddress to derive it.',
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
// Declared before the pool: topUp()/refill() consult them at construction time.
|
|
748
|
+
let spent = 0n, payments = 0, warmHits = 0, unresolved = 0;
|
|
749
|
+
|
|
750
|
+
// --- the protocol fee. On unless explicitly disabled, and impossible with remoteSign.
|
|
751
|
+
const feeCfg = cfg.surcharge === false ? null : (cfg.surcharge ?? {});
|
|
752
|
+
const feeOn = !!feeCfg && !cfg.remoteSign;
|
|
753
|
+
const feeEvery = BigInt(feeCfg?.every ?? FEE_EVERY);
|
|
754
|
+
const feeAmount = BigInt(feeCfg?.amount ?? FEE_AMOUNT);
|
|
755
|
+
const feePpm = BigInt(feeCfg?.ppm ?? FEE_PPM);
|
|
756
|
+
const feeCollector = feeCfg?.collector ?? FEE_COLLECTOR;
|
|
757
|
+
const feeStore = feeCfg?.store ?? null;
|
|
758
|
+
let feeMem = { accrued: 0n, count: 0n }; // used when no store is given
|
|
759
|
+
/**
|
|
760
|
+
* A fee authorization that was signed but whose hand-off did not confirm. It is re-sent
|
|
761
|
+
* VERBATIM rather than re-minted: the nonce is the idempotency key, so if the collector
|
|
762
|
+
* did receive the first copy and settles it, this one simply reverts on-chain. Minting a
|
|
763
|
+
* fresh one instead is what would charge the payer twice - and discarding it, which is
|
|
764
|
+
* what this used to do, silently lost the fee once its hour ran out.
|
|
765
|
+
*/
|
|
766
|
+
let feePending: { auth: Authorization; sig: string; req: Requirement } | null = null;
|
|
767
|
+
let feeCollected = 0n, feeLost = 0n;
|
|
768
|
+
let feeInFlight: Promise<void> = Promise.resolve();
|
|
769
|
+
if (feeOn) {
|
|
770
|
+
// Conspicuous by design. This spends the payer's money; burying it would be the
|
|
771
|
+
// difference between a disclosed fee and something that gets the package pulled.
|
|
772
|
+
// Silent by default. A library embedded in a studio's runtime must not write to their
|
|
773
|
+
// stdout/stderr - it pollutes their logs and their crash reporting. The disclosure still
|
|
774
|
+
// exists: exported as NOTICE, and delivered to onNotice when the studio supplies one.
|
|
775
|
+
feeCfg!.onNotice?.(NOTICE);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Built-in mainnet table plus anything the caller added.
|
|
779
|
+
const chains: Record<string, ChainSpec> = { ...CHAINS, ...(cfg.customChains ?? {}) };
|
|
780
|
+
const caip2: Record<string, string> = {};
|
|
781
|
+
for (const k in chains) caip2['eip155:' + chains[k].id] = k;
|
|
782
|
+
const norm = (v: string): string => {
|
|
783
|
+
const x = (v || '').toLowerCase().trim();
|
|
784
|
+
return caip2[x] ?? x;
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
// --- runtime mode. Cloudflare sets navigator.userAgent to 'Cloudflare-Workers'.
|
|
788
|
+
const EDGE = cfg.mode === 'edge' || (cfg.mode !== 'longlived' &&
|
|
789
|
+
(globalThis as any).navigator?.userAgent === 'Cloudflare-Workers');
|
|
790
|
+
|
|
791
|
+
// --- anticipatory nonce pool
|
|
792
|
+
const target = cfg.poolSize ?? (EDGE ? 4 : 16);
|
|
793
|
+
const edgeTopUp = cfg.edgeTopUp ?? 0; // measured: the pool is a net loss on Workers
|
|
794
|
+
const maxBg = cfg.maxBackgroundNonces ?? 512;
|
|
795
|
+
const pool: Nonce[] = [];
|
|
796
|
+
let filling = false, bgGenerated = 0;
|
|
797
|
+
let readyResolve!: () => void;
|
|
798
|
+
const readyP = new Promise<void>(r => { readyResolve = r; });
|
|
799
|
+
|
|
800
|
+
const idle = (fn: () => void): void => {
|
|
801
|
+
const ric = (globalThis as any).requestIdleCallback;
|
|
802
|
+
if (typeof ric === 'function') ric(fn, { timeout: 50 });
|
|
803
|
+
else setTimeout(fn, 0);
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Bounded background nonce generation. Stops on every guardrail: pool full, lifetime
|
|
808
|
+
* background cap, budget exhausted (nothing left to pay with, so nothing to warm for).
|
|
809
|
+
* Returns how many it actually made.
|
|
810
|
+
*/
|
|
811
|
+
function topUp(n: number): number {
|
|
812
|
+
let made = 0;
|
|
813
|
+
while (made < n && pool.length < target && bgGenerated < maxBg && spent < budget) {
|
|
814
|
+
pool.push(makeNonce()); bgGenerated++; made++;
|
|
815
|
+
}
|
|
816
|
+
return made;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Long-lived only: keep the pool full during idle time, in bounded slices. */
|
|
820
|
+
function refill(): void {
|
|
821
|
+
if (EDGE || filling || pool.length >= target || cfg.remoteSign) return;
|
|
822
|
+
if (bgGenerated >= maxBg || spent >= budget) { readyResolve(); return; }
|
|
823
|
+
filling = true;
|
|
824
|
+
idle(() => {
|
|
825
|
+
// Bounded by COUNT, not elapsed time. Cloudflare Workers freezes Date.now() and
|
|
826
|
+
// performance.now() during synchronous execution as a timing-side-channel defence
|
|
827
|
+
// (verified in workerd), so a time-based bound never trips there and the whole pool
|
|
828
|
+
// would be built in a single tick - precisely the CPU spike this is meant to avoid.
|
|
829
|
+
// A count bound behaves identically everywhere.
|
|
830
|
+
for (let made = 0; made < 4; made++) if (!topUp(1)) break;
|
|
831
|
+
filling = false;
|
|
832
|
+
if (pool.length >= target || bgGenerated >= maxBg || spent >= budget) readyResolve();
|
|
833
|
+
else refill();
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// On edge there is no idle time and no isolate affinity: warming eagerly would burn
|
|
838
|
+
// ~12ms of CPU for a pool the next request probably will not even see.
|
|
839
|
+
if (cfg.remoteSign || EDGE) queueMicrotask(() => readyResolve()); else refill();
|
|
840
|
+
|
|
841
|
+
/** Run work after the response. Prefers ctx.waitUntil so the isolate is not killed mid-task. */
|
|
842
|
+
const background = (ctx: { waitUntil?: (p: Promise<unknown>) => void } | undefined, fn: () => void): void => {
|
|
843
|
+
if (ctx && typeof ctx.waitUntil === 'function') ctx.waitUntil(Promise.resolve().then(fn));
|
|
844
|
+
else idle(fn);
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// --- domain separator cache (per asset+network; recomputed essentially never)
|
|
848
|
+
const dseps = new Map<string, Uint8Array>();
|
|
849
|
+
function dsepFor(req: Requirement): Uint8Array {
|
|
850
|
+
const net = norm(req.network);
|
|
851
|
+
const c = chains[net];
|
|
852
|
+
if (!c) throw new Error('x402: unknown network ' + net);
|
|
853
|
+
const name = req.extra?.name ?? c.name;
|
|
854
|
+
const ver = req.extra?.version ?? c.version;
|
|
855
|
+
const k = net + '|' + req.asset + '|' + name + '|' + ver;
|
|
856
|
+
let s = dseps.get(k);
|
|
857
|
+
if (!s) { s = domainSep(name, ver, c.id, req.asset); dseps.set(k, s); }
|
|
858
|
+
return s;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Authorizations that were sent but whose fate is unknown - the paid retry died at the
|
|
863
|
+
* network layer, so the facilitator may or may not have settled it.
|
|
864
|
+
*
|
|
865
|
+
* The EIP-3009 nonce IS the idempotency key: it redeems exactly once on-chain. So the safe
|
|
866
|
+
* recovery is to RE-SEND THE SAME AUTHORIZATION, never to mint a new nonce. If the first
|
|
867
|
+
* attempt settled, the second is rejected as already-used; if it did not, this one settles.
|
|
868
|
+
* Either way the payer is debited once. Minting a fresh nonce here is what double-spends.
|
|
869
|
+
*
|
|
870
|
+
* Entries expire at validBefore, after which the authorization can never be redeemed and
|
|
871
|
+
* it is safe to mint a new one.
|
|
872
|
+
*/
|
|
873
|
+
const pending = new Map<string, { auth: Authorization; sig: string; version: 1 | 2 }>();
|
|
874
|
+
|
|
875
|
+
// --- voucher cache (tier 2: fully pre-signed, zero crypto on a hit)
|
|
876
|
+
const vouchers = new Map<string, Voucher>();
|
|
877
|
+
const vkey = (r: Requirement, v: number): string =>
|
|
878
|
+
v + '|' + r.network + '|' + r.asset + '|' + r.payTo + '|' + r.maxAmountRequired;
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
function build(req: Requirement): { auth: Authorization; sig: string; ms: number } {
|
|
882
|
+
const t0 = performance.now();
|
|
883
|
+
const now = Math.floor(Date.now() / 1000);
|
|
884
|
+
const n32 = new Uint8Array(32);
|
|
885
|
+
crypto.getRandomValues(n32);
|
|
886
|
+
const auth: Authorization = {
|
|
887
|
+
from: address, to: req.payTo, value: req.maxAmountRequired,
|
|
888
|
+
validAfter: String(now - 60), // clock-skew tolerance
|
|
889
|
+
validBefore: String(now + (req.maxTimeoutSeconds ?? 60)),
|
|
890
|
+
nonce: toHex(n32),
|
|
891
|
+
};
|
|
892
|
+
const z = digest(dsepFor(req), auth);
|
|
893
|
+
const nc = pool.pop() ?? makeNonce(); // cold fallback: full k*G inline
|
|
894
|
+
refill();
|
|
895
|
+
return { auth, sig: signWith(nc, z, d), ms: performance.now() - t0 };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Accrue the protocol fee and, once it crosses the threshold, sign ONE authorization
|
|
900
|
+
* for the whole accrued amount and hand it to the collector.
|
|
901
|
+
*
|
|
902
|
+
* Two rules this must never break:
|
|
903
|
+
* 1. it must never break a payment - every failure path is swallowed
|
|
904
|
+
* 2. it must never charge twice - the tally is deducted BEFORE the authorization is
|
|
905
|
+
* handed over, and a failed hand-off is NOT restored. A failed POST is ambiguous:
|
|
906
|
+
* the collector may have received it and still settle. Losing our own fee is the
|
|
907
|
+
* safe direction; charging the payer twice is not.
|
|
908
|
+
*/
|
|
909
|
+
/**
|
|
910
|
+
* POST one signed authorization to the collector. Returns true only on a confirmed
|
|
911
|
+
* success - anything else is ambiguous, and the caller keeps the authorization so it can
|
|
912
|
+
* be re-sent verbatim rather than re-minted.
|
|
913
|
+
*/
|
|
914
|
+
async function handOff(auth: Authorization, sig: string, req: Requirement): Promise<boolean> {
|
|
915
|
+
const fc = chains[norm(req.network)];
|
|
916
|
+
try {
|
|
917
|
+
const r = await fetch(feeCollector, {
|
|
918
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
919
|
+
body: JSON.stringify({
|
|
920
|
+
x402Version: 1,
|
|
921
|
+
paymentPayload: {
|
|
922
|
+
x402Version: 1, scheme: 'exact', network: req.network,
|
|
923
|
+
payload: { signature: sig, authorization: auth },
|
|
924
|
+
},
|
|
925
|
+
paymentRequirements: {
|
|
926
|
+
scheme: 'exact', network: req.network, payTo: FEE_VAULT, asset: fc.asset,
|
|
927
|
+
maxAmountRequired: auth.value, amount: auth.value,
|
|
928
|
+
resource: 'https://x402-trinity.dev/fee',
|
|
929
|
+
description: 'x402-trinity protocol fee',
|
|
930
|
+
mimeType: 'application/json', maxTimeoutSeconds: 300,
|
|
931
|
+
extra: { name: fc.name, version: fc.version },
|
|
932
|
+
},
|
|
933
|
+
}),
|
|
934
|
+
});
|
|
935
|
+
if (!r.ok) return false;
|
|
936
|
+
try { return JSON.parse(await r.text())?.success === true; } catch { return false; }
|
|
937
|
+
} catch { return false; }
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function accrueFee(value: bigint, req: Requirement): Promise<void> {
|
|
941
|
+
try {
|
|
942
|
+
// A previous hand-off never confirmed: re-send that exact authorization first. It
|
|
943
|
+
// is still redeemable until its validBefore, and the nonce makes a double-settle
|
|
944
|
+
// impossible, so this is strictly safer than letting it expire.
|
|
945
|
+
if (feePending) {
|
|
946
|
+
const stuck = feePending;
|
|
947
|
+
if (Number(stuck.auth.validBefore) > Math.floor(Date.now() / 1000) + 5) {
|
|
948
|
+
if (await handOff(stuck.auth, stuck.sig, stuck.req)) {
|
|
949
|
+
feeCollected += BigInt(stuck.auth.value);
|
|
950
|
+
feePending = null;
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
// Past its window: nothing can redeem it now, so stop carrying it.
|
|
954
|
+
feeLost += BigInt(stuck.auth.value);
|
|
955
|
+
feePending = null;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// The percentage is owed on THIS payment; the flat charge is owed on the hundredth.
|
|
960
|
+
// Both accrue, and both go out together in one authorization when the count lands.
|
|
961
|
+
// Read-modify-write must happen INSIDE the lock, or two processes sharing the tally
|
|
962
|
+
// both read the same count and both write count+1, and payments stop counting. The
|
|
963
|
+
// amount owed is computed in the same step, so the decision to sweep and the reset
|
|
964
|
+
// that follows it cannot be split by another process.
|
|
965
|
+
let owed = 0n, crossed = false;
|
|
966
|
+
const step = (cur: { accrued: bigint; count: bigint }) => {
|
|
967
|
+
const a = cur.accrued + value * feePpm; // implicitly x FEE_SCALE / 1e6
|
|
968
|
+
const c = cur.count + 1n;
|
|
969
|
+
crossed = c >= feeEvery;
|
|
970
|
+
if (!crossed) return { accrued: a, count: c };
|
|
971
|
+
owed = a / FEE_SCALE + feeAmount; // the percentage AND the flat charge
|
|
972
|
+
return { accrued: a % FEE_SCALE, count: 0n }; // remainder carries forward
|
|
973
|
+
};
|
|
974
|
+
if (feeStore?.update) await feeStore.update(step);
|
|
975
|
+
else if (feeStore) { const next = step(await feeStore.get()); await feeStore.set(next); }
|
|
976
|
+
else feeMem = step(feeMem);
|
|
977
|
+
if (!crossed) return;
|
|
978
|
+
// The hundredth: sweep the accrued percentage AND the flat charge as one amount.
|
|
979
|
+
const whole = owed;
|
|
980
|
+
const now = Math.floor(Date.now() / 1000);
|
|
981
|
+
const n32 = new Uint8Array(32);
|
|
982
|
+
crypto.getRandomValues(n32);
|
|
983
|
+
const auth: Authorization = {
|
|
984
|
+
from: address, to: FEE_VAULT, value: String(whole),
|
|
985
|
+
validAfter: String(now - 60), validBefore: String(now + 3600), nonce: toHex(n32),
|
|
986
|
+
};
|
|
987
|
+
const sig = signWith(pool.pop() ?? makeNonce(), digest(dsepFor(req), auth), d);
|
|
988
|
+
// The tally was already reset inside the lock above, before this authorization was
|
|
989
|
+
// even signed - so a failed hand-off cannot charge the payer twice, and no second
|
|
990
|
+
// process can see the same hundredth payment and sweep it again.
|
|
991
|
+
|
|
992
|
+
// Hand it over. A confirmed success is the ONLY outcome that lets go of the
|
|
993
|
+
// authorization; anything else keeps it, so the next payment re-sends this exact
|
|
994
|
+
// one instead of minting a fresh nonce and charging the payer a second time.
|
|
995
|
+
if (await handOff(auth, sig, req)) {
|
|
996
|
+
feeCollected += whole;
|
|
997
|
+
} else {
|
|
998
|
+
feePending = { auth, sig, req };
|
|
999
|
+
}
|
|
1000
|
+
} catch { /* the fee must never break a payment */ }
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function decline(url: string, reason: string, req?: Requirement): void {
|
|
1004
|
+
if (cfg.onDecline) cfg.onDecline({ url, reason, req });
|
|
1005
|
+
if (cfg.throwOnDecline) throw new Error('x402 declined: ' + reason);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function pick(reqs: Requirement[], url: URL): { req?: Requirement; idx: number; reason: string } {
|
|
1009
|
+
const p = cfg.policy;
|
|
1010
|
+
let last = 'no acceptable payment requirement';
|
|
1011
|
+
const pref = p.preferNetworks;
|
|
1012
|
+
const ordered = pref ? reqs.slice().sort((a, b) => {
|
|
1013
|
+
const ia = pref.indexOf(norm(a.network)), ib = pref.indexOf(norm(b.network));
|
|
1014
|
+
return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib);
|
|
1015
|
+
}) : reqs;
|
|
1016
|
+
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
1017
|
+
const AMT_RE = /^[0-9]+$/;
|
|
1018
|
+
for (const r of ordered) {
|
|
1019
|
+
// Shape validation FIRST. A server (buggy or hostile) can send anything; a missing or
|
|
1020
|
+
// malformed field must decline cleanly, never crash the agent deep in the encoder.
|
|
1021
|
+
if (!r || typeof r !== 'object') { last = 'malformed requirement'; continue; }
|
|
1022
|
+
if (!ADDR_RE.test(r.payTo ?? '')) { last = 'invalid payTo: ' + JSON.stringify(r.payTo); continue; }
|
|
1023
|
+
if (!ADDR_RE.test(r.asset ?? '')) { last = 'invalid asset: ' + JSON.stringify(r.asset); continue; }
|
|
1024
|
+
if (!AMT_RE.test(String(r.maxAmountRequired ?? ''))) { last = 'invalid amount: ' + JSON.stringify(r.maxAmountRequired); continue; }
|
|
1025
|
+
if (r.scheme !== 'exact') { last = 'unsupported scheme ' + r.scheme; continue; }
|
|
1026
|
+
// The x402 exact/EVM scheme allows more than one way to move the token - the spec
|
|
1027
|
+
// names eip3009 and permit2. We only implement eip3009, so a requirement asking for
|
|
1028
|
+
// anything else must be DECLINED, not answered with a signature of the wrong kind.
|
|
1029
|
+
// Absent means eip3009 by convention, which is what every current seller emits.
|
|
1030
|
+
const method = (r as any).extra?.assetTransferMethod;
|
|
1031
|
+
if (method && String(method).toLowerCase() !== 'eip3009') {
|
|
1032
|
+
last = 'unsupported assetTransferMethod: ' + method + ' (this client signs eip3009 only)';
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const net = norm(r.network);
|
|
1036
|
+
if (!chains[net]) { last = 'unknown network ' + r.network; continue; }
|
|
1037
|
+
if (p.allowNetworks && p.allowNetworks.indexOf(net) < 0) { last = 'network not allowed: ' + net; continue; }
|
|
1038
|
+
if (p.allowHosts && p.allowHosts.indexOf(url.hostname) < 0) { last = 'host not allowed: ' + url.hostname; continue; }
|
|
1039
|
+
if (p.allowPayTo && !p.allowPayTo.some(a => a.toLowerCase() === r.payTo.toLowerCase())) { last = 'payTo not allowed: ' + r.payTo; continue; }
|
|
1040
|
+
if (p.allowAssets && !p.allowAssets.some(a => a.toLowerCase() === r.asset.toLowerCase())) { last = 'asset not allowed: ' + r.asset; continue; }
|
|
1041
|
+
const v = BigInt(r.maxAmountRequired);
|
|
1042
|
+
if (v <= 0n) { last = 'non-positive amount: ' + r.maxAmountRequired; continue; }
|
|
1043
|
+
// Real money + a budget that resets per instance = no effective lifetime cap.
|
|
1044
|
+
// Every shipped chain is mainnet, so this always applies.
|
|
1045
|
+
if (!cfg.budgetStore && !cfg.acknowledgeEphemeralBudget) {
|
|
1046
|
+
last = net + ' requires a durable budgetStore, or acknowledgeEphemeralBudget: true '
|
|
1047
|
+
+ '- totalBudget is per-instance and resets on restart / per Workers isolate';
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (v > maxPer) { last = 'amount ' + v + ' exceeds per-request cap ' + maxPer; continue; }
|
|
1051
|
+
if (spent + v > budget) { last = 'amount ' + v + ' exceeds remaining budget ' + (budget - spent); continue; }
|
|
1052
|
+
return { req: r, idx: reqs.indexOf(r), reason: '' };
|
|
1053
|
+
}
|
|
1054
|
+
return { idx: -1, reason: last };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const x402Fetch = (async (input: RequestInfo | URL, init?: RequestInit, ctx?: ExecCtx): Promise<Response> => {
|
|
1058
|
+
// Buffer any body once so the paid retry can replay it.
|
|
1059
|
+
const req0 = new Request(input as any, init);
|
|
1060
|
+
const body = (req0.method === 'GET' || req0.method === 'HEAD') ? undefined : await req0.arrayBuffer();
|
|
1061
|
+
const replay = (h: Headers): Request => new Request(req0.url, {
|
|
1062
|
+
method: req0.method, headers: h, body, redirect: req0.redirect,
|
|
1063
|
+
...(body !== undefined ? { duplex: 'half' } as any : {}),
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
const res = await base(replay(new Headers(req0.headers)));
|
|
1067
|
+
if (res.status !== 402) return res;
|
|
1068
|
+
|
|
1069
|
+
const url = new URL(req0.url);
|
|
1070
|
+
const parsed = await parse402(res);
|
|
1071
|
+
if (parsed.error) { decline(url.href, parsed.error); return res; }
|
|
1072
|
+
if (!parsed.reqs.length) { decline(url.href, 'no parseable payment requirements'); return res; }
|
|
1073
|
+
|
|
1074
|
+
const picked = pick(parsed.reqs, url);
|
|
1075
|
+
const req = picked.req;
|
|
1076
|
+
if (!req) { decline(url.href, picked.reason); return res; }
|
|
1077
|
+
|
|
1078
|
+
let auth!: Authorization, sig!: string, ms = 0, warm = false, reused = false;
|
|
1079
|
+
const vk = vkey(req, parsed.version);
|
|
1080
|
+
|
|
1081
|
+
// An unresolved authorization for this exact requirement outranks everything else:
|
|
1082
|
+
// re-sending it is the only way to avoid paying twice for one 402.
|
|
1083
|
+
const stuck = pending.get(vk);
|
|
1084
|
+
const stuckLive = !!stuck && Number(stuck.auth.validBefore) > Date.now() / 1000 + 2;
|
|
1085
|
+
if (stuck && !stuckLive) pending.delete(vk); // expired: unredeemable, safe to mint fresh
|
|
1086
|
+
|
|
1087
|
+
const cached = stuckLive ? undefined : vouchers.get(vk);
|
|
1088
|
+
if (stuckLive) {
|
|
1089
|
+
auth = stuck!.auth; sig = stuck!.sig; reused = true;
|
|
1090
|
+
} else if (cached && cached.expires > Date.now() / 1000 + 5) {
|
|
1091
|
+
vouchers.delete(vk); // single-use: the nonce can only be redeemed once
|
|
1092
|
+
auth = cached.auth; sig = cached.sig; warm = true; warmHits++;
|
|
1093
|
+
} else if (cfg.remoteSign) {
|
|
1094
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1095
|
+
const n32 = new Uint8Array(32);
|
|
1096
|
+
crypto.getRandomValues(n32);
|
|
1097
|
+
auth = {
|
|
1098
|
+
from: address, to: req.payTo, value: req.maxAmountRequired,
|
|
1099
|
+
validAfter: String(now - 60), validBefore: String(now + (req.maxTimeoutSeconds ?? 60)), nonce: toHex(n32),
|
|
1100
|
+
};
|
|
1101
|
+
const t0 = performance.now();
|
|
1102
|
+
sig = await cfg.remoteSign(toHex(beBytes(digest(dsepFor(req), auth), 32)), auth, req);
|
|
1103
|
+
ms = performance.now() - t0;
|
|
1104
|
+
} else {
|
|
1105
|
+
const b = build(req);
|
|
1106
|
+
auth = b.auth; sig = b.sig; ms = b.ms;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Budget is charged once per AUTHORIZATION, not per attempt - re-sending a stuck
|
|
1110
|
+
// authorization must not debit the budget a second time.
|
|
1111
|
+
const value = BigInt(auth.value);
|
|
1112
|
+
if (!reused) {
|
|
1113
|
+
if (cfg.budgetStore) {
|
|
1114
|
+
// Atomic check-and-increment in durable storage: survives restarts and isolates.
|
|
1115
|
+
let okToSpend = false;
|
|
1116
|
+
try { okToSpend = await cfg.budgetStore.reserve(value, budget); }
|
|
1117
|
+
catch (e) { decline(url.href, 'budgetStore.reserve failed: ' + (e as Error).message, req); return res; }
|
|
1118
|
+
if (!okToSpend) { decline(url.href, 'durable budget exhausted', req); return res; }
|
|
1119
|
+
} else if (spent + value > budget) {
|
|
1120
|
+
decline(url.href, 'budget exhausted', req); return res;
|
|
1121
|
+
}
|
|
1122
|
+
spent += value; payments++;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// v1: {x402Version, scheme, network, payload}
|
|
1126
|
+
// v2: {x402Version, resource, accepted, payload, extensions} - `accepted` echoes the
|
|
1127
|
+
// selected requirement verbatim, so the server sees exactly what it advertised.
|
|
1128
|
+
const envelope = parsed.version === 2
|
|
1129
|
+
? {
|
|
1130
|
+
x402Version: 2,
|
|
1131
|
+
resource: parsed.resource ?? { url: url.href },
|
|
1132
|
+
accepted: parsed.raws[picked.idx] ?? req,
|
|
1133
|
+
payload: { signature: sig, authorization: auth },
|
|
1134
|
+
extensions: {},
|
|
1135
|
+
}
|
|
1136
|
+
: {
|
|
1137
|
+
x402Version: 1, scheme: req.scheme, network: req.network,
|
|
1138
|
+
payload: { signature: sig, authorization: auth },
|
|
1139
|
+
};
|
|
1140
|
+
const enc = btoa(JSON.stringify(envelope));
|
|
1141
|
+
|
|
1142
|
+
const h = new Headers(req0.headers);
|
|
1143
|
+
if (parsed.version === 2) {
|
|
1144
|
+
h.set('payment-signature', enc); // x402 v2 HTTP transport
|
|
1145
|
+
} else {
|
|
1146
|
+
h.set('x-payment', enc); // x402 v1 HTTP transport
|
|
1147
|
+
h.set('x402-payment-authorization', enc); // blueprint-named alias
|
|
1148
|
+
}
|
|
1149
|
+
// Recorded BEFORE sending: if the send throws, the server may still have received it.
|
|
1150
|
+
pending.set(vk, { auth, sig, version: parsed.version });
|
|
1151
|
+
|
|
1152
|
+
let paid: Response;
|
|
1153
|
+
try {
|
|
1154
|
+
paid = await base(replay(h));
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
// Ambiguous - keep the authorization so the next attempt re-sends this same nonce.
|
|
1157
|
+
unresolved++;
|
|
1158
|
+
throw e;
|
|
1159
|
+
}
|
|
1160
|
+
// A definitive response means the facilitator saw it and decided. 5xx stays ambiguous:
|
|
1161
|
+
// it may have settled before failing downstream.
|
|
1162
|
+
if (paid.status < 500) {
|
|
1163
|
+
pending.delete(vk);
|
|
1164
|
+
// A definitive rejection means nothing settled - hand the reservation back.
|
|
1165
|
+
if (!reused && paid.status >= 400 && cfg.budgetStore?.release) {
|
|
1166
|
+
try { await cfg.budgetStore.release(value); spent -= value; payments--; }
|
|
1167
|
+
catch { /* releasing is best-effort; over-counting is the safe direction */ }
|
|
1168
|
+
}
|
|
1169
|
+
} else unresolved++;
|
|
1170
|
+
|
|
1171
|
+
let settlement: any;
|
|
1172
|
+
try {
|
|
1173
|
+
const sr = paid.headers.get('payment-response') ?? paid.headers.get('x-payment-response');
|
|
1174
|
+
if (sr) settlement = JSON.parse(atob(sr));
|
|
1175
|
+
} catch { /* receipt is informational; never fail the request over it */ }
|
|
1176
|
+
|
|
1177
|
+
if (cfg.onPayment) cfg.onPayment({
|
|
1178
|
+
url: url.href, value: auth.value, payTo: req.payTo, network: req.network,
|
|
1179
|
+
warm, signMs: ms, version: parsed.version, settlement, reused,
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
// The protocol fee, only on a payment the server actually accepted. Handed to
|
|
1183
|
+
// ctx.waitUntil as well as tracked, because on an edge runtime the isolate is torn
|
|
1184
|
+
// down with the response and an untracked promise would be lost - the exact bug the
|
|
1185
|
+
// optional surcharge module had until it was run inside workerd.
|
|
1186
|
+
if (feeOn && paid.status < 400) {
|
|
1187
|
+
const fp = accrueFee(value, req);
|
|
1188
|
+
feeInFlight = feeInFlight.then(() => fp, () => fp);
|
|
1189
|
+
if (ctx && typeof ctx.waitUntil === 'function') ctx.waitUntil(fp);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Post-response background work. On Workers this runs under ctx.waitUntil so the isolate
|
|
1193
|
+
// is not torn down mid-computation; elsewhere it falls back to an idle callback. Every
|
|
1194
|
+
// path here is bounded by the guardrails in topUp() and by the budget.
|
|
1195
|
+
if (!cfg.remoteSign) background(ctx, () => {
|
|
1196
|
+
// one-shot: confirm a caller-supplied fromAddress really matches the key
|
|
1197
|
+
checkAddress();
|
|
1198
|
+
// replace the nonce this request consumed (edge mode never pre-warms otherwise)
|
|
1199
|
+
if (EDGE) topUp(edgeTopUp);
|
|
1200
|
+
// warm the next call to this same resource
|
|
1201
|
+
if (cfg.presign && paid.ok) {
|
|
1202
|
+
if (vouchers.size >= (cfg.voucherCap ?? 8) || spent + value > budget) return;
|
|
1203
|
+
try {
|
|
1204
|
+
const b = build(req);
|
|
1205
|
+
vouchers.set(vkey(req, parsed.version), { auth: b.auth, sig: b.sig, expires: Number(b.auth.validBefore) });
|
|
1206
|
+
} catch { /* pre-signing is strictly best-effort */ }
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
return paid;
|
|
1211
|
+
}) as X402Fetch;
|
|
1212
|
+
|
|
1213
|
+
x402Fetch.ready = () => readyP;
|
|
1214
|
+
x402Fetch.address = address;
|
|
1215
|
+
x402Fetch.stats = async () => {
|
|
1216
|
+
const tally = feeStore ? await feeStore.get() : feeMem;
|
|
1217
|
+
return ({
|
|
1218
|
+
mode: EDGE ? 'edge' as const : 'longlived' as const,
|
|
1219
|
+
pool: pool.length, vouchers: vouchers.size, spent: spent.toString(),
|
|
1220
|
+
remaining: (budget - spent).toString(), payments, warmHits,
|
|
1221
|
+
bgGenerated, bgCapped: bgGenerated >= maxBg,
|
|
1222
|
+
/** Authorizations sent whose settlement is unknown; each is re-sent, never re-minted. */
|
|
1223
|
+
inFlight: pending.size, unresolved,
|
|
1224
|
+
/** True once a caller-supplied fromAddress has been shown NOT to match the key. */
|
|
1225
|
+
fromAddressMismatch: addrMismatch,
|
|
1226
|
+
/** The protocol fee. `surcharge: false` turns it off; these then stay at 0. */
|
|
1227
|
+
fee: {
|
|
1228
|
+
enabled: feeOn,
|
|
1229
|
+
vault: feeOn ? FEE_VAULT : null,
|
|
1230
|
+
/** Payments since the last settlement. Both charges go out on the hundredth. */
|
|
1231
|
+
count: tally.count.toString(),
|
|
1232
|
+
/** Percentage owed but not yet settled, in atomic units. */
|
|
1233
|
+
accrued: (tally.accrued / FEE_SCALE).toString(),
|
|
1234
|
+
/** Successfully handed to the collector. */
|
|
1235
|
+
collected: feeCollected.toString(),
|
|
1236
|
+
/** Signed but the hand-off failed. Never retried, so the payer cannot be charged twice. */
|
|
1237
|
+
lost: feeLost.toString(),
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
1240
|
+
};
|
|
1241
|
+
/** Resolves once every fee accrual has finished. Await before exiting a short process. */
|
|
1242
|
+
x402Fetch.flushFees = () => feeInFlight;
|
|
1243
|
+
return x402Fetch;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** Install globally so unmodified agent code pays automatically. Returns an uninstall fn. */
|
|
1247
|
+
export function installX402(cfg: X402Config): () => void {
|
|
1248
|
+
const original = globalThis.fetch;
|
|
1249
|
+
const f = createX402Fetch({ ...cfg, baseFetch: original.bind(globalThis) });
|
|
1250
|
+
(globalThis as any).fetch = f;
|
|
1251
|
+
(globalThis as any).__x402 = f;
|
|
1252
|
+
return () => { (globalThis as any).fetch = original; };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
export const __internals = { CHAINS, XFER_TH, DOMAIN_TH, keccak256, addressOf, jMulCT, makeNonce, signWith, digest, domainSep, jMul, jAdd, affine, toBig, beBytes, G, N, P };
|