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 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
@@ -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,4 @@
1
+ httpx>=0.24
2
+
3
+ [dev]
4
+ pytest
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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...")