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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.