keptvow 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.
keptvow/__init__.py ADDED
@@ -0,0 +1,248 @@
1
+ """Check who your AI agent is about to pay before an x402 payment goes out.
2
+
3
+ import keptvow
4
+
5
+ result = keptvow.check("0xSELLER", amount_usd=5)
6
+ result["verdict"] # "ok", "careful" or "stop"
7
+ result["advice"] # one plain sentence saying why
8
+
9
+ With requests or httpx, check a seller's 402 answer before paying it, and report afterwards
10
+ whether the result arrived:
11
+
12
+ r = session.get(url)
13
+ if r.status_code == 402:
14
+ keptvow.guard(r) # raises keptvow.KeptvowStop for a wallet with a bad record
15
+ ...
16
+ keptvow.report_outcome(paid_response) # in the background; never raises
17
+
18
+ Every answer is signed by Keptvow (result["signed"], checkable at
19
+ https://keptvow.com/.well-known/keptvow-signer.json) and says how long it stays fresh, so checking
20
+ the same seller again within five minutes reuses the answer. With a key, the same seller is billed
21
+ at most once a day, and delivery reports sent with that key make its checks free.
22
+
23
+ Free, no sign-up, no key. No dependencies. Set KEPTVOW_URL to use another Keptvow address.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import json
30
+ import os
31
+ import threading
32
+ import time
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+ from typing import Any, Callable, Iterable, Optional
37
+
38
+ __all__ = ["check", "guard", "report_outcome", "payment_options", "KeptvowStop", "DEFAULT_URL"]
39
+ __version__ = "0.1.0"
40
+
41
+ DEFAULT_URL = "https://keptvow.com"
42
+
43
+ # USDC (6 decimals) on Base, Base Sepolia and Ethereum, so prices can be judged in dollars.
44
+ _USDC = {
45
+ "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
46
+ "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
47
+ "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
48
+ }
49
+
50
+
51
+ class KeptvowStop(Exception):
52
+ """Raised instead of paying a wallet Keptvow says to stop at. `.check` holds the full answer."""
53
+
54
+ def __init__(self, check: dict):
55
+ super().__init__(f"Keptvow: not paying {check.get('pay_to')}: {check.get('advice')}")
56
+ self.check = check
57
+
58
+
59
+ def _base(base_url: Optional[str]) -> str:
60
+ return (base_url or os.environ.get("KEPTVOW_URL") or DEFAULT_URL).rstrip("/")
61
+
62
+
63
+ def _request(method: str, url: str, body: Optional[dict], headers: dict, timeout: float) -> tuple[int, dict]:
64
+ data = json.dumps(body).encode() if body is not None else None
65
+ if data is not None:
66
+ headers = {**headers, "Content-Type": "application/json"}
67
+ req = urllib.request.Request(url, data=data, method=method, headers={"Accept": "application/json", **headers})
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=timeout) as r:
70
+ return r.status, json.loads(r.read() or b"{}")
71
+ except urllib.error.HTTPError as e:
72
+ try:
73
+ return e.code, json.loads(e.read() or b"{}")
74
+ except ValueError:
75
+ return e.code, {}
76
+
77
+
78
+ # Answers still fresh, by base, wallet and amount: reused until their signed "Valid until".
79
+ _fresh: dict = {}
80
+ _fresh_lock = threading.Lock()
81
+ _CACHE_MAX = 1000
82
+
83
+
84
+ def check(
85
+ pay_to: str,
86
+ amount_usd: Optional[float] = None,
87
+ *,
88
+ api_key: Optional[str] = None,
89
+ base_url: Optional[str] = None,
90
+ cache: bool = True,
91
+ timeout: float = 5.0,
92
+ ) -> dict:
93
+ """Asks Keptvow about one wallet before paying it.
94
+
95
+ Returns {"verdict": "ok" | "careful" | "stop", "advice": ..., "evidence": ..., "signed": ...}.
96
+ A fresh answer for the same wallet and amount is reused unless cache=False.
97
+ Raises RuntimeError when Keptvow refuses the question (e.g. not a wallet address).
98
+ """
99
+ base = _base(base_url)
100
+ key = (base, pay_to.strip().lower(), amount_usd)
101
+ if cache:
102
+ with _fresh_lock:
103
+ hit = _fresh.get(key)
104
+ if hit and hit[0] > time.time() * 1000:
105
+ return hit[1]
106
+ q = {"pay_to": pay_to}
107
+ if amount_usd is not None:
108
+ q["amount_usd"] = str(amount_usd)
109
+ headers = {"X-Api-Key": api_key} if api_key else {}
110
+ status, j = _request("GET", f"{base}/v1/check?{urllib.parse.urlencode(q)}", None, headers, timeout)
111
+ if status >= 400:
112
+ raise RuntimeError(j.get("error") or f"Keptvow answered {status}")
113
+ until = (j.get("signed") or {}).get("valid_until_ms") if isinstance(j, dict) else None
114
+ if cache and isinstance(until, (int, float)) and until > time.time() * 1000:
115
+ with _fresh_lock:
116
+ if len(_fresh) >= _CACHE_MAX:
117
+ _fresh.pop(next(iter(_fresh)))
118
+ _fresh[key] = (until, j)
119
+ return j
120
+
121
+
122
+ def _header(response: Any, name: str) -> Optional[str]:
123
+ headers = getattr(response, "headers", None) or {}
124
+ try:
125
+ return headers.get(name) or headers.get(name.lower()) or headers.get(name.upper())
126
+ except AttributeError:
127
+ return None
128
+
129
+
130
+ def payment_options(response: Any) -> list[dict]:
131
+ """The payment options a 402 answer offers, from its JSON body or PAYMENT-REQUIRED header."""
132
+ try:
133
+ body = response.json()
134
+ if isinstance(body, dict) and isinstance(body.get("accepts"), list):
135
+ return [a for a in body["accepts"] if isinstance(a, dict)]
136
+ except Exception:
137
+ pass
138
+ header = _header(response, "payment-required")
139
+ if header:
140
+ try:
141
+ j = json.loads(base64.b64decode(header))
142
+ if isinstance(j.get("accepts"), list):
143
+ return [a for a in j["accepts"] if isinstance(a, dict)]
144
+ except Exception:
145
+ pass
146
+ return []
147
+
148
+
149
+ def guard(
150
+ response: Any,
151
+ *,
152
+ allow_careful: bool = True,
153
+ fail_open: bool = True,
154
+ on_check: Optional[Callable[[dict], None]] = None,
155
+ api_key: Optional[str] = None,
156
+ base_url: Optional[str] = None,
157
+ cache: bool = True,
158
+ timeout: float = 5.0,
159
+ ) -> list[dict]:
160
+ """Checks every wallet a 402 Payment Required answer asks to be paid at.
161
+
162
+ Works with requests and httpx responses (anything with .json() and .headers). Raises
163
+ KeptvowStop for a wallet with a bad record — or, with allow_careful=False, one with no track
164
+ record. If Keptvow can't be reached, payment goes ahead unless fail_open=False.
165
+ Returns the check results.
166
+ """
167
+ results = []
168
+ for option in payment_options(response):
169
+ pay_to = option.get("payTo")
170
+ if not isinstance(pay_to, str):
171
+ continue
172
+ units = option.get("maxAmountRequired", option.get("amount"))
173
+ amount = None
174
+ if units is not None and str(option.get("asset", "")).lower() in _USDC:
175
+ try:
176
+ amount = float(units) / 1e6
177
+ except (TypeError, ValueError):
178
+ amount = None
179
+ try:
180
+ result = check(pay_to, amount, api_key=api_key, base_url=base_url, cache=cache, timeout=timeout)
181
+ except Exception:
182
+ if fail_open:
183
+ continue
184
+ raise
185
+ if on_check:
186
+ on_check(result)
187
+ results.append(result)
188
+ if result.get("verdict") == "stop" or (not allow_careful and result.get("verdict") == "careful"):
189
+ raise KeptvowStop(result)
190
+ return results
191
+
192
+
193
+ def report_outcome(
194
+ response_or_tx: Any,
195
+ *,
196
+ delivered: Optional[bool] = None,
197
+ pay_to: Optional[str] = None,
198
+ api_key: Optional[str] = None,
199
+ base_url: Optional[str] = None,
200
+ background: bool = True,
201
+ timeout: float = 5.0,
202
+ ) -> bool:
203
+ """Tells Keptvow whether a paid request delivered.
204
+
205
+ Pass the paid response (its PAYMENT-RESPONSE receipt names the transaction, and its status
206
+ says whether it delivered), or a transaction hash with delivered=True/False. Keptvow checks
207
+ the payment on-chain, so only real buyers count. With api_key, that seller's checks on the
208
+ key come back free. Never raises; returns False when there was nothing to report.
209
+ """
210
+ body: dict = {}
211
+ if isinstance(response_or_tx, str):
212
+ body["tx"] = response_or_tx
213
+ else:
214
+ header = _header(response_or_tx, "payment-response") or _header(response_or_tx, "x-payment-response")
215
+ if not header:
216
+ return False
217
+ try:
218
+ receipt = json.loads(base64.b64decode(header))
219
+ except Exception:
220
+ return False
221
+ if receipt.get("success") is False:
222
+ return False
223
+ tx = receipt.get("transaction") or receipt.get("txHash")
224
+ if not tx:
225
+ return False
226
+ body["tx"] = tx
227
+ status = getattr(response_or_tx, "status_code", None) or getattr(response_or_tx, "status", None)
228
+ if isinstance(status, int):
229
+ body["status"] = status
230
+ if delivered is not None:
231
+ body["delivered"] = delivered
232
+ elif "status" not in body:
233
+ return False
234
+ if pay_to:
235
+ body["pay_to"] = pay_to
236
+ url = f"{_base(base_url)}/v1/outcomes"
237
+
238
+ def send() -> None:
239
+ try:
240
+ _request("POST", url, body, {"X-Api-Key": api_key} if api_key else {}, timeout)
241
+ except Exception:
242
+ pass
243
+
244
+ if background:
245
+ threading.Thread(target=send, daemon=True).start()
246
+ else:
247
+ send()
248
+ return True
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: keptvow
3
+ Version: 0.1.0
4
+ Summary: Check who your AI agent is about to pay before an x402 payment goes out: ok, careful or stop.
5
+ Author-email: Keptvow <support@keptvow.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://keptvow.com
8
+ Project-URL: Documentation, https://keptvow.com/docs
9
+ Keywords: x402,ai-agents,payments,trust,erc-8004,base,usdc
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+
15
+ # keptvow
16
+
17
+ Check who your AI agent is about to pay **before** an x402 payment goes out.
18
+
19
+ Every answer is one word — **ok**, **careful** or **stop** — with the reason, built from real
20
+ USDC payments on Base (how many different buyers paid the seller, how many came back), reports
21
+ from buyers who did or didn't get what they paid for, and daily checks that the seller's
22
+ services answer. Free, no sign-up, no key, no dependencies.
23
+
24
+ ```sh
25
+ pip install keptvow
26
+ ```
27
+
28
+ ## Ask about one wallet
29
+
30
+ ```python
31
+ import keptvow
32
+
33
+ result = keptvow.check("0xSELLER", amount_usd=5)
34
+ result["verdict"] # "ok", "careful" or "stop"
35
+ result["advice"] # one plain sentence saying why
36
+ ```
37
+
38
+ ## Check a seller's 402 answer before paying
39
+
40
+ Works with `requests` and `httpx` responses:
41
+
42
+ ```python
43
+ r = session.get(url)
44
+ if r.status_code == 402:
45
+ keptvow.guard(r) # raises keptvow.KeptvowStop for a wallet with a bad record
46
+ # ...go on and pay with your x402 client
47
+ ```
48
+
49
+ `guard(r, allow_careful=False)` also blocks wallets with no track record;
50
+ `fail_open=False` blocks payment when Keptvow can't be reached.
51
+
52
+ ## Report whether you got what you paid for
53
+
54
+ ```python
55
+ keptvow.report_outcome(paid_response) # reads the PAYMENT-RESPONSE receipt
56
+ keptvow.report_outcome("0xTX", delivered=False) # or name the payment yourself
57
+ ```
58
+
59
+ Sent in the background and never raises. Keptvow checks the payment on-chain, so only real
60
+ buyers count — this is what lets honest sellers earn **ok** and flags the ones that take the
61
+ money and deliver nothing.
62
+
63
+ Set `KEPTVOW_URL` to use another Keptvow address.
64
+
65
+ ## Other ways in
66
+
67
+ No package needed: `GET https://keptvow.com/v1/check?pay_to=0x…&amount_usd=5`, an MCP server at
68
+ `https://keptvow.com/mcp`, and a guide for AI agents at <https://keptvow.com/llms.txt>. Everything
69
+ else: <https://keptvow.com/docs>.
@@ -0,0 +1,5 @@
1
+ keptvow/__init__.py,sha256=3TwL_qr2fHuaKQF3Y_-JyCYsPBusHv3SHNG8KnloMOc,8932
2
+ keptvow-0.1.0.dist-info/METADATA,sha256=KTBubOMsjkfJajLLxpqj6CDhIrVx_v_Us6hjREA0O2I,2361
3
+ keptvow-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
4
+ keptvow-0.1.0.dist-info/top_level.txt,sha256=mAp5xH2R5dXjL5_szmO0rRsydkkUR7swEWkK3vvvteU,8
5
+ keptvow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ keptvow