keptvow 0.1.0__tar.gz
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-0.1.0/PKG-INFO +69 -0
- keptvow-0.1.0/README.md +55 -0
- keptvow-0.1.0/pyproject.toml +22 -0
- keptvow-0.1.0/setup.cfg +4 -0
- keptvow-0.1.0/src/keptvow/__init__.py +248 -0
- keptvow-0.1.0/src/keptvow.egg-info/PKG-INFO +69 -0
- keptvow-0.1.0/src/keptvow.egg-info/SOURCES.txt +8 -0
- keptvow-0.1.0/src/keptvow.egg-info/dependency_links.txt +1 -0
- keptvow-0.1.0/src/keptvow.egg-info/top_level.txt +1 -0
- keptvow-0.1.0/tests/test_keptvow.py +107 -0
keptvow-0.1.0/PKG-INFO
ADDED
|
@@ -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>.
|
keptvow-0.1.0/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# keptvow
|
|
2
|
+
|
|
3
|
+
Check who your AI agent is about to pay **before** an x402 payment goes out.
|
|
4
|
+
|
|
5
|
+
Every answer is one word — **ok**, **careful** or **stop** — with the reason, built from real
|
|
6
|
+
USDC payments on Base (how many different buyers paid the seller, how many came back), reports
|
|
7
|
+
from buyers who did or didn't get what they paid for, and daily checks that the seller's
|
|
8
|
+
services answer. Free, no sign-up, no key, no dependencies.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install keptvow
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Ask about one wallet
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import keptvow
|
|
18
|
+
|
|
19
|
+
result = keptvow.check("0xSELLER", amount_usd=5)
|
|
20
|
+
result["verdict"] # "ok", "careful" or "stop"
|
|
21
|
+
result["advice"] # one plain sentence saying why
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Check a seller's 402 answer before paying
|
|
25
|
+
|
|
26
|
+
Works with `requests` and `httpx` responses:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
r = session.get(url)
|
|
30
|
+
if r.status_code == 402:
|
|
31
|
+
keptvow.guard(r) # raises keptvow.KeptvowStop for a wallet with a bad record
|
|
32
|
+
# ...go on and pay with your x402 client
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`guard(r, allow_careful=False)` also blocks wallets with no track record;
|
|
36
|
+
`fail_open=False` blocks payment when Keptvow can't be reached.
|
|
37
|
+
|
|
38
|
+
## Report whether you got what you paid for
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
keptvow.report_outcome(paid_response) # reads the PAYMENT-RESPONSE receipt
|
|
42
|
+
keptvow.report_outcome("0xTX", delivered=False) # or name the payment yourself
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Sent in the background and never raises. Keptvow checks the payment on-chain, so only real
|
|
46
|
+
buyers count — this is what lets honest sellers earn **ok** and flags the ones that take the
|
|
47
|
+
money and deliver nothing.
|
|
48
|
+
|
|
49
|
+
Set `KEPTVOW_URL` to use another Keptvow address.
|
|
50
|
+
|
|
51
|
+
## Other ways in
|
|
52
|
+
|
|
53
|
+
No package needed: `GET https://keptvow.com/v1/check?pay_to=0x…&amount_usd=5`, an MCP server at
|
|
54
|
+
`https://keptvow.com/mcp`, and a guide for AI agents at <https://keptvow.com/llms.txt>. Everything
|
|
55
|
+
else: <https://keptvow.com/docs>.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keptvow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Check who your AI agent is about to pay before an x402 payment goes out: ok, careful or stop."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Keptvow", email = "support@keptvow.com" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["x402", "ai-agents", "payments", "trust", "erc-8004", "base", "usdc"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://keptvow.com"
|
|
22
|
+
Documentation = "https://keptvow.com/docs"
|
keptvow-0.1.0/setup.cfg
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keptvow
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Runs without a network: Keptvow's answers are faked. Set KEPTVOW_TEST_URL to also ask a
|
|
2
|
+
running Keptvow server for real."""
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
import keptvow
|
|
11
|
+
|
|
12
|
+
SCAM = "0x" + "c3" * 20
|
|
13
|
+
GOOD = "0x" + "a1" * 20
|
|
14
|
+
SIGNED = "0x" + "5e" * 20
|
|
15
|
+
USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FakeResponse:
|
|
19
|
+
def __init__(self, status, body=None, headers=None):
|
|
20
|
+
self.status_code = status
|
|
21
|
+
self._body = body
|
|
22
|
+
self.headers = headers or {}
|
|
23
|
+
|
|
24
|
+
def json(self):
|
|
25
|
+
if self._body is None:
|
|
26
|
+
raise ValueError("no body")
|
|
27
|
+
return self._body
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Test(unittest.TestCase):
|
|
31
|
+
def setUp(self):
|
|
32
|
+
self.calls = []
|
|
33
|
+
self.report_headers = []
|
|
34
|
+
keptvow._fresh.clear()
|
|
35
|
+
self.real = keptvow._request
|
|
36
|
+
|
|
37
|
+
def fake(method, url, body, headers, timeout):
|
|
38
|
+
self.calls.append((method, url, body))
|
|
39
|
+
if "/v1/check" in url:
|
|
40
|
+
q = dict(p.split("=", 1) for p in url.split("?", 1)[1].split("&"))
|
|
41
|
+
if q["pay_to"] == "down":
|
|
42
|
+
raise OSError("offline")
|
|
43
|
+
signed = {"valid_until_ms": (time.time() + 60) * 1000} if q["pay_to"] == SIGNED else None
|
|
44
|
+
return 200, {"pay_to": q["pay_to"], "verdict": "stop" if q["pay_to"] == SCAM else "ok", "advice": "test", "signed": signed}
|
|
45
|
+
self.report_headers.append(headers)
|
|
46
|
+
return 202, {"status": "queued"}
|
|
47
|
+
|
|
48
|
+
keptvow._request = fake
|
|
49
|
+
|
|
50
|
+
def tearDown(self):
|
|
51
|
+
keptvow._request = self.real
|
|
52
|
+
|
|
53
|
+
def test_check_asks_about_one_wallet(self):
|
|
54
|
+
r = keptvow.check(GOOD, 5, base_url="https://k.test")
|
|
55
|
+
self.assertEqual(r["verdict"], "ok")
|
|
56
|
+
self.assertIn("amount_usd=5", self.calls[-1][1])
|
|
57
|
+
|
|
58
|
+
def test_guard_stops_a_bad_seller_and_passes_a_good_one(self):
|
|
59
|
+
bad = FakeResponse(402, {"accepts": [{"payTo": SCAM, "maxAmountRequired": "2000000", "asset": USDC}]})
|
|
60
|
+
with self.assertRaises(keptvow.KeptvowStop) as e:
|
|
61
|
+
keptvow.guard(bad)
|
|
62
|
+
self.assertEqual(e.exception.check["pay_to"], SCAM)
|
|
63
|
+
self.assertIn("amount_usd=2.0", self.calls[-1][1])
|
|
64
|
+
# The payment request can also come in the PAYMENT-REQUIRED header.
|
|
65
|
+
header = base64.b64encode(json.dumps({"accepts": [{"payTo": GOOD}]}).encode()).decode()
|
|
66
|
+
self.assertEqual(keptvow.guard(FakeResponse(402, headers={"PAYMENT-REQUIRED": header}))[0]["verdict"], "ok")
|
|
67
|
+
|
|
68
|
+
def test_unreachable_keptvow_pays_on_unless_told_not_to(self):
|
|
69
|
+
r = FakeResponse(402, {"accepts": [{"payTo": "down"}]})
|
|
70
|
+
self.assertEqual(keptvow.guard(r), [])
|
|
71
|
+
with self.assertRaises(OSError):
|
|
72
|
+
keptvow.guard(r, fail_open=False)
|
|
73
|
+
|
|
74
|
+
def test_outcomes_come_from_the_payment_receipt(self):
|
|
75
|
+
receipt = base64.b64encode(json.dumps({"success": True, "transaction": "0x" + "ab" * 32}).encode()).decode()
|
|
76
|
+
self.assertTrue(keptvow.report_outcome(FakeResponse(200, {}, {"PAYMENT-RESPONSE": receipt}), background=False))
|
|
77
|
+
self.assertEqual(self.calls[-1][2], {"tx": "0x" + "ab" * 32, "status": 200})
|
|
78
|
+
self.assertFalse(keptvow.report_outcome(FakeResponse(200, {}, {})), "no receipt, nothing to report")
|
|
79
|
+
self.assertTrue(keptvow.report_outcome("0x" + "cd" * 32, delivered=False, background=False))
|
|
80
|
+
self.assertEqual(self.calls[-1][2], {"tx": "0x" + "cd" * 32, "delivered": False})
|
|
81
|
+
|
|
82
|
+
def test_a_fresh_signed_answer_is_reused(self):
|
|
83
|
+
keptvow.check(SIGNED)
|
|
84
|
+
keptvow.check(SIGNED.upper().replace("0X", "0x"))
|
|
85
|
+
self.assertEqual(sum(1 for c in self.calls if SIGNED in c[1].lower()), 1)
|
|
86
|
+
keptvow.check(SIGNED, cache=False)
|
|
87
|
+
self.assertEqual(sum(1 for c in self.calls if SIGNED in c[1].lower()), 2)
|
|
88
|
+
keptvow.check(GOOD)
|
|
89
|
+
keptvow.check(GOOD)
|
|
90
|
+
self.assertEqual(sum(1 for c in self.calls if GOOD in c[1]), 2, "unsigned answers are never reused")
|
|
91
|
+
|
|
92
|
+
def test_a_report_sent_with_a_key_carries_it(self):
|
|
93
|
+
keptvow.report_outcome("0x" + "ab" * 32, delivered=True, api_key="k_test", background=False)
|
|
94
|
+
self.assertEqual(self.report_headers[-1].get("X-Api-Key"), "k_test")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@unittest.skipUnless(os.environ.get("KEPTVOW_TEST_URL"), "set KEPTVOW_TEST_URL to test against a server")
|
|
98
|
+
class Live(unittest.TestCase):
|
|
99
|
+
def test_a_real_server_answers(self):
|
|
100
|
+
r = keptvow.check("0x" + "12" * 20, base_url=os.environ["KEPTVOW_TEST_URL"])
|
|
101
|
+
self.assertIn(r["verdict"], ("ok", "careful", "stop"))
|
|
102
|
+
with self.assertRaises(RuntimeError):
|
|
103
|
+
keptvow.check("not-a-wallet", base_url=os.environ["KEPTVOW_TEST_URL"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|