railgate 1.0.0__tar.gz

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.
railgate-1.0.0/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TextForAgents LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Third-party components are NOT vendored into this distribution. The x402
24
+ Python SDK is a regular PyPI dependency (see pyproject.toml) licensed under
25
+ the Apache License 2.0; its license text is reproduced in
26
+ licenses/x402-Apache-2.0.txt for convenience. See THIRD-PARTY-NOTICES.md.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: railgate
3
+ Version: 1.0.0
4
+ Summary: Rail-agnostic pre-signing payment policy gate with swappable settlement adapters (EVM/x402 + XRPL) — TextForAgents LLC
5
+ Author: TextForAgents LLC
6
+ License: MIT
7
+ Project-URL: Homepage, https://textforagents.com/products/payment-gate-settlement-rails/
8
+ Project-URL: Support, https://textforagents.com/products/payment-gate-settlement-rails/
9
+ Keywords: x402,xrpl,agent-payments,policy-gate,settlement
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business :: Financial
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: x402[evm]<3,>=2.25
17
+ Requires-Dist: xrpl-py<7,>=4
18
+ Dynamic: license-file
19
+
20
+ # railgate — payment gate settlement rails (P21)
21
+
22
+ By **TextForAgents LLC** · Support: support@textforagents.com ·
23
+ Product page: https://textforagents.com/products/payment-gate-settlement-rails/
24
+
25
+ A pre-signing payment policy gate for agents that settles on more than one
26
+ rail. One unmodified policy core (budgets, counterparty allowlists, velocity,
27
+ intent, replay protection — every decision receipted, every refusal
28
+ structured) drives swappable settlement adapters behind one interface:
29
+
30
+ - **EVM / x402** — EIP-3009 `exact` scheme authorizations via a facilitator.
31
+ - **XRPL** — Payments whose replay window closes at validation by protocol
32
+ sequence consumption; `LastLedgerSequence` is only the expiry ceiling.
33
+
34
+ A rail oracle picks the rail at decide time against **your standing rail
35
+ declaration** (pinned / cheapest / fastest finality / minimum replay
36
+ exposure, with fee and finality caps). Rail choice is a policy decision and
37
+ is receipted with the payment. If the settled fee disagrees with the quote
38
+ that justified the choice, the outcome is flagged `QUOTE_MISMATCH`.
39
+
40
+ **Operator-side only.** Settlement runs through *your* signer and *your*
41
+ funds. This package never custodies funds or keys, and v1 has no hosted or
42
+ multi-tenant mode. The adapters shipped here are test doubles encoding each
43
+ rail's published settlement semantics; `railgate.live` adds a read-only
44
+ live-testnet path (ServerInfo / AccountInfo) — it never signs or submits.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install railgate-1.0.0-py3-none-any.whl # or from the sdist
50
+ ```
51
+
52
+ Dependencies: `x402[evm]` (>=2.25,<3, Apache-2.0) and `xrpl-py` — both are
53
+ ordinary PyPI dependencies; no third-party code is vendored into this wheel.
54
+ The vendored EXP-001 policy core *is* included (`policy_gate/`) and is
55
+ byte-identical to the proven originals — verify after install:
56
+
57
+ ```bash
58
+ cd $(python -c "import policy_gate,os;print(os.path.dirname(policy_gate.__file__))") && sha256sum -c MANIFEST.sha256
59
+ ```
60
+
61
+ ## Quickstart
62
+
63
+ ```python
64
+ import time
65
+ from policy_gate import PaymentPolicy, PolicyGate
66
+ from railgate import RailGate, RailDeclaration
67
+ from railgate.adapters import EvmX402Adapter, XrplTestnetAdapter
68
+
69
+ clock = time.time
70
+ adapters = {}
71
+ for cls in (EvmX402Adapter, XrplTestnetAdapter):
72
+ a = cls(clock); adapters[a.rail] = a
73
+
74
+ policy = PaymentPolicy.from_dict({
75
+ "per_call_cap_atomic": 1_000_000, "session_budget_atomic": 5_000_000,
76
+ "daily_budget_atomic": 20_000_000, "per_counterparty_cap_atomic": 10_000_000,
77
+ "allowed_networks": [a.network for a in adapters.values()],
78
+ "allowed_assets": [a.asset for a in adapters.values()],
79
+ "counterparty_allowlist": [a.pay_to for a in adapters.values()],
80
+ "max_payments_per_window": 30, "window_seconds": 60,
81
+ "replay_window_seconds": 300, "intent_required": True, "asset_decimals": 6,
82
+ })
83
+ gate = RailGate(PolicyGate(policy, "/tmp/spend.db", clock=clock), adapters)
84
+ gate.declare_intent("buy weather data")
85
+ out = gate.pay(amount_atomic=100_000,
86
+ resource_url="https://api.example.test/weather",
87
+ declaration=RailDeclaration(mode="cheapest"))
88
+ print(out.status, out.rail, out.receipt) # settled xrpl-testnet {...}
89
+
90
+ # Live testnet read (read-only; no submission, no funds):
91
+ from railgate import xrpl_server_info
92
+ print(xrpl_server_info())
93
+ ```
94
+
95
+ ## Pricing
96
+
97
+ The adapter package is **free** (distribution). The hosted policy + rails
98
+ service is **$2 per agent / month flat** — no percentage of payment volume,
99
+ ever; rail fees (e.g. XRPL ~$0.0002/tx) pass through at cost. First month
100
+ free for the first 10 agents, once per operator. See the product page.
101
+
102
+ ## License
103
+
104
+ MIT for this package's own code (see LICENSE). The x402 dependency is
105
+ Apache-2.0 — see THIRD-PARTY-NOTICES.md.
@@ -0,0 +1,86 @@
1
+ # railgate — payment gate settlement rails (P21)
2
+
3
+ By **TextForAgents LLC** · Support: support@textforagents.com ·
4
+ Product page: https://textforagents.com/products/payment-gate-settlement-rails/
5
+
6
+ A pre-signing payment policy gate for agents that settles on more than one
7
+ rail. One unmodified policy core (budgets, counterparty allowlists, velocity,
8
+ intent, replay protection — every decision receipted, every refusal
9
+ structured) drives swappable settlement adapters behind one interface:
10
+
11
+ - **EVM / x402** — EIP-3009 `exact` scheme authorizations via a facilitator.
12
+ - **XRPL** — Payments whose replay window closes at validation by protocol
13
+ sequence consumption; `LastLedgerSequence` is only the expiry ceiling.
14
+
15
+ A rail oracle picks the rail at decide time against **your standing rail
16
+ declaration** (pinned / cheapest / fastest finality / minimum replay
17
+ exposure, with fee and finality caps). Rail choice is a policy decision and
18
+ is receipted with the payment. If the settled fee disagrees with the quote
19
+ that justified the choice, the outcome is flagged `QUOTE_MISMATCH`.
20
+
21
+ **Operator-side only.** Settlement runs through *your* signer and *your*
22
+ funds. This package never custodies funds or keys, and v1 has no hosted or
23
+ multi-tenant mode. The adapters shipped here are test doubles encoding each
24
+ rail's published settlement semantics; `railgate.live` adds a read-only
25
+ live-testnet path (ServerInfo / AccountInfo) — it never signs or submits.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install railgate-1.0.0-py3-none-any.whl # or from the sdist
31
+ ```
32
+
33
+ Dependencies: `x402[evm]` (>=2.25,<3, Apache-2.0) and `xrpl-py` — both are
34
+ ordinary PyPI dependencies; no third-party code is vendored into this wheel.
35
+ The vendored EXP-001 policy core *is* included (`policy_gate/`) and is
36
+ byte-identical to the proven originals — verify after install:
37
+
38
+ ```bash
39
+ cd $(python -c "import policy_gate,os;print(os.path.dirname(policy_gate.__file__))") && sha256sum -c MANIFEST.sha256
40
+ ```
41
+
42
+ ## Quickstart
43
+
44
+ ```python
45
+ import time
46
+ from policy_gate import PaymentPolicy, PolicyGate
47
+ from railgate import RailGate, RailDeclaration
48
+ from railgate.adapters import EvmX402Adapter, XrplTestnetAdapter
49
+
50
+ clock = time.time
51
+ adapters = {}
52
+ for cls in (EvmX402Adapter, XrplTestnetAdapter):
53
+ a = cls(clock); adapters[a.rail] = a
54
+
55
+ policy = PaymentPolicy.from_dict({
56
+ "per_call_cap_atomic": 1_000_000, "session_budget_atomic": 5_000_000,
57
+ "daily_budget_atomic": 20_000_000, "per_counterparty_cap_atomic": 10_000_000,
58
+ "allowed_networks": [a.network for a in adapters.values()],
59
+ "allowed_assets": [a.asset for a in adapters.values()],
60
+ "counterparty_allowlist": [a.pay_to for a in adapters.values()],
61
+ "max_payments_per_window": 30, "window_seconds": 60,
62
+ "replay_window_seconds": 300, "intent_required": True, "asset_decimals": 6,
63
+ })
64
+ gate = RailGate(PolicyGate(policy, "/tmp/spend.db", clock=clock), adapters)
65
+ gate.declare_intent("buy weather data")
66
+ out = gate.pay(amount_atomic=100_000,
67
+ resource_url="https://api.example.test/weather",
68
+ declaration=RailDeclaration(mode="cheapest"))
69
+ print(out.status, out.rail, out.receipt) # settled xrpl-testnet {...}
70
+
71
+ # Live testnet read (read-only; no submission, no funds):
72
+ from railgate import xrpl_server_info
73
+ print(xrpl_server_info())
74
+ ```
75
+
76
+ ## Pricing
77
+
78
+ The adapter package is **free** (distribution). The hosted policy + rails
79
+ service is **$2 per agent / month flat** — no percentage of payment volume,
80
+ ever; rail fees (e.g. XRPL ~$0.0002/tx) pass through at cost. First month
81
+ free for the first 10 agents, once per operator. See the product page.
82
+
83
+ ## License
84
+
85
+ MIT for this package's own code (see LICENSE). The x402 dependency is
86
+ Apache-2.0 — see THIRD-PARTY-NOTICES.md.
@@ -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"]
@@ -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)
@@ -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