voidly-pay 0.1.1__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.
voidly_pay/__init__.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Voidly Pay — Python SDK for agent-to-agent payments.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
from voidly_pay import VoidlyPay
|
|
6
|
+
pay = VoidlyPay() # mints + persists keypair on first use
|
|
7
|
+
pay.transfer(to="did:voidly:provider", amount=0.5) # 0.5 credits
|
|
8
|
+
|
|
9
|
+
The SDK signs envelopes with Ed25519 and POSTs them to the configured API
|
|
10
|
+
host (default: https://api.voidly.ai). Keys are persisted to
|
|
11
|
+
~/.voidly-pay/keypair.json with mode 0600.
|
|
12
|
+
|
|
13
|
+
For server-side x402 paywalls, see ``VoidlyPay.create_quote`` and
|
|
14
|
+
``VoidlyPay.verify_payment``. For client-side fetch-with-pay, see
|
|
15
|
+
``VoidlyPay.request_with_pay``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import secrets
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
31
|
+
|
|
32
|
+
import nacl.signing
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
DEFAULT_API = "https://api.voidly.ai"
|
|
38
|
+
MICRO_PER_CREDIT = 1_000_000
|
|
39
|
+
DEFAULT_EXPIRY_MINUTES = 30
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ─── Errors ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VoidlyPayError(Exception):
|
|
46
|
+
def __init__(self, code: str, message: str, status: int, hint: str | None = None, raw: Any = None):
|
|
47
|
+
super().__init__(f"[{code}] {message}")
|
|
48
|
+
self.code = code
|
|
49
|
+
self.status = status
|
|
50
|
+
self.hint = hint
|
|
51
|
+
self.raw = raw
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ─── Canonical JSON ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def canonicalize(value: Any) -> str:
|
|
58
|
+
"""Same canonical JSON the Worker expects: sorted keys, no whitespace,
|
|
59
|
+
omit None-valued keys, strict number/string serialization."""
|
|
60
|
+
if value is None:
|
|
61
|
+
return "null"
|
|
62
|
+
if isinstance(value, bool):
|
|
63
|
+
return "true" if value else "false"
|
|
64
|
+
if isinstance(value, int):
|
|
65
|
+
return str(value)
|
|
66
|
+
if isinstance(value, float):
|
|
67
|
+
# Server only accepts integers in amount fields. We still preserve
|
|
68
|
+
# repr for any float-valued metadata.
|
|
69
|
+
return repr(value)
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
return json.dumps(value, ensure_ascii=False)
|
|
72
|
+
if isinstance(value, (list, tuple)):
|
|
73
|
+
return "[" + ",".join(canonicalize(v) for v in value) + "]"
|
|
74
|
+
if isinstance(value, Mapping):
|
|
75
|
+
keys = sorted(k for k, v in value.items() if v is not None)
|
|
76
|
+
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonicalize(value[k]) for k in keys) + "}"
|
|
77
|
+
raise TypeError(f"unsupported type for canonicalization: {type(value)}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
81
|
+
return canonicalize(value).encode("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ─── DID derivation ───────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
_BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _b58encode(data: bytes) -> str:
|
|
90
|
+
if not data:
|
|
91
|
+
return ""
|
|
92
|
+
n = int.from_bytes(data, "big")
|
|
93
|
+
out = ""
|
|
94
|
+
while n > 0:
|
|
95
|
+
n, rem = divmod(n, 58)
|
|
96
|
+
out = _BASE58[rem] + out
|
|
97
|
+
for b in data:
|
|
98
|
+
if b == 0:
|
|
99
|
+
out = _BASE58[0] + out
|
|
100
|
+
else:
|
|
101
|
+
break
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def did_from_pubkey(pubkey: bytes) -> str:
|
|
106
|
+
return f"did:voidly:{_b58encode(pubkey[:16])}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── Storage ──────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Keypair:
|
|
114
|
+
did: str
|
|
115
|
+
secret_key: bytes
|
|
116
|
+
public_key: bytes
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _default_storage_path() -> Path:
|
|
120
|
+
home = Path.home()
|
|
121
|
+
return home / ".voidly-pay" / "keypair.json"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_or_create_keypair(path: Path | None, did_override: str | None = None) -> Keypair:
|
|
125
|
+
if path is None:
|
|
126
|
+
path = _default_storage_path()
|
|
127
|
+
if path.exists():
|
|
128
|
+
try:
|
|
129
|
+
j = json.loads(path.read_text())
|
|
130
|
+
sk = base64.b64decode(j["secret_key"])
|
|
131
|
+
sk_obj = nacl.signing.SigningKey(sk[:32]) # nacl expects 32-byte seed
|
|
132
|
+
pk = bytes(sk_obj.verify_key)
|
|
133
|
+
did = did_override or j.get("did") or did_from_pubkey(pk)
|
|
134
|
+
return Keypair(did=did, secret_key=bytes(sk_obj._signing_key), public_key=pk)
|
|
135
|
+
except Exception:
|
|
136
|
+
# corrupt file — back it up and mint fresh
|
|
137
|
+
path.rename(path.with_suffix(".corrupt"))
|
|
138
|
+
sk_obj = nacl.signing.SigningKey.generate()
|
|
139
|
+
pk = bytes(sk_obj.verify_key)
|
|
140
|
+
did = did_override or did_from_pubkey(pk)
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
# nacl.signing internal "_signing_key" is the 64-byte tweetnacl-compatible key
|
|
143
|
+
sk_full = bytes(sk_obj._signing_key)
|
|
144
|
+
path.write_text(json.dumps({"did": did, "secret_key": base64.b64encode(sk_full).decode()}))
|
|
145
|
+
os.chmod(path, 0o600)
|
|
146
|
+
return Keypair(did=did, secret_key=sk_full, public_key=pk)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ─── Main client ──────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class VoidlyPay:
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
api_url: str | None = None,
|
|
156
|
+
did: str | None = None,
|
|
157
|
+
secret_key: bytes | None = None,
|
|
158
|
+
storage_path: Path | str | None = None,
|
|
159
|
+
default_expiry_minutes: int = DEFAULT_EXPIRY_MINUTES,
|
|
160
|
+
session: requests.Session | None = None,
|
|
161
|
+
):
|
|
162
|
+
self.api_url = (api_url or DEFAULT_API).rstrip("/")
|
|
163
|
+
self.default_expiry_ms = default_expiry_minutes * 60_000
|
|
164
|
+
self.session = session or requests.Session()
|
|
165
|
+
if secret_key is not None:
|
|
166
|
+
sk_obj = nacl.signing.SigningKey(secret_key[:32])
|
|
167
|
+
pk = bytes(sk_obj.verify_key)
|
|
168
|
+
self._kp = Keypair(did=did or did_from_pubkey(pk), secret_key=bytes(sk_obj._signing_key), public_key=pk)
|
|
169
|
+
else:
|
|
170
|
+
sp = Path(storage_path) if storage_path else None
|
|
171
|
+
self._kp = _load_or_create_keypair(sp, did)
|
|
172
|
+
self.did = self._kp.did
|
|
173
|
+
|
|
174
|
+
# ─── Crypto ────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def public_key(self) -> str:
|
|
178
|
+
"""Public key as base64 (32 bytes). Used to register with the relay."""
|
|
179
|
+
return base64.b64encode(self._kp.public_key).decode()
|
|
180
|
+
|
|
181
|
+
def _sign(self, env: dict) -> str:
|
|
182
|
+
msg = canonical_bytes(env)
|
|
183
|
+
sig = nacl.signing.SigningKey(self._kp.secret_key[:32]).sign(msg).signature
|
|
184
|
+
return base64.b64encode(sig).decode()
|
|
185
|
+
|
|
186
|
+
def _now_iso(self) -> str:
|
|
187
|
+
from datetime import datetime, timezone
|
|
188
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
189
|
+
|
|
190
|
+
def _expires(self, ms: int | None = None) -> str:
|
|
191
|
+
from datetime import datetime, timezone, timedelta
|
|
192
|
+
return (datetime.now(timezone.utc) + timedelta(milliseconds=ms or self.default_expiry_ms)).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
193
|
+
|
|
194
|
+
# ─── HTTP ──────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def _req(self, method: str, path: str, body: Any = None, idempotency: str | None = None) -> dict:
|
|
197
|
+
headers = {"accept": "application/json"}
|
|
198
|
+
if body is not None:
|
|
199
|
+
headers["content-type"] = "application/json"
|
|
200
|
+
if idempotency:
|
|
201
|
+
headers["idempotency-key"] = idempotency
|
|
202
|
+
url = f"{self.api_url}{path}"
|
|
203
|
+
r = self.session.request(method, url, json=body, headers=headers, timeout=30)
|
|
204
|
+
try:
|
|
205
|
+
data = r.json() if r.text else {}
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
data = {"raw": r.text}
|
|
208
|
+
if not r.ok:
|
|
209
|
+
err = data.get("error", {}) if isinstance(data, dict) else {}
|
|
210
|
+
raise VoidlyPayError(
|
|
211
|
+
code=err.get("code") or data.get("reason") or f"http_{r.status_code}",
|
|
212
|
+
message=err.get("message") or data.get("reason") or f"request failed: {r.status_code}",
|
|
213
|
+
status=r.status_code,
|
|
214
|
+
hint=err.get("hint"),
|
|
215
|
+
raw=data,
|
|
216
|
+
)
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
# ─── Wallet ────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def ensure_wallet(self, did: str | None = None) -> dict:
|
|
222
|
+
return self._req("POST", "/v1/pay/wallet", {"did": did or self.did}).get("wallet", {})
|
|
223
|
+
|
|
224
|
+
def balance(self, did: str | None = None) -> dict:
|
|
225
|
+
target = did or self.did
|
|
226
|
+
r = self._req("GET", f"/v1/pay/wallet/{target}")
|
|
227
|
+
w = r.get("wallet", {})
|
|
228
|
+
return {
|
|
229
|
+
"balance_micro": w.get("balance_credits", 0),
|
|
230
|
+
"balance_credits": w.get("balance_credits", 0) / MICRO_PER_CREDIT,
|
|
231
|
+
"locked_micro": w.get("locked_credits", 0),
|
|
232
|
+
"daily_cap_micro": w.get("daily_cap_credits", 0),
|
|
233
|
+
"per_tx_cap_micro": w.get("per_tx_cap_credits", 0),
|
|
234
|
+
"frozen": bool(w.get("frozen")),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# ─── Transfer ──────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def transfer(self, to: str, amount: float, memo: str | None = None, expires_in_minutes: int | None = None) -> dict:
|
|
240
|
+
try:
|
|
241
|
+
self.ensure_wallet()
|
|
242
|
+
except VoidlyPayError:
|
|
243
|
+
pass
|
|
244
|
+
env = {
|
|
245
|
+
"schema": "voidly-credit-transfer/v1",
|
|
246
|
+
"from_did": self.did,
|
|
247
|
+
"to_did": to,
|
|
248
|
+
"amount_micro": round(amount * MICRO_PER_CREDIT),
|
|
249
|
+
"memo": memo,
|
|
250
|
+
"nonce": uuid.uuid4().hex,
|
|
251
|
+
"issued_at": self._now_iso(),
|
|
252
|
+
"expires_at": self._expires((expires_in_minutes * 60_000) if expires_in_minutes else None),
|
|
253
|
+
}
|
|
254
|
+
body = {"envelope": env, "signature": self._sign(env)}
|
|
255
|
+
r = self._req("POST", "/v1/pay/transfer", body, idempotency=env["nonce"])
|
|
256
|
+
if r.get("status") != "settled":
|
|
257
|
+
raise VoidlyPayError(code=r.get("reason", "transfer_failed"), message=f"transfer failed: {r.get('reason')}", status=400, raw=r)
|
|
258
|
+
return r
|
|
259
|
+
|
|
260
|
+
def batch_transfer(self, items: Iterable[dict]) -> dict:
|
|
261
|
+
items_list = list(items)
|
|
262
|
+
if not items_list:
|
|
263
|
+
raise ValueError("batch_transfer: items empty")
|
|
264
|
+
env = {
|
|
265
|
+
"schema": "voidly-batch-transfer/v1",
|
|
266
|
+
"from_did": self.did,
|
|
267
|
+
"batch_id": uuid.uuid4().hex,
|
|
268
|
+
"items": [{"to_did": i["to"], "amount_micro": round(i["amount"] * MICRO_PER_CREDIT), "memo": i.get("memo")} for i in items_list],
|
|
269
|
+
"issued_at": self._now_iso(),
|
|
270
|
+
"expires_at": self._expires(),
|
|
271
|
+
}
|
|
272
|
+
body = {"envelope": env, "signature": self._sign(env)}
|
|
273
|
+
r = self._req("POST", "/v1/pay/batch", body, idempotency=env["batch_id"])
|
|
274
|
+
if r.get("status") != "settled":
|
|
275
|
+
raise VoidlyPayError(code=r.get("reason", "batch_failed"), message=f"batch failed: {r.get('reason')}", status=400, raw=r)
|
|
276
|
+
return r
|
|
277
|
+
|
|
278
|
+
def get_transfer(self, transfer_id: str) -> dict:
|
|
279
|
+
return self._req("GET", f"/v1/pay/transfer/{transfer_id}").get("transfer", {})
|
|
280
|
+
|
|
281
|
+
def history(self, did: str | None = None, limit: int | None = None, before: str | None = None) -> dict:
|
|
282
|
+
params = []
|
|
283
|
+
if limit:
|
|
284
|
+
params.append(f"limit={limit}")
|
|
285
|
+
if before:
|
|
286
|
+
params.append(f"before={requests.utils.quote(before)}")
|
|
287
|
+
qs = ("?" + "&".join(params)) if params else ""
|
|
288
|
+
return self._req("GET", f"/v1/pay/history/{did or self.did}{qs}")
|
|
289
|
+
|
|
290
|
+
# ─── Escrow ────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
def open_escrow(self, to: str, amount: float, deadline_hours: int = 24, memo: str | None = None) -> dict:
|
|
293
|
+
from datetime import datetime, timezone, timedelta
|
|
294
|
+
dl = (datetime.now(timezone.utc) + timedelta(hours=deadline_hours)).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
295
|
+
env = {
|
|
296
|
+
"schema": "voidly-escrow-open/v1",
|
|
297
|
+
"from_did": self.did,
|
|
298
|
+
"to_did": to,
|
|
299
|
+
"amount_micro": round(amount * MICRO_PER_CREDIT),
|
|
300
|
+
"memo": memo,
|
|
301
|
+
"deadline_at": dl,
|
|
302
|
+
"nonce": uuid.uuid4().hex,
|
|
303
|
+
"issued_at": self._now_iso(),
|
|
304
|
+
"expires_at": self._expires(),
|
|
305
|
+
}
|
|
306
|
+
return self._req("POST", "/v1/pay/escrow/open", {"envelope": env, "signature": self._sign(env)}, idempotency=env["nonce"])
|
|
307
|
+
|
|
308
|
+
def release_escrow(self, escrow_id: str) -> dict:
|
|
309
|
+
env = {
|
|
310
|
+
"schema": "voidly-escrow-release/v1",
|
|
311
|
+
"escrow_id": escrow_id,
|
|
312
|
+
"actor_did": self.did,
|
|
313
|
+
"nonce": uuid.uuid4().hex,
|
|
314
|
+
"issued_at": self._now_iso(),
|
|
315
|
+
"expires_at": self._expires(),
|
|
316
|
+
}
|
|
317
|
+
return self._req("POST", "/v1/pay/escrow/release", {"envelope": env, "signature": self._sign(env)})
|
|
318
|
+
|
|
319
|
+
def refund_escrow(self, escrow_id: str, reason: str | None = None) -> dict:
|
|
320
|
+
env = {
|
|
321
|
+
"schema": "voidly-escrow-refund/v1",
|
|
322
|
+
"escrow_id": escrow_id,
|
|
323
|
+
"actor_did": self.did,
|
|
324
|
+
"reason": reason,
|
|
325
|
+
"nonce": uuid.uuid4().hex,
|
|
326
|
+
"issued_at": self._now_iso(),
|
|
327
|
+
"expires_at": self._expires(),
|
|
328
|
+
}
|
|
329
|
+
return self._req("POST", "/v1/pay/escrow/refund", {"envelope": env, "signature": self._sign(env)})
|
|
330
|
+
|
|
331
|
+
# ─── x402 (server-side) ────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
def create_quote(
|
|
334
|
+
self,
|
|
335
|
+
resource: str,
|
|
336
|
+
amount: float,
|
|
337
|
+
recipient: str | None = None,
|
|
338
|
+
method: str | None = None,
|
|
339
|
+
description: str | None = None,
|
|
340
|
+
metadata: dict | None = None,
|
|
341
|
+
ttl_seconds: int | None = None,
|
|
342
|
+
) -> dict:
|
|
343
|
+
env = {
|
|
344
|
+
"schema": "voidly-x402-quote/v1",
|
|
345
|
+
"server_did": self.did,
|
|
346
|
+
"resource": resource,
|
|
347
|
+
"amount_micro": round(amount * MICRO_PER_CREDIT),
|
|
348
|
+
"recipient_did": recipient or self.did,
|
|
349
|
+
"method": method,
|
|
350
|
+
"description": description,
|
|
351
|
+
"metadata": metadata,
|
|
352
|
+
"ttl_seconds": ttl_seconds,
|
|
353
|
+
"nonce": uuid.uuid4().hex,
|
|
354
|
+
"issued_at": self._now_iso(),
|
|
355
|
+
"expires_at": self._expires(),
|
|
356
|
+
}
|
|
357
|
+
return self._req("POST", "/v1/pay/x402/quote", {"envelope": env, "signature": self._sign(env)})
|
|
358
|
+
|
|
359
|
+
def verify_payment(self, quote_id: str, transfer_id: str | None = None, payment_header: str | None = None) -> dict:
|
|
360
|
+
env = {
|
|
361
|
+
"schema": "voidly-x402-verify/v1",
|
|
362
|
+
"server_did": self.did,
|
|
363
|
+
"quote_id": quote_id,
|
|
364
|
+
"transfer_id": transfer_id,
|
|
365
|
+
"payment_header": payment_header,
|
|
366
|
+
"nonce": uuid.uuid4().hex,
|
|
367
|
+
"issued_at": self._now_iso(),
|
|
368
|
+
"expires_at": self._expires(),
|
|
369
|
+
}
|
|
370
|
+
return self._req("POST", "/v1/pay/x402/verify", {"envelope": env, "signature": self._sign(env)})
|
|
371
|
+
|
|
372
|
+
# ─── x402 (client-side: pay-on-402) ────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
def request_with_pay(
|
|
375
|
+
self,
|
|
376
|
+
method: str,
|
|
377
|
+
url: str,
|
|
378
|
+
max_amount: float | None = None,
|
|
379
|
+
**kwargs,
|
|
380
|
+
) -> requests.Response:
|
|
381
|
+
"""Fetch a URL; if it returns 402, parse the quote, transfer, retry."""
|
|
382
|
+
r = self.session.request(method, url, **kwargs)
|
|
383
|
+
if r.status_code != 402:
|
|
384
|
+
return r
|
|
385
|
+
try:
|
|
386
|
+
body = r.json()
|
|
387
|
+
except json.JSONDecodeError:
|
|
388
|
+
raise VoidlyPayError(code="x402_body_unparseable", message="Server returned 402 but no JSON body", status=402)
|
|
389
|
+
accept = (body.get("x402") or {}).get("accepts", [None])[0]
|
|
390
|
+
if not accept:
|
|
391
|
+
raise VoidlyPayError(code="x402_no_accept", message="Server 402 missing x402.accepts", status=402, raw=body)
|
|
392
|
+
if accept.get("scheme") != "voidly-credit":
|
|
393
|
+
raise VoidlyPayError(code="x402_unsupported_scheme", message=f"Unsupported scheme: {accept.get('scheme')}", status=402)
|
|
394
|
+
credits = accept["amount_micro"] / MICRO_PER_CREDIT
|
|
395
|
+
if max_amount is not None and credits > max_amount:
|
|
396
|
+
raise VoidlyPayError(code="x402_amount_exceeds_max", message=f"Quote price {credits} exceeds maxAmount {max_amount}", status=402)
|
|
397
|
+
t = self.transfer(to=accept["recipient_did"], amount=credits, memo=f"x402: {accept['resource']}")
|
|
398
|
+
headers = dict(kwargs.pop("headers", {}) or {})
|
|
399
|
+
headers["X-Payment"] = f"voidly-credit transfer_id={t['transfer_id']}; quote_id={accept['quote_id']}"
|
|
400
|
+
return self.session.request(method, url, headers=headers, **kwargs)
|
|
401
|
+
|
|
402
|
+
# ─── Webhooks ──────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
def subscribe_webhook(
|
|
405
|
+
self,
|
|
406
|
+
url: str,
|
|
407
|
+
events: list[str] | None = None,
|
|
408
|
+
did_filter: str | None = None,
|
|
409
|
+
description: str | None = None,
|
|
410
|
+
) -> dict:
|
|
411
|
+
env = {
|
|
412
|
+
"schema": "voidly-webhook-subscribe/v1",
|
|
413
|
+
"did": self.did,
|
|
414
|
+
"url": url,
|
|
415
|
+
"event_filter": events,
|
|
416
|
+
"did_filter": did_filter,
|
|
417
|
+
"description": description,
|
|
418
|
+
"nonce": uuid.uuid4().hex,
|
|
419
|
+
"issued_at": self._now_iso(),
|
|
420
|
+
"expires_at": self._expires(),
|
|
421
|
+
}
|
|
422
|
+
return self._req("POST", "/v1/pay/webhooks", {"envelope": env, "signature": self._sign(env)}, idempotency=env["nonce"])
|
|
423
|
+
|
|
424
|
+
# ─── Network reads ─────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
def health(self) -> dict: return self._req("GET", "/v1/pay/health")
|
|
427
|
+
def manifest(self) -> dict: return self._req("GET", "/v1/pay/manifest.json")
|
|
428
|
+
def stats(self) -> dict: return self._req("GET", "/v1/pay/stats")
|
|
429
|
+
def activity(self, limit: int = 50) -> dict: return self._req("GET", f"/v1/pay/activity?limit={limit}")
|
|
430
|
+
def leaderboard(self, metric: str = "earned_24h", limit: int = 25) -> dict:
|
|
431
|
+
return self._req("GET", f"/v1/pay/leaderboard?metric={metric}&limit={limit}")
|
|
432
|
+
def feed(self, since: str | None = None, limit: int = 50) -> dict:
|
|
433
|
+
qs = f"?limit={limit}" + (f"&since={requests.utils.quote(since)}" if since else "")
|
|
434
|
+
return self._req("GET", f"/v1/pay/feed{qs}")
|
|
435
|
+
def trust(self, did: str | None = None) -> dict:
|
|
436
|
+
return self._req("GET", f"/v1/pay/trust/{did or self.did}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ─── Webhook signature verification ──────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def verify_webhook_signature(
|
|
443
|
+
body: str | bytes,
|
|
444
|
+
signature_header: str,
|
|
445
|
+
secret: str,
|
|
446
|
+
tolerance_seconds: int = 300,
|
|
447
|
+
) -> bool:
|
|
448
|
+
"""Verify the X-Voidly-Signature header on a webhook delivery.
|
|
449
|
+
|
|
450
|
+
Returns True iff the HMAC matches AND the timestamp is within tolerance.
|
|
451
|
+
Constant-time compare via hmac.compare_digest.
|
|
452
|
+
"""
|
|
453
|
+
if not signature_header.startswith("t="):
|
|
454
|
+
return False
|
|
455
|
+
parts = dict(p.split("=", 1) for p in signature_header.split(","))
|
|
456
|
+
try:
|
|
457
|
+
ts = int(parts["t"])
|
|
458
|
+
except (KeyError, ValueError):
|
|
459
|
+
return False
|
|
460
|
+
sig = parts.get("v1", "")
|
|
461
|
+
if abs(int(time.time()) - ts) > tolerance_seconds:
|
|
462
|
+
return False
|
|
463
|
+
body_bytes = body.encode("utf-8") if isinstance(body, str) else body
|
|
464
|
+
key_bytes = bytes.fromhex(secret)
|
|
465
|
+
msg = f"t={ts}.".encode() + body_bytes
|
|
466
|
+
expected = hmac.new(key_bytes, msg, hashlib.sha256).hexdigest()
|
|
467
|
+
return hmac.compare_digest(expected, sig)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
__all__ = [
|
|
471
|
+
"VoidlyPay",
|
|
472
|
+
"VoidlyPayError",
|
|
473
|
+
"verify_webhook_signature",
|
|
474
|
+
"did_from_pubkey",
|
|
475
|
+
"canonicalize",
|
|
476
|
+
"canonical_bytes",
|
|
477
|
+
"MICRO_PER_CREDIT",
|
|
478
|
+
"__version__",
|
|
479
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voidly-pay
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Voidly Pay SDK — agent-to-agent payments for AI agents
|
|
5
|
+
Author-email: Voidly <team@voidly.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: voidly,payments,ai-agents,x402,did,ed25519
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pynacl>=1.5.0
|
|
11
|
+
Requires-Dist: requests>=2.31.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-mock>=3.10; extra == "dev"
|
|
15
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# voidly-pay (Python)
|
|
18
|
+
|
|
19
|
+
> **The marketplace AI agents browse for paid HTTP services.** Pay any of 17+ paid endpoints for <$0.01 using one Ed25519 keypair. List your own paid endpoint in 60 seconds. Settles in <200ms via x402 + USDC on Base mainnet.
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/voidly-pay/)
|
|
22
|
+
[](https://www.x402.org)
|
|
23
|
+
[](https://repo.sourcify.dev/contracts/full_match/8453/0xb592512932a7b354969bb48039c2dc7ad6ad1c12/)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install voidly-pay
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 30-second tour
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from voidly_pay import VoidlyPay
|
|
33
|
+
|
|
34
|
+
pay = VoidlyPay() # mints + persists keypair
|
|
35
|
+
print("DID:", pay.did) # did:voidly:...
|
|
36
|
+
pay.faucet() # 10 free credits
|
|
37
|
+
|
|
38
|
+
# Browse the marketplace — 17 paid endpoints + N third-party listings
|
|
39
|
+
mp = pay.request("GET", "/v1/pay/marketplace").json()
|
|
40
|
+
for item in mp["items"][:5]:
|
|
41
|
+
print(f"{item['name']:40s} ${item['pricing']['amount_usdc']}")
|
|
42
|
+
|
|
43
|
+
# Pay any paid endpoint via auto-x402
|
|
44
|
+
r = pay.request_with_pay(
|
|
45
|
+
"GET",
|
|
46
|
+
"https://api.voidly.ai/v1/pay/wiki?title=Alan%20Turing",
|
|
47
|
+
max_amount=0.005,
|
|
48
|
+
)
|
|
49
|
+
receipt = r.json()
|
|
50
|
+
print(receipt["extract"][:200])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Pay anything that returns 402
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# Universal x402 client — handles 402 → quote → settle → retry
|
|
57
|
+
r = pay.request_with_pay(
|
|
58
|
+
"POST",
|
|
59
|
+
"https://api.voidly.ai/v1/pay/extract",
|
|
60
|
+
json={"url": "https://arxiv.org/pdf/2507.14183.pdf"},
|
|
61
|
+
max_amount=0.01,
|
|
62
|
+
)
|
|
63
|
+
print(r.json()["text_length"])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## List your own paid endpoint
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
pay.create_listing(
|
|
70
|
+
name="My Paid API",
|
|
71
|
+
tagline="Pay 1¢ for X, get Y signed.",
|
|
72
|
+
url="https://my-api.example.com/expensive",
|
|
73
|
+
amount_usdc=0.01,
|
|
74
|
+
category="data",
|
|
75
|
+
tags=["json", "agents"],
|
|
76
|
+
)
|
|
77
|
+
# Now appears at /v1/pay/marketplace, every Voidly-aware agent sees it.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or browser-only (no install): [voidly.ai/pay/list-your-service](https://voidly.ai/pay/list-your-service).
|
|
81
|
+
|
|
82
|
+
## Run a paid endpoint (FastAPI)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from fastapi import FastAPI, Depends
|
|
86
|
+
from voidly_pay import VoidlyPay
|
|
87
|
+
from voidly_pay.middleware import fastapi_x402
|
|
88
|
+
|
|
89
|
+
app = FastAPI()
|
|
90
|
+
pay = VoidlyPay()
|
|
91
|
+
|
|
92
|
+
# Charge $0.01 per request — settles atomically with the response.
|
|
93
|
+
@app.get("/expensive", dependencies=[Depends(fastapi_x402(pay, amount=0.01))])
|
|
94
|
+
def expensive():
|
|
95
|
+
return {"data": "the goods"}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Flask:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from flask import Flask
|
|
102
|
+
from voidly_pay import VoidlyPay
|
|
103
|
+
from voidly_pay.middleware import flask_x402
|
|
104
|
+
|
|
105
|
+
app = Flask(__name__)
|
|
106
|
+
pay = VoidlyPay()
|
|
107
|
+
|
|
108
|
+
@app.route("/expensive")
|
|
109
|
+
@flask_x402(pay, amount=0.01)
|
|
110
|
+
def expensive():
|
|
111
|
+
return {"data": "the goods"}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## What you can do
|
|
115
|
+
|
|
116
|
+
| Primitive | Method |
|
|
117
|
+
|---|---|
|
|
118
|
+
| **Marketplace** | `pay.create_listing(...)`, `pay.list_listings()`, `pay.get_listing(id)` |
|
|
119
|
+
| **Pay any URL** | `pay.request_with_pay(method, url, max_amount=...)` |
|
|
120
|
+
| **Direct transfer** | `pay.transfer(to, amount)` |
|
|
121
|
+
| **Batch transfer** | `pay.batch_transfer([{...}, ...])` |
|
|
122
|
+
| **Escrow** | `pay.open_escrow(to, amount, deadline_hours)` |
|
|
123
|
+
| **Streams (per-token billing)** | `pay.open_stream(...)`, `pay.meter_stream(...)`, `pay.finalize_stream(...)` |
|
|
124
|
+
| **Subscriptions** | `pay.subscribe(...)`, `pay.cancel_subscription(...)` |
|
|
125
|
+
| **x402 server-side quote** | `pay.create_quote(resource, amount)` |
|
|
126
|
+
| **x402 server-side verify** | `pay.verify_payment(quote_id, transfer_id)` |
|
|
127
|
+
| **Webhooks** | `pay.subscribe_webhook(url, events=[...])` |
|
|
128
|
+
| **Trust check** | `pay.health_check()` (6-check report incl. on-chain vault read) |
|
|
129
|
+
|
|
130
|
+
## What's in the marketplace today (Voidly's 17 paid endpoints)
|
|
131
|
+
|
|
132
|
+
| Endpoint | Price | What it does |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `voidly_hash` | $0.001 | SHA-256/512 + signed receipt |
|
|
135
|
+
| `voidly_timestamp` | $0.001 | Proof-of-existence (OpenTimestamps-style) |
|
|
136
|
+
| `voidly_random` | $0.001 | Signed CSPRNG bytes |
|
|
137
|
+
| `voidly_qr` | $0.001 | QR-code PNG of any text/URL |
|
|
138
|
+
| `voidly_wiki` | $0.001 | Wikipedia summary + signed citation |
|
|
139
|
+
| `voidly_exchange` | $0.001 | Fiat/crypto exchange rates |
|
|
140
|
+
| `voidly_markdown` | $0.001 | HTML → clean markdown (10x reduction) |
|
|
141
|
+
| `voidly_meta` | $0.001 | URL metadata (og + title + canonical) |
|
|
142
|
+
| `voidly_extract` | $0.01 | PDF/document → plain text |
|
|
143
|
+
| `voidly_scrape` | $0.01 | Fetch any URL + Voidly-signed receipt |
|
|
144
|
+
| `voidly_fetch` | $0.05 | Country-pinned fetch via 37+ probe network |
|
|
145
|
+
| `probe_attest` | $0.005 | Multi-vantage signed reachability proof |
|
|
146
|
+
| `forecast_pro` | $0.01 | 30-day country-shutdown risk forecast |
|
|
147
|
+
| `claim_verify_pro` | $0.005 | Evidence-backed verification of claims |
|
|
148
|
+
| `incident_summary_pro` | $0.005 | Plain-English summary of an incident |
|
|
149
|
+
| `agent_discover_pro` | $0.005 | Premium ranked agent search |
|
|
150
|
+
| `incidents_export_pro` | $0.05 | Bulk export (high limit, no rate cap) |
|
|
151
|
+
|
|
152
|
+
Plus N third-party listings registered self-serve at [voidly.ai/pay/list-your-service](https://voidly.ai/pay/list-your-service).
|
|
153
|
+
|
|
154
|
+
Live machine-readable catalog: [api.voidly.ai/v1/pay/marketplace](https://api.voidly.ai/v1/pay/marketplace).
|
|
155
|
+
|
|
156
|
+
## Configuration
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
pay = VoidlyPay(
|
|
160
|
+
api_url="https://api.voidly.ai",
|
|
161
|
+
secret_key=existing_key, # bring your own
|
|
162
|
+
storage_path="~/.my-keys/voidly.json",
|
|
163
|
+
default_expiry_minutes=30,
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Webhook verification
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from voidly_pay import verify_webhook_signature
|
|
171
|
+
|
|
172
|
+
ok = verify_webhook_signature(
|
|
173
|
+
body=raw_body,
|
|
174
|
+
signature_header=headers["X-Voidly-Signature"],
|
|
175
|
+
secret=os.environ["VOIDLY_WEBHOOK_SECRET"],
|
|
176
|
+
)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Why agents use this
|
|
180
|
+
|
|
181
|
+
| Problem | Voidly Pay solves it |
|
|
182
|
+
|---|---|
|
|
183
|
+
| **Need to add payment to your agent service** | x402 middleware ships for FastAPI, Flask, any web-fetch handler |
|
|
184
|
+
| **Need to discover paid services** | One install → 17 endpoints + open marketplace listings |
|
|
185
|
+
| **Don't want to manage 10 API keys** | One Ed25519 keypair, one wallet, every paid endpoint works |
|
|
186
|
+
| **Don't trust the agent's payment claims** | Every receipt is Ed25519-signed by Voidly. Verifiable offline. |
|
|
187
|
+
| **Need country-attested fetch** | 37+ probe network, signed (URL, country, ASN, probe-DID) |
|
|
188
|
+
|
|
189
|
+
## Honest disclosure
|
|
190
|
+
|
|
191
|
+
The Voidly Pay vault on Base mainnet (`0xb592512932a7b354969bb48039c2dc7ad6ad1c12`, [Sourcify-verified](https://repo.sourcify.dev/contracts/full_match/8453/0xb592512932a7b354969bb48039c2dc7ad6ad1c12/)) currently holds **$4 USDC**. We have approximately zero sustained external paying users yet. Live reserves at [voidly.ai/pay/proof](https://voidly.ai/pay/proof).
|
|
192
|
+
|
|
193
|
+
We opened the marketplace before the demand exists because we believe agent adoption is gated on discoverability, not on payment-rail UX.
|
|
194
|
+
|
|
195
|
+
## Framework adapters (use these for higher-level integration)
|
|
196
|
+
|
|
197
|
+
- **LangChain**: `pip install voidly-pay-langchain`
|
|
198
|
+
- **CrewAI**: `pip install voidly-pay-crewai`
|
|
199
|
+
- **Pydantic AI**: `pip install voidly-pay-pydantic-ai`
|
|
200
|
+
- **AutoGen**: `pip install voidly-pay-autogen`
|
|
201
|
+
- **LlamaIndex**: `pip install voidly-pay-llamaindex`
|
|
202
|
+
|
|
203
|
+
## Links
|
|
204
|
+
|
|
205
|
+
- [Marketplace JSON](https://api.voidly.ai/v1/pay/marketplace)
|
|
206
|
+
- [/pay/install](https://voidly.ai/pay/install) — one-click MCP install (any client)
|
|
207
|
+
- [/pay/marketplace](https://voidly.ai/pay/marketplace) — visual browse
|
|
208
|
+
- [/pay/list-your-service](https://voidly.ai/pay/list-your-service) — list in 60s
|
|
209
|
+
- [/pay/claim](https://voidly.ai/pay/claim) — free 10-credit faucet
|
|
210
|
+
- [/pay/proof](https://voidly.ai/pay/proof) — live reserves dashboard
|
|
211
|
+
- [/pay/for-builders](https://voidly.ai/pay/for-builders) — every middleware + adapter
|
|
212
|
+
- [Voidly Pay landing](https://voidly.ai/pay)
|
|
213
|
+
|
|
214
|
+
## Keywords
|
|
215
|
+
|
|
216
|
+
x402 · agent payments · python sdk · usdc · base mainnet · signed receipts · agent marketplace · pay per call · micropayments · fastapi x402 · flask x402 · langchain agent payments · crewai payments · llamaindex tools · pydantic-ai tools · autogen extensions
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
voidly_pay/__init__.py,sha256=lFZnWORvIOJGwTusTUDWrPTjHhWySKygETDMr_r66Ak,20654
|
|
2
|
+
voidly_pay-0.1.1.dist-info/METADATA,sha256=ZKJfhV504fLEI46LdkXyLAicnrblBWZ1fhPqRLOzQYs,8462
|
|
3
|
+
voidly_pay-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
4
|
+
voidly_pay-0.1.1.dist-info/top_level.txt,sha256=hlJmPLM5vAsdroCP3-ZkciqAng-l9IrpnRP60-L_VEM,11
|
|
5
|
+
voidly_pay-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
voidly_pay
|