voidly-pay 0.1.1__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.
@@ -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 &lt;$0.01 using one Ed25519 keypair. List your own paid endpoint in 60 seconds. Settles in &lt;200ms via x402 + USDC on Base mainnet.
20
+
21
+ [![PyPI version](https://img.shields.io/pypi/v/voidly-pay)](https://pypi.org/project/voidly-pay/)
22
+ [![x402](https://img.shields.io/badge/x402-canonical%20v2-blue)](https://www.x402.org)
23
+ [![vault](https://img.shields.io/badge/vault-Sourcify%20verified-emerald)](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,204 @@
1
+ # voidly-pay (Python)
2
+
3
+ > **The marketplace AI agents browse for paid HTTP services.** Pay any of 17+ paid endpoints for &lt;$0.01 using one Ed25519 keypair. List your own paid endpoint in 60 seconds. Settles in &lt;200ms via x402 + USDC on Base mainnet.
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/voidly-pay)](https://pypi.org/project/voidly-pay/)
6
+ [![x402](https://img.shields.io/badge/x402-canonical%20v2-blue)](https://www.x402.org)
7
+ [![vault](https://img.shields.io/badge/vault-Sourcify%20verified-emerald)](https://repo.sourcify.dev/contracts/full_match/8453/0xb592512932a7b354969bb48039c2dc7ad6ad1c12/)
8
+
9
+ ```bash
10
+ pip install voidly-pay
11
+ ```
12
+
13
+ ## 30-second tour
14
+
15
+ ```python
16
+ from voidly_pay import VoidlyPay
17
+
18
+ pay = VoidlyPay() # mints + persists keypair
19
+ print("DID:", pay.did) # did:voidly:...
20
+ pay.faucet() # 10 free credits
21
+
22
+ # Browse the marketplace — 17 paid endpoints + N third-party listings
23
+ mp = pay.request("GET", "/v1/pay/marketplace").json()
24
+ for item in mp["items"][:5]:
25
+ print(f"{item['name']:40s} ${item['pricing']['amount_usdc']}")
26
+
27
+ # Pay any paid endpoint via auto-x402
28
+ r = pay.request_with_pay(
29
+ "GET",
30
+ "https://api.voidly.ai/v1/pay/wiki?title=Alan%20Turing",
31
+ max_amount=0.005,
32
+ )
33
+ receipt = r.json()
34
+ print(receipt["extract"][:200])
35
+ ```
36
+
37
+ ## Pay anything that returns 402
38
+
39
+ ```python
40
+ # Universal x402 client — handles 402 → quote → settle → retry
41
+ r = pay.request_with_pay(
42
+ "POST",
43
+ "https://api.voidly.ai/v1/pay/extract",
44
+ json={"url": "https://arxiv.org/pdf/2507.14183.pdf"},
45
+ max_amount=0.01,
46
+ )
47
+ print(r.json()["text_length"])
48
+ ```
49
+
50
+ ## List your own paid endpoint
51
+
52
+ ```python
53
+ pay.create_listing(
54
+ name="My Paid API",
55
+ tagline="Pay 1¢ for X, get Y signed.",
56
+ url="https://my-api.example.com/expensive",
57
+ amount_usdc=0.01,
58
+ category="data",
59
+ tags=["json", "agents"],
60
+ )
61
+ # Now appears at /v1/pay/marketplace, every Voidly-aware agent sees it.
62
+ ```
63
+
64
+ Or browser-only (no install): [voidly.ai/pay/list-your-service](https://voidly.ai/pay/list-your-service).
65
+
66
+ ## Run a paid endpoint (FastAPI)
67
+
68
+ ```python
69
+ from fastapi import FastAPI, Depends
70
+ from voidly_pay import VoidlyPay
71
+ from voidly_pay.middleware import fastapi_x402
72
+
73
+ app = FastAPI()
74
+ pay = VoidlyPay()
75
+
76
+ # Charge $0.01 per request — settles atomically with the response.
77
+ @app.get("/expensive", dependencies=[Depends(fastapi_x402(pay, amount=0.01))])
78
+ def expensive():
79
+ return {"data": "the goods"}
80
+ ```
81
+
82
+ Flask:
83
+
84
+ ```python
85
+ from flask import Flask
86
+ from voidly_pay import VoidlyPay
87
+ from voidly_pay.middleware import flask_x402
88
+
89
+ app = Flask(__name__)
90
+ pay = VoidlyPay()
91
+
92
+ @app.route("/expensive")
93
+ @flask_x402(pay, amount=0.01)
94
+ def expensive():
95
+ return {"data": "the goods"}
96
+ ```
97
+
98
+ ## What you can do
99
+
100
+ | Primitive | Method |
101
+ |---|---|
102
+ | **Marketplace** | `pay.create_listing(...)`, `pay.list_listings()`, `pay.get_listing(id)` |
103
+ | **Pay any URL** | `pay.request_with_pay(method, url, max_amount=...)` |
104
+ | **Direct transfer** | `pay.transfer(to, amount)` |
105
+ | **Batch transfer** | `pay.batch_transfer([{...}, ...])` |
106
+ | **Escrow** | `pay.open_escrow(to, amount, deadline_hours)` |
107
+ | **Streams (per-token billing)** | `pay.open_stream(...)`, `pay.meter_stream(...)`, `pay.finalize_stream(...)` |
108
+ | **Subscriptions** | `pay.subscribe(...)`, `pay.cancel_subscription(...)` |
109
+ | **x402 server-side quote** | `pay.create_quote(resource, amount)` |
110
+ | **x402 server-side verify** | `pay.verify_payment(quote_id, transfer_id)` |
111
+ | **Webhooks** | `pay.subscribe_webhook(url, events=[...])` |
112
+ | **Trust check** | `pay.health_check()` (6-check report incl. on-chain vault read) |
113
+
114
+ ## What's in the marketplace today (Voidly's 17 paid endpoints)
115
+
116
+ | Endpoint | Price | What it does |
117
+ |---|---|---|
118
+ | `voidly_hash` | $0.001 | SHA-256/512 + signed receipt |
119
+ | `voidly_timestamp` | $0.001 | Proof-of-existence (OpenTimestamps-style) |
120
+ | `voidly_random` | $0.001 | Signed CSPRNG bytes |
121
+ | `voidly_qr` | $0.001 | QR-code PNG of any text/URL |
122
+ | `voidly_wiki` | $0.001 | Wikipedia summary + signed citation |
123
+ | `voidly_exchange` | $0.001 | Fiat/crypto exchange rates |
124
+ | `voidly_markdown` | $0.001 | HTML → clean markdown (10x reduction) |
125
+ | `voidly_meta` | $0.001 | URL metadata (og + title + canonical) |
126
+ | `voidly_extract` | $0.01 | PDF/document → plain text |
127
+ | `voidly_scrape` | $0.01 | Fetch any URL + Voidly-signed receipt |
128
+ | `voidly_fetch` | $0.05 | Country-pinned fetch via 37+ probe network |
129
+ | `probe_attest` | $0.005 | Multi-vantage signed reachability proof |
130
+ | `forecast_pro` | $0.01 | 30-day country-shutdown risk forecast |
131
+ | `claim_verify_pro` | $0.005 | Evidence-backed verification of claims |
132
+ | `incident_summary_pro` | $0.005 | Plain-English summary of an incident |
133
+ | `agent_discover_pro` | $0.005 | Premium ranked agent search |
134
+ | `incidents_export_pro` | $0.05 | Bulk export (high limit, no rate cap) |
135
+
136
+ Plus N third-party listings registered self-serve at [voidly.ai/pay/list-your-service](https://voidly.ai/pay/list-your-service).
137
+
138
+ Live machine-readable catalog: [api.voidly.ai/v1/pay/marketplace](https://api.voidly.ai/v1/pay/marketplace).
139
+
140
+ ## Configuration
141
+
142
+ ```python
143
+ pay = VoidlyPay(
144
+ api_url="https://api.voidly.ai",
145
+ secret_key=existing_key, # bring your own
146
+ storage_path="~/.my-keys/voidly.json",
147
+ default_expiry_minutes=30,
148
+ )
149
+ ```
150
+
151
+ ## Webhook verification
152
+
153
+ ```python
154
+ from voidly_pay import verify_webhook_signature
155
+
156
+ ok = verify_webhook_signature(
157
+ body=raw_body,
158
+ signature_header=headers["X-Voidly-Signature"],
159
+ secret=os.environ["VOIDLY_WEBHOOK_SECRET"],
160
+ )
161
+ ```
162
+
163
+ ## Why agents use this
164
+
165
+ | Problem | Voidly Pay solves it |
166
+ |---|---|
167
+ | **Need to add payment to your agent service** | x402 middleware ships for FastAPI, Flask, any web-fetch handler |
168
+ | **Need to discover paid services** | One install → 17 endpoints + open marketplace listings |
169
+ | **Don't want to manage 10 API keys** | One Ed25519 keypair, one wallet, every paid endpoint works |
170
+ | **Don't trust the agent's payment claims** | Every receipt is Ed25519-signed by Voidly. Verifiable offline. |
171
+ | **Need country-attested fetch** | 37+ probe network, signed (URL, country, ASN, probe-DID) |
172
+
173
+ ## Honest disclosure
174
+
175
+ 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).
176
+
177
+ We opened the marketplace before the demand exists because we believe agent adoption is gated on discoverability, not on payment-rail UX.
178
+
179
+ ## Framework adapters (use these for higher-level integration)
180
+
181
+ - **LangChain**: `pip install voidly-pay-langchain`
182
+ - **CrewAI**: `pip install voidly-pay-crewai`
183
+ - **Pydantic AI**: `pip install voidly-pay-pydantic-ai`
184
+ - **AutoGen**: `pip install voidly-pay-autogen`
185
+ - **LlamaIndex**: `pip install voidly-pay-llamaindex`
186
+
187
+ ## Links
188
+
189
+ - [Marketplace JSON](https://api.voidly.ai/v1/pay/marketplace)
190
+ - [/pay/install](https://voidly.ai/pay/install) — one-click MCP install (any client)
191
+ - [/pay/marketplace](https://voidly.ai/pay/marketplace) — visual browse
192
+ - [/pay/list-your-service](https://voidly.ai/pay/list-your-service) — list in 60s
193
+ - [/pay/claim](https://voidly.ai/pay/claim) — free 10-credit faucet
194
+ - [/pay/proof](https://voidly.ai/pay/proof) — live reserves dashboard
195
+ - [/pay/for-builders](https://voidly.ai/pay/for-builders) — every middleware + adapter
196
+ - [Voidly Pay landing](https://voidly.ai/pay)
197
+
198
+ ## Keywords
199
+
200
+ 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
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "voidly-pay"
3
+ version = "0.1.1"
4
+ description = "Voidly Pay SDK — agent-to-agent payments for AI agents"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Voidly", email = "team@voidly.ai" }]
9
+ keywords = ["voidly", "payments", "ai-agents", "x402", "did", "ed25519"]
10
+ dependencies = [
11
+ "pynacl>=1.5.0",
12
+ "requests>=2.31.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=7.0", "pytest-mock>=3.10", "responses>=0.23"]
17
+
18
+ [build-system]
19
+ requires = ["setuptools>=61"]
20
+ build-backend = "setuptools.build_meta"
21
+
22
+ [tool.setuptools]
23
+ packages = ["voidly_pay"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,167 @@
1
+ """Smoke tests for the Python voidly-pay SDK.
2
+
3
+ Exercises envelope shape, signing, persistence, webhook signature verify.
4
+ Real settlement is tested in the worker repo.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import os
13
+ import time
14
+ import unittest
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ import nacl.signing
19
+
20
+ from voidly_pay import (
21
+ VoidlyPay,
22
+ VoidlyPayError,
23
+ canonical_bytes,
24
+ canonicalize,
25
+ did_from_pubkey,
26
+ verify_webhook_signature,
27
+ )
28
+
29
+
30
+ def fake_session(routes: dict):
31
+ """Build a requests.Session mock that returns canned responses by route."""
32
+ sess = mock.Mock()
33
+ captured = []
34
+
35
+ def request(method, url, **kwargs):
36
+ captured.append({"method": method.upper(), "url": url, "json_body": kwargs.get("json"), "headers": kwargs.get("headers", {})})
37
+ from urllib.parse import urlparse
38
+ path = urlparse(url).path
39
+ key = f"{method.upper()} {path}"
40
+ if key not in routes and path not in routes:
41
+ resp = mock.Mock()
42
+ resp.status_code = 404
43
+ resp.ok = False
44
+ resp.text = json.dumps({"error": {"code": "no_route", "message": f"no fake for {key}"}})
45
+ resp.json = lambda: json.loads(resp.text)
46
+ return resp
47
+ return routes.get(key) or routes[path]
48
+
49
+ sess.request = mock.Mock(side_effect=request)
50
+ return sess, captured
51
+
52
+
53
+ def resp_json(payload, status=200):
54
+ r = mock.Mock()
55
+ r.status_code = status
56
+ r.ok = 200 <= status < 300
57
+ r.text = json.dumps(payload)
58
+ r.json = lambda: payload
59
+ return r
60
+
61
+
62
+ class CanonicalTests(unittest.TestCase):
63
+ def test_sorts_keys(self):
64
+ self.assertEqual(canonicalize({"b": 1, "a": 2}), '{"a":2,"b":1}')
65
+
66
+ def test_omits_none(self):
67
+ self.assertEqual(canonicalize({"a": 1, "b": None, "c": 2}), '{"a":1,"c":2}')
68
+
69
+ def test_nested(self):
70
+ self.assertEqual(canonicalize({"x": {"z": 1, "y": 2}}), '{"x":{"y":2,"z":1}}')
71
+
72
+ def test_parity_regardless_of_order(self):
73
+ a = canonical_bytes({"x": 1, "y": 2, "z": 3})
74
+ b = canonical_bytes({"z": 3, "x": 1, "y": 2})
75
+ self.assertEqual(a, b)
76
+
77
+
78
+ class DIDTests(unittest.TestCase):
79
+ def test_did_format(self):
80
+ kp = nacl.signing.SigningKey.generate()
81
+ pk = bytes(kp.verify_key)
82
+ did = did_from_pubkey(pk)
83
+ self.assertTrue(did.startswith("did:voidly:"))
84
+
85
+
86
+ class ConstructionTests(unittest.TestCase):
87
+ def test_persists_and_loads_keypair(self):
88
+ with mock.patch("voidly_pay._default_storage_path", return_value=Path(self._tmp())):
89
+ sess, _ = fake_session({})
90
+ p1 = VoidlyPay(api_url="http://x", session=sess)
91
+ p2 = VoidlyPay(api_url="http://x", session=sess)
92
+ self.assertEqual(p1.did, p2.did)
93
+
94
+ def test_explicit_secret_key(self):
95
+ kp = nacl.signing.SigningKey.generate()
96
+ sk = bytes(kp._signing_key)
97
+ sess, _ = fake_session({})
98
+ pay = VoidlyPay(api_url="http://x", secret_key=sk, session=sess)
99
+ self.assertEqual(pay.did, did_from_pubkey(bytes(kp.verify_key)))
100
+
101
+ def _tmp(self):
102
+ import tempfile
103
+ return tempfile.NamedTemporaryFile(delete=False).name
104
+
105
+
106
+ class TransferTests(unittest.TestCase):
107
+ def test_transfer_envelope_shape(self):
108
+ sess, captured = fake_session({
109
+ "POST /v1/pay/wallet": resp_json({"wallet": {"did": "x"}}),
110
+ "POST /v1/pay/transfer": resp_json({
111
+ "schema": "voidly-pay-receipt/v1",
112
+ "status": "settled",
113
+ "transfer_id": "t-1",
114
+ "envelope_hash": "h",
115
+ "settled_at": "2026-04-26T00:00:00Z",
116
+ "sender_new_balance_micro": 0,
117
+ "recipient_new_balance_micro": 1_000_000,
118
+ }),
119
+ })
120
+ kp = nacl.signing.SigningKey.generate()
121
+ pay = VoidlyPay(api_url="http://x.example", secret_key=bytes(kp._signing_key), session=sess)
122
+ r = pay.transfer(to="did:voidly:bob", amount=1.0, memo="hi")
123
+ self.assertEqual(r["transfer_id"], "t-1")
124
+ # Find the transfer call
125
+ tcall = next(c for c in captured if c["url"].endswith("/v1/pay/transfer"))
126
+ env = tcall["json_body"]["envelope"]
127
+ self.assertEqual(env["schema"], "voidly-credit-transfer/v1")
128
+ self.assertEqual(env["from_did"], pay.did)
129
+ self.assertEqual(env["to_did"], "did:voidly:bob")
130
+ self.assertEqual(env["amount_micro"], 1_000_000)
131
+ self.assertEqual(env["memo"], "hi")
132
+ self.assertIsInstance(tcall["json_body"]["signature"], str)
133
+
134
+ def test_transfer_raises_on_failure(self):
135
+ sess, _ = fake_session({
136
+ "POST /v1/pay/wallet": resp_json({"wallet": {}}),
137
+ "POST /v1/pay/transfer": resp_json({"error": {"code": "insufficient_balance", "message": "no funds"}}, status=402),
138
+ })
139
+ kp = nacl.signing.SigningKey.generate()
140
+ pay = VoidlyPay(api_url="http://x.example", secret_key=bytes(kp._signing_key), session=sess)
141
+ with self.assertRaises(VoidlyPayError) as ctx:
142
+ pay.transfer(to="did:voidly:bob", amount=1.0)
143
+ self.assertEqual(ctx.exception.code, "insufficient_balance")
144
+
145
+
146
+ class WebhookSignatureTests(unittest.TestCase):
147
+ def test_valid_signature(self):
148
+ secret = "deadbeef" * 8
149
+ body = '{"foo":1}'
150
+ ts = int(time.time())
151
+ sig = hmac.new(bytes.fromhex(secret), f"t={ts}.{body}".encode(), hashlib.sha256).hexdigest()
152
+ self.assertTrue(verify_webhook_signature(body, f"t={ts},v1={sig}", secret))
153
+
154
+ def test_tampered_rejected(self):
155
+ secret = "deadbeef" * 8
156
+ ts = int(time.time())
157
+ self.assertFalse(verify_webhook_signature("a", f"t={ts},v1=" + "0" * 64, secret))
158
+
159
+ def test_stale_rejected(self):
160
+ secret = "deadbeef" * 8
161
+ old = int(time.time()) - 3600
162
+ sig = hmac.new(bytes.fromhex(secret), f"t={old}.x".encode(), hashlib.sha256).hexdigest()
163
+ self.assertFalse(verify_webhook_signature("x", f"t={old},v1={sig}", secret))
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()
@@ -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 &lt;$0.01 using one Ed25519 keypair. List your own paid endpoint in 60 seconds. Settles in &lt;200ms via x402 + USDC on Base mainnet.
20
+
21
+ [![PyPI version](https://img.shields.io/pypi/v/voidly-pay)](https://pypi.org/project/voidly-pay/)
22
+ [![x402](https://img.shields.io/badge/x402-canonical%20v2-blue)](https://www.x402.org)
23
+ [![vault](https://img.shields.io/badge/vault-Sourcify%20verified-emerald)](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,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_sdk.py
4
+ voidly_pay/__init__.py
5
+ voidly_pay.egg-info/PKG-INFO
6
+ voidly_pay.egg-info/SOURCES.txt
7
+ voidly_pay.egg-info/dependency_links.txt
8
+ voidly_pay.egg-info/requires.txt
9
+ voidly_pay.egg-info/top_level.txt
@@ -0,0 +1,7 @@
1
+ pynacl>=1.5.0
2
+ requests>=2.31.0
3
+
4
+ [dev]
5
+ pytest>=7.0
6
+ pytest-mock>=3.10
7
+ responses>=0.23
@@ -0,0 +1 @@
1
+ voidly_pay