satoshi-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """Bitcoin REST API — developer-friendly access to your Bitcoin node."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("bitcoin-api")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.1.0"
bitcoin_api/auth.py ADDED
@@ -0,0 +1,67 @@
1
+ """API key authentication."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ from fastapi import Request
8
+
9
+ from .db import lookup_key
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class ApiKeyInfo:
16
+ tier: str # "anonymous", "free", "pro", "enterprise", "invalid"
17
+ key_hash: str | None = None
18
+ label: str | None = None
19
+ query_param_used: bool = False
20
+
21
+
22
+ def extract_api_key(request: Request) -> tuple[str | None, bool]:
23
+ key = request.headers.get("X-API-Key")
24
+ if key is not None:
25
+ return key, False
26
+ key = request.query_params.get("api_key")
27
+ if key is not None:
28
+ return key, True
29
+ return None, False
30
+
31
+
32
+ def extract_l402_token(request: Request) -> tuple[str | None, str | None]:
33
+ """Extract L402 token from Authorization header. Returns (macaroon_b64, preimage_hex) or (None, None)."""
34
+ auth = request.headers.get("Authorization", "")
35
+ if not auth.startswith("L402 "):
36
+ return None, None
37
+ token_part = auth[5:].strip()
38
+ if ":" not in token_part:
39
+ return None, None
40
+ mac_b64, preimage_hex = token_part.split(":", 1)
41
+ return mac_b64, preimage_hex
42
+
43
+
44
+ def hash_key(raw_key: str) -> str:
45
+ return hashlib.sha256(raw_key.encode()).hexdigest()
46
+
47
+
48
+ def authenticate(request: Request) -> ApiKeyInfo:
49
+ raw_key, via_query = extract_api_key(request)
50
+ if raw_key is None:
51
+ return ApiKeyInfo(tier="anonymous")
52
+
53
+ if via_query:
54
+ log.warning("API key passed via query param (deprecated) from %s", request.client.host if request.client else "unknown")
55
+
56
+ key_hash = hash_key(raw_key)
57
+ record = lookup_key(key_hash)
58
+
59
+ if record is None or not record["active"]:
60
+ return ApiKeyInfo(tier="invalid", query_param_used=via_query)
61
+
62
+ return ApiKeyInfo(
63
+ tier=record["tier"],
64
+ key_hash=record["key_hash"],
65
+ label=record.get("label"),
66
+ query_param_used=via_query,
67
+ )
bitcoin_api/cache.py ADDED
@@ -0,0 +1,155 @@
1
+ """TTL caching for expensive RPC calls."""
2
+
3
+ import threading
4
+
5
+ from cachetools import LRUCache, TTLCache
6
+
7
+ # Per-cache locks to avoid cross-cache contention
8
+ _info_lock = threading.Lock()
9
+ _count_lock = threading.Lock()
10
+ _fee_lock = threading.Lock()
11
+ _mempool_lock = threading.Lock()
12
+ _status_lock = threading.Lock()
13
+ _block_lock = threading.Lock()
14
+ _nextblock_lock = threading.Lock()
15
+
16
+ # Mutable data — short TTL
17
+ _fee_cache: TTLCache = TTLCache(maxsize=1, ttl=10)
18
+ _mempool_cache: TTLCache = TTLCache(maxsize=1, ttl=5)
19
+ _status_cache: TTLCache = TTLCache(maxsize=1, ttl=30)
20
+ _blockchain_info_cache: TTLCache = TTLCache(maxsize=1, ttl=10)
21
+ _block_count_cache: TTLCache = TTLCache(maxsize=1, ttl=5)
22
+ _nextblock_cache: TTLCache = TTLCache(maxsize=1, ttl=20)
23
+
24
+ # Immutable data — confirmed blocks never change (deep confirmations)
25
+ _block_cache: TTLCache = TTLCache(maxsize=64, ttl=3600)
26
+ # Recent blocks near tip — short TTL to handle reorgs
27
+ _recent_block_cache: TTLCache = TTLCache(maxsize=8, ttl=30)
28
+ # Block hash → height mapping for cache lookups (bounded to prevent memory leak)
29
+ _hash_to_height: LRUCache = LRUCache(maxsize=256)
30
+
31
+ REORG_SAFE_DEPTH = 6
32
+
33
+
34
+ def cached_blockchain_info(rpc):
35
+ key = "info"
36
+ with _info_lock:
37
+ if key in _blockchain_info_cache:
38
+ return _blockchain_info_cache[key]
39
+ result = rpc.call("getblockchaininfo")
40
+ with _info_lock:
41
+ _blockchain_info_cache[key] = result
42
+ return result
43
+
44
+
45
+ def cached_block_count(rpc):
46
+ key = "count"
47
+ with _count_lock:
48
+ if key in _block_count_cache:
49
+ return _block_count_cache[key]
50
+ result = rpc.call("getblockcount")
51
+ with _count_lock:
52
+ _block_count_cache[key] = result
53
+ return result
54
+
55
+
56
+ def cached_fee_estimates(rpc):
57
+ from bitcoinlib_rpc.fees import get_fee_estimates
58
+
59
+ key = "fees"
60
+ with _fee_lock:
61
+ if key in _fee_cache:
62
+ return _fee_cache[key]
63
+ result = get_fee_estimates(rpc)
64
+ with _fee_lock:
65
+ _fee_cache[key] = result
66
+ return result
67
+
68
+
69
+ def cached_mempool_analysis(rpc):
70
+ from bitcoinlib_rpc.mempool import analyze_mempool
71
+
72
+ key = "mempool"
73
+ with _mempool_lock:
74
+ if key in _mempool_cache:
75
+ return _mempool_cache[key]
76
+ result = analyze_mempool(rpc)
77
+ with _mempool_lock:
78
+ _mempool_cache[key] = result
79
+ return result
80
+
81
+
82
+ def cached_status(rpc):
83
+ from bitcoinlib_rpc.status import get_status
84
+
85
+ key = "status"
86
+ with _status_lock:
87
+ if key in _status_cache:
88
+ return _status_cache[key]
89
+ result = get_status(rpc)
90
+ with _status_lock:
91
+ _status_cache[key] = result
92
+ return result
93
+
94
+
95
+ def cached_block_analysis(rpc, height: int):
96
+ from bitcoinlib_rpc.blocks import analyze_block
97
+
98
+ tip = cached_block_count(rpc)
99
+
100
+ # Blocks near tip use short-TTL cache (reorg safety)
101
+ if (tip - height) < REORG_SAFE_DEPTH:
102
+ with _block_lock:
103
+ if height in _recent_block_cache:
104
+ return _recent_block_cache[height]
105
+ result = analyze_block(rpc, height)
106
+ with _block_lock:
107
+ _recent_block_cache[height] = result
108
+ return result
109
+
110
+ # Deep blocks use long-TTL cache
111
+ with _block_lock:
112
+ if height in _block_cache:
113
+ return _block_cache[height]
114
+ result = analyze_block(rpc, height)
115
+ with _block_lock:
116
+ _block_cache[height] = result
117
+ return result
118
+
119
+
120
+ def cached_block_by_hash(rpc, block_hash: str):
121
+ """Analyze a block by hash, caching the result by resolved height."""
122
+ from bitcoinlib_rpc.blocks import analyze_block
123
+
124
+ # Check if we already know this hash's height
125
+ with _block_lock:
126
+ height = _hash_to_height.get(block_hash)
127
+ if height is not None:
128
+ if height in _block_cache:
129
+ return _block_cache[height]
130
+ if height in _recent_block_cache:
131
+ return _recent_block_cache[height]
132
+
133
+ result = analyze_block(rpc, block_hash)
134
+ data = result.model_dump() if hasattr(result, "model_dump") else result
135
+ resolved_height = data.get("height")
136
+
137
+ if resolved_height is not None:
138
+ with _block_lock:
139
+ _hash_to_height[block_hash] = resolved_height
140
+ _block_cache[resolved_height] = result
141
+
142
+ return result
143
+
144
+
145
+ def cached_next_block(rpc):
146
+ from bitcoinlib_rpc.nextblock import analyze_next_block
147
+
148
+ key = "nextblock"
149
+ with _nextblock_lock:
150
+ if key in _nextblock_cache:
151
+ return _nextblock_cache[key]
152
+ result = analyze_next_block(rpc)
153
+ with _nextblock_lock:
154
+ _nextblock_cache[key] = result
155
+ return result
bitcoin_api/config.py ADDED
@@ -0,0 +1,42 @@
1
+ """Application settings from environment variables."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import SecretStr
6
+ from pydantic_settings import BaseSettings
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ # Bitcoin Core RPC
11
+ bitcoin_rpc_host: str = "127.0.0.1"
12
+ bitcoin_rpc_port: int = 8332
13
+ bitcoin_rpc_user: str | None = None
14
+ bitcoin_rpc_password: SecretStr | None = None
15
+ bitcoin_datadir: str | None = None
16
+
17
+ # API server
18
+ api_host: str = "0.0.0.0"
19
+ api_port: int = 9332
20
+ api_db_path: Path = Path("data/bitcoin_api.db")
21
+
22
+ # CORS (comma-separated origins, or "*" for all — use "*" only for local/dev)
23
+ cors_origins: str = "http://localhost:3000,http://localhost:9332"
24
+
25
+ # Rate limits (requests per minute)
26
+ rate_limit_anonymous: int = 30
27
+ rate_limit_free: int = 100
28
+ rate_limit_pro: int = 500
29
+ rate_limit_enterprise: int = 2000
30
+
31
+ # L402 Lightning payments (disabled by default)
32
+ l402_enabled: bool = False
33
+ lightning_backend: str = "mock" # "alby" or "mock"
34
+ alby_hub_url: str = ""
35
+ alby_hub_token: str = ""
36
+ l402_root_key: str = "" # hex-encoded 32-byte key; auto-generated if empty
37
+ l402_default_expiry: int = 3600 # seconds
38
+
39
+ model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
40
+
41
+
42
+ settings = Settings()
bitcoin_api/db.py ADDED
@@ -0,0 +1,136 @@
1
+ """SQLite database for API keys and usage tracking."""
2
+
3
+ import sqlite3
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from .config import settings
8
+
9
+ _local = threading.local()
10
+ _db_path: Path | None = None
11
+ _initialized = False
12
+ _init_lock = threading.Lock()
13
+
14
+ SCHEMA = """
15
+ CREATE TABLE IF NOT EXISTS api_keys (
16
+ key_hash TEXT PRIMARY KEY,
17
+ prefix TEXT NOT NULL,
18
+ tier TEXT NOT NULL DEFAULT 'free',
19
+ label TEXT,
20
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
21
+ active INTEGER NOT NULL DEFAULT 1
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS usage_log (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ key_hash TEXT,
27
+ endpoint TEXT NOT NULL,
28
+ status INTEGER NOT NULL,
29
+ ts TEXT NOT NULL DEFAULT (datetime('now'))
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_log(ts);
33
+ CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_hash);
34
+ CREATE INDEX IF NOT EXISTS idx_usage_key_ts ON usage_log(key_hash, ts);
35
+
36
+ CREATE TABLE IF NOT EXISTS l402_payments (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ payment_hash TEXT NOT NULL UNIQUE,
39
+ macaroon_id TEXT NOT NULL,
40
+ endpoint TEXT NOT NULL,
41
+ amount_sats INTEGER NOT NULL,
42
+ preimage TEXT,
43
+ status TEXT NOT NULL DEFAULT 'pending',
44
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
45
+ paid_at TEXT
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_l402_payment_hash ON l402_payments(payment_hash);
49
+ """
50
+
51
+
52
+ def _make_conn(path: Path) -> sqlite3.Connection:
53
+ conn = sqlite3.connect(str(path), check_same_thread=False)
54
+ conn.execute("PRAGMA journal_mode=WAL")
55
+ conn.execute("PRAGMA busy_timeout=5000")
56
+ conn.row_factory = sqlite3.Row
57
+ return conn
58
+
59
+
60
+ def get_db(db_path: Path | None = None) -> sqlite3.Connection:
61
+ global _db_path, _initialized
62
+
63
+ # First call initializes the path and schema
64
+ if not _initialized:
65
+ with _init_lock:
66
+ if not _initialized:
67
+ _db_path = db_path or settings.api_db_path
68
+ _db_path.parent.mkdir(parents=True, exist_ok=True)
69
+ conn = _make_conn(_db_path)
70
+ conn.executescript(SCHEMA)
71
+ conn.close()
72
+ _initialized = True
73
+
74
+ # Each thread gets its own connection
75
+ conn = getattr(_local, "conn", None)
76
+ if conn is None:
77
+ _local.conn = _make_conn(_db_path)
78
+ conn = _local.conn
79
+ return conn
80
+
81
+
82
+ def log_usage(key_hash: str | None, endpoint: str, status_code: int) -> None:
83
+ conn = get_db()
84
+ conn.execute(
85
+ "INSERT INTO usage_log (key_hash, endpoint, status) VALUES (?, ?, ?)",
86
+ (key_hash, endpoint, status_code),
87
+ )
88
+ conn.commit()
89
+
90
+
91
+ def count_daily_usage(key_hash: str) -> int:
92
+ conn = get_db()
93
+ row = conn.execute(
94
+ "SELECT COUNT(*) FROM usage_log WHERE key_hash = ? AND ts >= date('now')",
95
+ (key_hash,),
96
+ ).fetchone()
97
+ return row[0] if row else 0
98
+
99
+
100
+ def prune_old_logs(days: int = 90) -> int:
101
+ conn = get_db()
102
+ cursor = conn.execute(
103
+ "DELETE FROM usage_log WHERE ts < datetime('now', ?)",
104
+ (f"-{days} days",),
105
+ )
106
+ conn.commit()
107
+ return cursor.rowcount
108
+
109
+
110
+ def lookup_key(key_hash: str) -> dict | None:
111
+ conn = get_db()
112
+ row = conn.execute(
113
+ "SELECT key_hash, prefix, tier, label, active FROM api_keys WHERE key_hash = ?",
114
+ (key_hash,),
115
+ ).fetchone()
116
+ if row is None:
117
+ return None
118
+ return dict(row)
119
+
120
+
121
+ def log_l402_payment(payment_hash: str, macaroon_id: str, endpoint: str, amount_sats: int) -> None:
122
+ conn = get_db()
123
+ conn.execute(
124
+ "INSERT OR IGNORE INTO l402_payments (payment_hash, macaroon_id, endpoint, amount_sats) VALUES (?, ?, ?, ?)",
125
+ (payment_hash, macaroon_id, endpoint, amount_sats),
126
+ )
127
+ conn.commit()
128
+
129
+
130
+ def mark_l402_paid(payment_hash: str, preimage: str) -> None:
131
+ conn = get_db()
132
+ conn.execute(
133
+ "UPDATE l402_payments SET status = 'paid', preimage = ?, paid_at = datetime('now') WHERE payment_hash = ?",
134
+ (preimage, payment_hash),
135
+ )
136
+ conn.commit()
@@ -0,0 +1,39 @@
1
+ """FastAPI dependencies — RPC client singleton, auth, rate limiting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ from bitcoinlib_rpc import BitcoinRPC
8
+
9
+ from .config import settings
10
+
11
+ _rpc: BitcoinRPC | None = None
12
+ _rpc_lock = threading.Lock()
13
+
14
+
15
+ def _create_rpc() -> BitcoinRPC:
16
+ return BitcoinRPC(
17
+ host=settings.bitcoin_rpc_host,
18
+ port=settings.bitcoin_rpc_port,
19
+ user=settings.bitcoin_rpc_user,
20
+ password=settings.bitcoin_rpc_password.get_secret_value() if settings.bitcoin_rpc_password else None,
21
+ datadir=settings.bitcoin_datadir,
22
+ )
23
+
24
+
25
+ def get_rpc() -> BitcoinRPC:
26
+ """Lazy singleton for the Bitcoin RPC connection. Resets on connection failure."""
27
+ global _rpc
28
+ if _rpc is None:
29
+ with _rpc_lock:
30
+ if _rpc is None:
31
+ _rpc = _create_rpc()
32
+ return _rpc
33
+
34
+
35
+ def reset_rpc() -> None:
36
+ """Reset the RPC singleton (called on connection failure to allow recovery)."""
37
+ global _rpc
38
+ with _rpc_lock:
39
+ _rpc = None
bitcoin_api/l402.py ADDED
@@ -0,0 +1,120 @@
1
+ """L402 Lightning payment protocol — macaroon minting, verification, and challenges."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from dataclasses import dataclass
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class L402Token:
17
+ """Parsed L402 authorization token."""
18
+ macaroon_bytes: bytes
19
+ preimage: str # hex
20
+
21
+
22
+ @dataclass
23
+ class Macaroon:
24
+ """Simple macaroon for L402 — identifier contains payment_hash, signed with root key."""
25
+ identifier: str # JSON: {"payment_hash": "...", "endpoint": "...", "amount": N, "expires": T}
26
+ signature: str # HMAC-SHA256 hex
27
+
28
+
29
+ def mint_macaroon(
30
+ root_key: bytes,
31
+ payment_hash: str,
32
+ endpoint: str,
33
+ amount_sats: int,
34
+ expiry_seconds: int = 3600,
35
+ ) -> Macaroon:
36
+ """Create a new macaroon tied to a Lightning payment."""
37
+ identifier = json.dumps({
38
+ "payment_hash": payment_hash,
39
+ "endpoint": endpoint,
40
+ "amount_sats": amount_sats,
41
+ "expires": int(time.time()) + expiry_seconds,
42
+ "version": 1,
43
+ }, separators=(",", ":"))
44
+
45
+ signature = hmac.new(root_key, identifier.encode(), hashlib.sha256).hexdigest()
46
+
47
+ return Macaroon(identifier=identifier, signature=signature)
48
+
49
+
50
+ def serialize_macaroon(mac: Macaroon) -> str:
51
+ """Serialize macaroon to base64 string for HTTP headers."""
52
+ payload = json.dumps({
53
+ "identifier": mac.identifier,
54
+ "signature": mac.signature,
55
+ }, separators=(",", ":"))
56
+ return base64.urlsafe_b64encode(payload.encode()).decode()
57
+
58
+
59
+ def deserialize_macaroon(b64_str: str) -> Macaroon:
60
+ """Deserialize macaroon from base64 string."""
61
+ payload = json.loads(base64.urlsafe_b64decode(b64_str))
62
+ return Macaroon(
63
+ identifier=payload["identifier"],
64
+ signature=payload["signature"],
65
+ )
66
+
67
+
68
+ def verify_macaroon(root_key: bytes, mac: Macaroon) -> tuple[bool, str]:
69
+ """Verify macaroon signature and expiry. Returns (valid, reason)."""
70
+ # Verify signature
71
+ expected = hmac.new(root_key, mac.identifier.encode(), hashlib.sha256).hexdigest()
72
+ if not hmac.compare_digest(expected, mac.signature):
73
+ return False, "invalid signature"
74
+
75
+ # Parse identifier
76
+ try:
77
+ ident = json.loads(mac.identifier)
78
+ except json.JSONDecodeError:
79
+ return False, "malformed identifier"
80
+
81
+ # Check expiry
82
+ if time.time() > ident.get("expires", 0):
83
+ return False, "macaroon expired"
84
+
85
+ return True, "ok"
86
+
87
+
88
+ def verify_preimage(payment_hash: str, preimage_hex: str) -> bool:
89
+ """Verify that SHA256(preimage) == payment_hash."""
90
+ try:
91
+ preimage_bytes = bytes.fromhex(preimage_hex)
92
+ computed_hash = hashlib.sha256(preimage_bytes).hexdigest()
93
+ return hmac.compare_digest(computed_hash, payment_hash)
94
+ except (ValueError, TypeError):
95
+ return False
96
+
97
+
98
+ def parse_l402_header(auth_header: str) -> L402Token | None:
99
+ """Parse Authorization: L402 <macaroon>:<preimage> header."""
100
+ if not auth_header.startswith("L402 "):
101
+ return None
102
+ token_part = auth_header[5:].strip()
103
+ if ":" not in token_part:
104
+ return None
105
+ mac_b64, preimage_hex = token_part.split(":", 1)
106
+ try:
107
+ mac_bytes = base64.urlsafe_b64decode(mac_b64)
108
+ except Exception:
109
+ return None
110
+ return L402Token(macaroon_bytes=mac_bytes, preimage=preimage_hex)
111
+
112
+
113
+ def create_challenge(macaroon_b64: str, invoice: str) -> str:
114
+ """Create WWW-Authenticate header value for 402 response."""
115
+ return f'L402 macaroon="{macaroon_b64}", invoice="{invoice}"'
116
+
117
+
118
+ def generate_root_key() -> bytes:
119
+ """Generate a random 32-byte root key for macaroon signing."""
120
+ return os.urandom(32)
@@ -0,0 +1,97 @@
1
+ """Lightning Network client abstraction for L402 payments."""
2
+
3
+ import hashlib
4
+ import logging
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class Invoice:
15
+ payment_hash: str
16
+ payment_request: str
17
+ amount_sats: int
18
+ expiry: int
19
+
20
+
21
+ class LightningClient(ABC):
22
+ @abstractmethod
23
+ def create_invoice(self, amount_sats: int, memo: str, expiry: int = 3600) -> Invoice:
24
+ ...
25
+
26
+ @abstractmethod
27
+ def verify_payment(self, payment_hash: str) -> bool:
28
+ ...
29
+
30
+ @abstractmethod
31
+ def get_balance(self) -> int:
32
+ ...
33
+
34
+
35
+ class AlbyHubClient(LightningClient):
36
+ def __init__(self, base_url: str, token: str):
37
+ self.base_url = base_url.rstrip("/")
38
+ self.client = httpx.Client(
39
+ base_url=self.base_url,
40
+ headers={"Authorization": f"Bearer {token}"},
41
+ timeout=10.0,
42
+ )
43
+
44
+ def create_invoice(self, amount_sats: int, memo: str, expiry: int = 3600) -> Invoice:
45
+ resp = self.client.post("/api/v1/invoices", json={
46
+ "amount": amount_sats * 1000, # Alby uses millisats
47
+ "description": memo,
48
+ "expiry": expiry,
49
+ })
50
+ resp.raise_for_status()
51
+ data = resp.json()
52
+ return Invoice(
53
+ payment_hash=data["payment_hash"],
54
+ payment_request=data["payment_request"],
55
+ amount_sats=amount_sats,
56
+ expiry=expiry,
57
+ )
58
+
59
+ def verify_payment(self, payment_hash: str) -> bool:
60
+ resp = self.client.get(f"/api/v1/invoices/{payment_hash}")
61
+ if resp.status_code == 404:
62
+ return False
63
+ resp.raise_for_status()
64
+ data = resp.json()
65
+ return data.get("settled", False) or data.get("state") == "settled"
66
+
67
+ def get_balance(self) -> int:
68
+ resp = self.client.get("/api/v1/balance")
69
+ resp.raise_for_status()
70
+ data = resp.json()
71
+ return data.get("balance", 0) // 1000 # Convert millisats to sats
72
+
73
+
74
+ class MockLightningClient(LightningClient):
75
+ def __init__(self):
76
+ self.invoices: dict[str, Invoice] = {}
77
+ self.paid: set[str] = set()
78
+
79
+ def create_invoice(self, amount_sats: int, memo: str, expiry: int = 3600) -> Invoice:
80
+ payment_hash = hashlib.sha256(memo.encode()).hexdigest()
81
+ invoice = Invoice(
82
+ payment_hash=payment_hash,
83
+ payment_request=f"lnbc{amount_sats}n1mock{payment_hash[:20]}",
84
+ amount_sats=amount_sats,
85
+ expiry=expiry,
86
+ )
87
+ self.invoices[payment_hash] = invoice
88
+ return invoice
89
+
90
+ def verify_payment(self, payment_hash: str) -> bool:
91
+ return payment_hash in self.paid
92
+
93
+ def get_balance(self) -> int:
94
+ return 100000
95
+
96
+ def simulate_payment(self, payment_hash: str) -> None:
97
+ self.paid.add(payment_hash)