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 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