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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ x402_trinity