bolthub 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.
- bolthub-0.1.0/PKG-INFO +166 -0
- bolthub-0.1.0/README.md +152 -0
- bolthub-0.1.0/bolthub/__init__.py +17 -0
- bolthub-0.1.0/bolthub/client.py +205 -0
- bolthub-0.1.0/bolthub/session_store.py +157 -0
- bolthub-0.1.0/bolthub/wallets.py +129 -0
- bolthub-0.1.0/bolthub.egg-info/PKG-INFO +166 -0
- bolthub-0.1.0/bolthub.egg-info/SOURCES.txt +14 -0
- bolthub-0.1.0/bolthub.egg-info/dependency_links.txt +1 -0
- bolthub-0.1.0/bolthub.egg-info/requires.txt +4 -0
- bolthub-0.1.0/bolthub.egg-info/top_level.txt +1 -0
- bolthub-0.1.0/pyproject.toml +23 -0
- bolthub-0.1.0/setup.cfg +4 -0
- bolthub-0.1.0/tests/test_client.py +90 -0
- bolthub-0.1.0/tests/test_session_store.py +121 -0
- bolthub-0.1.0/tests/test_wallets.py +108 -0
bolthub-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bolthub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: L402 client for AI agents — pay Lightning invoices automatically to access paywalled APIs
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://bolthub.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/signaltech-org/bolthub.ai
|
|
8
|
+
Keywords: l402,lightning,bitcoin,micropayments,ai-agent,paywall
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx>=0.24
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# bolthub
|
|
16
|
+
|
|
17
|
+
L402 client for AI agents. Automatically handles 402 Payment Required challenges, pays Lightning invoices, and retries requests with proof of payment.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install bolthub
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from bolthub import L402Client, PhoenixdWallet
|
|
29
|
+
|
|
30
|
+
wallet = PhoenixdWallet(
|
|
31
|
+
url="https://your-phoenixd:9740",
|
|
32
|
+
password="your-phoenixd-password",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
client = L402Client(wallet, budget_sats=10_000)
|
|
36
|
+
|
|
37
|
+
resp = client.get(
|
|
38
|
+
"https://acme.gw.bolthub.ai/v1/market-data",
|
|
39
|
+
params={"symbol": "BTC"},
|
|
40
|
+
)
|
|
41
|
+
data = resp.json()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Wallet Adapters
|
|
45
|
+
|
|
46
|
+
### Phoenixd (recommended)
|
|
47
|
+
|
|
48
|
+
Fastest payment speed (<200ms). Get a hosted instance at [nodana.io](https://nodana.io) or self-host from [ACINQ](https://phoenix.acinq.co/server).
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from bolthub import PhoenixdWallet
|
|
52
|
+
|
|
53
|
+
wallet = PhoenixdWallet(
|
|
54
|
+
url="https://your-phoenixd:9740",
|
|
55
|
+
password="your-phoenixd-password",
|
|
56
|
+
timeout_seconds=35,
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### LND
|
|
61
|
+
|
|
62
|
+
Full Lightning node. Self-host or use [Umbrel](https://umbrel.com) / [Start9](https://start9.com).
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from bolthub import LndWallet
|
|
66
|
+
|
|
67
|
+
wallet = LndWallet(
|
|
68
|
+
host="https://your-lnd-node:8080",
|
|
69
|
+
macaroon="admin-macaroon-hex",
|
|
70
|
+
timeout_seconds=30,
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For agent deployments, use a scoped pay-only macaroon instead of `admin.macaroon`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
lncli bakemacaroon uri:/lnrpc.Lightning/SendPaymentSync \
|
|
78
|
+
uri:/lnrpc.Lightning/DecodePayReq \
|
|
79
|
+
--save_to=pay-only.macaroon
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### LNbits
|
|
83
|
+
|
|
84
|
+
Lightweight Lightning wallet with multi-wallet support. Create a dedicated wallet for your agent.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from bolthub import LnbitsWallet
|
|
88
|
+
|
|
89
|
+
wallet = LnbitsWallet(
|
|
90
|
+
url="https://lnbits.example.com",
|
|
91
|
+
admin_key="your-admin-key",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### NWC (Nostr Wallet Connect)
|
|
96
|
+
|
|
97
|
+
Easiest to set up but slower (1-3s per payment). Get a free NWC connection from [CoinOS](https://coinos.io) or use [Alby Hub](https://getalby.com).
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from bolthub import NwcWallet
|
|
101
|
+
|
|
102
|
+
# Provide a pay function that handles the NWC protocol.
|
|
103
|
+
# With pynostr or another NWC library:
|
|
104
|
+
def pay_via_nwc(bolt11: str) -> str:
|
|
105
|
+
# your NWC payment logic here
|
|
106
|
+
return preimage_hex
|
|
107
|
+
|
|
108
|
+
wallet = NwcWallet(pay_fn=pay_via_nwc)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Custom Wallet
|
|
112
|
+
|
|
113
|
+
Implement the `WalletAdapter` protocol:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
class MyWallet:
|
|
117
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
118
|
+
preimage = my_payment_logic(bolt11)
|
|
119
|
+
return preimage
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Budget Guards
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
client = L402Client(
|
|
126
|
+
wallet,
|
|
127
|
+
max_per_request_sats=100, # reject invoices over 100 sats
|
|
128
|
+
budget_sats=10_000, # total spending cap
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
print(client.total_spent) # sats spent so far
|
|
132
|
+
print(client.remaining_budget) # sats remaining
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Session Persistence
|
|
136
|
+
|
|
137
|
+
By default sessions are kept in memory. Use `FileSessionStore` to persist
|
|
138
|
+
tokens across process restarts (stored in `~/.bolthub/sessions.json`):
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from bolthub import L402Client, PhoenixdWallet, FileSessionStore
|
|
142
|
+
|
|
143
|
+
client = L402Client(
|
|
144
|
+
PhoenixdWallet(url=url, password=pw),
|
|
145
|
+
session_store=FileSessionStore(),
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## API Reference
|
|
150
|
+
|
|
151
|
+
| Export | Description |
|
|
152
|
+
|--------|-------------|
|
|
153
|
+
| `L402Client` | HTTP client with automatic L402 challenge handling |
|
|
154
|
+
| `LndWallet` | Wallet adapter for LND REST API |
|
|
155
|
+
| `LnbitsWallet` | Wallet adapter for LNbits |
|
|
156
|
+
| `PhoenixdWallet` | Wallet adapter for Phoenixd |
|
|
157
|
+
| `NwcWallet` | Wallet adapter accepting a custom pay callback |
|
|
158
|
+
| `WalletAdapter` | Protocol to implement for custom wallets |
|
|
159
|
+
| `FileSessionStore` | Disk-backed session token persistence |
|
|
160
|
+
| `SessionStore` | Protocol for custom session storage |
|
|
161
|
+
| `L402Error` | Base exception for L402 failures |
|
|
162
|
+
| `L402BudgetError` | Raised when budget limits are exceeded |
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
bolthub-0.1.0/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# bolthub
|
|
2
|
+
|
|
3
|
+
L402 client for AI agents. Automatically handles 402 Payment Required challenges, pays Lightning invoices, and retries requests with proof of payment.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install bolthub
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from bolthub import L402Client, PhoenixdWallet
|
|
15
|
+
|
|
16
|
+
wallet = PhoenixdWallet(
|
|
17
|
+
url="https://your-phoenixd:9740",
|
|
18
|
+
password="your-phoenixd-password",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
client = L402Client(wallet, budget_sats=10_000)
|
|
22
|
+
|
|
23
|
+
resp = client.get(
|
|
24
|
+
"https://acme.gw.bolthub.ai/v1/market-data",
|
|
25
|
+
params={"symbol": "BTC"},
|
|
26
|
+
)
|
|
27
|
+
data = resp.json()
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Wallet Adapters
|
|
31
|
+
|
|
32
|
+
### Phoenixd (recommended)
|
|
33
|
+
|
|
34
|
+
Fastest payment speed (<200ms). Get a hosted instance at [nodana.io](https://nodana.io) or self-host from [ACINQ](https://phoenix.acinq.co/server).
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from bolthub import PhoenixdWallet
|
|
38
|
+
|
|
39
|
+
wallet = PhoenixdWallet(
|
|
40
|
+
url="https://your-phoenixd:9740",
|
|
41
|
+
password="your-phoenixd-password",
|
|
42
|
+
timeout_seconds=35,
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### LND
|
|
47
|
+
|
|
48
|
+
Full Lightning node. Self-host or use [Umbrel](https://umbrel.com) / [Start9](https://start9.com).
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from bolthub import LndWallet
|
|
52
|
+
|
|
53
|
+
wallet = LndWallet(
|
|
54
|
+
host="https://your-lnd-node:8080",
|
|
55
|
+
macaroon="admin-macaroon-hex",
|
|
56
|
+
timeout_seconds=30,
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For agent deployments, use a scoped pay-only macaroon instead of `admin.macaroon`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
lncli bakemacaroon uri:/lnrpc.Lightning/SendPaymentSync \
|
|
64
|
+
uri:/lnrpc.Lightning/DecodePayReq \
|
|
65
|
+
--save_to=pay-only.macaroon
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### LNbits
|
|
69
|
+
|
|
70
|
+
Lightweight Lightning wallet with multi-wallet support. Create a dedicated wallet for your agent.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from bolthub import LnbitsWallet
|
|
74
|
+
|
|
75
|
+
wallet = LnbitsWallet(
|
|
76
|
+
url="https://lnbits.example.com",
|
|
77
|
+
admin_key="your-admin-key",
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### NWC (Nostr Wallet Connect)
|
|
82
|
+
|
|
83
|
+
Easiest to set up but slower (1-3s per payment). Get a free NWC connection from [CoinOS](https://coinos.io) or use [Alby Hub](https://getalby.com).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from bolthub import NwcWallet
|
|
87
|
+
|
|
88
|
+
# Provide a pay function that handles the NWC protocol.
|
|
89
|
+
# With pynostr or another NWC library:
|
|
90
|
+
def pay_via_nwc(bolt11: str) -> str:
|
|
91
|
+
# your NWC payment logic here
|
|
92
|
+
return preimage_hex
|
|
93
|
+
|
|
94
|
+
wallet = NwcWallet(pay_fn=pay_via_nwc)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Custom Wallet
|
|
98
|
+
|
|
99
|
+
Implement the `WalletAdapter` protocol:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
class MyWallet:
|
|
103
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
104
|
+
preimage = my_payment_logic(bolt11)
|
|
105
|
+
return preimage
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Budget Guards
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
client = L402Client(
|
|
112
|
+
wallet,
|
|
113
|
+
max_per_request_sats=100, # reject invoices over 100 sats
|
|
114
|
+
budget_sats=10_000, # total spending cap
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
print(client.total_spent) # sats spent so far
|
|
118
|
+
print(client.remaining_budget) # sats remaining
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Session Persistence
|
|
122
|
+
|
|
123
|
+
By default sessions are kept in memory. Use `FileSessionStore` to persist
|
|
124
|
+
tokens across process restarts (stored in `~/.bolthub/sessions.json`):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from bolthub import L402Client, PhoenixdWallet, FileSessionStore
|
|
128
|
+
|
|
129
|
+
client = L402Client(
|
|
130
|
+
PhoenixdWallet(url=url, password=pw),
|
|
131
|
+
session_store=FileSessionStore(),
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## API Reference
|
|
136
|
+
|
|
137
|
+
| Export | Description |
|
|
138
|
+
|--------|-------------|
|
|
139
|
+
| `L402Client` | HTTP client with automatic L402 challenge handling |
|
|
140
|
+
| `LndWallet` | Wallet adapter for LND REST API |
|
|
141
|
+
| `LnbitsWallet` | Wallet adapter for LNbits |
|
|
142
|
+
| `PhoenixdWallet` | Wallet adapter for Phoenixd |
|
|
143
|
+
| `NwcWallet` | Wallet adapter accepting a custom pay callback |
|
|
144
|
+
| `WalletAdapter` | Protocol to implement for custom wallets |
|
|
145
|
+
| `FileSessionStore` | Disk-backed session token persistence |
|
|
146
|
+
| `SessionStore` | Protocol for custom session storage |
|
|
147
|
+
| `L402Error` | Base exception for L402 failures |
|
|
148
|
+
| `L402BudgetError` | Raised when budget limits are exceeded |
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .client import L402Client, L402Error, L402BudgetError
|
|
2
|
+
from .wallets import LndWallet, LnbitsWallet, PhoenixdWallet, NwcWallet, WalletAdapter
|
|
3
|
+
from .session_store import FileSessionStore, SessionStore, SessionData
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"L402Client",
|
|
7
|
+
"L402Error",
|
|
8
|
+
"L402BudgetError",
|
|
9
|
+
"LndWallet",
|
|
10
|
+
"LnbitsWallet",
|
|
11
|
+
"PhoenixdWallet",
|
|
12
|
+
"NwcWallet",
|
|
13
|
+
"WalletAdapter",
|
|
14
|
+
"FileSessionStore",
|
|
15
|
+
"SessionStore",
|
|
16
|
+
"SessionData",
|
|
17
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""L402 HTTP client with automatic payment-challenge handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .session_store import SessionStore, SessionData, InMemorySessionStore
|
|
14
|
+
from .wallets import WalletAdapter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class L402Error(Exception):
|
|
18
|
+
"""Base exception for all L402-related failures."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class L402BudgetError(L402Error):
|
|
22
|
+
"""Raised when an invoice exceeds per-request or total budget limits."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class _SessionInfo:
|
|
27
|
+
token: str
|
|
28
|
+
expires_at: float
|
|
29
|
+
balance: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class L402Client:
|
|
33
|
+
"""HTTP client that transparently handles the L402 payment protocol.
|
|
34
|
+
|
|
35
|
+
When a server responds with ``402 Payment Required`` and a
|
|
36
|
+
``WWW-Authenticate: L402`` challenge, the client automatically pays the
|
|
37
|
+
embedded Lightning invoice via the configured wallet adapter, then
|
|
38
|
+
retries the request with proof of payment.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
wallet: Lightning wallet adapter used to pay invoices.
|
|
42
|
+
max_per_request_sats: Maximum sats allowed for a single invoice.
|
|
43
|
+
budget_sats: Total sats the client may spend before refusing to pay.
|
|
44
|
+
timeout: Timeout in seconds for each HTTP round-trip.
|
|
45
|
+
session_store: Pluggable session store. Defaults to in-memory.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
wallet: WalletAdapter,
|
|
51
|
+
*,
|
|
52
|
+
max_per_request_sats: int | None = None,
|
|
53
|
+
budget_sats: int | None = None,
|
|
54
|
+
timeout: float = 30.0,
|
|
55
|
+
session_store: SessionStore | None = None,
|
|
56
|
+
):
|
|
57
|
+
self._wallet = wallet
|
|
58
|
+
self._max_per_request = max_per_request_sats
|
|
59
|
+
self._budget = budget_sats
|
|
60
|
+
self._spent = 0
|
|
61
|
+
self._timeout = timeout
|
|
62
|
+
self._client = httpx.Client(timeout=timeout)
|
|
63
|
+
self._store: SessionStore = session_store or InMemorySessionStore()
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def total_spent(self) -> int:
|
|
67
|
+
"""Total satoshis spent across all requests since construction."""
|
|
68
|
+
return self._spent
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def remaining_budget(self) -> int | None:
|
|
72
|
+
"""Satoshis remaining, or ``None`` if no budget was set."""
|
|
73
|
+
if self._budget is None:
|
|
74
|
+
return None
|
|
75
|
+
return max(0, self._budget - self._spent)
|
|
76
|
+
|
|
77
|
+
def get_sessions(self) -> dict[str, _SessionInfo]:
|
|
78
|
+
"""Return a snapshot of all cached session tokens."""
|
|
79
|
+
return {
|
|
80
|
+
k: _SessionInfo(token=s.token, expires_at=s.expires_at, balance=s.balance)
|
|
81
|
+
for k, s in self._store.items()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
def clear_sessions(self) -> None:
|
|
85
|
+
"""Remove all cached session tokens."""
|
|
86
|
+
self._store.clear()
|
|
87
|
+
|
|
88
|
+
def get(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
89
|
+
"""Convenience wrapper around :meth:`request` with ``method="GET"``."""
|
|
90
|
+
return self.request("GET", url, **kwargs)
|
|
91
|
+
|
|
92
|
+
def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
|
93
|
+
"""Convenience wrapper around :meth:`request` with ``method="POST"``."""
|
|
94
|
+
return self.request("POST", url, **kwargs)
|
|
95
|
+
|
|
96
|
+
def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
97
|
+
"""Send an HTTP request, automatically handling L402 challenges."""
|
|
98
|
+
session_key = self._session_key(url)
|
|
99
|
+
session = self._store.get(session_key)
|
|
100
|
+
|
|
101
|
+
if session and session.expires_at > time.time():
|
|
102
|
+
headers = dict(kwargs.get("headers", {}))
|
|
103
|
+
headers["X-Session-Token"] = session.token
|
|
104
|
+
kw = {**kwargs, "headers": headers}
|
|
105
|
+
resp = self._client.request(method, url, **kw)
|
|
106
|
+
if resp.status_code != 402:
|
|
107
|
+
self._update_session(session_key, resp)
|
|
108
|
+
return resp
|
|
109
|
+
self._store.delete(session_key)
|
|
110
|
+
|
|
111
|
+
resp = self._client.request(method, url, **kwargs)
|
|
112
|
+
|
|
113
|
+
if resp.status_code != 402:
|
|
114
|
+
return resp
|
|
115
|
+
|
|
116
|
+
challenge = self._parse_challenge(resp)
|
|
117
|
+
if challenge is None:
|
|
118
|
+
raise L402Error("Failed to parse L402 challenge from 402 response")
|
|
119
|
+
|
|
120
|
+
macaroon, invoice = challenge
|
|
121
|
+
amount = self._extract_amount(resp)
|
|
122
|
+
|
|
123
|
+
if amount is not None:
|
|
124
|
+
if self._max_per_request is not None and amount > self._max_per_request:
|
|
125
|
+
raise L402BudgetError(
|
|
126
|
+
f"Invoice amount {amount} sats exceeds per-request limit of {self._max_per_request} sats"
|
|
127
|
+
)
|
|
128
|
+
if self._budget is not None and self._spent + amount > self._budget:
|
|
129
|
+
raise L402BudgetError(
|
|
130
|
+
f"Invoice amount {amount} sats would exceed total budget "
|
|
131
|
+
f"(spent: {self._spent}, budget: {self._budget})"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
preimage = self._wallet.pay_invoice(invoice)
|
|
135
|
+
|
|
136
|
+
if amount is not None:
|
|
137
|
+
self._spent += amount
|
|
138
|
+
|
|
139
|
+
headers = dict(kwargs.get("headers", {}))
|
|
140
|
+
headers["Authorization"] = f"L402 {macaroon}:{preimage}"
|
|
141
|
+
kwargs["headers"] = headers
|
|
142
|
+
|
|
143
|
+
resp = self._client.request(method, url, **kwargs)
|
|
144
|
+
self._update_session(session_key, resp)
|
|
145
|
+
return resp
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
self._client.close()
|
|
149
|
+
|
|
150
|
+
def __enter__(self) -> L402Client:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(self, *args: Any) -> None:
|
|
154
|
+
self.close()
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _session_key(url: str) -> str:
|
|
158
|
+
parsed = urlparse(url)
|
|
159
|
+
return f"{parsed.netloc}{parsed.path}"
|
|
160
|
+
|
|
161
|
+
def _update_session(self, key: str, resp: httpx.Response) -> None:
|
|
162
|
+
token = resp.headers.get("x-session-token")
|
|
163
|
+
if not token:
|
|
164
|
+
return
|
|
165
|
+
expires_str = resp.headers.get("x-session-expires", "")
|
|
166
|
+
balance_str = resp.headers.get("x-session-balance", "")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
from datetime import datetime, timezone
|
|
170
|
+
expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00")).timestamp()
|
|
171
|
+
except Exception:
|
|
172
|
+
expires_at = time.time() + 3600
|
|
173
|
+
|
|
174
|
+
balance: int | None = None
|
|
175
|
+
if balance_str:
|
|
176
|
+
try:
|
|
177
|
+
balance = int(balance_str)
|
|
178
|
+
except ValueError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if balance is not None and balance <= 0:
|
|
182
|
+
self._store.delete(key)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
self._store.set(key, SessionData(token=token, expires_at=expires_at, balance=balance))
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _parse_challenge(resp: httpx.Response) -> tuple[str, str] | None:
|
|
189
|
+
www_auth = resp.headers.get("www-authenticate", "")
|
|
190
|
+
mac_match = re.search(r'macaroon="([^"]+)"', www_auth)
|
|
191
|
+
inv_match = re.search(r'invoice="([^"]+)"', www_auth)
|
|
192
|
+
if not mac_match or not inv_match:
|
|
193
|
+
return None
|
|
194
|
+
return mac_match.group(1), inv_match.group(1)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _extract_amount(resp: httpx.Response) -> int | None:
|
|
198
|
+
try:
|
|
199
|
+
body = resp.json()
|
|
200
|
+
val = body.get("amountSats")
|
|
201
|
+
if isinstance(val, (int, float)) and val > 0:
|
|
202
|
+
return int(val)
|
|
203
|
+
return None
|
|
204
|
+
except Exception:
|
|
205
|
+
return None
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Session token storage backends for the L402 client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Protocol, Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SessionData:
|
|
17
|
+
"""A cached gateway session token with expiry and optional balance."""
|
|
18
|
+
|
|
19
|
+
token: str
|
|
20
|
+
expires_at: float
|
|
21
|
+
balance: int | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SessionStore(Protocol):
|
|
25
|
+
"""Pluggable storage backend for gateway session tokens.
|
|
26
|
+
|
|
27
|
+
The default in-memory store is suitable for short-lived scripts.
|
|
28
|
+
Use :class:`FileSessionStore` for CLI tools or long-running agents
|
|
29
|
+
that should survive restarts.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def get(self, key: str) -> SessionData | None: ...
|
|
33
|
+
def set(self, key: str, session: SessionData) -> None: ...
|
|
34
|
+
def delete(self, key: str) -> None: ...
|
|
35
|
+
def clear(self) -> None: ...
|
|
36
|
+
def items(self) -> Iterator[tuple[str, SessionData]]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InMemorySessionStore:
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._sessions: dict[str, SessionData] = {}
|
|
42
|
+
|
|
43
|
+
def get(self, key: str) -> SessionData | None:
|
|
44
|
+
return self._sessions.get(key)
|
|
45
|
+
|
|
46
|
+
def set(self, key: str, session: SessionData) -> None:
|
|
47
|
+
self._sessions[key] = session
|
|
48
|
+
|
|
49
|
+
def delete(self, key: str) -> None:
|
|
50
|
+
self._sessions.pop(key, None)
|
|
51
|
+
|
|
52
|
+
def clear(self) -> None:
|
|
53
|
+
self._sessions.clear()
|
|
54
|
+
|
|
55
|
+
def items(self) -> Iterator[tuple[str, SessionData]]:
|
|
56
|
+
yield from self._sessions.items()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DEFAULT_DIR = os.path.join(os.path.expanduser("~"), ".bolthub")
|
|
60
|
+
_DEFAULT_FILE = "sessions.json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FileSessionStore:
|
|
64
|
+
"""Persists session tokens to a JSON file on disk.
|
|
65
|
+
|
|
66
|
+
Defaults to ``~/.bolthub/sessions.json``. Writes are atomic
|
|
67
|
+
(write-to-temp then rename) and the file is created with ``0600``
|
|
68
|
+
permissions.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
file_path: Custom path to the session file.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, file_path: str | None = None) -> None:
|
|
75
|
+
self._file_path = file_path or os.path.join(_DEFAULT_DIR, _DEFAULT_FILE)
|
|
76
|
+
self._sessions: dict[str, SessionData] = {}
|
|
77
|
+
self._load()
|
|
78
|
+
|
|
79
|
+
def get(self, key: str) -> SessionData | None:
|
|
80
|
+
session = self._sessions.get(key)
|
|
81
|
+
if session is None:
|
|
82
|
+
return None
|
|
83
|
+
if session.expires_at <= time.time():
|
|
84
|
+
del self._sessions[key]
|
|
85
|
+
self._persist()
|
|
86
|
+
return None
|
|
87
|
+
return session
|
|
88
|
+
|
|
89
|
+
def set(self, key: str, session: SessionData) -> None:
|
|
90
|
+
self._sessions[key] = session
|
|
91
|
+
self._persist()
|
|
92
|
+
|
|
93
|
+
def delete(self, key: str) -> None:
|
|
94
|
+
if key in self._sessions:
|
|
95
|
+
del self._sessions[key]
|
|
96
|
+
self._persist()
|
|
97
|
+
|
|
98
|
+
def clear(self) -> None:
|
|
99
|
+
self._sessions.clear()
|
|
100
|
+
self._persist()
|
|
101
|
+
|
|
102
|
+
def items(self) -> Iterator[tuple[str, SessionData]]:
|
|
103
|
+
yield from self._sessions.items()
|
|
104
|
+
|
|
105
|
+
def _load(self) -> None:
|
|
106
|
+
try:
|
|
107
|
+
with open(self._file_path, "r") as f:
|
|
108
|
+
data = json.load(f)
|
|
109
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if data.get("v") != 1 or not isinstance(data.get("sessions"), dict):
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
now = time.time()
|
|
116
|
+
pruned = False
|
|
117
|
+
for key, raw in data["sessions"].items():
|
|
118
|
+
expires_at = raw.get("expires_at", raw.get("expiresAt", 0))
|
|
119
|
+
token = raw.get("token", "")
|
|
120
|
+
if expires_at > now and token:
|
|
121
|
+
balance = raw.get("balance")
|
|
122
|
+
self._sessions[key] = SessionData(
|
|
123
|
+
token=token,
|
|
124
|
+
expires_at=expires_at,
|
|
125
|
+
balance=balance,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
pruned = True
|
|
129
|
+
|
|
130
|
+
if pruned:
|
|
131
|
+
self._persist()
|
|
132
|
+
|
|
133
|
+
def _persist(self) -> None:
|
|
134
|
+
dir_path = os.path.dirname(self._file_path)
|
|
135
|
+
os.makedirs(dir_path, mode=0o700, exist_ok=True)
|
|
136
|
+
|
|
137
|
+
sessions_dict: dict[str, dict] = {}
|
|
138
|
+
for key, s in self._sessions.items():
|
|
139
|
+
entry: dict = {"token": s.token, "expiresAt": s.expires_at}
|
|
140
|
+
if s.balance is not None:
|
|
141
|
+
entry["balance"] = s.balance
|
|
142
|
+
sessions_dict[key] = entry
|
|
143
|
+
|
|
144
|
+
payload = json.dumps({"v": 1, "sessions": sessions_dict}, indent=2)
|
|
145
|
+
|
|
146
|
+
fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
|
|
147
|
+
try:
|
|
148
|
+
os.write(fd, payload.encode())
|
|
149
|
+
os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
|
|
150
|
+
os.close(fd)
|
|
151
|
+
os.rename(tmp_path, self._file_path)
|
|
152
|
+
except Exception:
|
|
153
|
+
os.close(fd) if not os.get_inheritable(fd) else None
|
|
154
|
+
try:
|
|
155
|
+
os.unlink(tmp_path)
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Lightning wallet adapters for the L402 client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Callable, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class WalletAdapter(Protocol):
|
|
13
|
+
"""Interface that any Lightning wallet must implement.
|
|
14
|
+
|
|
15
|
+
Supply a built-in adapter (``LndWallet``, ``LnbitsWallet``, etc.) or
|
|
16
|
+
provide your own object that satisfies this protocol.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
20
|
+
"""Pay a BOLT-11 invoice and return the preimage hex string."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LndWallet:
|
|
25
|
+
"""Wallet adapter that pays invoices through an LND node's REST API.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
host: LND REST endpoint, e.g. ``https://localhost:8080``.
|
|
29
|
+
macaroon: Hex-encoded admin macaroon with send permission.
|
|
30
|
+
timeout_seconds: Payment timeout passed to LND. Defaults to 30.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, host: str, macaroon: str, timeout_seconds: int = 30):
|
|
34
|
+
self._host = host.rstrip("/")
|
|
35
|
+
self._macaroon = macaroon
|
|
36
|
+
self._timeout = timeout_seconds
|
|
37
|
+
|
|
38
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
39
|
+
resp = httpx.post(
|
|
40
|
+
f"{self._host}/v2/router/send",
|
|
41
|
+
headers={
|
|
42
|
+
"Grpc-Metadata-macaroon": self._macaroon,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
json={
|
|
46
|
+
"payment_request": bolt11,
|
|
47
|
+
"timeout_seconds": self._timeout,
|
|
48
|
+
},
|
|
49
|
+
timeout=self._timeout + 5,
|
|
50
|
+
)
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
data = resp.json()
|
|
53
|
+
preimage = data.get("result", {}).get("payment_preimage")
|
|
54
|
+
if not preimage:
|
|
55
|
+
raise RuntimeError("LND payment response missing preimage")
|
|
56
|
+
return preimage
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LnbitsWallet:
|
|
60
|
+
"""Wallet adapter that pays invoices through an LNbits instance.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
url: LNbits base URL, e.g. ``https://lnbits.example.com``.
|
|
64
|
+
admin_key: Admin API key with outgoing payment permission.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, url: str, admin_key: str):
|
|
68
|
+
self._url = url.rstrip("/")
|
|
69
|
+
self._admin_key = admin_key
|
|
70
|
+
|
|
71
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
72
|
+
resp = httpx.post(
|
|
73
|
+
f"{self._url}/api/v1/payments",
|
|
74
|
+
headers={
|
|
75
|
+
"X-Api-Key": self._admin_key,
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
json={"out": True, "bolt11": bolt11},
|
|
79
|
+
timeout=30,
|
|
80
|
+
)
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
data = resp.json()
|
|
83
|
+
preimage = data.get("preimage") or data.get("payment_preimage")
|
|
84
|
+
if not preimage:
|
|
85
|
+
raise RuntimeError("LNbits payment response missing preimage")
|
|
86
|
+
return preimage
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PhoenixdWallet:
|
|
90
|
+
"""Wallet adapter for Phoenixd (ACINQ). Uses HTTP Basic auth.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
url: Phoenixd HTTP base URL, e.g. ``http://localhost:9740``.
|
|
94
|
+
password: HTTP password used for Basic authentication.
|
|
95
|
+
timeout_seconds: Payment request timeout. Defaults to 35.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, url: str, password: str, timeout_seconds: int = 35):
|
|
99
|
+
self._url = url.rstrip("/")
|
|
100
|
+
self._auth = "Basic " + base64.b64encode(f":{password}".encode()).decode()
|
|
101
|
+
self._timeout = timeout_seconds
|
|
102
|
+
|
|
103
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
104
|
+
resp = httpx.post(
|
|
105
|
+
f"{self._url}/payinvoice",
|
|
106
|
+
headers={"Authorization": self._auth},
|
|
107
|
+
data={"invoice": bolt11},
|
|
108
|
+
timeout=self._timeout,
|
|
109
|
+
)
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
data = resp.json()
|
|
112
|
+
preimage = data.get("paymentPreimage")
|
|
113
|
+
if not preimage:
|
|
114
|
+
raise RuntimeError("Phoenixd payment response missing preimage")
|
|
115
|
+
return preimage
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class NwcWallet:
|
|
119
|
+
"""Wallet adapter that delegates to a callback (e.g. from an NWC library).
|
|
120
|
+
|
|
121
|
+
The ``pay_fn`` callable receives a BOLT11 invoice string and must return
|
|
122
|
+
the payment preimage as a hex string.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, pay_fn: Callable[[str], str]):
|
|
126
|
+
self._pay_fn = pay_fn
|
|
127
|
+
|
|
128
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
129
|
+
return self._pay_fn(bolt11)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bolthub
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: L402 client for AI agents — pay Lightning invoices automatically to access paywalled APIs
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://bolthub.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/signaltech-org/bolthub.ai
|
|
8
|
+
Keywords: l402,lightning,bitcoin,micropayments,ai-agent,paywall
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: httpx>=0.24
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# bolthub
|
|
16
|
+
|
|
17
|
+
L402 client for AI agents. Automatically handles 402 Payment Required challenges, pays Lightning invoices, and retries requests with proof of payment.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install bolthub
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from bolthub import L402Client, PhoenixdWallet
|
|
29
|
+
|
|
30
|
+
wallet = PhoenixdWallet(
|
|
31
|
+
url="https://your-phoenixd:9740",
|
|
32
|
+
password="your-phoenixd-password",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
client = L402Client(wallet, budget_sats=10_000)
|
|
36
|
+
|
|
37
|
+
resp = client.get(
|
|
38
|
+
"https://acme.gw.bolthub.ai/v1/market-data",
|
|
39
|
+
params={"symbol": "BTC"},
|
|
40
|
+
)
|
|
41
|
+
data = resp.json()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Wallet Adapters
|
|
45
|
+
|
|
46
|
+
### Phoenixd (recommended)
|
|
47
|
+
|
|
48
|
+
Fastest payment speed (<200ms). Get a hosted instance at [nodana.io](https://nodana.io) or self-host from [ACINQ](https://phoenix.acinq.co/server).
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from bolthub import PhoenixdWallet
|
|
52
|
+
|
|
53
|
+
wallet = PhoenixdWallet(
|
|
54
|
+
url="https://your-phoenixd:9740",
|
|
55
|
+
password="your-phoenixd-password",
|
|
56
|
+
timeout_seconds=35,
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### LND
|
|
61
|
+
|
|
62
|
+
Full Lightning node. Self-host or use [Umbrel](https://umbrel.com) / [Start9](https://start9.com).
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from bolthub import LndWallet
|
|
66
|
+
|
|
67
|
+
wallet = LndWallet(
|
|
68
|
+
host="https://your-lnd-node:8080",
|
|
69
|
+
macaroon="admin-macaroon-hex",
|
|
70
|
+
timeout_seconds=30,
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For agent deployments, use a scoped pay-only macaroon instead of `admin.macaroon`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
lncli bakemacaroon uri:/lnrpc.Lightning/SendPaymentSync \
|
|
78
|
+
uri:/lnrpc.Lightning/DecodePayReq \
|
|
79
|
+
--save_to=pay-only.macaroon
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### LNbits
|
|
83
|
+
|
|
84
|
+
Lightweight Lightning wallet with multi-wallet support. Create a dedicated wallet for your agent.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from bolthub import LnbitsWallet
|
|
88
|
+
|
|
89
|
+
wallet = LnbitsWallet(
|
|
90
|
+
url="https://lnbits.example.com",
|
|
91
|
+
admin_key="your-admin-key",
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### NWC (Nostr Wallet Connect)
|
|
96
|
+
|
|
97
|
+
Easiest to set up but slower (1-3s per payment). Get a free NWC connection from [CoinOS](https://coinos.io) or use [Alby Hub](https://getalby.com).
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from bolthub import NwcWallet
|
|
101
|
+
|
|
102
|
+
# Provide a pay function that handles the NWC protocol.
|
|
103
|
+
# With pynostr or another NWC library:
|
|
104
|
+
def pay_via_nwc(bolt11: str) -> str:
|
|
105
|
+
# your NWC payment logic here
|
|
106
|
+
return preimage_hex
|
|
107
|
+
|
|
108
|
+
wallet = NwcWallet(pay_fn=pay_via_nwc)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Custom Wallet
|
|
112
|
+
|
|
113
|
+
Implement the `WalletAdapter` protocol:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
class MyWallet:
|
|
117
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
118
|
+
preimage = my_payment_logic(bolt11)
|
|
119
|
+
return preimage
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Budget Guards
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
client = L402Client(
|
|
126
|
+
wallet,
|
|
127
|
+
max_per_request_sats=100, # reject invoices over 100 sats
|
|
128
|
+
budget_sats=10_000, # total spending cap
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
print(client.total_spent) # sats spent so far
|
|
132
|
+
print(client.remaining_budget) # sats remaining
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Session Persistence
|
|
136
|
+
|
|
137
|
+
By default sessions are kept in memory. Use `FileSessionStore` to persist
|
|
138
|
+
tokens across process restarts (stored in `~/.bolthub/sessions.json`):
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from bolthub import L402Client, PhoenixdWallet, FileSessionStore
|
|
142
|
+
|
|
143
|
+
client = L402Client(
|
|
144
|
+
PhoenixdWallet(url=url, password=pw),
|
|
145
|
+
session_store=FileSessionStore(),
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## API Reference
|
|
150
|
+
|
|
151
|
+
| Export | Description |
|
|
152
|
+
|--------|-------------|
|
|
153
|
+
| `L402Client` | HTTP client with automatic L402 challenge handling |
|
|
154
|
+
| `LndWallet` | Wallet adapter for LND REST API |
|
|
155
|
+
| `LnbitsWallet` | Wallet adapter for LNbits |
|
|
156
|
+
| `PhoenixdWallet` | Wallet adapter for Phoenixd |
|
|
157
|
+
| `NwcWallet` | Wallet adapter accepting a custom pay callback |
|
|
158
|
+
| `WalletAdapter` | Protocol to implement for custom wallets |
|
|
159
|
+
| `FileSessionStore` | Disk-backed session token persistence |
|
|
160
|
+
| `SessionStore` | Protocol for custom session storage |
|
|
161
|
+
| `L402Error` | Base exception for L402 failures |
|
|
162
|
+
| `L402BudgetError` | Raised when budget limits are exceeded |
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
bolthub/__init__.py
|
|
4
|
+
bolthub/client.py
|
|
5
|
+
bolthub/session_store.py
|
|
6
|
+
bolthub/wallets.py
|
|
7
|
+
bolthub.egg-info/PKG-INFO
|
|
8
|
+
bolthub.egg-info/SOURCES.txt
|
|
9
|
+
bolthub.egg-info/dependency_links.txt
|
|
10
|
+
bolthub.egg-info/requires.txt
|
|
11
|
+
bolthub.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
13
|
+
tests/test_session_store.py
|
|
14
|
+
tests/test_wallets.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bolthub
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bolthub"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "L402 client for AI agents — pay Lightning invoices automatically to access paywalled APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["httpx>=0.24"]
|
|
13
|
+
keywords = ["l402", "lightning", "bitcoin", "micropayments", "ai-agent", "paywall"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://bolthub.ai"
|
|
20
|
+
Repository = "https://github.com/signaltech-org/bolthub.ai"
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
bolthub-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from bolthub import L402Client, L402Error, L402BudgetError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockWallet:
|
|
9
|
+
def __init__(self, preimage="abc123"):
|
|
10
|
+
self._preimage = preimage
|
|
11
|
+
self.calls = []
|
|
12
|
+
|
|
13
|
+
def pay_invoice(self, bolt11: str) -> str:
|
|
14
|
+
self.calls.append(bolt11)
|
|
15
|
+
return self._preimage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_402_response(amount_sats=100):
|
|
19
|
+
return httpx.Response(
|
|
20
|
+
status_code=402,
|
|
21
|
+
headers={
|
|
22
|
+
"WWW-Authenticate": 'L402 macaroon="mac123", invoice="lnbc1000..."',
|
|
23
|
+
},
|
|
24
|
+
json={"error": "Payment Required", "amountSats": amount_sats},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_200_response(data=None):
|
|
29
|
+
return httpx.Response(status_code=200, json=data or {"ok": True})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestL402Client:
|
|
33
|
+
def test_returns_response_if_not_402(self):
|
|
34
|
+
wallet = MockWallet()
|
|
35
|
+
client = L402Client(wallet)
|
|
36
|
+
with patch.object(client._client, "request", return_value=make_200_response()):
|
|
37
|
+
resp = client.get("https://example.com/api")
|
|
38
|
+
assert resp.status_code == 200
|
|
39
|
+
assert len(wallet.calls) == 0
|
|
40
|
+
|
|
41
|
+
def test_handles_402_and_retries(self):
|
|
42
|
+
wallet = MockWallet("preimage123")
|
|
43
|
+
client = L402Client(wallet)
|
|
44
|
+
responses = [make_402_response(), make_200_response()]
|
|
45
|
+
call_count = 0
|
|
46
|
+
|
|
47
|
+
def side_effect(*args, **kwargs):
|
|
48
|
+
nonlocal call_count
|
|
49
|
+
resp = responses[call_count]
|
|
50
|
+
call_count += 1
|
|
51
|
+
return resp
|
|
52
|
+
|
|
53
|
+
with patch.object(client._client, "request", side_effect=side_effect):
|
|
54
|
+
resp = client.get("https://example.com/api")
|
|
55
|
+
|
|
56
|
+
assert resp.status_code == 200
|
|
57
|
+
assert wallet.calls == ["lnbc1000..."]
|
|
58
|
+
|
|
59
|
+
def test_raises_on_402_without_challenge(self):
|
|
60
|
+
wallet = MockWallet()
|
|
61
|
+
client = L402Client(wallet)
|
|
62
|
+
bare_402 = httpx.Response(status_code=402, json={"error": "pay"})
|
|
63
|
+
with patch.object(client._client, "request", return_value=bare_402):
|
|
64
|
+
with pytest.raises(L402Error, match="Failed to parse"):
|
|
65
|
+
client.get("https://example.com/api")
|
|
66
|
+
|
|
67
|
+
def test_budget_exceeded(self):
|
|
68
|
+
wallet = MockWallet()
|
|
69
|
+
client = L402Client(wallet, budget_sats=50)
|
|
70
|
+
with patch.object(client._client, "request", return_value=make_402_response(100)):
|
|
71
|
+
with pytest.raises(L402BudgetError, match="exceed total budget"):
|
|
72
|
+
client.get("https://example.com/api")
|
|
73
|
+
|
|
74
|
+
def test_per_request_limit(self):
|
|
75
|
+
wallet = MockWallet()
|
|
76
|
+
client = L402Client(wallet, max_per_request_sats=10)
|
|
77
|
+
with patch.object(client._client, "request", return_value=make_402_response(100)):
|
|
78
|
+
with pytest.raises(L402BudgetError, match="per-request limit"):
|
|
79
|
+
client.get("https://example.com/api")
|
|
80
|
+
|
|
81
|
+
def test_tracks_spent(self):
|
|
82
|
+
wallet = MockWallet()
|
|
83
|
+
client = L402Client(wallet, budget_sats=1000)
|
|
84
|
+
assert client.total_spent == 0
|
|
85
|
+
assert client.remaining_budget == 1000
|
|
86
|
+
|
|
87
|
+
def test_context_manager(self):
|
|
88
|
+
wallet = MockWallet()
|
|
89
|
+
with L402Client(wallet) as client:
|
|
90
|
+
assert client is not None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from bolthub.session_store import (
|
|
9
|
+
FileSessionStore,
|
|
10
|
+
InMemorySessionStore,
|
|
11
|
+
SessionData,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestInMemorySessionStore:
|
|
16
|
+
def test_get_set_delete(self):
|
|
17
|
+
store = InMemorySessionStore()
|
|
18
|
+
session = SessionData(token="tok1", expires_at=time.time() + 60)
|
|
19
|
+
store.set("k", session)
|
|
20
|
+
assert store.get("k") is session
|
|
21
|
+
store.delete("k")
|
|
22
|
+
assert store.get("k") is None
|
|
23
|
+
|
|
24
|
+
def test_clear(self):
|
|
25
|
+
store = InMemorySessionStore()
|
|
26
|
+
store.set("a", SessionData(token="t1", expires_at=time.time() + 60))
|
|
27
|
+
store.set("b", SessionData(token="t2", expires_at=time.time() + 60))
|
|
28
|
+
store.clear()
|
|
29
|
+
assert store.get("a") is None
|
|
30
|
+
assert store.get("b") is None
|
|
31
|
+
|
|
32
|
+
def test_items(self):
|
|
33
|
+
store = InMemorySessionStore()
|
|
34
|
+
store.set("x", SessionData(token="t1", expires_at=time.time() + 60))
|
|
35
|
+
store.set("y", SessionData(token="t2", expires_at=time.time() + 60))
|
|
36
|
+
keys = sorted(k for k, _ in store.items())
|
|
37
|
+
assert keys == ["x", "y"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestFileSessionStore:
|
|
41
|
+
def _tmp_path(self):
|
|
42
|
+
d = tempfile.mkdtemp(prefix="bolthub-test-")
|
|
43
|
+
return os.path.join(d, "sessions.json")
|
|
44
|
+
|
|
45
|
+
def test_stores_and_retrieves(self):
|
|
46
|
+
path = self._tmp_path()
|
|
47
|
+
try:
|
|
48
|
+
store = FileSessionStore(file_path=path)
|
|
49
|
+
s = SessionData(token="tok1", expires_at=time.time() + 60, balance=5)
|
|
50
|
+
store.set("host/path", s)
|
|
51
|
+
got = store.get("host/path")
|
|
52
|
+
assert got is not None
|
|
53
|
+
assert got.token == "tok1"
|
|
54
|
+
assert got.balance == 5
|
|
55
|
+
finally:
|
|
56
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
57
|
+
|
|
58
|
+
def test_returns_none_for_missing(self):
|
|
59
|
+
path = self._tmp_path()
|
|
60
|
+
try:
|
|
61
|
+
store = FileSessionStore(file_path=path)
|
|
62
|
+
assert store.get("no-key") is None
|
|
63
|
+
finally:
|
|
64
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
65
|
+
|
|
66
|
+
def test_prunes_expired_on_get(self):
|
|
67
|
+
path = self._tmp_path()
|
|
68
|
+
try:
|
|
69
|
+
store = FileSessionStore(file_path=path)
|
|
70
|
+
store.set("old", SessionData(token="t", expires_at=time.time() - 10))
|
|
71
|
+
assert store.get("old") is None
|
|
72
|
+
finally:
|
|
73
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
74
|
+
|
|
75
|
+
def test_persists_and_reloads(self):
|
|
76
|
+
path = self._tmp_path()
|
|
77
|
+
try:
|
|
78
|
+
store1 = FileSessionStore(file_path=path)
|
|
79
|
+
store1.set("k", SessionData(token="disk", expires_at=time.time() + 60))
|
|
80
|
+
|
|
81
|
+
store2 = FileSessionStore(file_path=path)
|
|
82
|
+
got = store2.get("k")
|
|
83
|
+
assert got is not None
|
|
84
|
+
assert got.token == "disk"
|
|
85
|
+
finally:
|
|
86
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
87
|
+
|
|
88
|
+
def test_prunes_expired_on_load(self):
|
|
89
|
+
path = self._tmp_path()
|
|
90
|
+
try:
|
|
91
|
+
store1 = FileSessionStore(file_path=path)
|
|
92
|
+
store1.set("fresh", SessionData(token="a", expires_at=time.time() + 60))
|
|
93
|
+
store1.set("stale", SessionData(token="b", expires_at=time.time() - 10))
|
|
94
|
+
|
|
95
|
+
store2 = FileSessionStore(file_path=path)
|
|
96
|
+
assert store2.get("fresh") is not None
|
|
97
|
+
assert store2.get("stale") is None
|
|
98
|
+
finally:
|
|
99
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
100
|
+
|
|
101
|
+
def test_delete(self):
|
|
102
|
+
path = self._tmp_path()
|
|
103
|
+
try:
|
|
104
|
+
store = FileSessionStore(file_path=path)
|
|
105
|
+
store.set("k", SessionData(token="t", expires_at=time.time() + 60))
|
|
106
|
+
store.delete("k")
|
|
107
|
+
assert store.get("k") is None
|
|
108
|
+
finally:
|
|
109
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
110
|
+
|
|
111
|
+
def test_clear(self):
|
|
112
|
+
path = self._tmp_path()
|
|
113
|
+
try:
|
|
114
|
+
store = FileSessionStore(file_path=path)
|
|
115
|
+
store.set("a", SessionData(token="t1", expires_at=time.time() + 60))
|
|
116
|
+
store.set("b", SessionData(token="t2", expires_at=time.time() + 60))
|
|
117
|
+
store.clear()
|
|
118
|
+
assert store.get("a") is None
|
|
119
|
+
assert store.get("b") is None
|
|
120
|
+
finally:
|
|
121
|
+
shutil.rmtree(os.path.dirname(path), ignore_errors=True)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import patch, MagicMock
|
|
3
|
+
|
|
4
|
+
from bolthub import LndWallet, LnbitsWallet, PhoenixdWallet, NwcWallet
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestLndWallet:
|
|
8
|
+
def test_pays_invoice(self):
|
|
9
|
+
mock_resp = MagicMock()
|
|
10
|
+
mock_resp.status_code = 200
|
|
11
|
+
mock_resp.raise_for_status = MagicMock()
|
|
12
|
+
mock_resp.json.return_value = {"result": {"payment_preimage": "abc123"}}
|
|
13
|
+
|
|
14
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp) as mock_post:
|
|
15
|
+
wallet = LndWallet(host="https://lnd.example.com:8080", macaroon="deadbeef")
|
|
16
|
+
preimage = wallet.pay_invoice("lnbc1000...")
|
|
17
|
+
|
|
18
|
+
assert preimage == "abc123"
|
|
19
|
+
mock_post.assert_called_once()
|
|
20
|
+
call_args = mock_post.call_args
|
|
21
|
+
assert "/v2/router/send" in call_args[0][0]
|
|
22
|
+
assert call_args[1]["headers"]["Grpc-Metadata-macaroon"] == "deadbeef"
|
|
23
|
+
|
|
24
|
+
def test_strips_trailing_slash(self):
|
|
25
|
+
mock_resp = MagicMock()
|
|
26
|
+
mock_resp.raise_for_status = MagicMock()
|
|
27
|
+
mock_resp.json.return_value = {"result": {"payment_preimage": "ok"}}
|
|
28
|
+
|
|
29
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp) as mock_post:
|
|
30
|
+
wallet = LndWallet(host="https://lnd.example.com/", macaroon="m")
|
|
31
|
+
wallet.pay_invoice("lnbc...")
|
|
32
|
+
|
|
33
|
+
url = mock_post.call_args[0][0]
|
|
34
|
+
assert url == "https://lnd.example.com/v2/router/send"
|
|
35
|
+
|
|
36
|
+
def test_raises_on_missing_preimage(self):
|
|
37
|
+
mock_resp = MagicMock()
|
|
38
|
+
mock_resp.raise_for_status = MagicMock()
|
|
39
|
+
mock_resp.json.return_value = {"result": {}}
|
|
40
|
+
|
|
41
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp):
|
|
42
|
+
wallet = LndWallet(host="https://lnd.example.com", macaroon="m")
|
|
43
|
+
with pytest.raises(RuntimeError, match="missing preimage"):
|
|
44
|
+
wallet.pay_invoice("lnbc...")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestLnbitsWallet:
|
|
48
|
+
def test_pays_invoice(self):
|
|
49
|
+
mock_resp = MagicMock()
|
|
50
|
+
mock_resp.raise_for_status = MagicMock()
|
|
51
|
+
mock_resp.json.return_value = {"preimage": "lnbits_pre"}
|
|
52
|
+
|
|
53
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp) as mock_post:
|
|
54
|
+
wallet = LnbitsWallet(url="https://lnbits.example.com", admin_key="key1")
|
|
55
|
+
preimage = wallet.pay_invoice("lnbc500...")
|
|
56
|
+
|
|
57
|
+
assert preimage == "lnbits_pre"
|
|
58
|
+
assert mock_post.call_args[1]["headers"]["X-Api-Key"] == "key1"
|
|
59
|
+
|
|
60
|
+
def test_accepts_payment_preimage_field(self):
|
|
61
|
+
mock_resp = MagicMock()
|
|
62
|
+
mock_resp.raise_for_status = MagicMock()
|
|
63
|
+
mock_resp.json.return_value = {"payment_preimage": "alt"}
|
|
64
|
+
|
|
65
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp):
|
|
66
|
+
wallet = LnbitsWallet(url="https://lnbits.example.com", admin_key="k")
|
|
67
|
+
assert wallet.pay_invoice("lnbc...") == "alt"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestPhoenixdWallet:
|
|
71
|
+
def test_pays_invoice(self):
|
|
72
|
+
mock_resp = MagicMock()
|
|
73
|
+
mock_resp.raise_for_status = MagicMock()
|
|
74
|
+
mock_resp.json.return_value = {"paymentPreimage": "phx_pre"}
|
|
75
|
+
|
|
76
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp) as mock_post:
|
|
77
|
+
wallet = PhoenixdWallet(url="http://localhost:9740", password="pass")
|
|
78
|
+
preimage = wallet.pay_invoice("lnbc300...")
|
|
79
|
+
|
|
80
|
+
assert preimage == "phx_pre"
|
|
81
|
+
assert "Basic" in mock_post.call_args[1]["headers"]["Authorization"]
|
|
82
|
+
|
|
83
|
+
def test_raises_on_missing_preimage(self):
|
|
84
|
+
mock_resp = MagicMock()
|
|
85
|
+
mock_resp.raise_for_status = MagicMock()
|
|
86
|
+
mock_resp.json.return_value = {}
|
|
87
|
+
|
|
88
|
+
with patch("bolthub.wallets.httpx.post", return_value=mock_resp):
|
|
89
|
+
wallet = PhoenixdWallet(url="http://localhost:9740", password="p")
|
|
90
|
+
with pytest.raises(RuntimeError, match="missing preimage"):
|
|
91
|
+
wallet.pay_invoice("lnbc...")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestNwcWallet:
|
|
95
|
+
def test_delegates_to_pay_fn(self):
|
|
96
|
+
pay_fn = MagicMock(return_value="nwc_pre")
|
|
97
|
+
wallet = NwcWallet(pay_fn=pay_fn)
|
|
98
|
+
result = wallet.pay_invoice("lnbc...")
|
|
99
|
+
|
|
100
|
+
assert result == "nwc_pre"
|
|
101
|
+
pay_fn.assert_called_once_with("lnbc...")
|
|
102
|
+
|
|
103
|
+
def test_propagates_errors(self):
|
|
104
|
+
pay_fn = MagicMock(side_effect=RuntimeError("NWC error"))
|
|
105
|
+
wallet = NwcWallet(pay_fn=pay_fn)
|
|
106
|
+
|
|
107
|
+
with pytest.raises(RuntimeError, match="NWC error"):
|
|
108
|
+
wallet.pay_invoice("lnbc...")
|