payagent 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.
- payagent/__init__.py +63 -0
- payagent/client.py +146 -0
- payagent/escrow.py +204 -0
- payagent/exceptions.py +43 -0
- payagent/guardrails.py +237 -0
- payagent/paywall.py +246 -0
- payagent/providers/__init__.py +16 -0
- payagent/providers/base.py +66 -0
- payagent/providers/fiat.py +178 -0
- payagent/providers/solana.py +147 -0
- payagent/providers/x402.py +180 -0
- payagent/py.typed +0 -0
- payagent/wallet.py +198 -0
- payagent-0.1.0.dist-info/METADATA +248 -0
- payagent-0.1.0.dist-info/RECORD +17 -0
- payagent-0.1.0.dist-info/WHEEL +4 -0
- payagent-0.1.0.dist-info/licenses/LICENSE +21 -0
payagent/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""payagent — Universal Payment & Monetization Engine for AI Agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from payagent.client import AgentPayClient
|
|
6
|
+
from payagent.escrow import EscrowRecord, EscrowSession, EscrowState
|
|
7
|
+
from payagent.exceptions import (
|
|
8
|
+
AgentPayError,
|
|
9
|
+
BudgetExceededError,
|
|
10
|
+
EscrowError,
|
|
11
|
+
EscrowValidationError,
|
|
12
|
+
HumanApprovalRequiredError,
|
|
13
|
+
InsufficientFundsError,
|
|
14
|
+
PaymentError,
|
|
15
|
+
PaymentVerificationError,
|
|
16
|
+
PolicyViolationError,
|
|
17
|
+
ProviderNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from payagent.guardrails import PolicyEnforcer, SpendingLedger, SpendingPolicy
|
|
20
|
+
from payagent.paywall import PaymentVerifier, paywall
|
|
21
|
+
from payagent.providers import (
|
|
22
|
+
BaseProvider,
|
|
23
|
+
FiatProvider,
|
|
24
|
+
PaymentResult,
|
|
25
|
+
SolanaProvider,
|
|
26
|
+
X402Provider,
|
|
27
|
+
)
|
|
28
|
+
from payagent.wallet import AgentWallet
|
|
29
|
+
|
|
30
|
+
__version__ = "0.1.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# core
|
|
34
|
+
"AgentWallet",
|
|
35
|
+
"AgentPayClient",
|
|
36
|
+
"EscrowSession",
|
|
37
|
+
"EscrowRecord",
|
|
38
|
+
"EscrowState",
|
|
39
|
+
"paywall",
|
|
40
|
+
"PaymentVerifier",
|
|
41
|
+
# policy
|
|
42
|
+
"SpendingPolicy",
|
|
43
|
+
"SpendingLedger",
|
|
44
|
+
"PolicyEnforcer",
|
|
45
|
+
# providers
|
|
46
|
+
"BaseProvider",
|
|
47
|
+
"PaymentResult",
|
|
48
|
+
"X402Provider",
|
|
49
|
+
"SolanaProvider",
|
|
50
|
+
"FiatProvider",
|
|
51
|
+
# exceptions
|
|
52
|
+
"AgentPayError",
|
|
53
|
+
"InsufficientFundsError",
|
|
54
|
+
"BudgetExceededError",
|
|
55
|
+
"PolicyViolationError",
|
|
56
|
+
"HumanApprovalRequiredError",
|
|
57
|
+
"PaymentError",
|
|
58
|
+
"PaymentVerificationError",
|
|
59
|
+
"ProviderNotFoundError",
|
|
60
|
+
"EscrowError",
|
|
61
|
+
"EscrowValidationError",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
payagent/client.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""HTTP client with automatic HTTP 402 payment handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from payagent.exceptions import PaymentError
|
|
11
|
+
from payagent.providers.x402 import X402Provider
|
|
12
|
+
from payagent.wallet import AgentWallet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentPayClient:
|
|
16
|
+
"""``httpx.AsyncClient`` wrapper that settles 402 challenges via a wallet.
|
|
17
|
+
|
|
18
|
+
Flow:
|
|
19
|
+
1. Issue the original request.
|
|
20
|
+
2. On ``402 Payment Required``, parse payment headers.
|
|
21
|
+
3. Pay via :class:`AgentWallet`.
|
|
22
|
+
4. Retry once with ``X-PAYMENT-PROOF`` (and related) headers.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
wallet: AgentWallet,
|
|
28
|
+
*,
|
|
29
|
+
client: httpx.AsyncClient | None = None,
|
|
30
|
+
max_payment_retries: int = 1,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
base_url: str = "",
|
|
33
|
+
default_headers: dict[str, str] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.wallet = wallet
|
|
36
|
+
self.max_payment_retries = max_payment_retries
|
|
37
|
+
self._owns_client = client is None
|
|
38
|
+
self._client = client or httpx.AsyncClient(
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
headers=default_headers or {},
|
|
42
|
+
)
|
|
43
|
+
self.last_payment: Any | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def http(self) -> httpx.AsyncClient:
|
|
47
|
+
return self._client
|
|
48
|
+
|
|
49
|
+
async def request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
url: str,
|
|
53
|
+
*,
|
|
54
|
+
headers: dict[str, str] | None = None,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> httpx.Response:
|
|
57
|
+
hdrs = dict(headers or {})
|
|
58
|
+
response = await self._client.request(method, url, headers=hdrs, **kwargs)
|
|
59
|
+
|
|
60
|
+
retries = 0
|
|
61
|
+
while response.status_code == 402 and retries < self.max_payment_retries:
|
|
62
|
+
payment_info = self._extract_payment_requirement(response)
|
|
63
|
+
domain = self._domain_for(url)
|
|
64
|
+
amount = float(payment_info["amount"])
|
|
65
|
+
currency = payment_info["currency"]
|
|
66
|
+
recipient = payment_info["address"]
|
|
67
|
+
network = payment_info.get("network")
|
|
68
|
+
|
|
69
|
+
result = await self.wallet.pay(
|
|
70
|
+
amount=amount,
|
|
71
|
+
currency=currency,
|
|
72
|
+
recipient=recipient,
|
|
73
|
+
domain=domain,
|
|
74
|
+
network=network,
|
|
75
|
+
metadata={"url": str(url), "method": method},
|
|
76
|
+
)
|
|
77
|
+
self.last_payment = result
|
|
78
|
+
|
|
79
|
+
pay_headers = {
|
|
80
|
+
**hdrs,
|
|
81
|
+
"X-PAYMENT-PROOF": result.as_proof_header(),
|
|
82
|
+
"X-PAYMENT-TX": result.tx_hash,
|
|
83
|
+
"X-PAYMENT-AMOUNT": str(result.amount),
|
|
84
|
+
"X-PAYMENT-CURRENCY": result.currency,
|
|
85
|
+
"X-PAYMENT-PROVIDER": result.provider,
|
|
86
|
+
}
|
|
87
|
+
response = await self._client.request(method, url, headers=pay_headers, **kwargs)
|
|
88
|
+
retries += 1
|
|
89
|
+
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
93
|
+
return await self.request("GET", url, **kwargs)
|
|
94
|
+
|
|
95
|
+
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
96
|
+
return await self.request("POST", url, **kwargs)
|
|
97
|
+
|
|
98
|
+
async def put(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
99
|
+
return await self.request("PUT", url, **kwargs)
|
|
100
|
+
|
|
101
|
+
async def delete(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
102
|
+
return await self.request("DELETE", url, **kwargs)
|
|
103
|
+
|
|
104
|
+
async def patch(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
105
|
+
return await self.request("PATCH", url, **kwargs)
|
|
106
|
+
|
|
107
|
+
def _extract_payment_requirement(self, response: httpx.Response) -> dict[str, str]:
|
|
108
|
+
try:
|
|
109
|
+
return X402Provider.parse_402_headers(response.headers)
|
|
110
|
+
except PaymentError:
|
|
111
|
+
# Fallback: JSON body {amount, address/currency}
|
|
112
|
+
try:
|
|
113
|
+
data = response.json()
|
|
114
|
+
except Exception as exc: # noqa: BLE001
|
|
115
|
+
raise PaymentError(
|
|
116
|
+
"402 response missing payment headers and JSON body"
|
|
117
|
+
) from exc
|
|
118
|
+
address = data.get("address") or data.get("recipient") or data.get("pay_to")
|
|
119
|
+
amount = data.get("amount") or data.get("price")
|
|
120
|
+
currency = (data.get("currency") or "USDC").upper()
|
|
121
|
+
network = data.get("network") or "base"
|
|
122
|
+
if address is None or amount is None:
|
|
123
|
+
raise PaymentError("402 JSON body missing address/amount")
|
|
124
|
+
return {
|
|
125
|
+
"address": str(address),
|
|
126
|
+
"amount": str(amount),
|
|
127
|
+
"currency": currency,
|
|
128
|
+
"network": str(network),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _domain_for(url: str) -> str | None:
|
|
133
|
+
try:
|
|
134
|
+
return urlparse(str(url)).hostname
|
|
135
|
+
except Exception: # noqa: BLE001
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
async def aclose(self) -> None:
|
|
139
|
+
if self._owns_client:
|
|
140
|
+
await self._client.aclose()
|
|
141
|
+
|
|
142
|
+
async def __aenter__(self) -> AgentPayClient:
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
async def __aexit__(self, *args: object) -> None:
|
|
146
|
+
await self.aclose()
|
payagent/escrow.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Conditional escrow: lock → execute → validate → release or refund."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import inspect
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Awaitable, Callable, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from payagent.exceptions import EscrowError, EscrowValidationError
|
|
13
|
+
from payagent.providers.base import PaymentResult
|
|
14
|
+
from payagent.wallet import AgentWallet
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
ValidatorFn = Callable[[T], bool | Awaitable[bool]]
|
|
19
|
+
JobFn = Callable[[], T | Awaitable[T]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EscrowState(str, enum.Enum):
|
|
23
|
+
CREATED = "created"
|
|
24
|
+
LOCKED = "locked"
|
|
25
|
+
EXECUTED = "executed"
|
|
26
|
+
RELEASED = "released"
|
|
27
|
+
REFUNDED = "refunded"
|
|
28
|
+
FAILED = "failed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EscrowRecord:
|
|
33
|
+
session_id: str
|
|
34
|
+
amount: float
|
|
35
|
+
currency: str
|
|
36
|
+
recipient: str
|
|
37
|
+
state: EscrowState = EscrowState.CREATED
|
|
38
|
+
lock_result: PaymentResult | None = None
|
|
39
|
+
release_result: PaymentResult | None = None
|
|
40
|
+
refund_result: PaymentResult | None = None
|
|
41
|
+
job_result: Any = None
|
|
42
|
+
error: str | None = None
|
|
43
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
44
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _maybe_await(value: T | Awaitable[T]) -> T:
|
|
48
|
+
if inspect.isawaitable(value):
|
|
49
|
+
return await value
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EscrowSession(Generic[T]):
|
|
54
|
+
"""Hold funds until ``validator_fn(result)`` succeeds.
|
|
55
|
+
|
|
56
|
+
Model (mock-friendly):
|
|
57
|
+
* **lock** — reserve / debit funds to an escrow address (or internal hold)
|
|
58
|
+
* **job** — run the paid work
|
|
59
|
+
* **validate** — user-supplied check (Pydantic model, status code, etc.)
|
|
60
|
+
* **release** — pay the service provider on success
|
|
61
|
+
* **refund** — credit back on failure (mock ledger bump / reverse entry)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
wallet: AgentWallet,
|
|
67
|
+
validator_fn: ValidatorFn[T],
|
|
68
|
+
*,
|
|
69
|
+
escrow_address: str = "escrow://payagent",
|
|
70
|
+
refund_address: str = "refund://payagent",
|
|
71
|
+
) -> None:
|
|
72
|
+
self.wallet = wallet
|
|
73
|
+
self.validator_fn = validator_fn
|
|
74
|
+
self.escrow_address = escrow_address
|
|
75
|
+
self.refund_address = refund_address
|
|
76
|
+
self.record: EscrowRecord | None = None
|
|
77
|
+
|
|
78
|
+
async def run(
|
|
79
|
+
self,
|
|
80
|
+
job: JobFn[T],
|
|
81
|
+
*,
|
|
82
|
+
amount: float,
|
|
83
|
+
currency: str,
|
|
84
|
+
recipient: str,
|
|
85
|
+
domain: str | None = None,
|
|
86
|
+
network: str | None = None,
|
|
87
|
+
memo: str | None = None,
|
|
88
|
+
metadata: dict[str, Any] | None = None,
|
|
89
|
+
) -> T:
|
|
90
|
+
session_id = uuid.uuid4().hex
|
|
91
|
+
self.record = EscrowRecord(
|
|
92
|
+
session_id=session_id,
|
|
93
|
+
amount=amount,
|
|
94
|
+
currency=currency,
|
|
95
|
+
recipient=recipient,
|
|
96
|
+
metadata=dict(metadata or {}),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# 1) Lock funds (policy-checked payment to escrow address)
|
|
100
|
+
try:
|
|
101
|
+
lock = await self.wallet.pay(
|
|
102
|
+
amount=amount,
|
|
103
|
+
currency=currency,
|
|
104
|
+
recipient=self.escrow_address,
|
|
105
|
+
domain=domain,
|
|
106
|
+
network=network,
|
|
107
|
+
memo=memo or f"escrow-lock:{session_id}",
|
|
108
|
+
metadata={"escrow_session": session_id, "phase": "lock", **(metadata or {})},
|
|
109
|
+
)
|
|
110
|
+
self.record.lock_result = lock
|
|
111
|
+
self.record.state = EscrowState.LOCKED
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
self.record.state = EscrowState.FAILED
|
|
114
|
+
self.record.error = str(exc)
|
|
115
|
+
raise EscrowError(f"escrow lock failed: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
# 2) Execute job
|
|
118
|
+
try:
|
|
119
|
+
result = await _maybe_await(job())
|
|
120
|
+
self.record.job_result = result
|
|
121
|
+
self.record.state = EscrowState.EXECUTED
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
self.record.error = str(exc)
|
|
124
|
+
await self._refund(session_id, amount, currency, domain, network)
|
|
125
|
+
raise EscrowError(f"escrow job failed: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
# 3) Validate
|
|
128
|
+
try:
|
|
129
|
+
ok = await _maybe_await(self.validator_fn(result))
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
self.record.error = str(exc)
|
|
132
|
+
await self._refund(session_id, amount, currency, domain, network)
|
|
133
|
+
raise EscrowValidationError(f"validator raised: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
if not ok:
|
|
136
|
+
self.record.error = "validator returned False"
|
|
137
|
+
await self._refund(session_id, amount, currency, domain, network)
|
|
138
|
+
raise EscrowValidationError("escrow validation failed; funds refunded")
|
|
139
|
+
|
|
140
|
+
# 4) Release to provider (do not double-count budget: record_spend=False)
|
|
141
|
+
try:
|
|
142
|
+
release = await self.wallet.pay(
|
|
143
|
+
amount=amount,
|
|
144
|
+
currency=currency,
|
|
145
|
+
recipient=recipient,
|
|
146
|
+
domain=domain,
|
|
147
|
+
network=network,
|
|
148
|
+
memo=memo or f"escrow-release:{session_id}",
|
|
149
|
+
metadata={
|
|
150
|
+
"escrow_session": session_id,
|
|
151
|
+
"phase": "release",
|
|
152
|
+
"lock_tx": lock.tx_hash,
|
|
153
|
+
**(metadata or {}),
|
|
154
|
+
},
|
|
155
|
+
record_spend=False,
|
|
156
|
+
)
|
|
157
|
+
self.record.release_result = release
|
|
158
|
+
self.record.state = EscrowState.RELEASED
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
self.record.error = str(exc)
|
|
161
|
+
await self._refund(session_id, amount, currency, domain, network)
|
|
162
|
+
raise EscrowError(f"escrow release failed: {exc}") from exc
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
async def _refund(
|
|
167
|
+
self,
|
|
168
|
+
session_id: str,
|
|
169
|
+
amount: float,
|
|
170
|
+
currency: str,
|
|
171
|
+
domain: str | None,
|
|
172
|
+
network: str | None,
|
|
173
|
+
) -> None:
|
|
174
|
+
assert self.record is not None
|
|
175
|
+
try:
|
|
176
|
+
# Mock-friendly: pay back to refund address without re-applying budget
|
|
177
|
+
# (budget already charged on lock).
|
|
178
|
+
refund = await self.wallet.pay(
|
|
179
|
+
amount=amount,
|
|
180
|
+
currency=currency,
|
|
181
|
+
recipient=self.refund_address,
|
|
182
|
+
domain=domain,
|
|
183
|
+
network=network,
|
|
184
|
+
memo=f"escrow-refund:{session_id}",
|
|
185
|
+
metadata={"escrow_session": session_id, "phase": "refund"},
|
|
186
|
+
record_spend=False,
|
|
187
|
+
)
|
|
188
|
+
# Credit mock balances where possible
|
|
189
|
+
self._credit_mock_providers(amount, currency)
|
|
190
|
+
self.record.refund_result = refund
|
|
191
|
+
self.record.state = EscrowState.REFUNDED
|
|
192
|
+
except Exception as exc: # noqa: BLE001
|
|
193
|
+
self.record.state = EscrowState.FAILED
|
|
194
|
+
self.record.error = f"refund failed: {exc}"
|
|
195
|
+
|
|
196
|
+
def _credit_mock_providers(self, amount: float, currency: str) -> None:
|
|
197
|
+
"""Best-effort restore mock balances after refund."""
|
|
198
|
+
cur = currency.upper()
|
|
199
|
+
for p in self.wallet.providers:
|
|
200
|
+
if not p.supports(cur):
|
|
201
|
+
continue
|
|
202
|
+
bal = getattr(p, "_mock_balance", None)
|
|
203
|
+
if isinstance(bal, (int, float)) and getattr(p, "mock", False):
|
|
204
|
+
setattr(p, "_mock_balance", float(bal) + amount)
|
payagent/exceptions.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Custom exceptions for payagent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentPayError(Exception):
|
|
7
|
+
"""Base error for the payagent library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InsufficientFundsError(AgentPayError):
|
|
11
|
+
"""Wallet or provider balance is too low for the requested payment."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BudgetExceededError(AgentPayError):
|
|
15
|
+
"""Spending would exceed a configured budget limit (per-tx, daily, monthly)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolicyViolationError(AgentPayError):
|
|
19
|
+
"""Payment violates a spending policy rule (domain allowlist, etc.)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HumanApprovalRequiredError(AgentPayError):
|
|
23
|
+
"""Amount exceeds the human-in-the-loop threshold; approval is required."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PaymentError(AgentPayError):
|
|
27
|
+
"""Payment execution failed at the provider layer."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PaymentVerificationError(AgentPayError):
|
|
31
|
+
"""Payment proof / transaction could not be verified."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProviderNotFoundError(AgentPayError):
|
|
35
|
+
"""No provider registered for the requested currency or network."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EscrowError(AgentPayError):
|
|
39
|
+
"""Escrow session failed (lock, release, refund, or validation)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EscrowValidationError(EscrowError):
|
|
43
|
+
"""Escrow job result failed validation; funds should be refunded."""
|
payagent/guardrails.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Budget limits, domain allowlists, and HITL policy enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Iterable
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from payagent.exceptions import (
|
|
15
|
+
BudgetExceededError,
|
|
16
|
+
HumanApprovalRequiredError,
|
|
17
|
+
PolicyViolationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _utc_now() -> datetime:
|
|
22
|
+
return datetime.now(timezone.utc)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _day_key(dt: datetime | None = None) -> str:
|
|
26
|
+
d = dt or _utc_now()
|
|
27
|
+
return d.astimezone(timezone.utc).strftime("%Y-%m-%d")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _month_key(dt: datetime | None = None) -> str:
|
|
31
|
+
d = dt or _utc_now()
|
|
32
|
+
return d.astimezone(timezone.utc).strftime("%Y-%m")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SpendingPolicy:
|
|
37
|
+
"""Declarative spending limits and rules for an agent wallet."""
|
|
38
|
+
|
|
39
|
+
max_per_tx: float = 0.50
|
|
40
|
+
daily_limit: float = 10.0
|
|
41
|
+
monthly_limit: float = 100.0
|
|
42
|
+
allowlist_domains: list[str] = field(default_factory=list)
|
|
43
|
+
require_human_approval_above: float | None = None
|
|
44
|
+
# If True, empty allowlist means "allow all". If False, empty blocks all hosts.
|
|
45
|
+
allow_all_when_empty_allowlist: bool = True
|
|
46
|
+
|
|
47
|
+
def domain_allowed(self, domain_or_url: str | None) -> bool:
|
|
48
|
+
if domain_or_url is None:
|
|
49
|
+
return True
|
|
50
|
+
host = domain_or_url
|
|
51
|
+
if "://" in domain_or_url:
|
|
52
|
+
host = urlparse(domain_or_url).hostname or domain_or_url
|
|
53
|
+
host = host.lower().strip(".")
|
|
54
|
+
if not self.allowlist_domains:
|
|
55
|
+
return self.allow_all_when_empty_allowlist
|
|
56
|
+
for pattern in self.allowlist_domains:
|
|
57
|
+
p = pattern.lower().strip(".")
|
|
58
|
+
if host == p or host.endswith("." + p):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SpendingLedger:
|
|
64
|
+
"""Thread-safe expenditure tracker (in-memory or lightweight SQLite)."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
67
|
+
self._lock = threading.RLock()
|
|
68
|
+
self._memory: list[tuple[float, float, str | None]] = [] # ts, amount, domain
|
|
69
|
+
self._db_path = Path(db_path) if db_path else None
|
|
70
|
+
self._conn: sqlite3.Connection | None = None
|
|
71
|
+
if self._db_path is not None:
|
|
72
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
|
74
|
+
self._conn.execute(
|
|
75
|
+
"""
|
|
76
|
+
CREATE TABLE IF NOT EXISTS spend (
|
|
77
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
78
|
+
ts REAL NOT NULL,
|
|
79
|
+
amount REAL NOT NULL,
|
|
80
|
+
domain TEXT,
|
|
81
|
+
day_key TEXT NOT NULL,
|
|
82
|
+
month_key TEXT NOT NULL
|
|
83
|
+
)
|
|
84
|
+
"""
|
|
85
|
+
)
|
|
86
|
+
self._conn.commit()
|
|
87
|
+
|
|
88
|
+
def record(self, amount: float, domain: str | None = None) -> None:
|
|
89
|
+
now = time.time()
|
|
90
|
+
dt = datetime.fromtimestamp(now, tz=timezone.utc)
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._conn is not None:
|
|
93
|
+
self._conn.execute(
|
|
94
|
+
"INSERT INTO spend (ts, amount, domain, day_key, month_key) VALUES (?, ?, ?, ?, ?)",
|
|
95
|
+
(now, amount, domain, _day_key(dt), _month_key(dt)),
|
|
96
|
+
)
|
|
97
|
+
self._conn.commit()
|
|
98
|
+
else:
|
|
99
|
+
self._memory.append((now, amount, domain))
|
|
100
|
+
|
|
101
|
+
def spent_today(self) -> float:
|
|
102
|
+
key = _day_key()
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._conn is not None:
|
|
105
|
+
cur = self._conn.execute(
|
|
106
|
+
"SELECT COALESCE(SUM(amount), 0) FROM spend WHERE day_key = ?",
|
|
107
|
+
(key,),
|
|
108
|
+
)
|
|
109
|
+
row = cur.fetchone()
|
|
110
|
+
return float(row[0] if row else 0.0)
|
|
111
|
+
total = 0.0
|
|
112
|
+
for ts, amount, _ in self._memory:
|
|
113
|
+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
114
|
+
if _day_key(dt) == key:
|
|
115
|
+
total += amount
|
|
116
|
+
return total
|
|
117
|
+
|
|
118
|
+
def spent_this_month(self) -> float:
|
|
119
|
+
key = _month_key()
|
|
120
|
+
with self._lock:
|
|
121
|
+
if self._conn is not None:
|
|
122
|
+
cur = self._conn.execute(
|
|
123
|
+
"SELECT COALESCE(SUM(amount), 0) FROM spend WHERE month_key = ?",
|
|
124
|
+
(key,),
|
|
125
|
+
)
|
|
126
|
+
row = cur.fetchone()
|
|
127
|
+
return float(row[0] if row else 0.0)
|
|
128
|
+
total = 0.0
|
|
129
|
+
for ts, amount, _ in self._memory:
|
|
130
|
+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
131
|
+
if _month_key(dt) == key:
|
|
132
|
+
total += amount
|
|
133
|
+
return total
|
|
134
|
+
|
|
135
|
+
def reset(self) -> None:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._memory.clear()
|
|
138
|
+
if self._conn is not None:
|
|
139
|
+
self._conn.execute("DELETE FROM spend")
|
|
140
|
+
self._conn.commit()
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
with self._lock:
|
|
144
|
+
if self._conn is not None:
|
|
145
|
+
self._conn.close()
|
|
146
|
+
self._conn = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
ApprovalCallback = Callable[[float, str, str | None], bool]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PolicyEnforcer:
|
|
153
|
+
"""Checks a proposed payment against :class:`SpendingPolicy` + ledger."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
policy: SpendingPolicy,
|
|
158
|
+
ledger: SpendingLedger | None = None,
|
|
159
|
+
*,
|
|
160
|
+
approval_callback: ApprovalCallback | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
self.policy = policy
|
|
163
|
+
self.ledger = ledger or SpendingLedger()
|
|
164
|
+
self.approval_callback = approval_callback
|
|
165
|
+
|
|
166
|
+
def check(
|
|
167
|
+
self,
|
|
168
|
+
amount: float,
|
|
169
|
+
*,
|
|
170
|
+
domain: str | None = None,
|
|
171
|
+
currency: str = "USDC",
|
|
172
|
+
recipient: str | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Raise if the payment is not allowed. Does not record spend."""
|
|
175
|
+
_ = currency, recipient
|
|
176
|
+
if amount <= 0:
|
|
177
|
+
raise PolicyViolationError("payment amount must be positive")
|
|
178
|
+
|
|
179
|
+
if amount > self.policy.max_per_tx:
|
|
180
|
+
raise BudgetExceededError(
|
|
181
|
+
f"amount {amount} exceeds max_per_tx {self.policy.max_per_tx}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
daily = self.ledger.spent_today()
|
|
185
|
+
if daily + amount > self.policy.daily_limit:
|
|
186
|
+
raise BudgetExceededError(
|
|
187
|
+
f"daily spend {daily} + {amount} exceeds daily_limit {self.policy.daily_limit}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
monthly = self.ledger.spent_this_month()
|
|
191
|
+
if monthly + amount > self.policy.monthly_limit:
|
|
192
|
+
raise BudgetExceededError(
|
|
193
|
+
f"monthly spend {monthly} + {amount} exceeds monthly_limit {self.policy.monthly_limit}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not self.policy.domain_allowed(domain):
|
|
197
|
+
raise PolicyViolationError(f"domain not allowlisted: {domain!r}")
|
|
198
|
+
|
|
199
|
+
thr = self.policy.require_human_approval_above
|
|
200
|
+
if thr is not None and amount > thr:
|
|
201
|
+
approved = False
|
|
202
|
+
if self.approval_callback is not None:
|
|
203
|
+
approved = bool(self.approval_callback(amount, currency, domain))
|
|
204
|
+
if not approved:
|
|
205
|
+
raise HumanApprovalRequiredError(
|
|
206
|
+
f"amount {amount} requires human approval (threshold {thr})"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def authorize_and_record(
|
|
210
|
+
self,
|
|
211
|
+
amount: float,
|
|
212
|
+
*,
|
|
213
|
+
domain: str | None = None,
|
|
214
|
+
currency: str = "USDC",
|
|
215
|
+
recipient: str | None = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Policy check then record spend (call only after payment succeeds, or before lock)."""
|
|
218
|
+
self.check(amount, domain=domain, currency=currency, recipient=recipient)
|
|
219
|
+
self.ledger.record(amount, domain)
|
|
220
|
+
|
|
221
|
+
def remaining_daily(self) -> float:
|
|
222
|
+
return max(0.0, self.policy.daily_limit - self.ledger.spent_today())
|
|
223
|
+
|
|
224
|
+
def remaining_monthly(self) -> float:
|
|
225
|
+
return max(0.0, self.policy.monthly_limit - self.ledger.spent_this_month())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def merge_allowlists(*lists: Iterable[str]) -> list[str]:
|
|
229
|
+
seen: set[str] = set()
|
|
230
|
+
out: list[str] = []
|
|
231
|
+
for lst in lists:
|
|
232
|
+
for item in lst:
|
|
233
|
+
key = item.lower()
|
|
234
|
+
if key not in seen:
|
|
235
|
+
seen.add(key)
|
|
236
|
+
out.append(item)
|
|
237
|
+
return out
|