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.
- bitcoin_api/__init__.py +8 -0
- bitcoin_api/auth.py +67 -0
- bitcoin_api/cache.py +155 -0
- bitcoin_api/config.py +42 -0
- bitcoin_api/db.py +136 -0
- bitcoin_api/dependencies.py +39 -0
- bitcoin_api/l402.py +120 -0
- bitcoin_api/lightning.py +97 -0
- bitcoin_api/main.py +437 -0
- bitcoin_api/models.py +195 -0
- bitcoin_api/pricing.py +66 -0
- bitcoin_api/rate_limit.py +92 -0
- bitcoin_api/routers/__init__.py +0 -0
- bitcoin_api/routers/blocks.py +325 -0
- bitcoin_api/routers/fees.py +225 -0
- bitcoin_api/routers/mempool.py +240 -0
- bitcoin_api/routers/mining.py +100 -0
- bitcoin_api/routers/network.py +201 -0
- bitcoin_api/routers/prices.py +90 -0
- bitcoin_api/routers/status.py +78 -0
- bitcoin_api/routers/transactions.py +348 -0
- satoshi_api-0.1.0.dist-info/METADATA +209 -0
- satoshi_api-0.1.0.dist-info/RECORD +26 -0
- satoshi_api-0.1.0.dist-info/WHEEL +4 -0
- satoshi_api-0.1.0.dist-info/entry_points.txt +3 -0
- satoshi_api-0.1.0.dist-info/licenses/LICENSE +21 -0
bitcoin_api/__init__.py
ADDED
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)
|
bitcoin_api/lightning.py
ADDED
|
@@ -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)
|