botsrated 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.
- botsrated/__init__.py +41 -0
- botsrated/client.py +282 -0
- botsrated/guard.py +218 -0
- botsrated-0.1.0.dist-info/METADATA +88 -0
- botsrated-0.1.0.dist-info/RECORD +7 -0
- botsrated-0.1.0.dist-info/WHEEL +4 -0
- botsrated-0.1.0.dist-info/licenses/LICENSE +21 -0
botsrated/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""BotsRated: ask what was measured about an x402 seller before paying it."""
|
|
2
|
+
|
|
3
|
+
from botsrated.client import (
|
|
4
|
+
BASE_MAINNET,
|
|
5
|
+
BASE_USDC,
|
|
6
|
+
BOTSRATED_BASE_URL,
|
|
7
|
+
BOTSRATED_PAY_TO,
|
|
8
|
+
FREE_DAILY_QUOTA,
|
|
9
|
+
PAID_ROUTE_MAX_ATOMIC,
|
|
10
|
+
BotsRatedClient,
|
|
11
|
+
BotsRatedError,
|
|
12
|
+
Budget,
|
|
13
|
+
PreflightResult,
|
|
14
|
+
host_of,
|
|
15
|
+
is_free_quota_answer,
|
|
16
|
+
usd_to_atomic,
|
|
17
|
+
)
|
|
18
|
+
from botsrated.guard import Decision, GuardPolicy, PrePaymentGuard, Reason, SellerFacts, evaluate_payment, facts_from
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BASE_MAINNET",
|
|
22
|
+
"BASE_USDC",
|
|
23
|
+
"BOTSRATED_BASE_URL",
|
|
24
|
+
"BOTSRATED_PAY_TO",
|
|
25
|
+
"FREE_DAILY_QUOTA",
|
|
26
|
+
"PAID_ROUTE_MAX_ATOMIC",
|
|
27
|
+
"BotsRatedClient",
|
|
28
|
+
"BotsRatedError",
|
|
29
|
+
"Budget",
|
|
30
|
+
"Decision",
|
|
31
|
+
"GuardPolicy",
|
|
32
|
+
"PreflightResult",
|
|
33
|
+
"PrePaymentGuard",
|
|
34
|
+
"Reason",
|
|
35
|
+
"SellerFacts",
|
|
36
|
+
"evaluate_payment",
|
|
37
|
+
"facts_from",
|
|
38
|
+
"host_of",
|
|
39
|
+
"is_free_quota_answer",
|
|
40
|
+
"usd_to_atomic",
|
|
41
|
+
]
|
botsrated/client.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""HTTP client for BotsRated. Free routes by default; paid routes only with an explicit budget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from typing import Any, Literal, Protocol
|
|
13
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
BOTSRATED_BASE_URL = "https://botsrated.com"
|
|
18
|
+
BOTSRATED_PAY_TO = "0x7F936f42F46B6c4Cbd3242cF8A1759dfe829E349"
|
|
19
|
+
BASE_MAINNET = "eip155:8453"
|
|
20
|
+
BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
|
21
|
+
# Published prices (since 2026-10-23) in atomic USDC. Quotes above these are refused.
|
|
22
|
+
PAID_ROUTE_MAX_ATOMIC: dict[str, int] = {"check": 1000, "report": 2000, "best": 10000}
|
|
23
|
+
# Free calls per client (IP) per UTC day. Inside the quota BotsRated answers 200 with charged: false, no 402.
|
|
24
|
+
FREE_DAILY_QUOTA: dict[str, int] = {"check": 100, "report": 10, "best": 0}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_free_quota_answer(body: Any) -> bool:
|
|
28
|
+
"""True when a 2xx BotsRated body was served from the free daily quota (nothing was charged)."""
|
|
29
|
+
return isinstance(body, dict) and body.get("charged") is False and isinstance(body.get("free_quota"), dict)
|
|
30
|
+
|
|
31
|
+
PaidRoute = Literal["check", "report", "best"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BotsRatedError(Exception):
|
|
35
|
+
def __init__(self, message: str, code: str, status: int | None = None) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
self.status = status
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ResponseLike(Protocol):
|
|
42
|
+
status_code: int
|
|
43
|
+
headers: Any
|
|
44
|
+
|
|
45
|
+
def json(self) -> Any: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
PayingGet = Callable[[str], _ResponseLike]
|
|
49
|
+
"""A GET that pays x402 challenges, e.g. ``x402_requests(x402_client).get`` or a sync httpx x402 client's ``get``."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usd_to_atomic(usd: float | str | Decimal) -> int:
|
|
53
|
+
d = Decimal(str(usd))
|
|
54
|
+
if d < 0:
|
|
55
|
+
raise BotsRatedError(f"invalid USD amount: {usd}", "invalid_amount")
|
|
56
|
+
return int((d * 1_000_000).to_integral_value())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def host_of(url_or_host: str) -> str:
|
|
60
|
+
s = url_or_host.strip()
|
|
61
|
+
host = urlsplit(s if "://" in s else f"https://{s}").hostname
|
|
62
|
+
if not host:
|
|
63
|
+
raise BotsRatedError(f"not a URL or host: {url_or_host}", "invalid_host")
|
|
64
|
+
return host.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _eq(a: str | None, b: str | None) -> bool:
|
|
68
|
+
return bool(a) and bool(b) and a.lower() == b.lower() # type: ignore[union-attr]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Budget:
|
|
72
|
+
"""Spend tracker in atomic USDC. Thread-safe. Reserve before paying; commit or release after."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, limit_atomic: int) -> None:
|
|
75
|
+
if limit_atomic <= 0:
|
|
76
|
+
raise BotsRatedError("budget must be greater than 0", "invalid_budget")
|
|
77
|
+
self.limit_atomic = limit_atomic
|
|
78
|
+
self.spent_atomic = 0
|
|
79
|
+
self.pending_atomic = 0
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def remaining_atomic(self) -> int:
|
|
84
|
+
return self.limit_atomic - self.spent_atomic - self.pending_atomic
|
|
85
|
+
|
|
86
|
+
def reserve(self, amount: int) -> None:
|
|
87
|
+
with self._lock:
|
|
88
|
+
if amount > self.remaining_atomic:
|
|
89
|
+
raise BotsRatedError(
|
|
90
|
+
f"budget exceeded: need {amount / 1e6} USD, {self.remaining_atomic / 1e6} USD left",
|
|
91
|
+
"budget_exceeded",
|
|
92
|
+
)
|
|
93
|
+
self.pending_atomic += amount
|
|
94
|
+
|
|
95
|
+
def commit(self, amount: int) -> None:
|
|
96
|
+
with self._lock:
|
|
97
|
+
self.pending_atomic -= amount
|
|
98
|
+
self.spent_atomic += amount
|
|
99
|
+
|
|
100
|
+
def release(self, amount: int) -> None:
|
|
101
|
+
with self._lock:
|
|
102
|
+
self.pending_atomic -= amount
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class PreflightResult:
|
|
107
|
+
found: bool
|
|
108
|
+
host: str
|
|
109
|
+
body: dict[str, Any]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_payment_required(resp: _ResponseLike) -> dict[str, Any]:
|
|
113
|
+
header = resp.headers.get("payment-required")
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(base64.b64decode(header)) if header else resp.json()
|
|
116
|
+
except Exception as e: # noqa: BLE001
|
|
117
|
+
raise BotsRatedError("could not parse the 402 payment terms", "bad_402") from e
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BotsRatedClient:
|
|
121
|
+
"""BotsRated client.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
base_url: Defaults to https://botsrated.com.
|
|
125
|
+
http: Optional ``httpx.Client`` for unpaid requests.
|
|
126
|
+
timeout: Seconds per unpaid request.
|
|
127
|
+
cache_ttl: Preflight cache per host, seconds. 0 disables.
|
|
128
|
+
budget_usd: Total USD allowed for paid routes. Paid routes stay disabled unless this and ``paying_get`` are set.
|
|
129
|
+
paying_get: A GET callable that pays x402 challenges.
|
|
130
|
+
expected_pay_to: BotsRated payTo to accept. Defaults to ``BOTSRATED_PAY_TO``.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
base_url: str = BOTSRATED_BASE_URL,
|
|
136
|
+
*,
|
|
137
|
+
http: httpx.Client | None = None,
|
|
138
|
+
timeout: float = 4.0,
|
|
139
|
+
cache_ttl: float = 300.0,
|
|
140
|
+
budget_usd: float | str | None = None,
|
|
141
|
+
paying_get: PayingGet | None = None,
|
|
142
|
+
expected_pay_to: str = BOTSRATED_PAY_TO,
|
|
143
|
+
) -> None:
|
|
144
|
+
self.base_url = base_url.rstrip("/")
|
|
145
|
+
self.host = host_of(self.base_url)
|
|
146
|
+
self._http = http or httpx.Client(timeout=timeout, headers={"accept": "application/json"})
|
|
147
|
+
self._cache_ttl = cache_ttl
|
|
148
|
+
self._cache: dict[str, tuple[float, PreflightResult]] = {}
|
|
149
|
+
self._paying_get = paying_get
|
|
150
|
+
self._expected_pay_to = expected_pay_to
|
|
151
|
+
self.budget: Budget | None = None
|
|
152
|
+
self.last_free_quota: dict[str, Any] | None = None
|
|
153
|
+
if (budget_usd is None) != (paying_get is None):
|
|
154
|
+
raise BotsRatedError("paid routes need both budget_usd and paying_get", "invalid_config")
|
|
155
|
+
if budget_usd is not None:
|
|
156
|
+
self.budget = Budget(usd_to_atomic(budget_usd))
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def paid_enabled(self) -> bool:
|
|
160
|
+
return self.budget is not None
|
|
161
|
+
|
|
162
|
+
def _get(self, path: str) -> httpx.Response:
|
|
163
|
+
try:
|
|
164
|
+
return self._http.get(self.base_url + path)
|
|
165
|
+
except httpx.HTTPError as e:
|
|
166
|
+
raise BotsRatedError(f"BotsRated unreachable: {e}", "unreachable") from e
|
|
167
|
+
|
|
168
|
+
def preflight(self, url_or_host: str) -> PreflightResult:
|
|
169
|
+
"""Free. Latest stored probe for a seller; ``found=False`` when the host is not covered."""
|
|
170
|
+
host = host_of(url_or_host)
|
|
171
|
+
hit = self._cache.get(host)
|
|
172
|
+
if hit and time.monotonic() - hit[0] < self._cache_ttl:
|
|
173
|
+
return hit[1]
|
|
174
|
+
target = url_or_host if "://" in url_or_host else host
|
|
175
|
+
resp = self._get(f"/v1/preflight?url={quote(target, safe='')}")
|
|
176
|
+
try:
|
|
177
|
+
body = resp.json()
|
|
178
|
+
except ValueError:
|
|
179
|
+
body = {}
|
|
180
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
181
|
+
if resp.status_code == 404 and isinstance(err, dict) and err.get("code") == "host_not_covered":
|
|
182
|
+
result = PreflightResult(False, host, {**body, "found": False})
|
|
183
|
+
elif resp.status_code >= 400:
|
|
184
|
+
msg = err.get("message") if isinstance(err, dict) else None
|
|
185
|
+
code = err.get("code") if isinstance(err, dict) else None
|
|
186
|
+
raise BotsRatedError(msg or f"preflight returned {resp.status_code}", code or "http_error", resp.status_code)
|
|
187
|
+
else:
|
|
188
|
+
result = PreflightResult(body.get("found") is not False, host, body)
|
|
189
|
+
if self._cache_ttl > 0:
|
|
190
|
+
self._cache[host] = (time.monotonic(), result)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def check(self, url_or_host: str) -> dict[str, Any]:
|
|
194
|
+
"""Paid ($0.001; first 100/day free). Facts, paid test-buys and recent receipts for one host."""
|
|
195
|
+
return self._paid(f"/v1/check?host={quote(host_of(url_or_host), safe='')}", "check")
|
|
196
|
+
|
|
197
|
+
def report(self, url_or_host: str) -> dict[str, Any]:
|
|
198
|
+
"""Paid ($0.002; first 10/day free). Full receipt history for one host."""
|
|
199
|
+
return self._paid(f"/v1/report/{quote(host_of(url_or_host), safe='')}", "report")
|
|
200
|
+
|
|
201
|
+
def best(self, task: str, sort: str | None = None, max_price_usd: float | None = None) -> dict[str, Any]:
|
|
202
|
+
"""Paid ($0.01). Top providers for a task."""
|
|
203
|
+
q: dict[str, str] = {"task": task}
|
|
204
|
+
if sort:
|
|
205
|
+
q["sort"] = sort
|
|
206
|
+
if max_price_usd is not None:
|
|
207
|
+
q["max_price"] = str(max_price_usd)
|
|
208
|
+
return self._paid(f"/v1/best?{urlencode(q)}", "best")
|
|
209
|
+
|
|
210
|
+
def acceptable_payment(self, req: dict[str, Any], max_atomic: int = PAID_ROUTE_MAX_ATOMIC["best"]) -> bool:
|
|
211
|
+
amount = str(req.get("amount", ""))
|
|
212
|
+
return (
|
|
213
|
+
req.get("scheme") == "exact"
|
|
214
|
+
and req.get("network") == BASE_MAINNET
|
|
215
|
+
and _eq(req.get("asset"), BASE_USDC)
|
|
216
|
+
and _eq(req.get("payTo") or req.get("pay_to"), self._expected_pay_to)
|
|
217
|
+
and amount.isdigit()
|
|
218
|
+
and int(amount) <= max_atomic
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def is_reserved_payment(self, req: dict[str, Any]) -> bool:
|
|
222
|
+
"""True if ``req`` is an acceptable BotsRated payment this client has reserved budget for."""
|
|
223
|
+
return (
|
|
224
|
+
self.budget is not None
|
|
225
|
+
and self.acceptable_payment(req)
|
|
226
|
+
and self.budget.pending_atomic >= int(req["amount"])
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _paid(self, path: str, route: PaidRoute) -> dict[str, Any]:
|
|
230
|
+
# Ask unpaid first. Inside the free daily quota this already returns the data (200, charged: false):
|
|
231
|
+
# nothing is paid and the budget is untouched. Bad input gets a free 4xx here, before any payment.
|
|
232
|
+
quote_resp = self._get(path)
|
|
233
|
+
if 200 <= quote_resp.status_code < 300:
|
|
234
|
+
try:
|
|
235
|
+
body = quote_resp.json()
|
|
236
|
+
except ValueError:
|
|
237
|
+
body = None
|
|
238
|
+
if is_free_quota_answer(body):
|
|
239
|
+
self.last_free_quota = body["free_quota"]
|
|
240
|
+
return body
|
|
241
|
+
raise BotsRatedError(
|
|
242
|
+
f"expected 402 or a free-quota answer, got {quote_resp.status_code}", "unexpected_status", quote_resp.status_code
|
|
243
|
+
)
|
|
244
|
+
if self.budget is None or self._paying_get is None:
|
|
245
|
+
msg = "paid BotsRated routes are disabled; pass budget_usd and paying_get"
|
|
246
|
+
if quote_resp.status_code == 402:
|
|
247
|
+
msg = "free daily quota used up and " + msg
|
|
248
|
+
raise BotsRatedError(msg, "paid_disabled", quote_resp.status_code)
|
|
249
|
+
if quote_resp.status_code != 402:
|
|
250
|
+
try:
|
|
251
|
+
err = quote_resp.json().get("error") or {}
|
|
252
|
+
except ValueError:
|
|
253
|
+
err = {}
|
|
254
|
+
raise BotsRatedError(
|
|
255
|
+
err.get("message") or f"expected 402, got {quote_resp.status_code}",
|
|
256
|
+
err.get("code") or "unexpected_status",
|
|
257
|
+
quote_resp.status_code,
|
|
258
|
+
)
|
|
259
|
+
terms = read_payment_required(quote_resp)
|
|
260
|
+
option = next((a for a in terms.get("accepts", []) if self.acceptable_payment(a, PAID_ROUTE_MAX_ATOMIC[route])), None)
|
|
261
|
+
if option is None:
|
|
262
|
+
raise BotsRatedError(
|
|
263
|
+
"no acceptable payment option (Base USDC, exact, BotsRated payTo, published price)", "terms_rejected"
|
|
264
|
+
)
|
|
265
|
+
amount = int(option["amount"])
|
|
266
|
+
self.budget.reserve(amount)
|
|
267
|
+
try:
|
|
268
|
+
resp = self._paying_get(self.base_url + path)
|
|
269
|
+
except Exception:
|
|
270
|
+
self.budget.release(amount)
|
|
271
|
+
raise
|
|
272
|
+
if not 200 <= resp.status_code < 300:
|
|
273
|
+
self.budget.release(amount)
|
|
274
|
+
raise BotsRatedError(f"paid {route} returned {resp.status_code}", "paid_call_failed", resp.status_code)
|
|
275
|
+
out = resp.json()
|
|
276
|
+
if is_free_quota_answer(out):
|
|
277
|
+
# Answered from the free quota on the paid attempt: no payment happened.
|
|
278
|
+
self.budget.release(amount)
|
|
279
|
+
self.last_free_quota = out["free_quota"]
|
|
280
|
+
return out
|
|
281
|
+
self.budget.commit(amount)
|
|
282
|
+
return out
|
botsrated/guard.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Pre-payment guard: decide whether to pay an x402 seller, using BotsRated's measurements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from botsrated.client import BotsRatedClient, BotsRatedError, host_of, usd_to_atomic
|
|
11
|
+
|
|
12
|
+
Effect = Literal["block", "warn", "info"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Reason:
|
|
17
|
+
code: str
|
|
18
|
+
detail: str
|
|
19
|
+
effect: Effect
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SellerFacts:
|
|
24
|
+
covered: bool
|
|
25
|
+
host: str
|
|
26
|
+
source: Literal["preflight", "check"]
|
|
27
|
+
known_pay_tos: list[str] = field(default_factory=list)
|
|
28
|
+
observed_network: str | None = None
|
|
29
|
+
probed_url: str | None = None
|
|
30
|
+
observed_amount: str | None = None
|
|
31
|
+
valid_402_rate: float | None = None
|
|
32
|
+
recent_probes: int | None = None
|
|
33
|
+
stale: bool | None = None
|
|
34
|
+
last_probe_at: str | None = None
|
|
35
|
+
receipt_url: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Decision:
|
|
40
|
+
action: Literal["allow", "block"]
|
|
41
|
+
host: str | None
|
|
42
|
+
reasons: list[Reason]
|
|
43
|
+
facts: SellerFacts | None = None
|
|
44
|
+
|
|
45
|
+
def summary(self) -> str:
|
|
46
|
+
blocking = [r for r in self.reasons if r.effect == "block"] or self.reasons
|
|
47
|
+
listed = "; ".join(f"{r.code}: {r.detail}" for r in blocking)
|
|
48
|
+
receipt = f" (receipt: {self.facts.receipt_url})" if self.facts and self.facts.receipt_url else ""
|
|
49
|
+
verb = "blocked" if self.action == "block" else "allowed"
|
|
50
|
+
return f"BotsRated pre-payment check {verb} {self.host or 'payment'}" + (f" - {listed}" if listed else "") + receipt
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class GuardPolicy:
|
|
55
|
+
not_covered: Literal["allow", "block"] = "allow"
|
|
56
|
+
pay_to_mismatch: Literal["allow", "warn", "block"] = "block"
|
|
57
|
+
price_above_observed: Literal["allow", "warn", "block"] = "warn"
|
|
58
|
+
stale: Literal["allow", "block"] = "allow"
|
|
59
|
+
min_valid_402_rate: float | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def facts_from(body: dict[str, Any], host: str, source: Literal["preflight", "check"]) -> SellerFacts:
|
|
63
|
+
probe = body.get("latest_probe") or (body.get("receipts") or [{}])[0] or {}
|
|
64
|
+
measured = body.get("measured") or {}
|
|
65
|
+
listing = body.get("listing") or {}
|
|
66
|
+
rw = body.get("recent_window") or measured.get("recent_window") or {}
|
|
67
|
+
pay_tos = [listing.get("payTo"), probe.get("live_payTo"), measured.get("last_quoted_payTo")]
|
|
68
|
+
freshness = body.get("freshness") or {}
|
|
69
|
+
return SellerFacts(
|
|
70
|
+
covered=body.get("found") is not False,
|
|
71
|
+
host=host,
|
|
72
|
+
source=source,
|
|
73
|
+
known_pay_tos=sorted({p.lower() for p in pay_tos if isinstance(p, str) and p}),
|
|
74
|
+
observed_network=probe.get("live_network") or listing.get("network"),
|
|
75
|
+
probed_url=probe.get("url") or (body.get("route_probed") or {}).get("url"),
|
|
76
|
+
observed_amount=probe.get("live_amount") or measured.get("last_quoted_amount_atomic"),
|
|
77
|
+
valid_402_rate=rw.get("valid_402_rate"),
|
|
78
|
+
recent_probes=rw.get("probes"),
|
|
79
|
+
stale=freshness.get("stale"),
|
|
80
|
+
last_probe_at=freshness.get("last_probe_at"),
|
|
81
|
+
receipt_url=probe.get("receipt_url"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _same_route(a: str | None, b: str | None) -> bool:
|
|
86
|
+
if not a or not b:
|
|
87
|
+
return False
|
|
88
|
+
x, y = urlsplit(a), urlsplit(b)
|
|
89
|
+
return (x.hostname or "") == (y.hostname or "") and x.path.rstrip("/") == y.path.rstrip("/")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def as_requirements(req: Any) -> dict[str, Any]:
|
|
93
|
+
"""Accepts a dict or an x402 ``PaymentRequirements`` model; returns camelCase keys."""
|
|
94
|
+
if isinstance(req, dict):
|
|
95
|
+
d = dict(req)
|
|
96
|
+
elif hasattr(req, "model_dump"):
|
|
97
|
+
d = req.model_dump(by_alias=True)
|
|
98
|
+
else:
|
|
99
|
+
d = {k: getattr(req, k, None) for k in ("scheme", "network", "asset", "amount", "pay_to")}
|
|
100
|
+
if "payTo" not in d and "pay_to" in d:
|
|
101
|
+
d["payTo"] = d["pay_to"]
|
|
102
|
+
return d
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def evaluate_payment(facts: SellerFacts, req: dict[str, Any], resource_url: str | None, policy: GuardPolicy | None = None) -> Decision:
|
|
106
|
+
p = policy or GuardPolicy()
|
|
107
|
+
reasons: list[Reason] = []
|
|
108
|
+
|
|
109
|
+
def add(code: str, detail: str, effect: str) -> None:
|
|
110
|
+
if effect != "allow":
|
|
111
|
+
reasons.append(Reason(code, detail, effect)) # type: ignore[arg-type]
|
|
112
|
+
|
|
113
|
+
req = as_requirements(req)
|
|
114
|
+
pay_to = str(req.get("payTo") or "")
|
|
115
|
+
amount = str(req.get("amount") or "")
|
|
116
|
+
if not facts.covered:
|
|
117
|
+
add("not_covered", f"{facts.host} is not in BotsRated's probe set", "block" if p.not_covered == "block" else "info")
|
|
118
|
+
else:
|
|
119
|
+
if facts.observed_network and facts.observed_network != req.get("network"):
|
|
120
|
+
add("network_not_measured", f"paying on {req.get('network')}; BotsRated measured {facts.observed_network}", "info")
|
|
121
|
+
elif facts.known_pay_tos and pay_to.lower() not in facts.known_pay_tos:
|
|
122
|
+
add(
|
|
123
|
+
"payto_mismatch",
|
|
124
|
+
f"payTo {pay_to} was never seen by BotsRated for {facts.host} (seen: {', '.join(facts.known_pay_tos)})",
|
|
125
|
+
p.pay_to_mismatch,
|
|
126
|
+
)
|
|
127
|
+
if (
|
|
128
|
+
facts.observed_amount
|
|
129
|
+
and str(facts.observed_amount).isdigit()
|
|
130
|
+
and amount.isdigit()
|
|
131
|
+
and _same_route(resource_url, facts.probed_url)
|
|
132
|
+
and int(amount) > int(facts.observed_amount)
|
|
133
|
+
):
|
|
134
|
+
add("price_above_observed", f"quoted {amount} atomic; BotsRated last observed {facts.observed_amount} on this route", p.price_above_observed)
|
|
135
|
+
if facts.stale:
|
|
136
|
+
add("stale", f"BotsRated data is older than 24 h (last probe {facts.last_probe_at})", "block" if p.stale == "block" else "info")
|
|
137
|
+
if (
|
|
138
|
+
p.min_valid_402_rate is not None
|
|
139
|
+
and facts.valid_402_rate is not None
|
|
140
|
+
and (facts.recent_probes or 0) >= 3
|
|
141
|
+
and facts.valid_402_rate < p.min_valid_402_rate
|
|
142
|
+
):
|
|
143
|
+
add("low_valid_402_rate", f"recent valid-402 rate {facts.valid_402_rate} is below {p.min_valid_402_rate}", "block")
|
|
144
|
+
action: Literal["allow", "block"] = "block" if any(r.effect == "block" for r in reasons) else "allow"
|
|
145
|
+
return Decision(action, facts.host, reasons, facts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class PrePaymentGuard:
|
|
149
|
+
"""Decides on x402 payments using BotsRated's free preflight (and, opt-in, a paid check).
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
client: A ``BotsRatedClient``; a free-only client is created if omitted.
|
|
153
|
+
policy: What to block or warn on.
|
|
154
|
+
fail_open: Allow the payment when BotsRated is unreachable. Default True.
|
|
155
|
+
paid_check_at_or_above_usd: Run a check for payments at or above this: free within the daily quota
|
|
156
|
+
(100/day), then a $0.001 paid check with a paid-enabled client (skipped otherwise).
|
|
157
|
+
on_decision: Called with every decision.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
client: BotsRatedClient | None = None,
|
|
163
|
+
policy: GuardPolicy | None = None,
|
|
164
|
+
*,
|
|
165
|
+
fail_open: bool = True,
|
|
166
|
+
paid_check_at_or_above_usd: float | None = None,
|
|
167
|
+
on_decision: Callable[[Decision], None] | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self.client = client or BotsRatedClient()
|
|
170
|
+
self.policy = policy or GuardPolicy()
|
|
171
|
+
self.fail_open = fail_open
|
|
172
|
+
self.paid_threshold = paid_check_at_or_above_usd
|
|
173
|
+
self.on_decision = on_decision
|
|
174
|
+
|
|
175
|
+
def decide(self, resource_url: str | None, requirements: Any) -> Decision:
|
|
176
|
+
req = as_requirements(requirements)
|
|
177
|
+
if not resource_url:
|
|
178
|
+
eff: Effect = "info" if self.fail_open else "block"
|
|
179
|
+
return Decision("allow" if self.fail_open else "block", None, [Reason("no_resource_url", "payment has no resource URL", eff)])
|
|
180
|
+
host = host_of(resource_url)
|
|
181
|
+
if host == self.client.host:
|
|
182
|
+
ok = self.client.is_reserved_payment(req)
|
|
183
|
+
reasons = [] if ok else [Reason("botsrated_payment_not_reserved", "payments to BotsRated must go through BotsRatedClient with a budget", "block")]
|
|
184
|
+
return Decision("allow" if ok else "block", host, reasons)
|
|
185
|
+
try:
|
|
186
|
+
pre = self.client.preflight(resource_url)
|
|
187
|
+
facts = facts_from(pre.body, host, "preflight")
|
|
188
|
+
extra: list[Reason] = []
|
|
189
|
+
if (
|
|
190
|
+
pre.found
|
|
191
|
+
and self.paid_threshold is not None
|
|
192
|
+
and str(req.get("amount", "")).isdigit()
|
|
193
|
+
and int(req["amount"]) >= usd_to_atomic(self.paid_threshold)
|
|
194
|
+
):
|
|
195
|
+
try:
|
|
196
|
+
facts = facts_from(self.client.check(host), host, "check")
|
|
197
|
+
except BotsRatedError as e:
|
|
198
|
+
extra.append(Reason("paid_check_skipped", str(e), "info"))
|
|
199
|
+
d = evaluate_payment(facts, req, resource_url, self.policy)
|
|
200
|
+
d.reasons.extend(extra)
|
|
201
|
+
return d
|
|
202
|
+
except BotsRatedError as e:
|
|
203
|
+
eff = "info" if self.fail_open else "block"
|
|
204
|
+
return Decision("allow" if self.fail_open else "block", host, [Reason("botsrated_unavailable", str(e), eff)])
|
|
205
|
+
|
|
206
|
+
def x402_hook(self, context: Any) -> Any:
|
|
207
|
+
"""Hook for ``x402Client(Sync).on_before_payment_creation``. Returns ``AbortResult`` to block."""
|
|
208
|
+
pr = context.payment_required
|
|
209
|
+
resource = getattr(pr, "resource", None)
|
|
210
|
+
url = getattr(resource, "url", None) if resource is not None else None
|
|
211
|
+
d = self.decide(url, context.selected_requirements)
|
|
212
|
+
if self.on_decision:
|
|
213
|
+
self.on_decision(d)
|
|
214
|
+
if d.action == "block":
|
|
215
|
+
from x402 import AbortResult # optional dependency: botsrated[x402]
|
|
216
|
+
|
|
217
|
+
return AbortResult(reason=d.summary())
|
|
218
|
+
return None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: botsrated
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pre-payment check for x402 agents: free BotsRated preflight before paying a seller; paid lookups opt-in with a budget.
|
|
5
|
+
Project-URL: Homepage, https://botsrated.com
|
|
6
|
+
Project-URL: Repository, https://github.com/botsrated/botsrated
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,base,botsrated,payments,preflight,usdc,x402
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: x402
|
|
13
|
+
Requires-Dist: x402[evm,httpx]>=2.25; extra == 'x402'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# botsrated (Python)
|
|
17
|
+
|
|
18
|
+
A pre-payment check for x402 agents. Before your agent pays a seller, it asks
|
|
19
|
+
[BotsRated](https://botsrated.com) what it has measured about that seller, using the free
|
|
20
|
+
`GET /v1/preflight` route. Paid lookups are opt-in and capped by a budget you set.
|
|
21
|
+
|
|
22
|
+
BotsRated is in beta. It covers about 4,800 x402 seller hosts on Base, taken from the CDP Bazaar,
|
|
23
|
+
Agentic.Market, 402index and x402-list. Each host gets an unpaid probe about every 5.4 h, and every
|
|
24
|
+
receipt is signed.
|
|
25
|
+
|
|
26
|
+
**Status: published on PyPI: [`botsrated`](https://pypi.org/project/botsrated/) 0.1.0.**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install botsrated # free preflight + guard
|
|
30
|
+
pip install "botsrated[x402]" # + x402 SDK hook / paid lookups
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Guard an x402 client (free)
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from x402 import x402ClientSync
|
|
37
|
+
from x402.mechanisms.evm import EthAccountSigner
|
|
38
|
+
from x402.mechanisms.evm.exact import ExactEvmScheme
|
|
39
|
+
from botsrated import PrePaymentGuard
|
|
40
|
+
|
|
41
|
+
client = x402ClientSync()
|
|
42
|
+
client.register("eip155:*", ExactEvmScheme(signer=EthAccountSigner(account)))
|
|
43
|
+
client.on_before_payment_creation(PrePaymentGuard().x402_hook)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Defaults:
|
|
47
|
+
- A payTo that differs from every payTo BotsRated saw for the host, on the same network, is **blocked**.
|
|
48
|
+
- A price above the last observed price on the same route is a warning.
|
|
49
|
+
- A host that is not covered is allowed.
|
|
50
|
+
- Data older than 24 h is allowed.
|
|
51
|
+
- If BotsRated cannot be reached (4 s timeout), the payment is allowed (`fail_open`).
|
|
52
|
+
- The minimum valid-402 rate is off.
|
|
53
|
+
|
|
54
|
+
Change these with `GuardPolicy(...)`. Preflight answers are cached for 5 minutes per host.
|
|
55
|
+
|
|
56
|
+
## Opt-in paid lookups with a budget
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from x402.http.clients import x402_requests
|
|
60
|
+
from botsrated import BotsRatedClient, PrePaymentGuard
|
|
61
|
+
|
|
62
|
+
session = x402_requests(client) # pays x402 challenges
|
|
63
|
+
botsrated = BotsRatedClient(budget_usd="0.05", paying_get=session.get)
|
|
64
|
+
guard = PrePaymentGuard(botsrated, paid_check_at_or_above_usd=0.25)
|
|
65
|
+
client.on_before_payment_creation(guard.x402_hook)
|
|
66
|
+
|
|
67
|
+
botsrated.check("seller.example") # $0.001 (first 100/day free)
|
|
68
|
+
botsrated.report("seller.example") # $0.002 (first 10/day free)
|
|
69
|
+
botsrated.best("web search") # $0.01
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
How paid calls work:
|
|
73
|
+
- Free daily quota: the first 100 `/v1/check` and 10 `/v1/report` calls per client per UTC day are
|
|
74
|
+
served free (200 with `charged: false` and a `free_quota` block). The client asks unpaid first, so
|
|
75
|
+
inside the quota nothing is paid and the budget is untouched; `check()`/`report()` also work without
|
|
76
|
+
a budget until the quota is used up (then `paid_disabled`). `client.last_free_quota` shows what is left.
|
|
77
|
+
- Otherwise the client reads the 402 quote unpaid first. It accepts only `exact` USDC on Base to BotsRated's payTo
|
|
78
|
+
`0x7F936f42F46B6c4Cbd3242cF8A1759dfe829E349`, at or below the published price.
|
|
79
|
+
- The amount is reserved from the budget before paying and released if the call fails.
|
|
80
|
+
- When the guard is registered, it refuses any payment to botsrated.com that the client did not reserve.
|
|
81
|
+
|
|
82
|
+
## Tests
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv run --with pytest --with respx --with "x402[evm]" pytest
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
License: MIT. BotsRated reports measurements, not endorsements.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
botsrated/__init__.py,sha256=eVINDliCS2nS3zePFoZKYKz2WGbuNIVOt1cJlvyXQKI,924
|
|
2
|
+
botsrated/client.py,sha256=1fv6fGJ76KwsmvlZ6E-717EJGWdnS25M118074Hgksw,11602
|
|
3
|
+
botsrated/guard.py,sha256=kDDOI0wq4naPpdgTwRNJud9oFILGr2mTohqjlS9v0O8,9455
|
|
4
|
+
botsrated-0.1.0.dist-info/METADATA,sha256=6LHNgDg7O-5bFvTKFeJvPQgCa_Vd2LSu5kc-4c6mTV4,3717
|
|
5
|
+
botsrated-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
botsrated-0.1.0.dist-info/licenses/LICENSE,sha256=3mkSwjwOJ_YSnozCObgbawNbWffdiM2Z04kunzx7yBg,1066
|
|
7
|
+
botsrated-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BotsRated
|
|
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.
|