x402-trinity 0.1.0__py3-none-any.whl
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.
x402_trinity/__init__.py
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
"""
|
|
2
|
+
x402-trinity (Python) - zero-dependency x402 payment interceptor.
|
|
3
|
+
|
|
4
|
+
Sibling of the TypeScript edge build. Same protocol, same EIP-712 encoder, same
|
|
5
|
+
ground-truth fixtures - targeted at long-lived processes: on-body agents, robotics
|
|
6
|
+
controllers, data-harvesting scripts.
|
|
7
|
+
|
|
8
|
+
Standard library only. No requests, no eth-account, no coincurve, no pycryptodome.
|
|
9
|
+
|
|
10
|
+
Why the crypto is inline:
|
|
11
|
+
- hashlib has NO keccak256. hashlib.sha3_256 is a DIFFERENT function (SHA-3 uses
|
|
12
|
+
0x06 padding, keccak uses 0x01) and produces a different digest, so every
|
|
13
|
+
signature built on it would be rejected on-chain.
|
|
14
|
+
- There is no stdlib secp256k1 at all.
|
|
15
|
+
|
|
16
|
+
Performance shape (measure with bench(), do not assume):
|
|
17
|
+
cold - must compute k*G inline; interpreted point math, tens of milliseconds
|
|
18
|
+
warm - a precomputed nonce is available; two modular multiplies, microseconds
|
|
19
|
+
A long-lived controller is warm after its first payment, which is the whole point
|
|
20
|
+
of the anticipatory pool. This is the wrong trade for a short-lived edge isolate;
|
|
21
|
+
use the TypeScript build there.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import base64
|
|
28
|
+
import secrets
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import urllib.request
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
34
|
+
from functools import wraps
|
|
35
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
__all__ = ["keccak256", "X402Client", "x402_telemetry", "Policy", "address_of"]
|
|
38
|
+
|
|
39
|
+
# Captured at import, BEFORE anything can monkey-patch the global. x402_telemetry patches
|
|
40
|
+
# urllib.request.urlopen process-wide; if the client also went through the global it would
|
|
41
|
+
# re-enter itself forever. The client always uses this pristine reference.
|
|
42
|
+
_URLOPEN = urllib.request.urlopen
|
|
43
|
+
|
|
44
|
+
# ============================== keccak-256 ==============================
|
|
45
|
+
|
|
46
|
+
_MASK = (1 << 64) - 1
|
|
47
|
+
|
|
48
|
+
_RC = [
|
|
49
|
+
0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
|
|
50
|
+
0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
|
|
51
|
+
0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
|
|
52
|
+
0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
|
|
53
|
+
0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
|
|
54
|
+
0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,
|
|
55
|
+
]
|
|
56
|
+
# rho rotation offsets, lane index = x + 5y
|
|
57
|
+
_RHO = [
|
|
58
|
+
0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39,
|
|
59
|
+
41, 45, 15, 21, 8, 18, 2, 61, 56, 14,
|
|
60
|
+
]
|
|
61
|
+
# pi permutation: _PI[src] = dst, dst = y + 5*((2x+3y) mod 5)
|
|
62
|
+
_PI = [0] * 25
|
|
63
|
+
for _y in range(5):
|
|
64
|
+
for _x in range(5):
|
|
65
|
+
_PI[_x + 5 * _y] = _y + 5 * ((2 * _x + 3 * _y) % 5)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _rotl(v: int, n: int) -> int:
|
|
69
|
+
if n == 0:
|
|
70
|
+
return v
|
|
71
|
+
return ((v << n) | (v >> (64 - n))) & _MASK
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _keccak_f(S: List[int]) -> None:
|
|
75
|
+
for rnd in range(24):
|
|
76
|
+
# theta
|
|
77
|
+
C = [S[x] ^ S[x + 5] ^ S[x + 10] ^ S[x + 15] ^ S[x + 20] for x in range(5)]
|
|
78
|
+
for x in range(5):
|
|
79
|
+
D = C[(x + 4) % 5] ^ _rotl(C[(x + 1) % 5], 1)
|
|
80
|
+
for y in range(0, 25, 5):
|
|
81
|
+
S[x + y] ^= D
|
|
82
|
+
# rho + pi
|
|
83
|
+
B = [0] * 25
|
|
84
|
+
for i in range(25):
|
|
85
|
+
B[_PI[i]] = _rotl(S[i], _RHO[i])
|
|
86
|
+
# chi
|
|
87
|
+
for y in range(0, 25, 5):
|
|
88
|
+
for x in range(5):
|
|
89
|
+
S[x + y] = B[x + y] ^ ((~B[(x + 1) % 5 + y] & _MASK) & B[(x + 2) % 5 + y])
|
|
90
|
+
# iota
|
|
91
|
+
S[0] ^= _RC[rnd]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def keccak256(*parts: bytes) -> bytes:
|
|
95
|
+
"""keccak256 over one or more byte runs, absorbed as if concatenated."""
|
|
96
|
+
data = b"".join(parts)
|
|
97
|
+
S = [0] * 25
|
|
98
|
+
rate = 136
|
|
99
|
+
off = 0
|
|
100
|
+
while len(data) - off >= rate:
|
|
101
|
+
blk = data[off:off + rate]
|
|
102
|
+
for i in range(17):
|
|
103
|
+
S[i] ^= int.from_bytes(blk[i * 8:(i + 1) * 8], "little")
|
|
104
|
+
_keccak_f(S)
|
|
105
|
+
off += rate
|
|
106
|
+
rem = bytearray(data[off:])
|
|
107
|
+
rem.append(0x01) # keccak padding, NOT SHA-3's 0x06
|
|
108
|
+
rem.extend(b"\x00" * (rate - len(rem)))
|
|
109
|
+
rem[rate - 1] |= 0x80
|
|
110
|
+
for i in range(17):
|
|
111
|
+
S[i] ^= int.from_bytes(bytes(rem[i * 8:(i + 1) * 8]), "little")
|
|
112
|
+
_keccak_f(S)
|
|
113
|
+
return b"".join(S[i].to_bytes(8, "little") for i in range(4))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ============================== secp256k1 ==============================
|
|
117
|
+
|
|
118
|
+
_P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
|
119
|
+
_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
120
|
+
_GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
|
121
|
+
_GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
|
122
|
+
_HALF_N = _N >> 1
|
|
123
|
+
_G = (_GX, _GY, 1)
|
|
124
|
+
|
|
125
|
+
Point = Tuple[int, int, int] # jacobian
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _j_dbl(p: Point) -> Point:
|
|
129
|
+
X, Y, Z = p
|
|
130
|
+
if Y == 0 or Z == 0:
|
|
131
|
+
return (0, 1, 0)
|
|
132
|
+
A = X * X % _P
|
|
133
|
+
B = Y * Y % _P
|
|
134
|
+
C = B * B % _P
|
|
135
|
+
D = 2 * (((X + B) * (X + B) - A - C) % _P) % _P
|
|
136
|
+
E = 3 * A % _P
|
|
137
|
+
F = E * E % _P
|
|
138
|
+
X3 = (F - 2 * D) % _P
|
|
139
|
+
return (X3, (E * (D - X3) - 8 * C) % _P, 2 * Y * Z % _P)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _j_add(p: Point, q: Point) -> Point:
|
|
143
|
+
X1, Y1, Z1 = p
|
|
144
|
+
X2, Y2, Z2 = q
|
|
145
|
+
if Z1 == 0:
|
|
146
|
+
return q
|
|
147
|
+
if Z2 == 0:
|
|
148
|
+
return p
|
|
149
|
+
ZZ1 = Z1 * Z1 % _P
|
|
150
|
+
ZZ2 = Z2 * Z2 % _P
|
|
151
|
+
U1 = X1 * ZZ2 % _P
|
|
152
|
+
U2 = X2 * ZZ1 % _P
|
|
153
|
+
S1 = Y1 * Z2 * ZZ2 % _P
|
|
154
|
+
S2 = Y2 * Z1 * ZZ1 % _P
|
|
155
|
+
H = (U2 - U1) % _P
|
|
156
|
+
R = (S2 - S1) % _P
|
|
157
|
+
if H == 0:
|
|
158
|
+
return _j_dbl(p) if R == 0 else (0, 1, 0)
|
|
159
|
+
HH = H * H % _P
|
|
160
|
+
HHH = H * HH % _P
|
|
161
|
+
V = U1 * HH % _P
|
|
162
|
+
X3 = (R * R - HHH - 2 * V) % _P
|
|
163
|
+
return (X3, (R * (V - X3) - S1 * HHH) % _P, Z1 * Z2 * H % _P)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _j_mul(k: int, p: Point) -> Point:
|
|
167
|
+
"""
|
|
168
|
+
Double-and-add. Variable-time: it branches on key bits. Runs only during key setup
|
|
169
|
+
and idle nonce fill, never while a request is in flight - but it is NOT hardened
|
|
170
|
+
against a co-resident attacker measuring timing. See README before using this in a
|
|
171
|
+
shared-tenant process.
|
|
172
|
+
"""
|
|
173
|
+
r: Point = (0, 1, 0)
|
|
174
|
+
a = p
|
|
175
|
+
while k > 0:
|
|
176
|
+
if k & 1:
|
|
177
|
+
r = _j_add(r, a)
|
|
178
|
+
a = _j_dbl(a)
|
|
179
|
+
k >>= 1
|
|
180
|
+
return r
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _j_mul_ct(k: int, p: Point) -> Point:
|
|
184
|
+
"""
|
|
185
|
+
Constant-time-hardened scalar multiply, for scalars that ARE secret: the private key
|
|
186
|
+
and the ECDSA nonce k (leaking k leaks the key outright).
|
|
187
|
+
|
|
188
|
+
Two countermeasures:
|
|
189
|
+
1. Scalar blinding - compute (k + r*n)*G. Identical result, since n*G is the point
|
|
190
|
+
at infinity, but the bit pattern is re-randomised on every call.
|
|
191
|
+
2. Always-add-and-double over a FIXED iteration count with a branchless select, so
|
|
192
|
+
the add/no-add pattern no longer tracks key bits and the loop count no longer
|
|
193
|
+
reveals the scalar's bit length.
|
|
194
|
+
|
|
195
|
+
HONEST SCOPE - hardening, not a constant-time proof. Python's int arithmetic is itself
|
|
196
|
+
variable-time (CPython short-circuits on operand size and allocates per operation) and
|
|
197
|
+
no pure-Python implementation can remove that. What is removed is the large,
|
|
198
|
+
directly key-correlated leak: the data-dependent branch on each key bit. If the threat
|
|
199
|
+
model truly requires constant-time signing, use remote_sign and an HSM.
|
|
200
|
+
Cost: roughly 2x the variable-time path. This matters here because a robotics
|
|
201
|
+
controller is long-lived and may share hardware with other tenants.
|
|
202
|
+
"""
|
|
203
|
+
kb = k + secrets.randbits(32) * _N # blinded: same result, randomised bits
|
|
204
|
+
R: Point = (0, 1, 0)
|
|
205
|
+
for i in range(287, -1, -1):
|
|
206
|
+
R = _j_dbl(R)
|
|
207
|
+
T = _j_add(R, p)
|
|
208
|
+
m = -((kb >> i) & 1) # 0 when the bit is 0, -1 when 1
|
|
209
|
+
R = ((R[0] & ~m) | (T[0] & m),
|
|
210
|
+
(R[1] & ~m) | (T[1] & m),
|
|
211
|
+
(R[2] & ~m) | (T[2] & m))
|
|
212
|
+
return R
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _affine(p: Point) -> Tuple[int, int]:
|
|
216
|
+
zi = pow(p[2], -1, _P)
|
|
217
|
+
z2 = zi * zi % _P
|
|
218
|
+
return (p[0] * z2 % _P, p[1] * z2 % _P * zi % _P)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _rand_scalar() -> int:
|
|
222
|
+
while True:
|
|
223
|
+
v = int.from_bytes(secrets.token_bytes(32), "big")
|
|
224
|
+
if 0 < v < _N:
|
|
225
|
+
return v
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def address_of(d: int) -> str:
|
|
229
|
+
"""Lowercase 20-byte address for a private scalar."""
|
|
230
|
+
x, y = _affine(_j_mul_ct(d, _G)) # d is the private key
|
|
231
|
+
return "0x" + keccak256(x.to_bytes(32, "big"), y.to_bytes(32, "big"))[12:].hex()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ============ anticipatory ECDSA nonce = the pipeline ============
|
|
235
|
+
|
|
236
|
+
class _Nonce:
|
|
237
|
+
__slots__ = ("k_inv", "r", "rec")
|
|
238
|
+
|
|
239
|
+
def __init__(self, k_inv: int, r: int, rec: int):
|
|
240
|
+
self.k_inv = k_inv
|
|
241
|
+
self.r = r
|
|
242
|
+
self.rec = rec
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def make_nonce() -> _Nonce:
|
|
246
|
+
"""
|
|
247
|
+
Precompute k*G, r and k^-1 - the entire expensive half of ECDSA, none of which
|
|
248
|
+
depends on the message. Single-use: reusing k across two signatures leaks the key.
|
|
249
|
+
"""
|
|
250
|
+
while True:
|
|
251
|
+
k = _rand_scalar()
|
|
252
|
+
rx, ry = _affine(_j_mul_ct(k, _G)) # k is secret: leaking it leaks the key
|
|
253
|
+
r = rx % _N
|
|
254
|
+
if r == 0:
|
|
255
|
+
continue
|
|
256
|
+
return _Nonce(pow(k, -1, _N), r, (ry & 1) | (2 if rx >= _N else 0))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def sign_with(nc: _Nonce, z: int, d: int) -> str:
|
|
260
|
+
"""Live path: two modular multiplies plus low-s normalization (EIP-2)."""
|
|
261
|
+
s = nc.k_inv * (z + nc.r * d) % _N
|
|
262
|
+
rec = nc.rec
|
|
263
|
+
if s > _HALF_N:
|
|
264
|
+
s = _N - s
|
|
265
|
+
rec ^= 1
|
|
266
|
+
return "0x" + nc.r.to_bytes(32, "big").hex() + s.to_bytes(32, "big").hex() + bytes([27 + rec]).hex()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ===================== EIP-712 / EIP-3009 =====================
|
|
270
|
+
|
|
271
|
+
_DOMAIN_TH = keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
|
|
272
|
+
_XFER_TH = keccak256(
|
|
273
|
+
b"TransferWithAuthorization(address from,address to,uint256 value,"
|
|
274
|
+
b"uint256 validAfter,uint256 validBefore,bytes32 nonce)"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _word(v) -> bytes:
|
|
279
|
+
"""One abi.encode word: int, hex string, or bytes."""
|
|
280
|
+
if isinstance(v, int):
|
|
281
|
+
return v.to_bytes(32, "big")
|
|
282
|
+
b = bytes.fromhex(v[2:] if v.startswith("0x") else v) if isinstance(v, str) else v
|
|
283
|
+
return b"\x00" * (32 - len(b)) + b
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def domain_sep(name: str, version: str, chain_id: int, verifying: str) -> bytes:
|
|
287
|
+
return keccak256(_DOMAIN_TH, keccak256(name.encode()), keccak256(version.encode()),
|
|
288
|
+
_word(chain_id), _word(verifying))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def digest(dsep: bytes, auth: Dict[str, str]) -> int:
|
|
292
|
+
struct_hash = keccak256(
|
|
293
|
+
_XFER_TH, _word(auth["from"]), _word(auth["to"]), _word(int(auth["value"])),
|
|
294
|
+
_word(int(auth["validAfter"])), _word(int(auth["validBefore"])), _word(auth["nonce"]),
|
|
295
|
+
)
|
|
296
|
+
return int.from_bytes(keccak256(b"\x19\x01", dsep, struct_hash), "big")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ========================= x402 protocol =========================
|
|
300
|
+
|
|
301
|
+
# MAINNET ONLY. Every field was read off the deployed contract - name(), version(),
|
|
302
|
+
# eth_chainId and DOMAIN_SEPARATOR() - not copied from documentation. A wrong `name`
|
|
303
|
+
# produces a wrong domain separator and every payment on that chain is silently rejected.
|
|
304
|
+
# Add other networks (including a testnet, to rehearse) via X402Client(custom_chains=...).
|
|
305
|
+
# =========================== THE PROTOCOL FEE ===========================
|
|
306
|
+
# x402-trinity adds 0.1% ON TOP of what you pay a seller, and it is ON BY DEFAULT.
|
|
307
|
+
#
|
|
308
|
+
# - it is ADDED, never deducted: the seller always receives their full asking
|
|
309
|
+
# price, and the extra comes out of the payer's wallet
|
|
310
|
+
# - a notice is printed the first time a client is constructed; you can send it
|
|
311
|
+
# somewhere else, but it always fires
|
|
312
|
+
# - it is collected on each payment, and settled by a facilitator, so neither you
|
|
313
|
+
# nor the payer spends gas moving it
|
|
314
|
+
# - turn it off in one line: X402Client(surcharge=False, ...)
|
|
315
|
+
#
|
|
316
|
+
# If you would rather not pay it, the opt-out above is supported, deliberately easy,
|
|
317
|
+
# and will not be removed.
|
|
318
|
+
# =======================================================================
|
|
319
|
+
FEE_VAULT = "0x2f011f21D6Ec758Bc18f0f9142EeD01Ce2d8a0d3"
|
|
320
|
+
FEE_PPM = 1000 # 1000 parts per million = 0.1%
|
|
321
|
+
# The rate as a percentage string, derived so the notice can never drift from the rate.
|
|
322
|
+
FEE_PCT = ("%f" % (FEE_PPM / 10000.0)).rstrip("0").rstrip(".") or "0"
|
|
323
|
+
# Accrue to this before collecting. The default is 1, i.e. no threshold: every fee is
|
|
324
|
+
# handed to the collector as soon as it is earned. Raise it with surcharge={"collect_at": N}.
|
|
325
|
+
FEE_COLLECT_AT = 1
|
|
326
|
+
FEE_SCALE = 1_000_000 # tally precision, so sub-unit fees are not lost
|
|
327
|
+
# Where a signed fee authorization is sent. This is an x402 FACILITATOR, not a service of
|
|
328
|
+
# ours, and that is the whole point: under x402 the facilitator submits the transfer and
|
|
329
|
+
# pays the gas - not us, and not the payer.
|
|
330
|
+
FEE_COLLECTOR = "https://facilitator.payai.network/settle"
|
|
331
|
+
_FEE_NOTICE_SHOWN = False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
CHAINS: Dict[str, Dict[str, Any]] = {
|
|
335
|
+
# Base + USDC only - the pair that has actually moved money. Add anything else with
|
|
336
|
+
# X402Client(custom_chains=...); the machinery is chain-agnostic, the confidence is not.
|
|
337
|
+
"base": {"id": 8453, "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "name": "USD Coin", "version": "2"},
|
|
338
|
+
}
|
|
339
|
+
_CAIP2 = {"eip155:%d" % c["id"]: k for k, c in CHAINS.items()}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _norm_net(s: str) -> str:
|
|
343
|
+
v = (s or "").strip().lower()
|
|
344
|
+
return _CAIP2.get(v, v)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class Policy:
|
|
348
|
+
"""
|
|
349
|
+
Spend limits. max_amount_per_request and total_budget are REQUIRED - an auto-payer
|
|
350
|
+
without caps is a money leak controlled by whoever runs the server.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(self, max_amount_per_request, total_budget, allow_hosts=None,
|
|
354
|
+
allow_pay_to=None, allow_assets=None, allow_networks=None,
|
|
355
|
+
prefer_networks=None):
|
|
356
|
+
self.max_per = int(max_amount_per_request)
|
|
357
|
+
self.budget = int(total_budget)
|
|
358
|
+
if self.max_per <= 0 or self.budget <= 0:
|
|
359
|
+
raise ValueError("x402: max_amount_per_request and total_budget must be > 0")
|
|
360
|
+
self.allow_hosts = allow_hosts
|
|
361
|
+
self.allow_pay_to = [a.lower() for a in allow_pay_to] if allow_pay_to else None
|
|
362
|
+
self.allow_assets = [a.lower() for a in allow_assets] if allow_assets else None
|
|
363
|
+
self.allow_networks = allow_networks
|
|
364
|
+
self.prefer_networks = prefer_networks
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class X402Error(Exception):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class X402Client:
|
|
372
|
+
"""
|
|
373
|
+
Wraps urllib. On a 402 it signs an EIP-3009 authorization and replays the request.
|
|
374
|
+
|
|
375
|
+
client = X402Client(private_key=KEY, policy=Policy(max_amount_per_request=5000,
|
|
376
|
+
total_budget=1_000_000,
|
|
377
|
+
allow_hosts=["telemetry.local"]))
|
|
378
|
+
body = client.urlopen("https://telemetry.local/v1/stream").read()
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(self, private_key: Optional[str] = None, shards: Optional[List[str]] = None,
|
|
382
|
+
remote_sign: Optional[Callable[[str, dict, dict], str]] = None,
|
|
383
|
+
from_address: Optional[str] = None, policy: Optional[Policy] = None,
|
|
384
|
+
pool_size: int = 16, warm_in_background: bool = True,
|
|
385
|
+
max_background_nonces: int = 512, presign: bool = False, voucher_cap: int = 8,
|
|
386
|
+
on_payment: Optional[Callable[[dict], None]] = None,
|
|
387
|
+
on_decline: Optional[Callable[[dict], None]] = None,
|
|
388
|
+
raise_on_decline: bool = False, opener: Optional[Callable] = None,
|
|
389
|
+
budget_store: Optional[Any] = None,
|
|
390
|
+
acknowledge_ephemeral_budget: bool = False,
|
|
391
|
+
custom_chains: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
392
|
+
surcharge: Any = None):
|
|
393
|
+
"""
|
|
394
|
+
budget_store - DURABLE spend ledger, required for mainnet unless waived.
|
|
395
|
+
|
|
396
|
+
policy.total_budget alone is an in-memory counter scoped to ONE client. It resets on
|
|
397
|
+
process restart and on every new client, so on mainnet the real ceiling becomes the
|
|
398
|
+
wallet balance rather than the cap you set. Pass an object with:
|
|
399
|
+
|
|
400
|
+
reserve(amount: int, total_budget: int) -> bool # MUST be atomic
|
|
401
|
+
release(amount: int) -> None # optional
|
|
402
|
+
|
|
403
|
+
backed by something durable (sqlite, Redis, a file with a lock).
|
|
404
|
+
|
|
405
|
+
acknowledge_ephemeral_budget - opt out of the guard and accept a per-instance cap.
|
|
406
|
+
Testnets are unaffected either way.
|
|
407
|
+
"""
|
|
408
|
+
if policy is None:
|
|
409
|
+
raise ValueError("x402: policy is required")
|
|
410
|
+
self.policy = policy
|
|
411
|
+
self.remote_sign = remote_sign
|
|
412
|
+
self.on_payment = on_payment
|
|
413
|
+
self.on_decline = on_decline
|
|
414
|
+
self.raise_on_decline = raise_on_decline
|
|
415
|
+
self._open = opener or _URLOPEN
|
|
416
|
+
self.budget_store = budget_store
|
|
417
|
+
self.ack_ephemeral = acknowledge_ephemeral_budget
|
|
418
|
+
# Built-in mainnet table plus anything the caller added. VERIFY a custom chain
|
|
419
|
+
# against its deployed DOMAIN_SEPARATOR() before using it with real funds.
|
|
420
|
+
self.chains = dict(CHAINS)
|
|
421
|
+
if custom_chains:
|
|
422
|
+
self.chains.update(custom_chains)
|
|
423
|
+
self._caip2 = {"eip155:%d" % c["id"]: k for k, c in self.chains.items()}
|
|
424
|
+
self.presign = presign
|
|
425
|
+
self.voucher_cap = voucher_cap
|
|
426
|
+
|
|
427
|
+
d = 0
|
|
428
|
+
if private_key:
|
|
429
|
+
d = int(private_key[2:] if private_key.startswith("0x") else private_key, 16)
|
|
430
|
+
elif shards:
|
|
431
|
+
for s in shards:
|
|
432
|
+
d = (d + int(s[2:] if s.startswith("0x") else s, 16)) % _N
|
|
433
|
+
elif not remote_sign:
|
|
434
|
+
raise ValueError("x402: private_key, shards or remote_sign required")
|
|
435
|
+
if not remote_sign and not (0 < d < _N):
|
|
436
|
+
raise ValueError("x402: invalid key material")
|
|
437
|
+
if remote_sign and d == 0 and not from_address:
|
|
438
|
+
raise ValueError("x402: from_address required with remote_sign")
|
|
439
|
+
self._d = d
|
|
440
|
+
self.address = (from_address if d == 0 else address_of(d)).lower()
|
|
441
|
+
|
|
442
|
+
self._pool: List[_Nonce] = []
|
|
443
|
+
self._pool_target = pool_size
|
|
444
|
+
self._max_bg = max_background_nonces
|
|
445
|
+
self._bg_generated = 0
|
|
446
|
+
self._lock = threading.Lock()
|
|
447
|
+
self._dseps: Dict[str, bytes] = {}
|
|
448
|
+
self._vouchers: Dict[str, dict] = {}
|
|
449
|
+
self._pending: Dict[str, dict] = {}
|
|
450
|
+
self.spent = 0
|
|
451
|
+
self.payments = 0
|
|
452
|
+
self.warm_hits = 0
|
|
453
|
+
self.unresolved = 0
|
|
454
|
+
|
|
455
|
+
# ---- the protocol fee -------------------------------------------
|
|
456
|
+
# Skipped entirely with remote_sign: there is no local key to sign a fee
|
|
457
|
+
# authorization with, and we will not ask an HSM to sign one.
|
|
458
|
+
global _FEE_NOTICE_SHOWN
|
|
459
|
+
self._fee_cfg = None if (surcharge is False or remote_sign) else (surcharge or {})
|
|
460
|
+
self.fee_accrued = 0 # scaled by FEE_SCALE
|
|
461
|
+
self.fee_collected = 0 # atomic units actually handed over
|
|
462
|
+
self.fee_lost = 0 # atomic units deducted but never confirmed
|
|
463
|
+
if self._fee_cfg is not None:
|
|
464
|
+
self._fee_collect_at = int(self._fee_cfg.get("collect_at", FEE_COLLECT_AT))
|
|
465
|
+
self._fee_collector = self._fee_cfg.get("collector", FEE_COLLECTOR)
|
|
466
|
+
notice = self._fee_cfg.get("on_notice")
|
|
467
|
+
msg = ("x402-trinity: a " + FEE_PCT + "% protocol fee is added ON TOP of each "
|
|
468
|
+
"payment you make and sent to " + FEE_VAULT + ". Sellers are never "
|
|
469
|
+
"shorted. It is collected on each payment. Disable it with "
|
|
470
|
+
"surcharge=False.")
|
|
471
|
+
if not _FEE_NOTICE_SHOWN:
|
|
472
|
+
_FEE_NOTICE_SHOWN = True
|
|
473
|
+
if callable(notice):
|
|
474
|
+
try: notice(msg)
|
|
475
|
+
except Exception: pass
|
|
476
|
+
else:
|
|
477
|
+
import sys as _sys
|
|
478
|
+
print(msg, file=_sys.stderr)
|
|
479
|
+
|
|
480
|
+
self._stop = threading.Event()
|
|
481
|
+
self._thread = None
|
|
482
|
+
if warm_in_background and not remote_sign:
|
|
483
|
+
self._thread = threading.Thread(target=self._warm_loop, daemon=True)
|
|
484
|
+
self._thread.start()
|
|
485
|
+
|
|
486
|
+
# ---- nonce pool -------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def _warm_loop(self) -> None:
|
|
489
|
+
"""Fill the pool during idle time. Bounded by every guardrail."""
|
|
490
|
+
while not self._stop.is_set():
|
|
491
|
+
with self._lock:
|
|
492
|
+
need = (len(self._pool) < self._pool_target
|
|
493
|
+
and self._bg_generated < self._max_bg
|
|
494
|
+
and self.spent < self.policy.budget)
|
|
495
|
+
if not need:
|
|
496
|
+
self._stop.wait(0.25)
|
|
497
|
+
continue
|
|
498
|
+
nc = make_nonce() # outside the lock: this is the slow part
|
|
499
|
+
with self._lock:
|
|
500
|
+
if len(self._pool) < self._pool_target:
|
|
501
|
+
self._pool.append(nc)
|
|
502
|
+
self._bg_generated += 1
|
|
503
|
+
|
|
504
|
+
def ready(self, timeout: float = 30.0) -> bool:
|
|
505
|
+
"""Block until the pool is warm. Returns False on timeout."""
|
|
506
|
+
end = time.time() + timeout
|
|
507
|
+
while time.time() < end:
|
|
508
|
+
with self._lock:
|
|
509
|
+
if len(self._pool) >= self._pool_target:
|
|
510
|
+
return True
|
|
511
|
+
time.sleep(0.01)
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
def close(self) -> None:
|
|
515
|
+
self._stop.set()
|
|
516
|
+
|
|
517
|
+
def _take_nonce(self) -> _Nonce:
|
|
518
|
+
with self._lock:
|
|
519
|
+
if self._pool:
|
|
520
|
+
return self._pool.pop()
|
|
521
|
+
return make_nonce() # cold fallback: full k*G inline
|
|
522
|
+
|
|
523
|
+
# ---- protocol ---------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def _norm(self, v: str) -> str:
|
|
526
|
+
x = (v or "").strip().lower()
|
|
527
|
+
return self._caip2.get(x, x)
|
|
528
|
+
|
|
529
|
+
def _dsep_for(self, req: dict) -> bytes:
|
|
530
|
+
net = self._norm(req["network"])
|
|
531
|
+
c = self.chains.get(net)
|
|
532
|
+
if not c:
|
|
533
|
+
raise X402Error("unknown network " + net)
|
|
534
|
+
extra = req.get("extra") or {}
|
|
535
|
+
name = extra.get("name", c["name"])
|
|
536
|
+
ver = extra.get("version", c["version"])
|
|
537
|
+
key = "%s|%s|%s|%s" % (net, req["asset"], name, ver)
|
|
538
|
+
if key not in self._dseps:
|
|
539
|
+
self._dseps[key] = domain_sep(name, ver, c["id"], req["asset"])
|
|
540
|
+
return self._dseps[key]
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def _as_requirement(a: dict) -> dict:
|
|
544
|
+
r = dict(a)
|
|
545
|
+
r["network"] = _norm_net(a.get("network", ""))
|
|
546
|
+
# NOT defaulted to "0": a missing amount must decline, not quietly become a
|
|
547
|
+
# zero-value payment that burns a nonce and can never settle.
|
|
548
|
+
r["maxAmountRequired"] = str(a.get("amount", a.get("maxAmountRequired", "")))
|
|
549
|
+
return r
|
|
550
|
+
|
|
551
|
+
def _parse_402(self, headers, body: bytes) -> dict:
|
|
552
|
+
"""Returns {reqs, raws, version, resource, error}."""
|
|
553
|
+
get = lambda h: headers.get(h) or headers.get(h.title()) or headers.get(h.upper())
|
|
554
|
+
|
|
555
|
+
wa = get("www-authenticate")
|
|
556
|
+
if wa and wa.strip().lower().startswith("payment"):
|
|
557
|
+
return {"reqs": [], "raws": [], "version": 1,
|
|
558
|
+
"error": "MPP challenge (WWW-Authenticate: Payment); this client speaks x402 only"}
|
|
559
|
+
|
|
560
|
+
pr = get("payment-required") # x402 v2
|
|
561
|
+
if pr:
|
|
562
|
+
try:
|
|
563
|
+
j = json.loads(pr)
|
|
564
|
+
raws = list(j.get("accepts") or [])
|
|
565
|
+
if raws:
|
|
566
|
+
return {"reqs": [self._as_requirement(a) for a in raws], "raws": raws,
|
|
567
|
+
"version": 2, "resource": j.get("resource"), "error": None}
|
|
568
|
+
except Exception:
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
try: # x402 v1
|
|
572
|
+
j = json.loads(body.decode("utf-8"))
|
|
573
|
+
ver = j.get("x402Version")
|
|
574
|
+
if isinstance(ver, int) and ver > 2:
|
|
575
|
+
return {"reqs": [], "raws": [], "version": 1,
|
|
576
|
+
"error": "server speaks x402 v%d; this client implements v1 and v2" % ver}
|
|
577
|
+
raws = list(j.get("accepts") or [])
|
|
578
|
+
if raws:
|
|
579
|
+
return {"reqs": [self._as_requirement(a) for a in raws], "raws": raws,
|
|
580
|
+
"version": 2 if ver == 2 else 1, "resource": j.get("resource"), "error": None}
|
|
581
|
+
except Exception:
|
|
582
|
+
pass
|
|
583
|
+
return {"reqs": [], "raws": [], "version": 1, "error": None}
|
|
584
|
+
|
|
585
|
+
def _pick(self, reqs: List[dict], host: str) -> Tuple[Optional[dict], int, str]:
|
|
586
|
+
p = self.policy
|
|
587
|
+
last = "no acceptable payment requirement"
|
|
588
|
+
order = list(range(len(reqs)))
|
|
589
|
+
if p.prefer_networks:
|
|
590
|
+
pref = p.prefer_networks
|
|
591
|
+
order.sort(key=lambda i: pref.index(self._norm(reqs[i]["network"])) if self._norm(reqs[i]["network"]) in pref else 10 ** 9)
|
|
592
|
+
import re as _re
|
|
593
|
+
addr_re = _re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
594
|
+
for i in order:
|
|
595
|
+
r = reqs[i]
|
|
596
|
+
# Shape validation FIRST: a buggy or hostile server must produce a clean decline,
|
|
597
|
+
# never an exception deep inside the encoder.
|
|
598
|
+
if not isinstance(r, dict):
|
|
599
|
+
last = "malformed requirement"; continue
|
|
600
|
+
if not addr_re.match(str(r.get("payTo") or "")):
|
|
601
|
+
last = "invalid payTo: %r" % (r.get("payTo"),); continue
|
|
602
|
+
if not addr_re.match(str(r.get("asset") or "")):
|
|
603
|
+
last = "invalid asset: %r" % (r.get("asset"),); continue
|
|
604
|
+
if not str(r.get("maxAmountRequired") or "").isdigit():
|
|
605
|
+
last = "invalid amount: %r" % (r.get("maxAmountRequired"),); continue
|
|
606
|
+
if r.get("scheme") != "exact":
|
|
607
|
+
last = "unsupported scheme %s" % r.get("scheme"); continue
|
|
608
|
+
net = r["network"]
|
|
609
|
+
if net not in self.chains:
|
|
610
|
+
last = "unknown network %s" % net; continue
|
|
611
|
+
if p.allow_networks and net not in p.allow_networks:
|
|
612
|
+
last = "network not allowed: %s" % net; continue
|
|
613
|
+
if p.allow_hosts and host not in p.allow_hosts:
|
|
614
|
+
last = "host not allowed: %s" % host; continue
|
|
615
|
+
if p.allow_pay_to and r["payTo"].lower() not in p.allow_pay_to:
|
|
616
|
+
last = "payTo not allowed: %s" % r["payTo"]; continue
|
|
617
|
+
if p.allow_assets and r["asset"].lower() not in p.allow_assets:
|
|
618
|
+
last = "asset not allowed: %s" % r["asset"]; continue
|
|
619
|
+
v = int(r["maxAmountRequired"])
|
|
620
|
+
if v <= 0:
|
|
621
|
+
last = "non-positive amount: %s" % r["maxAmountRequired"]; continue
|
|
622
|
+
# Real money + a budget that resets per instance = no effective lifetime cap.
|
|
623
|
+
# Every shipped chain is mainnet, so this always applies.
|
|
624
|
+
if not self.budget_store and not self.ack_ephemeral:
|
|
625
|
+
last = ("%s requires a durable budget_store, or "
|
|
626
|
+
"acknowledge_ephemeral_budget=True - total_budget is per-instance "
|
|
627
|
+
"and resets on restart" % net)
|
|
628
|
+
continue
|
|
629
|
+
if v > p.max_per:
|
|
630
|
+
last = "amount %d exceeds per-request cap %d" % (v, p.max_per); continue
|
|
631
|
+
if self.spent + v > p.budget:
|
|
632
|
+
last = "amount %d exceeds remaining budget %d" % (v, p.budget - self.spent); continue
|
|
633
|
+
return r, i, ""
|
|
634
|
+
return None, -1, last
|
|
635
|
+
|
|
636
|
+
def _build(self, req: dict) -> Tuple[dict, str, float]:
|
|
637
|
+
t0 = time.perf_counter()
|
|
638
|
+
now = int(time.time())
|
|
639
|
+
auth = {
|
|
640
|
+
"from": self.address, "to": req["payTo"], "value": req["maxAmountRequired"],
|
|
641
|
+
"validAfter": str(now - 60),
|
|
642
|
+
"validBefore": str(now + int(req.get("maxTimeoutSeconds") or 60)),
|
|
643
|
+
"nonce": "0x" + secrets.token_bytes(32).hex(),
|
|
644
|
+
}
|
|
645
|
+
z = digest(self._dsep_for(req), auth)
|
|
646
|
+
sig = sign_with(self._take_nonce(), z, self._d)
|
|
647
|
+
return auth, sig, (time.perf_counter() - t0) * 1000.0
|
|
648
|
+
|
|
649
|
+
def _decline(self, url: str, reason: str, req=None) -> None:
|
|
650
|
+
if self.on_decline:
|
|
651
|
+
self.on_decline({"url": url, "reason": reason, "req": req})
|
|
652
|
+
if self.raise_on_decline:
|
|
653
|
+
raise X402Error("x402 declined: " + reason)
|
|
654
|
+
|
|
655
|
+
# ---- the interceptor --------------------------------------------
|
|
656
|
+
|
|
657
|
+
def urlopen(self, url, data: Optional[bytes] = None,
|
|
658
|
+
headers: Optional[dict] = None, timeout: Optional[float] = None):
|
|
659
|
+
headers = dict(headers or {})
|
|
660
|
+
req0 = urllib.request.Request(url, data=data, headers=headers)
|
|
661
|
+
try:
|
|
662
|
+
return self._open(req0, timeout=timeout)
|
|
663
|
+
except urllib.error.HTTPError as e:
|
|
664
|
+
if e.code != 402:
|
|
665
|
+
raise
|
|
666
|
+
body = e.read()
|
|
667
|
+
resp_headers = dict(e.headers.items())
|
|
668
|
+
err = e
|
|
669
|
+
|
|
670
|
+
parsed = self._parse_402(resp_headers, body)
|
|
671
|
+
full_url = req0.full_url
|
|
672
|
+
host = urllib.parse.urlparse(full_url).hostname or ""
|
|
673
|
+
|
|
674
|
+
if parsed["error"]:
|
|
675
|
+
self._decline(full_url, parsed["error"]); raise err
|
|
676
|
+
if not parsed["reqs"]:
|
|
677
|
+
self._decline(full_url, "no parseable payment requirements"); raise err
|
|
678
|
+
|
|
679
|
+
req, idx, reason = self._pick(parsed["reqs"], host)
|
|
680
|
+
if req is None:
|
|
681
|
+
self._decline(full_url, reason); raise err
|
|
682
|
+
|
|
683
|
+
version = parsed["version"]
|
|
684
|
+
vk = "%d|%s|%s|%s|%s" % (version, req["network"], req["asset"], req["payTo"], req["maxAmountRequired"])
|
|
685
|
+
|
|
686
|
+
reused = warm = False
|
|
687
|
+
ms = 0.0
|
|
688
|
+
stuck = self._pending.get(vk)
|
|
689
|
+
if stuck and int(stuck["auth"]["validBefore"]) > time.time() + 2:
|
|
690
|
+
# The EIP-3009 nonce is the idempotency key: re-send the SAME authorization.
|
|
691
|
+
# Minting a fresh nonce here is what double-spends.
|
|
692
|
+
auth, sig, reused = stuck["auth"], stuck["sig"], True
|
|
693
|
+
else:
|
|
694
|
+
self._pending.pop(vk, None) # expired: unredeemable, safe to mint fresh
|
|
695
|
+
v = self._vouchers.pop(vk, None)
|
|
696
|
+
if v and v["expires"] > time.time() + 5:
|
|
697
|
+
auth, sig, warm = v["auth"], v["sig"], True
|
|
698
|
+
self.warm_hits += 1
|
|
699
|
+
elif self.remote_sign:
|
|
700
|
+
now = int(time.time())
|
|
701
|
+
auth = {
|
|
702
|
+
"from": self.address, "to": req["payTo"], "value": req["maxAmountRequired"],
|
|
703
|
+
"validAfter": str(now - 60),
|
|
704
|
+
"validBefore": str(now + int(req.get("maxTimeoutSeconds") or 60)),
|
|
705
|
+
"nonce": "0x" + secrets.token_bytes(32).hex(),
|
|
706
|
+
}
|
|
707
|
+
t0 = time.perf_counter()
|
|
708
|
+
z = digest(self._dsep_for(req), auth)
|
|
709
|
+
sig = self.remote_sign("0x" + z.to_bytes(32, "big").hex(), auth, req)
|
|
710
|
+
ms = (time.perf_counter() - t0) * 1000.0
|
|
711
|
+
else:
|
|
712
|
+
auth, sig, ms = self._build(req)
|
|
713
|
+
|
|
714
|
+
value = int(auth["value"])
|
|
715
|
+
if not reused:
|
|
716
|
+
if self.budget_store is not None:
|
|
717
|
+
try:
|
|
718
|
+
allowed = self.budget_store.reserve(value, self.policy.budget)
|
|
719
|
+
except Exception as e:
|
|
720
|
+
self._decline(full_url, "budget_store.reserve failed: %s" % e, req); raise err
|
|
721
|
+
if not allowed:
|
|
722
|
+
self._decline(full_url, "durable budget exhausted", req); raise err
|
|
723
|
+
elif self.spent + value > self.policy.budget:
|
|
724
|
+
self._decline(full_url, "budget exhausted", req); raise err
|
|
725
|
+
self.spent += value
|
|
726
|
+
self.payments += 1
|
|
727
|
+
|
|
728
|
+
if version == 2:
|
|
729
|
+
envelope = {
|
|
730
|
+
"x402Version": 2,
|
|
731
|
+
"resource": parsed.get("resource") or {"url": full_url},
|
|
732
|
+
"accepted": parsed["raws"][idx],
|
|
733
|
+
"payload": {"signature": sig, "authorization": auth},
|
|
734
|
+
"extensions": {},
|
|
735
|
+
}
|
|
736
|
+
pay_header = "PAYMENT-SIGNATURE"
|
|
737
|
+
else:
|
|
738
|
+
envelope = {"x402Version": 1, "scheme": req["scheme"], "network": req["network"],
|
|
739
|
+
"payload": {"signature": sig, "authorization": auth}}
|
|
740
|
+
pay_header = "X-PAYMENT"
|
|
741
|
+
enc = base64.b64encode(json.dumps(envelope).encode()).decode()
|
|
742
|
+
|
|
743
|
+
h2 = dict(headers)
|
|
744
|
+
h2[pay_header] = enc
|
|
745
|
+
if version == 1:
|
|
746
|
+
h2["x402-payment-authorization"] = enc
|
|
747
|
+
|
|
748
|
+
self._pending[vk] = {"auth": auth, "sig": sig} # recorded BEFORE sending
|
|
749
|
+
req2 = urllib.request.Request(full_url, data=data, headers=h2)
|
|
750
|
+
try:
|
|
751
|
+
resp = self._open(req2, timeout=timeout)
|
|
752
|
+
except urllib.error.HTTPError as e2:
|
|
753
|
+
if e2.code >= 500:
|
|
754
|
+
self.unresolved += 1 # ambiguous: keep it, re-send next time
|
|
755
|
+
else:
|
|
756
|
+
self._pending.pop(vk, None)
|
|
757
|
+
# A definitive rejection means nothing settled - return the reservation.
|
|
758
|
+
if not reused and e2.code >= 400 and self.budget_store is not None:
|
|
759
|
+
rel = getattr(self.budget_store, "release", None)
|
|
760
|
+
if rel:
|
|
761
|
+
try:
|
|
762
|
+
rel(value); self.spent -= value; self.payments -= 1
|
|
763
|
+
except Exception:
|
|
764
|
+
pass # best-effort; over-counting is the safe direction
|
|
765
|
+
raise
|
|
766
|
+
except Exception:
|
|
767
|
+
self.unresolved += 1 # connection died: outcome unknown
|
|
768
|
+
raise
|
|
769
|
+
self._pending.pop(vk, None)
|
|
770
|
+
|
|
771
|
+
settlement = None
|
|
772
|
+
try:
|
|
773
|
+
sr = resp.headers.get("payment-response") or resp.headers.get("x-payment-response")
|
|
774
|
+
if sr:
|
|
775
|
+
settlement = json.loads(base64.b64decode(sr))
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
if not reused:
|
|
780
|
+
self._accrue_fee(value, req)
|
|
781
|
+
|
|
782
|
+
if self.on_payment:
|
|
783
|
+
self.on_payment({"url": full_url, "value": auth["value"], "payTo": req["payTo"],
|
|
784
|
+
"network": req["network"], "warm": warm, "reused": reused,
|
|
785
|
+
"sign_ms": ms, "version": version, "settlement": settlement})
|
|
786
|
+
|
|
787
|
+
if self.presign and not self.remote_sign and len(self._vouchers) < self.voucher_cap \
|
|
788
|
+
and self.spent + value <= self.policy.budget:
|
|
789
|
+
try:
|
|
790
|
+
a2, s2, _ = self._build(req)
|
|
791
|
+
self._vouchers[vk] = {"auth": a2, "sig": s2, "expires": int(a2["validBefore"])}
|
|
792
|
+
except Exception:
|
|
793
|
+
pass # pre-signing is strictly best-effort
|
|
794
|
+
|
|
795
|
+
return resp
|
|
796
|
+
|
|
797
|
+
def _accrue_fee(self, value: int, req: dict) -> None:
|
|
798
|
+
"""
|
|
799
|
+
Accrue the protocol fee and, once it crosses the threshold, sign ONE authorization
|
|
800
|
+
for the whole accrued amount and hand it to the collector.
|
|
801
|
+
|
|
802
|
+
Two rules this must never break:
|
|
803
|
+
1. it must never break a payment - every failure path is swallowed
|
|
804
|
+
2. it must never charge twice - the tally is deducted BEFORE the authorization is
|
|
805
|
+
handed over, and a failed hand-off is NOT restored. A failed POST is ambiguous:
|
|
806
|
+
the collector may have received it and still settle. Losing our own fee is the
|
|
807
|
+
safe direction; charging the payer twice is not.
|
|
808
|
+
"""
|
|
809
|
+
if self._fee_cfg is None:
|
|
810
|
+
return
|
|
811
|
+
try:
|
|
812
|
+
add = value * FEE_PPM # implicitly x FEE_SCALE / 1e6
|
|
813
|
+
with self._lock:
|
|
814
|
+
self.fee_accrued += add
|
|
815
|
+
scaled = self.fee_accrued
|
|
816
|
+
whole = scaled // FEE_SCALE
|
|
817
|
+
if whole < self._fee_collect_at:
|
|
818
|
+
return
|
|
819
|
+
self.fee_accrued = scaled - whole * FEE_SCALE # deducted BEFORE hand-off
|
|
820
|
+
|
|
821
|
+
now = int(time.time())
|
|
822
|
+
auth = {
|
|
823
|
+
"from": self.address, "to": FEE_VAULT, "value": str(whole),
|
|
824
|
+
"validAfter": str(now - 60), "validBefore": str(now + 3600),
|
|
825
|
+
"nonce": "0x" + secrets.token_bytes(32).hex(),
|
|
826
|
+
}
|
|
827
|
+
sig = sign_with(self._take_nonce(), digest(self._dsep_for(req), auth), self._d)
|
|
828
|
+
|
|
829
|
+
# Facilitator /settle shape: it wants the payload AND the requirements it should
|
|
830
|
+
# be checked against. We are the payee here, so we state the requirements
|
|
831
|
+
# ourselves - a facilitator has no concept of "seller".
|
|
832
|
+
fc = self.chains[self._norm(req["network"])]
|
|
833
|
+
body = json.dumps({
|
|
834
|
+
"x402Version": 1,
|
|
835
|
+
"paymentPayload": {
|
|
836
|
+
"x402Version": 1, "scheme": "exact", "network": req["network"],
|
|
837
|
+
"payload": {"signature": sig, "authorization": auth},
|
|
838
|
+
},
|
|
839
|
+
"paymentRequirements": {
|
|
840
|
+
"scheme": "exact", "network": req["network"], "payTo": FEE_VAULT,
|
|
841
|
+
"asset": fc["asset"], "maxAmountRequired": str(whole), "amount": str(whole),
|
|
842
|
+
"resource": "https://x402-trinity.dev/fee",
|
|
843
|
+
"description": "x402-trinity protocol fee",
|
|
844
|
+
"mimeType": "application/json", "maxTimeoutSeconds": 300,
|
|
845
|
+
"extra": {"name": fc["name"], "version": fc["version"]},
|
|
846
|
+
},
|
|
847
|
+
}).encode()
|
|
848
|
+
ok = False
|
|
849
|
+
try:
|
|
850
|
+
r = urllib.request.Request(self._fee_collector, data=body,
|
|
851
|
+
headers={"content-type": "application/json"})
|
|
852
|
+
with _URLOPEN(r, timeout=30) as resp:
|
|
853
|
+
ok = json.loads(resp.read().decode()).get("success") is True
|
|
854
|
+
except Exception:
|
|
855
|
+
ok = False
|
|
856
|
+
with self._lock:
|
|
857
|
+
if ok: self.fee_collected += whole
|
|
858
|
+
else: self.fee_lost += whole
|
|
859
|
+
except Exception:
|
|
860
|
+
pass # the fee must never break a payment
|
|
861
|
+
|
|
862
|
+
def stats(self) -> dict:
|
|
863
|
+
with self._lock:
|
|
864
|
+
pool = len(self._pool)
|
|
865
|
+
bg = self._bg_generated
|
|
866
|
+
return {"pool": pool, "vouchers": len(self._vouchers), "spent": self.spent,
|
|
867
|
+
"remaining": self.policy.budget - self.spent, "payments": self.payments,
|
|
868
|
+
"warm_hits": self.warm_hits, "bg_generated": bg,
|
|
869
|
+
"bg_capped": bg >= self._max_bg, "in_flight": len(self._pending),
|
|
870
|
+
"unresolved": self.unresolved,
|
|
871
|
+
"fee": {"enabled": self._fee_cfg is not None,
|
|
872
|
+
"vault": FEE_VAULT if self._fee_cfg is not None else None,
|
|
873
|
+
"accrued": str(self.fee_accrued // FEE_SCALE),
|
|
874
|
+
"collected": str(self.fee_collected),
|
|
875
|
+
"lost": str(self.fee_lost)}}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
# ======================== the decorator ========================
|
|
879
|
+
|
|
880
|
+
def x402_telemetry(client: Optional[X402Client] = None, **cfg):
|
|
881
|
+
"""
|
|
882
|
+
Hooks payment into raw data-harvesting scripts. Inside the decorated function,
|
|
883
|
+
urllib.request.urlopen pays 402s automatically - existing code is untouched.
|
|
884
|
+
|
|
885
|
+
@x402_telemetry(private_key=KEY, policy=Policy(max_amount_per_request=5000,
|
|
886
|
+
total_budget=1_000_000))
|
|
887
|
+
def harvest():
|
|
888
|
+
return urllib.request.urlopen("https://sensor.local/v1/lidar").read()
|
|
889
|
+
|
|
890
|
+
The patch is process-global for the duration of the call and is always restored.
|
|
891
|
+
"""
|
|
892
|
+
def decorate(fn):
|
|
893
|
+
@wraps(fn)
|
|
894
|
+
def wrapper(*args, **kwargs):
|
|
895
|
+
c = client or X402Client(**cfg)
|
|
896
|
+
original = urllib.request.urlopen
|
|
897
|
+
|
|
898
|
+
def patched(url, data=None, timeout=None, *a, **kw):
|
|
899
|
+
if isinstance(url, urllib.request.Request):
|
|
900
|
+
return c.urlopen(url.full_url, data=url.data if url.data else data,
|
|
901
|
+
headers=dict(url.headers), timeout=timeout)
|
|
902
|
+
return c.urlopen(url, data=data, timeout=timeout)
|
|
903
|
+
|
|
904
|
+
urllib.request.urlopen = patched
|
|
905
|
+
try:
|
|
906
|
+
return fn(*args, **kwargs)
|
|
907
|
+
finally:
|
|
908
|
+
urllib.request.urlopen = original
|
|
909
|
+
if client is None:
|
|
910
|
+
c.close()
|
|
911
|
+
wrapper.x402_client = client
|
|
912
|
+
return wrapper
|
|
913
|
+
return decorate
|
|
914
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File-backed durable budget store for the Python wrapper. OPTIONAL, stdlib only.
|
|
3
|
+
|
|
4
|
+
Needs no Cloudflare, no database, no network - a laptop, a VPS or a robotics controller
|
|
5
|
+
is enough.
|
|
6
|
+
|
|
7
|
+
from x402_trinity import X402Client, Policy
|
|
8
|
+
from budget_file import FileBudgetStore
|
|
9
|
+
|
|
10
|
+
client = X402Client(
|
|
11
|
+
private_key=os.environ["X402_PRIVATE_KEY"],
|
|
12
|
+
policy=Policy(max_amount_per_request=5000, total_budget=1_000_000,
|
|
13
|
+
allow_networks=["base"]),
|
|
14
|
+
budget_store=FileBudgetStore("./.x402-budget.json"),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
The spend total survives restarts, which is the point: an in-memory budget resets every
|
|
18
|
+
time the process starts, so on mainnet it caps nothing.
|
|
19
|
+
|
|
20
|
+
Concurrency: an exclusive lock file (O_EXCL) prevents two processes on the same machine
|
|
21
|
+
from both passing the same check. It does NOT coordinate across machines - back the same
|
|
22
|
+
interface with Redis/Postgres for that.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BudgetError(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileBudgetStore:
|
|
36
|
+
def __init__(self, path: str, lock_timeout: float = 5.0):
|
|
37
|
+
self.path = path
|
|
38
|
+
self.lock_path = path + ".lock"
|
|
39
|
+
self.lock_timeout = lock_timeout
|
|
40
|
+
|
|
41
|
+
# ---- internals ----
|
|
42
|
+
|
|
43
|
+
def _read(self) -> int:
|
|
44
|
+
if not os.path.exists(self.path):
|
|
45
|
+
return 0
|
|
46
|
+
try:
|
|
47
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
48
|
+
v = int(json.load(f).get("spent", 0))
|
|
49
|
+
return max(v, 0)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
# A corrupt ledger must NOT read as zero - that would silently reset the budget
|
|
52
|
+
# and re-open unlimited spending. Fail closed.
|
|
53
|
+
raise BudgetError(
|
|
54
|
+
"x402 budget file is unreadable: %s (%s). "
|
|
55
|
+
"Refusing to treat it as zero spend." % (self.path, e)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _write(self, v: int) -> None:
|
|
59
|
+
tmp = self.path + ".tmp"
|
|
60
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
61
|
+
json.dump({"spent": str(v), "updated": datetime.now(timezone.utc).isoformat()}, f)
|
|
62
|
+
os.replace(tmp, self.path) # atomic on the same filesystem
|
|
63
|
+
|
|
64
|
+
class _Lock:
|
|
65
|
+
def __init__(self, outer):
|
|
66
|
+
self.outer = outer
|
|
67
|
+
self.fd = None
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
deadline = time.time() + self.outer.lock_timeout
|
|
71
|
+
while True:
|
|
72
|
+
try:
|
|
73
|
+
self.fd = os.open(self.outer.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
74
|
+
return self
|
|
75
|
+
except FileExistsError:
|
|
76
|
+
if time.time() > deadline:
|
|
77
|
+
raise BudgetError("x402 budget lock timed out: %s" % self.outer.lock_path)
|
|
78
|
+
time.sleep(0.015)
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *a):
|
|
81
|
+
try:
|
|
82
|
+
if self.fd is not None:
|
|
83
|
+
os.close(self.fd)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
os.unlink(self.outer.lock_path)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
# ---- the interface the wrapper calls ----
|
|
92
|
+
|
|
93
|
+
def reserve(self, amount: int, total_budget: int) -> bool:
|
|
94
|
+
"""Atomic check-and-increment. False if the payment would exceed the budget."""
|
|
95
|
+
with self._Lock(self):
|
|
96
|
+
now = self._read()
|
|
97
|
+
if now + amount > total_budget:
|
|
98
|
+
return False
|
|
99
|
+
self._write(now + amount)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def release(self, amount: int) -> None:
|
|
103
|
+
with self._Lock(self):
|
|
104
|
+
now = self._read()
|
|
105
|
+
self._write(max(now - amount, 0))
|
|
106
|
+
|
|
107
|
+
def spent(self) -> int:
|
|
108
|
+
return self._read()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: x402-trinity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency, Python standard-library-only execution core for machine-to-machine x402 payment handshakes.
|
|
5
|
+
Author: devmster
|
|
6
|
+
Maintainer: devmster
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/devmster/x402-trinity
|
|
9
|
+
Project-URL: Repository, https://github.com/devmster/x402-trinity
|
|
10
|
+
Project-URL: Issues, https://github.com/devmster/x402-trinity/issues
|
|
11
|
+
Keywords: x402,http-402,ai-agent,robotics-commerce,embedded-crypto,stdlib-only,gasless-transfer,eip-3009,constant-time-signing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# x402-trinity
|
|
23
|
+
|
|
24
|
+
**Let a Python process pay for things by itself — with hard spending limits, no hosted wallet
|
|
25
|
+
service, and no changes to the code doing the fetching.**
|
|
26
|
+
|
|
27
|
+
Standard library only. No dependencies, at all.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install x402-trinity
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Why
|
|
34
|
+
|
|
35
|
+
When a script hits a paid resource it gets `402 Payment Required`. Without something to handle
|
|
36
|
+
that, the request simply fails.
|
|
37
|
+
|
|
38
|
+
x402-trinity handles it: reads the challenge, checks it against limits you set, signs, retries.
|
|
39
|
+
All of it locally — no hosted wallet service, no third-party API, and the key never leaves your
|
|
40
|
+
process.
|
|
41
|
+
|
|
42
|
+
Gas exists but the payer doesn't pay it — under EIP-3009 the facilitator submits the transfer,
|
|
43
|
+
so the wallet only ever needs USDC.
|
|
44
|
+
|
|
45
|
+
## Use
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import os
|
|
49
|
+
from x402_trinity import X402Client, Policy
|
|
50
|
+
|
|
51
|
+
client = X402Client(
|
|
52
|
+
private_key=os.environ["X402_PRIVATE_KEY"],
|
|
53
|
+
policy=Policy(
|
|
54
|
+
max_amount_per_request=5000, # 0.005 USDC per call (REQUIRED)
|
|
55
|
+
total_budget=1_000_000, # 1.00 USDC lifetime (REQUIRED)
|
|
56
|
+
allow_hosts=["api.example.com"],
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
body = client.urlopen("https://api.example.com/data").read()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or as a decorator, so payment-unaware code just works:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from x402_trinity import x402_telemetry, Policy
|
|
67
|
+
|
|
68
|
+
@x402_telemetry(private_key=KEY, policy=Policy(max_amount_per_request=5000,
|
|
69
|
+
total_budget=1_000_000))
|
|
70
|
+
def harvest():
|
|
71
|
+
return urllib.request.urlopen("https://sensor.local/v1/lidar").read()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Inside the decorated function `urllib.request.urlopen` pays 402s automatically. Existing code
|
|
75
|
+
is untouched.
|
|
76
|
+
|
|
77
|
+
## Scope
|
|
78
|
+
|
|
79
|
+
**Buyer only.** To charge for a resource, use the TypeScript seller in the same project.
|
|
80
|
+
|
|
81
|
+
**Base mainnet + USDC.** Other EVM chains work by passing `custom_chains` to `X402Client`.
|
|
82
|
+
Check any entry against the deployed contract first: call `DOMAIN_SEPARATOR()` and confirm it
|
|
83
|
+
equals what this library computes. A wrong `name` or `version` produces a signature that looks
|
|
84
|
+
valid and the contract rejects.
|
|
85
|
+
|
|
86
|
+
## Limits are not optional
|
|
87
|
+
|
|
88
|
+
`max_amount_per_request` and `total_budget` are required arguments. An auto-payer without caps
|
|
89
|
+
is a money leak controlled by whoever runs the server on the other end. Set `allow_hosts` and
|
|
90
|
+
nothing else can be paid, however convincing the challenge looks.
|
|
91
|
+
|
|
92
|
+
## Before you use it with real money
|
|
93
|
+
|
|
94
|
+
This software signs payment authorizations. You are responsible for the funds in any wallet you
|
|
95
|
+
configure it with, for the limits you set, and for the behaviour of any autonomous process you
|
|
96
|
+
connect to it. Use a dedicated wallet holding only what you would accept losing.
|
|
97
|
+
|
|
98
|
+
## Links
|
|
99
|
+
|
|
100
|
+
- Source: https://github.com/devmster/x402-trinity
|
|
101
|
+
- TypeScript package (buyer, seller, MCP server): `npm install x402-trinity`
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
x402_trinity/__init__.py,sha256=nyaKOS_6JGXy1ME8fmE-rqCqIEDh8embRNyasbx4Q78,40491
|
|
2
|
+
x402_trinity/budget_file.py,sha256=_7IwwNPDO9p79t8tKN6xdHlPU_eduoRd56gQMLp8ZUY,3763
|
|
3
|
+
x402_trinity-0.1.0.dist-info/METADATA,sha256=kkBAv4vpM9WW15XT_dgAE4pYckQQUTTtsDPzQi-4StE,3852
|
|
4
|
+
x402_trinity-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
x402_trinity-0.1.0.dist-info/top_level.txt,sha256=KenpobrLvhji_Q70V7yBlfLvZtt4q-U6AJsqchr4pmQ,13
|
|
6
|
+
x402_trinity-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
x402_trinity
|