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 +26 -0
- railgate-1.0.0/PKG-INFO +105 -0
- railgate-1.0.0/README.md +86 -0
- railgate-1.0.0/policy_gate/MANIFEST.sha256 +3 -0
- railgate-1.0.0/policy_gate/__init__.py +7 -0
- railgate-1.0.0/policy_gate/factory.py +80 -0
- railgate-1.0.0/policy_gate/gate.py +273 -0
- railgate-1.0.0/policy_gate/policy.py +174 -0
- railgate-1.0.0/policy_gate/store.py +152 -0
- railgate-1.0.0/pyproject.toml +32 -0
- railgate-1.0.0/railgate/__init__.py +19 -0
- railgate-1.0.0/railgate/adapters/__init__.py +6 -0
- railgate-1.0.0/railgate/adapters/base.py +90 -0
- railgate-1.0.0/railgate/adapters/evm.py +110 -0
- railgate-1.0.0/railgate/adapters/xrpl.py +133 -0
- railgate-1.0.0/railgate/facade.py +138 -0
- railgate-1.0.0/railgate/live.py +53 -0
- railgate-1.0.0/railgate/oracle.py +91 -0
- railgate-1.0.0/railgate.egg-info/PKG-INFO +105 -0
- railgate-1.0.0/railgate.egg-info/SOURCES.txt +22 -0
- railgate-1.0.0/railgate.egg-info/dependency_links.txt +1 -0
- railgate-1.0.0/railgate.egg-info/requires.txt +2 -0
- railgate-1.0.0/railgate.egg-info/top_level.txt +2 -0
- railgate-1.0.0/setup.cfg +4 -0
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.
|
railgate-1.0.0/PKG-INFO
ADDED
|
@@ -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.
|
railgate-1.0.0/README.md
ADDED
|
@@ -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,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
|