railgate 1.0.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,3 @@
1
+ bdbff3b6c8a22b1c8d06e50a5043ae9cef9bc17f202085e3ae5d909fe9851dfb policy.py
2
+ 6acc91164c89ae08003ba53fb77ca21c45439ca2fa68e3344c8c402b8ffd2298 store.py
3
+ bdaec65f0a1cb3174e16ab6025a1f67c177b41102c3e1384dbe892059762185f gate.py
@@ -0,0 +1,7 @@
1
+ """Pre-signing payment policy gate for x402 buyer agents (EXP-001)."""
2
+ from .factory import GatedClient, create_gated_client
3
+ from .gate import PolicyGate, PolicyRefusal
4
+ from .policy import PaymentPolicy, PolicyError
5
+
6
+ __all__ = ["GatedClient", "create_gated_client", "PolicyGate", "PolicyRefusal",
7
+ "PaymentPolicy", "PolicyError"]
policy_gate/factory.py ADDED
@@ -0,0 +1,80 @@
1
+ """Mandatory-construction factory for gated x402 clients (EXP-001).
2
+
3
+ AgentBadge's evidence line is the spec: "a cap you can forget is a cap you
4
+ will forget." So there is no code path here that builds a paying client
5
+ without a policy — create_gated_client(policy=None) raises TypeError with
6
+ the same shape AgentBadge used: refusing to create an uncapped paying
7
+ client. The vendored SDK's own SpendControls are ALSO set from the same
8
+ policy (defense in depth): the SDK filters non-allowlisted assets and
9
+ over-cap accepts during selection, and this gate enforces the stateful
10
+ layer (budgets, counterparty, velocity, intent, replay) at the
11
+ pre-signing hook.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from x402 import x402Client
18
+ from x402.mechanisms.evm.exact import ExactEvmScheme
19
+ from x402.schemas import PaymentAbortedError
20
+
21
+ from .gate import PolicyGate, PolicyRefusal
22
+ from .policy import PaymentPolicy, usd_money_string
23
+
24
+
25
+ @dataclass
26
+ class GatedClient:
27
+ client: x402Client
28
+ gate: PolicyGate
29
+ policy: PaymentPolicy
30
+
31
+ async def pay(self, payment_required, *, intent: str | None = None,
32
+ expected_pay_to: str | None = None,
33
+ expected_resource: str | None = None):
34
+ """Create a payment payload through the gate.
35
+
36
+ Declares intent first when given. On policy denial raises
37
+ PolicyRefusal with the structured refusal dict (never a bare
38
+ exception string to parse).
39
+ """
40
+ if intent is not None:
41
+ self.gate.declare_intent(intent, expected_pay_to=expected_pay_to,
42
+ expected_resource=expected_resource)
43
+ self.gate.last_refusal = None
44
+ try:
45
+ return await self.client.create_payment_payload(payment_required)
46
+ except PaymentAbortedError:
47
+ if self.gate.last_refusal is not None:
48
+ raise PolicyRefusal(self.gate.last_refusal) from None
49
+ raise
50
+
51
+
52
+ def create_gated_client(policy: PaymentPolicy | None, signer, *,
53
+ store_path: str, audit_path: str | None = None,
54
+ clock=None) -> GatedClient:
55
+ if policy is None:
56
+ raise TypeError("PaymentPolicy required — refusing to create an uncapped paying client")
57
+ if signer is None:
58
+ raise TypeError("signer required — refusing to create a paying client without a signer")
59
+ gate = PolicyGate(policy, store_path, audit_path=audit_path, clock=clock)
60
+ client = x402Client()
61
+ for network in policy.allowed_networks:
62
+ if ":*" in network:
63
+ raise ValueError("factory needs concrete networks to register schemes; "
64
+ "wildcard network patterns are enforced by the gate but "
65
+ "cannot be registered — list networks explicitly")
66
+ client.register(network, ExactEvmScheme(signer=signer))
67
+ allowed_assets = [
68
+ {"network": network, "asset": asset,
69
+ "max_amount_per_payment": str(policy.per_call_cap_atomic)}
70
+ for network in policy.allowed_networks
71
+ for asset in policy.allowed_assets
72
+ ]
73
+ client.set_spend_controls({
74
+ "max_amount_per_payment": usd_money_string(policy.per_call_cap_atomic, policy.asset_decimals),
75
+ "allowed_assets": allowed_assets,
76
+ })
77
+ client.on_before_payment_creation(gate.before_hook)
78
+ client.on_after_payment_creation(gate.after_hook)
79
+ client.on_payment_creation_failure(gate.failure_hook)
80
+ return GatedClient(client=client, gate=gate, policy=policy)
policy_gate/gate.py ADDED
@@ -0,0 +1,273 @@
1
+ """PolicyGate — the pre-signing enforcement layer (EXP-001).
2
+
3
+ Sits on the REAL x402 client's payment-creation hooks:
4
+
5
+ before-hook -> evaluate policy; on deny return AbortResult (the client
6
+ raises PaymentAbortedError and the scheme/signer is NEVER
7
+ invoked, so no signature exists); on allow, atomically
8
+ reserve budget in the SpendStore.
9
+ after-hook -> commit the reservation (a signature now exists).
10
+ failure-hook -> release the reservation (creation failed downstream).
11
+
12
+ Every decision (allow and deny) is appended to an audit JSONL. Every denial
13
+ builds a structured refusal {code, detail, amount, pay_to, network,
14
+ suggested_action, ...} — MEMO-003 Q3: the caller never parses prose.
15
+
16
+ Refusal actions:
17
+ do_not_retry — same request can never pass (allowlist, mismatch)
18
+ escalate_to_operator — a human/policy change is the way out (budgets, caps)
19
+ degrade_or_skip — try a cheaper resource or proceed without it
20
+ retry_after_window — velocity / replay window; wait, then retry
21
+ declare_intent_and_retry — intent was missing; declare it and retry
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import threading
28
+ import time
29
+ import uuid
30
+ from collections import defaultdict
31
+ from datetime import datetime, timezone
32
+
33
+ from x402.schemas import AbortResult
34
+
35
+ from .policy import PaymentPolicy, parse_atomic_amount
36
+ from .store import SpendStore, StoreError
37
+
38
+ _ACTION = {
39
+ "PER_CALL_CAP": "degrade_or_skip",
40
+ "SESSION_BUDGET": "escalate_to_operator",
41
+ "DAILY_BUDGET": "escalate_to_operator",
42
+ "COUNTERPARTY_CAP": "escalate_to_operator",
43
+ "COUNTERPARTY_NOT_ALLOWED": "escalate_to_operator",
44
+ "NETWORK_NOT_ALLOWED": "do_not_retry",
45
+ "ASSET_NOT_ALLOWED": "do_not_retry",
46
+ "VELOCITY": "retry_after_window",
47
+ "REPLAY_DUPLICATE": "retry_after_window",
48
+ "INTENT_REQUIRED": "declare_intent_and_retry",
49
+ "INTENT_MISMATCH": "do_not_retry",
50
+ "MALFORMED_AMOUNT": "do_not_retry",
51
+ "UNSUPPORTED_VERSION": "do_not_retry",
52
+ "INTERNAL_FAIL_CLOSED": "escalate_to_operator",
53
+ }
54
+
55
+
56
+ class PolicyRefusal(Exception):
57
+ """Structured refusal raised by GatedClient.pay(). Carries the refusal dict."""
58
+
59
+ def __init__(self, refusal: dict):
60
+ self.refusal = refusal
61
+ super().__init__(f"{refusal.get('code')}: {refusal.get('detail')}")
62
+
63
+
64
+ class PolicyGate:
65
+ def __init__(self, policy: PaymentPolicy, store_path: str,
66
+ audit_path: str | None = None, clock=None):
67
+ if policy is None:
68
+ raise TypeError("PaymentPolicy required — refusing to create an uncapped paying client")
69
+ self.policy = policy
70
+ self._clock = clock or time.time
71
+ self.session_id = uuid.uuid4().hex
72
+ self.store = SpendStore(store_path, self.session_id)
73
+ self._audit_path = audit_path
74
+ self._audit_lock = threading.Lock()
75
+ self._pending: dict[str, list[int]] = defaultdict(list)
76
+ self._pending_lock = threading.Lock()
77
+ # Intent state: a declaration authorizes payments that match it.
78
+ # Each declare_intent() bumps the declaration counter; the counter is
79
+ # part of the payment fingerprint, so re-declaring intent is what
80
+ # makes a deliberate repeat purchase distinguishable from a loop
81
+ # replaying one authorization forever (CARD-003 loss mode #1).
82
+ self._intent_text: str | None = None
83
+ self._intent_hash: str | None = None
84
+ self._intent_pay_to: str | None = None
85
+ self._intent_resource: str | None = None
86
+ self._intent_decl_id = 0
87
+ self.last_refusal: dict | None = None
88
+
89
+ # ---------------------------------------------------------------- intent
90
+ def declare_intent(self, text: str, *, expected_pay_to: str | None = None,
91
+ expected_resource: str | None = None) -> int:
92
+ """Declare what the next payment(s) are FOR. Returns declaration id.
93
+
94
+ expected_pay_to / expected_resource bind the intent: a payment whose
95
+ counterparty or resource differs is refused INTENT_MISMATCH — the
96
+ signature is bound to the declared purpose before it exists.
97
+ """
98
+ if not isinstance(text, str) or not text.strip():
99
+ raise ValueError("intent text must be a non-empty string")
100
+ self._intent_text = text
101
+ self._intent_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
102
+ self._intent_pay_to = expected_pay_to.lower() if expected_pay_to else None
103
+ self._intent_resource = expected_resource
104
+ self._intent_decl_id += 1
105
+ return self._intent_decl_id
106
+
107
+ # ---------------------------------------------------------------- helpers
108
+ def _now(self) -> float:
109
+ return float(self._clock())
110
+
111
+ @staticmethod
112
+ def _day_start(now: float) -> float:
113
+ dt = datetime.fromtimestamp(now, tz=timezone.utc)
114
+ return dt.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
115
+
116
+ @staticmethod
117
+ def _fingerprint(*, intent_hash: str | None, decl_id: int, network: str,
118
+ asset: str, pay_to: str, amount: int, resource_url: str | None) -> str:
119
+ raw = "|".join([intent_hash or "", str(decl_id), network, asset.lower(),
120
+ pay_to.lower(), str(amount), resource_url or ""])
121
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
122
+
123
+ def _audit(self, record: dict) -> None:
124
+ if not self._audit_path:
125
+ return
126
+ record = {"ts": self._now(), "session_id": self.session_id, **record}
127
+ line = json.dumps(record, sort_keys=True)
128
+ with self._audit_lock:
129
+ with open(self._audit_path, "a", encoding="utf-8") as f:
130
+ f.write(line + "\n")
131
+
132
+ def _deny(self, code: str, detail: str, *, amount=None, pay_to=None,
133
+ network=None, resource_url=None) -> AbortResult:
134
+ refusal = {
135
+ "code": code,
136
+ "detail": detail,
137
+ "suggested_action": _ACTION[code],
138
+ "amount_atomic": amount,
139
+ "pay_to": pay_to,
140
+ "network": network,
141
+ "resource_url": resource_url,
142
+ "session_id": self.session_id,
143
+ }
144
+ if code in ("VELOCITY", "REPLAY_DUPLICATE"):
145
+ refusal["retry_after_seconds"] = (
146
+ self.policy.window_seconds if code == "VELOCITY"
147
+ else self.policy.replay_window_seconds)
148
+ self.last_refusal = refusal
149
+ self._audit({"decision": "deny", **refusal})
150
+ return AbortResult(reason="policy_refusal:" + json.dumps(refusal, sort_keys=True))
151
+
152
+ # ---------------------------------------------------------------- hooks
153
+ def before_hook(self, ctx) -> AbortResult | None:
154
+ """x402 on_before_payment_creation hook. None = allow."""
155
+ req = ctx.selected_requirements
156
+ payment_required = ctx.payment_required
157
+ resource_url = None
158
+ if getattr(payment_required, "resource", None) is not None:
159
+ resource_url = payment_required.resource.url
160
+ try:
161
+ amount_raw = req.get_amount() if hasattr(req, "get_amount") else getattr(req, "amount", None)
162
+ pay_to = getattr(req, "pay_to", None)
163
+ network = getattr(req, "network", None)
164
+ asset = getattr(req, "asset", None)
165
+ if pay_to is None or network is None or asset is None:
166
+ return self._deny("UNSUPPORTED_VERSION",
167
+ "requirements shape not supported by the gate (V2 required)",
168
+ resource_url=resource_url)
169
+ amount = parse_atomic_amount(amount_raw)
170
+ if amount is None:
171
+ return self._deny("MALFORMED_AMOUNT",
172
+ f"amount {amount_raw!r} is not an integer atomic amount",
173
+ pay_to=pay_to, network=network, resource_url=resource_url)
174
+ p = self.policy
175
+ if not p.network_allowed(str(network)):
176
+ return self._deny("NETWORK_NOT_ALLOWED", f"network {network} not in policy allowlist",
177
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
178
+ if not p.asset_allowed(str(asset)):
179
+ return self._deny("ASSET_NOT_ALLOWED", f"asset {asset} not in policy allowlist",
180
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
181
+ if not p.counterparty_allowed(str(pay_to)):
182
+ return self._deny("COUNTERPARTY_NOT_ALLOWED", f"pay_to {pay_to} not in counterparty allowlist",
183
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
184
+ if amount > p.per_call_cap_atomic:
185
+ return self._deny("PER_CALL_CAP",
186
+ f"amount {amount} exceeds per-call cap {p.per_call_cap_atomic}",
187
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
188
+ if amount <= 0:
189
+ return self._deny("MALFORMED_AMOUNT", "amount must be positive",
190
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
191
+ if p.intent_required and self._intent_hash is None:
192
+ return self._deny("INTENT_REQUIRED", "no intent declared for this payment",
193
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
194
+ if self._intent_pay_to is not None and str(pay_to).lower() != self._intent_pay_to:
195
+ return self._deny("INTENT_MISMATCH",
196
+ f"payment pay_to {pay_to} does not match intent-bound counterparty {self._intent_pay_to}",
197
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
198
+ if self._intent_resource is not None and resource_url != self._intent_resource:
199
+ return self._deny("INTENT_MISMATCH",
200
+ f"payment resource {resource_url} does not match intent-bound resource {self._intent_resource}",
201
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
202
+ now = self._now()
203
+ fingerprint = self._fingerprint(
204
+ intent_hash=self._intent_hash, decl_id=self._intent_decl_id,
205
+ network=str(network), asset=str(asset), pay_to=str(pay_to),
206
+ amount=amount, resource_url=resource_url)
207
+ status, value = self.store.check_and_reserve(
208
+ fingerprint=fingerprint, intent_hash=self._intent_hash,
209
+ amount=amount, pay_to=str(pay_to), network=str(network),
210
+ asset=str(asset), resource_url=resource_url, now=now,
211
+ day_start=self._day_start(now),
212
+ session_budget=p.session_budget_atomic,
213
+ daily_budget=p.daily_budget_atomic,
214
+ per_counterparty_cap=p.per_counterparty_cap_atomic,
215
+ max_per_window=p.max_payments_per_window,
216
+ window_seconds=p.window_seconds,
217
+ replay_window=p.replay_window_seconds)
218
+ if status == "deny":
219
+ return self._deny(str(value), f"aggregate policy limit reached: {value}",
220
+ amount=amount, pay_to=pay_to, network=network, resource_url=resource_url)
221
+ with self._pending_lock:
222
+ self._pending[fingerprint].append(int(value))
223
+ self._audit({"decision": "allow", "amount_atomic": amount, "pay_to": pay_to,
224
+ "network": network, "resource_url": resource_url,
225
+ "intent_hash": self._intent_hash, "reservation_id": int(value)})
226
+ return None
227
+ except StoreError as e:
228
+ return self._deny("INTERNAL_FAIL_CLOSED", f"spend store unavailable; failing closed: {e}",
229
+ resource_url=resource_url)
230
+ except Exception as e: # any unexpected gate error must fail closed, never open
231
+ return self._deny("INTERNAL_FAIL_CLOSED", f"gate evaluation error; failing closed: {e!r}",
232
+ resource_url=resource_url)
233
+
234
+ def _pop_reservation(self, ctx) -> int | None:
235
+ req = ctx.selected_requirements
236
+ payment_required = ctx.payment_required
237
+ resource_url = None
238
+ if getattr(payment_required, "resource", None) is not None:
239
+ resource_url = payment_required.resource.url
240
+ amount = parse_atomic_amount(req.get_amount() if hasattr(req, "get_amount") else None)
241
+ if amount is None:
242
+ return None
243
+ fingerprint = self._fingerprint(
244
+ intent_hash=self._intent_hash, decl_id=self._intent_decl_id,
245
+ network=str(req.network), asset=str(req.asset), pay_to=str(req.pay_to),
246
+ amount=amount, resource_url=resource_url)
247
+ with self._pending_lock:
248
+ stack = self._pending.get(fingerprint)
249
+ if stack:
250
+ rid = stack.pop()
251
+ if not stack:
252
+ del self._pending[fingerprint]
253
+ return rid
254
+ return None
255
+
256
+ def after_hook(self, ctx) -> None:
257
+ rid = self._pop_reservation(ctx)
258
+ if rid is not None:
259
+ try:
260
+ self.store.commit(rid)
261
+ except StoreError:
262
+ # Signature exists but ledger failed: keep counting it as
263
+ # reserved (it still counts against budgets). Never release.
264
+ pass
265
+
266
+ def failure_hook(self, ctx) -> None:
267
+ rid = self._pop_reservation(ctx)
268
+ if rid is not None:
269
+ try:
270
+ self.store.release(rid)
271
+ except StoreError:
272
+ pass
273
+ return None
policy_gate/policy.py ADDED
@@ -0,0 +1,174 @@
1
+ """Declarative payment policy for the pre-signing gate (EXP-001).
2
+
3
+ One small JSON document expresses every control (MEMO-003 Q2). All money is
4
+ integer atomic units (e.g. USDC has 6 decimals: $0.10 = 100000). No floats
5
+ anywhere near money. Validation is strict: unknown fields, missing caps,
6
+ non-positive caps and wrong types are all rejected at load time, because a
7
+ policy that fails open is worse than no policy.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+
15
+ _ATOMIC_RE = re.compile(r"^\d+$")
16
+
17
+ _FIELDS = {
18
+ "per_call_cap_atomic",
19
+ "session_budget_atomic",
20
+ "daily_budget_atomic",
21
+ "per_counterparty_cap_atomic",
22
+ "allowed_networks",
23
+ "allowed_assets",
24
+ "counterparty_allowlist",
25
+ "max_payments_per_window",
26
+ "window_seconds",
27
+ "replay_window_seconds",
28
+ "intent_required",
29
+ "asset_decimals",
30
+ }
31
+
32
+
33
+ class PolicyError(ValueError):
34
+ """Raised when a policy document is invalid. Always fail closed."""
35
+
36
+
37
+ def _require_int(doc: dict, key: str, *, allow_none: bool = False) -> int | None:
38
+ if key not in doc:
39
+ raise PolicyError(f"policy missing required field: {key}")
40
+ v = doc[key]
41
+ if v is None and allow_none:
42
+ return None
43
+ # bool is a subclass of int; a JSON true is never a money amount.
44
+ if isinstance(v, bool) or not isinstance(v, int):
45
+ raise PolicyError(f"policy field {key} must be an integer (atomic units), got {type(v).__name__}")
46
+ return v
47
+
48
+
49
+ def _require_str_list(doc: dict, key: str) -> list[str]:
50
+ if key not in doc:
51
+ raise PolicyError(f"policy missing required field: {key}")
52
+ v = doc[key]
53
+ if not isinstance(v, list) or not v or not all(isinstance(x, str) and x for x in v):
54
+ raise PolicyError(f"policy field {key} must be a non-empty list of strings")
55
+ return list(v)
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class PaymentPolicy:
60
+ per_call_cap_atomic: int
61
+ session_budget_atomic: int
62
+ daily_budget_atomic: int
63
+ allowed_networks: tuple[str, ...]
64
+ allowed_assets: tuple[str, ...] # stored lowercased
65
+ counterparty_allowlist: tuple[str, ...] # stored lowercased; "*" = any payee
66
+ per_counterparty_cap_atomic: int | None = None
67
+ max_payments_per_window: int = 30
68
+ window_seconds: int = 60
69
+ replay_window_seconds: int = 300
70
+ intent_required: bool = True
71
+ asset_decimals: int = 6
72
+
73
+ @classmethod
74
+ def from_dict(cls, doc: dict) -> "PaymentPolicy":
75
+ if not isinstance(doc, dict):
76
+ raise PolicyError("policy must be a JSON object")
77
+ unknown = set(doc) - _FIELDS
78
+ if unknown:
79
+ raise PolicyError(f"unknown policy fields: {sorted(unknown)}")
80
+ per_call = _require_int(doc, "per_call_cap_atomic")
81
+ session = _require_int(doc, "session_budget_atomic")
82
+ daily = _require_int(doc, "daily_budget_atomic")
83
+ per_cp = _require_int(doc, "per_counterparty_cap_atomic", allow_none=True) \
84
+ if "per_counterparty_cap_atomic" in doc else None
85
+ for name, val in (("per_call_cap_atomic", per_call),
86
+ ("session_budget_atomic", session),
87
+ ("daily_budget_atomic", daily),
88
+ ("per_counterparty_cap_atomic", per_cp)):
89
+ if val is not None and val <= 0:
90
+ raise PolicyError(f"policy field {name} must be > 0")
91
+ # NOTE: no ordering is imposed between caps and budgets. A session
92
+ # budget smaller than the per-call cap is a legitimate configuration
93
+ # (the tighter limit simply binds at runtime); EXP-001 audit pass 1
94
+ # removed an earlier session>=cap / daily>=session rule that rejected
95
+ # such policies.
96
+ mpw = doc.get("max_payments_per_window", 30)
97
+ ws = doc.get("window_seconds", 60)
98
+ rws = doc.get("replay_window_seconds", 300)
99
+ for name, val in (("max_payments_per_window", mpw), ("window_seconds", ws),
100
+ ("replay_window_seconds", rws)):
101
+ if isinstance(val, bool) or not isinstance(val, int) or val <= 0:
102
+ raise PolicyError(f"policy field {name} must be a positive integer")
103
+ intent_required = doc.get("intent_required", True)
104
+ if not isinstance(intent_required, bool):
105
+ raise PolicyError("policy field intent_required must be a boolean")
106
+ decimals = doc.get("asset_decimals", 6)
107
+ if isinstance(decimals, bool) or not isinstance(decimals, int) or not (0 <= decimals <= 36):
108
+ raise PolicyError("policy field asset_decimals must be an integer 0..36")
109
+ return cls(
110
+ per_call_cap_atomic=per_call,
111
+ session_budget_atomic=session,
112
+ daily_budget_atomic=daily,
113
+ per_counterparty_cap_atomic=per_cp,
114
+ allowed_networks=tuple(_require_str_list(doc, "allowed_networks")),
115
+ allowed_assets=tuple(a.lower() for a in _require_str_list(doc, "allowed_assets")),
116
+ counterparty_allowlist=tuple(
117
+ a if a == "*" else a.lower()
118
+ for a in _require_str_list(doc, "counterparty_allowlist")),
119
+ max_payments_per_window=mpw,
120
+ window_seconds=ws,
121
+ replay_window_seconds=rws,
122
+ intent_required=intent_required,
123
+ asset_decimals=decimals,
124
+ )
125
+
126
+ @classmethod
127
+ def from_json(cls, text: str) -> "PaymentPolicy":
128
+ try:
129
+ doc = json.loads(text)
130
+ except json.JSONDecodeError as e:
131
+ raise PolicyError(f"policy is not valid JSON: {e}") from e
132
+ return cls.from_dict(doc)
133
+
134
+ @classmethod
135
+ def from_file(cls, path: str) -> "PaymentPolicy":
136
+ with open(path, encoding="utf-8") as f:
137
+ return cls.from_json(f.read())
138
+
139
+ def network_allowed(self, network: str) -> bool:
140
+ for pat in self.allowed_networks:
141
+ if pat.endswith(":*"):
142
+ if network.startswith(pat[:-1]):
143
+ return True
144
+ elif network == pat:
145
+ return True
146
+ return False
147
+
148
+ def asset_allowed(self, asset: str) -> bool:
149
+ return asset.lower() in self.allowed_assets
150
+
151
+ def counterparty_allowed(self, pay_to: str) -> bool:
152
+ return "*" in self.counterparty_allowlist or pay_to.lower() in self.counterparty_allowlist
153
+
154
+
155
+ def parse_atomic_amount(raw: object) -> int | None:
156
+ """Parse an x402 atomic amount string. Returns None when malformed.
157
+
158
+ x402 amounts are integer strings in the asset's smallest unit. Anything
159
+ else (decimals, signs, exponents, whitespace tricks, non-strings) is
160
+ malformed and the gate fails closed on it.
161
+ """
162
+ if not isinstance(raw, str) or not _ATOMIC_RE.fullmatch(raw):
163
+ return None
164
+ return int(raw)
165
+
166
+
167
+ def usd_money_string(atomic: int, decimals: int) -> str:
168
+ """Render an atomic cap as an x402 Money string like "$1.50" (for SpendControls)."""
169
+ scale = 10 ** decimals
170
+ whole, frac = divmod(atomic, scale)
171
+ if frac == 0:
172
+ return f"${whole}"
173
+ frac_s = str(frac).rjust(decimals, "0").rstrip("0")
174
+ return f"${whole}.{frac_s}"