Compare commits
2 commits
266b16834b
...
bae881587c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bae881587c | ||
|
|
5443cb75bf |
8 changed files with 936 additions and 0 deletions
|
|
@ -482,6 +482,12 @@ def register_async_tasks() -> None:
|
|||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
|
||||
# nostr transport (RPC over nostr relays)
|
||||
if settings.nostr_transport_enabled:
|
||||
from lnbits.core.services.nostr_transport import start_nostr_transport
|
||||
|
||||
create_permanent_task(start_nostr_transport)
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
server_log_task = initialize_server_websocket_logger()
|
||||
|
|
|
|||
135
lnbits/core/services/nostr_transport/__init__.py
Normal file
135
lnbits/core/services/nostr_transport/__init__.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""
|
||||
Nostr transport layer for LNbits.
|
||||
|
||||
Enables LNbits API access over nostr relays using NIP-44 encrypted
|
||||
kind-21000 events, modeled after Lightning.pub's architecture.
|
||||
No port forwarding, DNS, or SSL required.
|
||||
|
||||
Usage:
|
||||
Set these in .env:
|
||||
NOSTR_TRANSPORT_ENABLED=true
|
||||
NOSTR_TRANSPORT_PRIVATE_KEY=<hex_private_key>
|
||||
NOSTR_TRANSPORT_RELAYS=["wss://relay.damus.io"]
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.nostr import generate_keypair, normalize_private_key
|
||||
|
||||
from .dispatcher import dispatch, register_default_rpcs
|
||||
from .models import NostrRpcRequest, NostrRpcResponse
|
||||
from .relay_pool import NostrTransportPool
|
||||
|
||||
_pool: NostrTransportPool | None = None
|
||||
|
||||
|
||||
async def start_nostr_transport():
|
||||
"""
|
||||
Main entry point. Called from app.py via create_permanent_task().
|
||||
Initializes the relay pool, registers RPC endpoints, and starts
|
||||
listening for incoming nostr events.
|
||||
"""
|
||||
global _pool
|
||||
|
||||
if not settings.nostr_transport_enabled:
|
||||
return
|
||||
|
||||
# Resolve keypair
|
||||
private_key_hex = settings.nostr_transport_private_key
|
||||
if not private_key_hex:
|
||||
logger.info("Nostr transport: no private key configured, generating one...")
|
||||
private_key_hex, public_key_hex = generate_keypair()
|
||||
settings.nostr_transport_private_key = private_key_hex
|
||||
settings.nostr_transport_public_key = public_key_hex
|
||||
logger.info(
|
||||
f"Nostr transport: generated keypair. "
|
||||
f"Public key (share this): {public_key_hex}"
|
||||
)
|
||||
else:
|
||||
private_key_hex = normalize_private_key(private_key_hex)
|
||||
# Derive public key from private key
|
||||
import secp256k1
|
||||
|
||||
privkey = secp256k1.PrivateKey(bytes.fromhex(private_key_hex), True)
|
||||
public_key_hex = privkey.pubkey.serialize()[1:].hex()
|
||||
settings.nostr_transport_public_key = public_key_hex
|
||||
|
||||
relay_urls = settings.nostr_transport_relays
|
||||
if not relay_urls:
|
||||
logger.warning("Nostr transport: no relays configured, disabling")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Nostr transport: starting with pubkey {public_key_hex[:16]}... "
|
||||
f"on {len(relay_urls)} relay(s)"
|
||||
)
|
||||
|
||||
# Register RPC endpoints
|
||||
register_default_rpcs()
|
||||
|
||||
# Create and start relay pool
|
||||
_pool = NostrTransportPool(
|
||||
private_key_hex=private_key_hex,
|
||||
public_key_hex=public_key_hex,
|
||||
relay_urls=relay_urls,
|
||||
event_callback=_handle_event,
|
||||
)
|
||||
await _pool.start()
|
||||
|
||||
# Keep alive until shutdown
|
||||
while not _pool._is_shutting_down():
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await stop_nostr_transport()
|
||||
|
||||
|
||||
async def stop_nostr_transport():
|
||||
"""Clean shutdown of the nostr transport."""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.stop()
|
||||
_pool = None
|
||||
logger.info("Nostr transport: stopped")
|
||||
|
||||
|
||||
async def _handle_event(sender_pubkey: str, plaintext: str):
|
||||
"""
|
||||
Handle a decrypted incoming event.
|
||||
Parse as RPC request, dispatch, and send response.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(plaintext)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Nostr transport: received non-JSON event content")
|
||||
return
|
||||
|
||||
# Parse request
|
||||
try:
|
||||
request = NostrRpcRequest(**data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Nostr transport: invalid RPC request: {e}")
|
||||
# Try to send error response if we have a request_id
|
||||
request_id = data.get("request_id", "unknown")
|
||||
response = NostrRpcResponse(
|
||||
status="ERROR",
|
||||
request_id=request_id,
|
||||
error=f"Invalid request: {e}",
|
||||
)
|
||||
if _pool:
|
||||
await _pool.send_response(sender_pubkey, response.json())
|
||||
return
|
||||
|
||||
# Validate authIdentifier matches sender (Lightning.pub pattern)
|
||||
# In our case, the sender pubkey from the event IS the auth identity
|
||||
|
||||
# Dispatch
|
||||
response = await dispatch(request, sender_pubkey)
|
||||
|
||||
# Send response
|
||||
if _pool:
|
||||
await _pool.send_response(sender_pubkey, response.json())
|
||||
79
lnbits/core/services/nostr_transport/auth.py
Normal file
79
lnbits/core/services/nostr_transport/auth.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Nostr pubkey -> LNbits account/wallet authorization.
|
||||
|
||||
Maps a nostr event sender's pubkey to the appropriate LNbits
|
||||
authorization context, following the same patterns as the HTTP
|
||||
decorators in lnbits/decorators.py.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.users import get_account_by_pubkey
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Account, UserExtra
|
||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
async def resolve_nostr_auth(
|
||||
pubkey: str,
|
||||
wallet_id: str | None = None,
|
||||
key_type_str: str | None = None,
|
||||
) -> WalletTypeInfo | Account | None:
|
||||
"""
|
||||
Resolve a nostr pubkey + optional wallet_id + key_type to an
|
||||
LNbits authorization context.
|
||||
|
||||
Returns:
|
||||
WalletTypeInfo - for wallet-scoped operations (payments, etc.)
|
||||
Account - for account-level operations (create wallet, etc.)
|
||||
None - for public endpoints (decode, payment status)
|
||||
"""
|
||||
if wallet_id is None and key_type_str is None:
|
||||
# Public endpoint -- no auth needed, but still resolve account
|
||||
# if pubkey is provided (for audit/logging)
|
||||
account = await get_account_by_pubkey(pubkey)
|
||||
if account:
|
||||
return account
|
||||
return None
|
||||
|
||||
# Resolve account from pubkey
|
||||
account = await get_account_by_pubkey(pubkey)
|
||||
if not account:
|
||||
if not settings.new_accounts_allowed:
|
||||
raise PermissionError("Account creation is disabled.")
|
||||
# Auto-create account (same as nostr_login in auth_api.py)
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
pubkey=pubkey,
|
||||
extra=UserExtra(provider="nostr"),
|
||||
)
|
||||
user = await create_user_account(account)
|
||||
logger.info(f"Nostr transport: auto-created account for {pubkey[:16]}...")
|
||||
account = Account(
|
||||
id=user.id,
|
||||
pubkey=pubkey,
|
||||
extra=UserExtra(provider="nostr"),
|
||||
)
|
||||
|
||||
# TODO: upstream LNbits (dev) has account.activated field
|
||||
# if not account.activated:
|
||||
# raise PermissionError("Account is not activated.")
|
||||
|
||||
# Account-level operation (no wallet specified)
|
||||
if wallet_id is None:
|
||||
return account
|
||||
|
||||
# Wallet-scoped operation
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet:
|
||||
raise ValueError(f"Wallet not found: {wallet_id}")
|
||||
|
||||
if wallet.user != account.id:
|
||||
raise PermissionError("Wallet does not belong to this account.")
|
||||
|
||||
key_type = KeyType.admin if key_type_str == "admin" else KeyType.invoice
|
||||
return WalletTypeInfo(key_type=key_type, wallet=wallet)
|
||||
187
lnbits/core/services/nostr_transport/crypto.py
Normal file
187
lnbits/core/services/nostr_transport/crypto.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""
|
||||
NIP-44 v2 encryption/decryption for nostr transport.
|
||||
|
||||
Implements the NIP-44 v2 spec using secp256k1 (ECDH) and
|
||||
pycryptodome (ChaCha20, HMAC-SHA256, HKDF), all already in LNbits' deps.
|
||||
|
||||
Also re-exports sign_event() and verify_event() from lnbits.utils.nostr.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import struct
|
||||
from math import floor, log2
|
||||
|
||||
import secp256k1
|
||||
from Cryptodome.Cipher import ChaCha20
|
||||
|
||||
from lnbits.utils.nostr import sign_event, verify_event # noqa: F401
|
||||
|
||||
_NIP44_SALT = b"nip44-v2"
|
||||
_VERSION = 2
|
||||
|
||||
|
||||
def get_conversation_key(
|
||||
private_key_hex: str, public_key_hex: str
|
||||
) -> bytes:
|
||||
"""
|
||||
Compute NIP-44 v2 conversation key via ECDH + HKDF-extract.
|
||||
|
||||
The public key must be 32-byte x-only (as in nostr). We prepend 0x02
|
||||
to make it a valid compressed SEC1 point for secp256k1.
|
||||
"""
|
||||
# x-only pubkey -> compressed pubkey (assume even y)
|
||||
pubkey_bytes = b"\x02" + bytes.fromhex(public_key_hex)
|
||||
pubkey = secp256k1.PublicKey(pubkey_bytes, True)
|
||||
|
||||
# ECDH: multiply pubkey by privkey scalar, get shared point
|
||||
# tweak_mul multiplies the point by a scalar, returning a new PublicKey
|
||||
shared_point = pubkey.tweak_mul(bytes.fromhex(private_key_hex))
|
||||
# serialize(compressed=True) gives 0x02/0x03 + 32-byte x coordinate
|
||||
# strip the prefix byte to get the raw x coordinate
|
||||
shared_x = shared_point.serialize()[1:]
|
||||
|
||||
# HKDF-extract with salt="nip44-v2"
|
||||
return _hkdf_extract(salt=_NIP44_SALT, ikm=shared_x)
|
||||
|
||||
|
||||
def nip44_encrypt(
|
||||
plaintext: str, sender_privkey_hex: str, recipient_pubkey_hex: str
|
||||
) -> str:
|
||||
"""Encrypt a plaintext string using NIP-44 v2."""
|
||||
conversation_key = get_conversation_key(sender_privkey_hex, recipient_pubkey_hex)
|
||||
nonce = os.urandom(32)
|
||||
return _encrypt(plaintext, conversation_key, nonce)
|
||||
|
||||
|
||||
def nip44_decrypt(
|
||||
payload: str, recipient_privkey_hex: str, sender_pubkey_hex: str
|
||||
) -> str:
|
||||
"""Decrypt a NIP-44 v2 payload."""
|
||||
conversation_key = get_conversation_key(recipient_privkey_hex, sender_pubkey_hex)
|
||||
return _decrypt(payload, conversation_key)
|
||||
|
||||
|
||||
# --- Internal implementation ---
|
||||
|
||||
|
||||
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
||||
"""HKDF-extract (RFC 5869) with SHA-256."""
|
||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
||||
"""HKDF-expand (RFC 5869) with SHA-256."""
|
||||
hash_len = 32 # SHA-256
|
||||
n = (length + hash_len - 1) // hash_len
|
||||
okm = b""
|
||||
t = b""
|
||||
for i in range(1, n + 1):
|
||||
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
||||
okm += t
|
||||
return okm[:length]
|
||||
|
||||
|
||||
def _calc_padded_len(unpadded_len: int) -> int:
|
||||
"""NIP-44 v2 padding length calculation."""
|
||||
if unpadded_len <= 32:
|
||||
return 32
|
||||
next_power = 1 << (floor(log2(unpadded_len - 1)) + 1)
|
||||
if next_power <= 256:
|
||||
chunk = 32
|
||||
else:
|
||||
chunk = next_power // 8
|
||||
return chunk * (floor((unpadded_len - 1) / chunk) + 1)
|
||||
|
||||
|
||||
def _pad(plaintext: str) -> bytes:
|
||||
"""Convert plaintext to NIP-44 v2 padded byte array."""
|
||||
unpadded = plaintext.encode("utf-8")
|
||||
unpadded_len = len(unpadded)
|
||||
if unpadded_len < 1 or unpadded_len > 65535:
|
||||
raise ValueError(f"Invalid plaintext length: {unpadded_len}")
|
||||
padded_len = _calc_padded_len(unpadded_len)
|
||||
prefix = struct.pack(">H", unpadded_len) # big-endian uint16
|
||||
suffix = b"\x00" * (padded_len - unpadded_len)
|
||||
return prefix + unpadded + suffix
|
||||
|
||||
|
||||
def _unpad(padded: bytes) -> str:
|
||||
"""Convert NIP-44 v2 padded byte array back to plaintext."""
|
||||
unpadded_len = struct.unpack(">H", padded[:2])[0]
|
||||
if unpadded_len == 0:
|
||||
raise ValueError("Invalid padding: zero length")
|
||||
unpadded = padded[2 : 2 + unpadded_len]
|
||||
if len(unpadded) != unpadded_len:
|
||||
raise ValueError("Invalid padding: length mismatch")
|
||||
expected_padded_total = 2 + _calc_padded_len(unpadded_len)
|
||||
if len(padded) != expected_padded_total:
|
||||
raise ValueError("Invalid padding: wrong padded size")
|
||||
return unpadded.decode("utf-8")
|
||||
|
||||
|
||||
def _get_message_keys(conversation_key: bytes, nonce: bytes) -> tuple[bytes, bytes, bytes]:
|
||||
"""Derive chacha_key, chacha_nonce, hmac_key from conversation_key and nonce."""
|
||||
keys = _hkdf_expand(conversation_key, nonce, 76)
|
||||
chacha_key = keys[0:32]
|
||||
chacha_nonce = keys[32:44]
|
||||
hmac_key = keys[44:76]
|
||||
return chacha_key, chacha_nonce, hmac_key
|
||||
|
||||
|
||||
def _encrypt(plaintext: str, conversation_key: bytes, nonce: bytes) -> str:
|
||||
"""Encrypt using NIP-44 v2."""
|
||||
chacha_key, chacha_nonce, hmac_key = _get_message_keys(conversation_key, nonce)
|
||||
padded = _pad(plaintext)
|
||||
|
||||
# ChaCha20 encrypt (RFC 8439, counter=0)
|
||||
cipher = ChaCha20.new(key=chacha_key, nonce=chacha_nonce)
|
||||
ciphertext = cipher.encrypt(padded)
|
||||
|
||||
# HMAC-SHA256 over nonce + ciphertext
|
||||
mac = hmac.new(hmac_key, nonce + ciphertext, hashlib.sha256).digest()
|
||||
|
||||
# Assemble: version(1) + nonce(32) + ciphertext(variable) + mac(32)
|
||||
payload = bytes([_VERSION]) + nonce + ciphertext + mac
|
||||
return base64.b64encode(payload).decode("ascii")
|
||||
|
||||
|
||||
def _decrypt(payload_b64: str, conversation_key: bytes) -> str:
|
||||
"""Decrypt a NIP-44 v2 payload."""
|
||||
if not payload_b64:
|
||||
raise ValueError("Empty payload")
|
||||
if payload_b64[0] == "#":
|
||||
raise ValueError("Unknown encoding version")
|
||||
|
||||
plen = len(payload_b64)
|
||||
if plen < 132 or plen > 87472:
|
||||
raise ValueError(f"Invalid payload size: {plen}")
|
||||
|
||||
data = base64.b64decode(payload_b64)
|
||||
dlen = len(data)
|
||||
if dlen < 99 or dlen > 65603:
|
||||
raise ValueError(f"Invalid data size: {dlen}")
|
||||
|
||||
version = data[0]
|
||||
if version != _VERSION:
|
||||
raise ValueError(f"Unknown version: {version}")
|
||||
|
||||
nonce = data[1:33]
|
||||
ciphertext = data[33 : dlen - 32]
|
||||
mac = data[dlen - 32 : dlen]
|
||||
|
||||
# Derive keys
|
||||
chacha_key, chacha_nonce, hmac_key = _get_message_keys(conversation_key, nonce)
|
||||
|
||||
# Verify MAC (constant-time)
|
||||
expected_mac = hmac.new(hmac_key, nonce + ciphertext, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(mac, expected_mac):
|
||||
raise ValueError("Invalid MAC")
|
||||
|
||||
# Decrypt
|
||||
cipher = ChaCha20.new(key=chacha_key, nonce=chacha_nonce)
|
||||
padded = cipher.decrypt(ciphertext)
|
||||
|
||||
return _unpad(padded)
|
||||
249
lnbits/core/services/nostr_transport/dispatcher.py
Normal file
249
lnbits/core/services/nostr_transport/dispatcher.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""
|
||||
RPC dispatcher for the nostr transport.
|
||||
|
||||
Maps rpc_name strings to LNbits service functions, handles auth
|
||||
resolution, and returns serialized responses. Modeled after
|
||||
Lightning.pub's nostr_transport.ts auto-generated dispatcher.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
|
||||
|
||||
from .auth import resolve_nostr_auth
|
||||
from .models import NostrRpcRequest, NostrRpcResponse
|
||||
|
||||
# Auth level constants
|
||||
AUTH_NONE = "none"
|
||||
AUTH_ACCOUNT = "account"
|
||||
AUTH_INVOICE_KEY = "invoice_key"
|
||||
AUTH_ADMIN_KEY = "admin_key"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RpcEndpoint:
|
||||
handler: Callable[..., Awaitable[Any]]
|
||||
auth_level: str # AUTH_NONE, AUTH_ACCOUNT, AUTH_INVOICE_KEY, AUTH_ADMIN_KEY
|
||||
|
||||
|
||||
# Global RPC registry
|
||||
_RPC_REGISTRY: dict[str, RpcEndpoint] = {}
|
||||
|
||||
|
||||
def register_rpc(
|
||||
name: str,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
auth_level: str = AUTH_INVOICE_KEY,
|
||||
):
|
||||
"""Register an RPC handler."""
|
||||
_RPC_REGISTRY[name] = RpcEndpoint(handler=handler, auth_level=auth_level)
|
||||
|
||||
|
||||
async def dispatch(request: NostrRpcRequest, sender_pubkey: str) -> NostrRpcResponse:
|
||||
"""
|
||||
Route an RPC request to the appropriate handler.
|
||||
|
||||
1. Look up rpc_name in registry
|
||||
2. Resolve auth from sender pubkey
|
||||
3. Validate auth level
|
||||
4. Call handler
|
||||
5. Return response
|
||||
"""
|
||||
endpoint = _RPC_REGISTRY.get(request.rpc_name)
|
||||
if not endpoint:
|
||||
return NostrRpcResponse(
|
||||
status="ERROR",
|
||||
request_id=request.request_id,
|
||||
error=f"Unknown RPC: {request.rpc_name}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Resolve auth
|
||||
auth_ctx = await _resolve_auth(
|
||||
endpoint.auth_level, sender_pubkey, request.wallet_id, request.key_type
|
||||
)
|
||||
|
||||
# Call handler
|
||||
result = await endpoint.handler(auth_ctx, request)
|
||||
return NostrRpcResponse(
|
||||
status="OK",
|
||||
request_id=request.request_id,
|
||||
data=result,
|
||||
)
|
||||
except (PermissionError, ValueError) as e:
|
||||
return NostrRpcResponse(
|
||||
status="ERROR",
|
||||
request_id=request.request_id,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Nostr transport: RPC error in {request.rpc_name}: {e}")
|
||||
return NostrRpcResponse(
|
||||
status="ERROR",
|
||||
request_id=request.request_id,
|
||||
error="Internal error",
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_auth(
|
||||
auth_level: str,
|
||||
sender_pubkey: str,
|
||||
wallet_id: str | None,
|
||||
key_type_str: str | None,
|
||||
) -> WalletTypeInfo | Account | None:
|
||||
"""Resolve and validate auth for the given level."""
|
||||
if auth_level == AUTH_NONE:
|
||||
return None
|
||||
|
||||
auth_ctx = await resolve_nostr_auth(sender_pubkey, wallet_id, key_type_str)
|
||||
|
||||
if auth_level == AUTH_ACCOUNT:
|
||||
if auth_ctx is None:
|
||||
raise PermissionError("Authentication required.")
|
||||
return auth_ctx
|
||||
|
||||
if auth_level in (AUTH_INVOICE_KEY, AUTH_ADMIN_KEY):
|
||||
if not isinstance(auth_ctx, WalletTypeInfo):
|
||||
raise PermissionError("Wallet authentication required (provide wallet_id).")
|
||||
if auth_level == AUTH_ADMIN_KEY and auth_ctx.key_type != KeyType.admin:
|
||||
raise PermissionError("Admin key required for this operation.")
|
||||
return auth_ctx
|
||||
|
||||
raise PermissionError(f"Unknown auth level: {auth_level}")
|
||||
|
||||
|
||||
# --- Default RPC handlers ---
|
||||
|
||||
|
||||
def register_default_rpcs():
|
||||
"""Register the core LNbits wallet RPCs."""
|
||||
register_rpc("create_invoice", _handle_create_invoice, AUTH_INVOICE_KEY)
|
||||
register_rpc("pay_invoice", _handle_pay_invoice, AUTH_ADMIN_KEY)
|
||||
register_rpc("get_payment", _handle_get_payment, AUTH_NONE)
|
||||
register_rpc("list_payments", _handle_list_payments, AUTH_INVOICE_KEY)
|
||||
register_rpc("get_wallet", _handle_get_wallet, AUTH_INVOICE_KEY)
|
||||
register_rpc("create_wallet", _handle_create_wallet, AUTH_ACCOUNT)
|
||||
register_rpc("decode_payment", _handle_decode_payment, AUTH_NONE)
|
||||
|
||||
|
||||
async def _handle_create_invoice(
|
||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||
) -> dict:
|
||||
from lnbits.core.models import CreateInvoice
|
||||
from lnbits.core.services.payments import create_payment_request
|
||||
|
||||
body = request.body or {}
|
||||
invoice_data = CreateInvoice(
|
||||
out=False,
|
||||
amount=body.get("amount", 0),
|
||||
memo=body.get("memo"),
|
||||
unit=body.get("unit", "sat"),
|
||||
expiry=body.get("expiry"),
|
||||
extra=body.get("extra"),
|
||||
webhook=body.get("webhook"),
|
||||
)
|
||||
payment = await create_payment_request(auth.wallet.id, invoice_data)
|
||||
return payment.dict()
|
||||
|
||||
|
||||
async def _handle_pay_invoice(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
|
||||
from lnbits.core.services.payments import pay_invoice
|
||||
|
||||
body = request.body or {}
|
||||
bolt11 = body.get("bolt11", "")
|
||||
if not bolt11:
|
||||
raise ValueError("bolt11 is required")
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=auth.wallet.id,
|
||||
payment_request=bolt11,
|
||||
max_sat=body.get("max_sat"),
|
||||
extra=body.get("extra"),
|
||||
description=body.get("description", ""),
|
||||
)
|
||||
return payment.dict()
|
||||
|
||||
|
||||
async def _handle_get_payment(
|
||||
auth: None, request: NostrRpcRequest
|
||||
) -> dict | None:
|
||||
from lnbits.core.crud.payments import get_standalone_payment
|
||||
|
||||
body = request.body or {}
|
||||
query = request.query or {}
|
||||
payment_hash = body.get("payment_hash") or query.get("payment_hash", "")
|
||||
if not payment_hash:
|
||||
raise ValueError("payment_hash is required")
|
||||
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
if not payment:
|
||||
return None
|
||||
return payment.dict()
|
||||
|
||||
|
||||
async def _handle_list_payments(
|
||||
auth: WalletTypeInfo, request: NostrRpcRequest
|
||||
) -> list[dict]:
|
||||
from lnbits.core.crud.payments import get_payments
|
||||
|
||||
query = request.query or {}
|
||||
payments = await get_payments(
|
||||
wallet_id=auth.wallet.id,
|
||||
complete=query.get("complete", False),
|
||||
pending=query.get("pending", False),
|
||||
outgoing=query.get("outgoing", False),
|
||||
incoming=query.get("incoming", False),
|
||||
limit=query.get("limit"),
|
||||
offset=query.get("offset"),
|
||||
)
|
||||
return [p.dict() for p in payments]
|
||||
|
||||
|
||||
async def _handle_get_wallet(auth: WalletTypeInfo, request: NostrRpcRequest) -> dict:
|
||||
return {
|
||||
"id": auth.wallet.id,
|
||||
"name": auth.wallet.name,
|
||||
"balance": auth.wallet.balance_msat,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_create_wallet(auth: Account, request: NostrRpcRequest) -> dict:
|
||||
from lnbits.core.crud.wallets import create_wallet
|
||||
|
||||
body = request.body or {}
|
||||
wallet = await create_wallet(
|
||||
user_id=auth.id,
|
||||
wallet_name=body.get("name", "Nostr Wallet"),
|
||||
)
|
||||
return {
|
||||
"id": wallet.id,
|
||||
"name": wallet.name,
|
||||
"adminkey": wallet.adminkey,
|
||||
"inkey": wallet.inkey,
|
||||
}
|
||||
|
||||
|
||||
async def _handle_decode_payment(auth: None, request: NostrRpcRequest) -> dict:
|
||||
from lnbits import bolt11
|
||||
|
||||
body = request.body or {}
|
||||
payment_request = body.get("payment_request", "")
|
||||
if not payment_request:
|
||||
raise ValueError("payment_request is required")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"amount_msat": invoice.amount_msat,
|
||||
"description": invoice.description,
|
||||
"description_hash": invoice.description_hash,
|
||||
"payee": invoice.payee,
|
||||
"date": invoice.date,
|
||||
"expiry": invoice.expiry,
|
||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||
}
|
||||
29
lnbits/core/services/nostr_transport/models.py
Normal file
29
lnbits/core/services/nostr_transport/models.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NostrRpcRequest(BaseModel):
|
||||
"""
|
||||
Incoming RPC request over nostr transport.
|
||||
Mirrors Lightning.pub's NostrRequest type from nostr_transport.ts.
|
||||
"""
|
||||
|
||||
rpc_name: str
|
||||
wallet_id: str | None = None
|
||||
key_type: str | None = None # "admin" or "invoice"
|
||||
request_id: str
|
||||
body: dict | None = None
|
||||
query: dict | None = None
|
||||
|
||||
|
||||
class NostrRpcResponse(BaseModel):
|
||||
"""
|
||||
Outgoing RPC response over nostr transport.
|
||||
Mirrors Lightning.pub's response format: {status, requestId, ...data}.
|
||||
"""
|
||||
|
||||
status: str # "OK" or "ERROR"
|
||||
request_id: str
|
||||
data: Any | None = None
|
||||
error: str | None = None
|
||||
241
lnbits/core/services/nostr_transport/relay_pool.py
Normal file
241
lnbits/core/services/nostr_transport/relay_pool.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
"""
|
||||
Nostr relay connection pool for the transport layer.
|
||||
|
||||
Manages WebSocket connections to multiple nostr relays, subscribes for
|
||||
incoming RPC events (kind 21000) tagged with the LNbits instance's
|
||||
pubkey, validates and decrypts them, and publishes encrypted responses.
|
||||
|
||||
Follows the reconnection pattern from lnbits/wallets/nwc.py and the
|
||||
relay pool design from Lightning.pub's nostrPool.ts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import secp256k1
|
||||
from loguru import logger
|
||||
from websockets import connect as ws_connect
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.nostr import json_dumps, sign_event, verify_event
|
||||
|
||||
from .crypto import nip44_decrypt, nip44_encrypt
|
||||
|
||||
EVENT_KIND = 21000
|
||||
# Events older than this are deduplication-expired (seconds)
|
||||
_HANDLED_EVENT_TTL = 7200 # 2 hours
|
||||
|
||||
|
||||
class NostrTransportPool:
|
||||
"""
|
||||
Manages relay connections for the nostr transport layer.
|
||||
|
||||
Subscribes for kind-21000 events tagged with our pubkey,
|
||||
decrypts them, and forwards to a callback. Publishes encrypted
|
||||
response events back to the sender.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key_hex: str,
|
||||
public_key_hex: str,
|
||||
relay_urls: list[str],
|
||||
event_callback: Callable[..., Awaitable[None]],
|
||||
):
|
||||
self.private_key_hex = private_key_hex
|
||||
self.public_key_hex = public_key_hex
|
||||
self.relay_urls = relay_urls
|
||||
self.event_callback = event_callback
|
||||
|
||||
# secp256k1 key objects for signing
|
||||
self._privkey = secp256k1.PrivateKey(bytes.fromhex(private_key_hex), True)
|
||||
|
||||
# Deduplication: event_id -> timestamp
|
||||
self._handled_events: dict[str, float] = {}
|
||||
|
||||
# Active relay tasks and websockets
|
||||
self._relay_tasks: list[asyncio.Task] = []
|
||||
self._relay_ws: dict[str, object] = {} # relay_url -> ws
|
||||
|
||||
self._shutdown = False
|
||||
|
||||
def _is_shutting_down(self) -> bool:
|
||||
return self._shutdown or not settings.lnbits_running
|
||||
|
||||
async def start(self):
|
||||
"""Connect to all configured relays."""
|
||||
logger.info(
|
||||
f"Nostr transport: connecting to {len(self.relay_urls)} relay(s) "
|
||||
f"as {self.public_key_hex[:16]}..."
|
||||
)
|
||||
for relay_url in self.relay_urls:
|
||||
task = asyncio.create_task(self._connect_to_relay(relay_url))
|
||||
self._relay_tasks.append(task)
|
||||
|
||||
# Periodic cleanup of handled events cache
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup_handled_events())
|
||||
|
||||
async def stop(self):
|
||||
"""Shut down all relay connections."""
|
||||
self._shutdown = True
|
||||
for task in self._relay_tasks:
|
||||
task.cancel()
|
||||
self._cleanup_task.cancel()
|
||||
# Close websockets
|
||||
for url, ws in self._relay_ws.items():
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._relay_ws.clear()
|
||||
logger.info("Nostr transport: all relays disconnected")
|
||||
|
||||
async def send_response(self, recipient_pubkey: str, content: str):
|
||||
"""Encrypt and publish a response event to the sender."""
|
||||
encrypted = nip44_encrypt(content, self.private_key_hex, recipient_pubkey)
|
||||
|
||||
event = {
|
||||
"kind": EVENT_KIND,
|
||||
"created_at": int(time.time()),
|
||||
"tags": [["p", recipient_pubkey]],
|
||||
"content": encrypted,
|
||||
}
|
||||
event = sign_event(event, self.public_key_hex, self._privkey)
|
||||
|
||||
event_json = json.dumps(["EVENT", event])
|
||||
# Publish to all connected relays
|
||||
sent = False
|
||||
for url, ws in list(self._relay_ws.items()):
|
||||
try:
|
||||
await ws.send(event_json)
|
||||
sent = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Nostr transport: failed to send to {url}: {e}")
|
||||
if not sent:
|
||||
logger.error("Nostr transport: could not send response to any relay")
|
||||
|
||||
# --- Internal ---
|
||||
|
||||
async def _connect_to_relay(self, relay_url: str):
|
||||
"""Connect to a single relay with auto-reconnect."""
|
||||
while not self._is_shutting_down():
|
||||
try:
|
||||
async with ws_connect(relay_url) as ws:
|
||||
self._relay_ws[relay_url] = ws
|
||||
logger.info(f"Nostr transport: connected to {relay_url}")
|
||||
|
||||
# Subscribe for events tagged with our pubkey
|
||||
sub_id = f"lnbits_transport_{self.public_key_hex[:8]}"
|
||||
sub_filter = {
|
||||
"kinds": [EVENT_KIND],
|
||||
"#p": [self.public_key_hex],
|
||||
"since": int(time.time()),
|
||||
}
|
||||
await ws.send(json.dumps(["REQ", sub_id, sub_filter]))
|
||||
|
||||
# Receive loop
|
||||
while not self._is_shutting_down():
|
||||
try:
|
||||
reply = await ws.recv()
|
||||
if isinstance(reply, bytes):
|
||||
reply = reply.decode("utf-8")
|
||||
await self._on_message(relay_url, reply)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Nostr transport: recv error on {relay_url}: {e}"
|
||||
)
|
||||
break
|
||||
|
||||
logger.debug(f"Nostr transport: connection to {relay_url} closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Nostr transport: error connecting to {relay_url}: {e}")
|
||||
|
||||
self._relay_ws.pop(relay_url, None)
|
||||
if not self._is_shutting_down():
|
||||
logger.debug(f"Nostr transport: reconnecting to {relay_url} in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _on_message(self, relay_url: str, message: str):
|
||||
"""Handle an incoming message from a relay."""
|
||||
try:
|
||||
msg = json.loads(message)
|
||||
if msg[0] == "EVENT":
|
||||
await self._on_event(relay_url, msg[2])
|
||||
elif msg[0] == "OK":
|
||||
ok = msg[2] if len(msg) > 2 else True
|
||||
if not ok:
|
||||
reason = msg[3] if len(msg) > 3 else "unknown"
|
||||
logger.warning(
|
||||
f"Nostr transport: event rejected by {relay_url}: {reason}"
|
||||
)
|
||||
elif msg[0] == "EOSE":
|
||||
logger.debug(f"Nostr transport: EOSE from {relay_url}")
|
||||
elif msg[0] == "NOTICE":
|
||||
logger.info(f"Nostr transport: notice from {relay_url}: {msg[1]}")
|
||||
elif msg[0] == "CLOSED":
|
||||
logger.warning(
|
||||
f"Nostr transport: subscription closed by {relay_url}: "
|
||||
f"{msg[2] if len(msg) > 2 else 'no reason'}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Nostr transport: error parsing message from {relay_url}: {e}")
|
||||
|
||||
async def _on_event(self, relay_url: str, event: dict):
|
||||
"""Process an incoming nostr event."""
|
||||
event_id = event.get("id", "")
|
||||
|
||||
# Deduplicate
|
||||
if event_id in self._handled_events:
|
||||
return
|
||||
self._handled_events[event_id] = time.time()
|
||||
|
||||
# Validate event kind
|
||||
if event.get("kind") != EVENT_KIND:
|
||||
return
|
||||
|
||||
# Verify signature
|
||||
if not verify_event(event):
|
||||
logger.warning(f"Nostr transport: invalid signature on event {event_id[:16]}")
|
||||
return
|
||||
|
||||
sender_pubkey = event.get("pubkey", "")
|
||||
if not sender_pubkey:
|
||||
return
|
||||
|
||||
# Decrypt content
|
||||
try:
|
||||
plaintext = nip44_decrypt(
|
||||
event["content"], self.private_key_hex, sender_pubkey
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Nostr transport: decryption failed for event {event_id[:16]}: {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# Forward to callback
|
||||
try:
|
||||
await self.event_callback(sender_pubkey, plaintext)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Nostr transport: error in event callback for {event_id[:16]}: {e}"
|
||||
)
|
||||
|
||||
async def _cleanup_handled_events(self):
|
||||
"""Periodically clean up the deduplication cache."""
|
||||
while not self._is_shutting_down():
|
||||
await asyncio.sleep(600) # every 10 minutes
|
||||
now = time.time()
|
||||
expired = [
|
||||
eid
|
||||
for eid, ts in self._handled_events.items()
|
||||
if now - ts > _HANDLED_EVENT_TTL
|
||||
]
|
||||
for eid in expired:
|
||||
del self._handled_events[eid]
|
||||
if expired:
|
||||
logger.debug(
|
||||
f"Nostr transport: cleaned {len(expired)} expired event IDs"
|
||||
)
|
||||
|
|
@ -758,6 +758,15 @@ class NostrAuthSettings(LNbitsSettings):
|
|||
)
|
||||
|
||||
|
||||
class NostrTransportSettings(LNbitsSettings):
|
||||
nostr_transport_enabled: bool = Field(default=False)
|
||||
nostr_transport_relays: list[str] = Field(
|
||||
default=["wss://relay.damus.io", "wss://relay.primal.net"]
|
||||
)
|
||||
nostr_transport_private_key: str = Field(default="")
|
||||
nostr_transport_public_key: str = Field(default="")
|
||||
|
||||
|
||||
class GoogleAuthSettings(LNbitsSettings):
|
||||
google_client_id: str = Field(default="")
|
||||
google_client_secret: str = Field(default="")
|
||||
|
|
@ -879,6 +888,7 @@ class EditableSettings(
|
|||
AuditSettings,
|
||||
AuthSettings,
|
||||
NostrAuthSettings,
|
||||
NostrTransportSettings,
|
||||
GoogleAuthSettings,
|
||||
GitHubAuthSettings,
|
||||
KeycloakAuthSettings,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue