feat: add utils

This commit is contained in:
Vlad Stan
2026-07-09 16:35:40 +03:00
parent c094c820ef
commit 4bcc22d463
3 changed files with 454 additions and 5 deletions
+348 -2
View File
@@ -7,6 +7,7 @@ import secrets
import time import time
from collections.abc import Awaitable, Callable, Iterable, Mapping from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from functools import wraps from functools import wraps
from typing import Any, TypeVar, cast, get_type_hints from typing import Any, TypeVar, cast, get_type_hints
@@ -15,19 +16,37 @@ from pydantic import BaseModel
from lnbits.helpers import sha256s from lnbits.helpers import sha256s
from .models import ( from .models import (
Bolt11Request,
CreateInvoicePublicRequest, CreateInvoicePublicRequest,
CreateInvoiceRequest, CreateInvoiceRequest,
CreateInvoiceResponse, CreateInvoiceResponse,
CurrencyConvertRequest,
CurrencyConvertResponse,
CurrencyListResponse,
CurrencyRateRequest,
CurrencyRateResponse,
DecodeInvoiceResponse,
EmptyRequest, EmptyRequest,
ExtensionApiRequest, ExtensionApiRequest,
FiatToSatsRequest,
FiatToSatsResponse,
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
InvoiceAmountMsatResponse,
InvoiceExpiryResponse,
InvoiceMemoResponse,
InvoicePaymentHashResponse,
ListUserWalletsResponse, ListUserWalletsResponse,
LogRequest, LogRequest,
LogResponse, LogResponse,
NowResponse, NowResponse,
RandomIdRequest, RandomIdRequest,
RandomIdResponse, RandomIdResponse,
RandomSecretAndHashRequest,
RandomSecretAndHashResponse,
SatsToFiatRequest,
SatsToFiatResponse,
ServerHealthResponse,
StorageDeleteRequest, StorageDeleteRequest,
StorageDeleteResponse, StorageDeleteResponse,
StorageGetRequest, StorageGetRequest,
@@ -37,6 +56,9 @@ from .models import (
StorageSetRequest, StorageSetRequest,
StorageSetResponse, StorageSetResponse,
UserWalletSummary, UserWalletSummary,
ValidateInvoiceResponse,
VerifyPreimageRequest,
VerifyPreimageResponse,
) )
from .storage import ( from .storage import (
storage_delete_row, storage_delete_row,
@@ -313,8 +335,8 @@ class ExtensionAPI:
payment = await create_payment_request( payment = await create_payment_request(
request.wallet_id, request.wallet_id,
CreateInvoice( CreateInvoice(
amount=request.amount_sat, amount=request.amount,
unit=request.currency or "sat", unit=request.currency,
memo=request.memo, memo=request.memo,
extra=request.extra, extra=request.extra,
extension=self.extension_id, extension=self.extension_id,
@@ -480,6 +502,281 @@ class ExtensionAPI:
log("extension:%s %s", self.extension_id, request.message) log("extension:%s %s", self.extension_id, request.message)
return LogResponse() return LogResponse()
@extension_api_method(
method_id="utils.currencies.list",
namespace="utils",
name="List currencies",
host_name="utils_currencies_list",
sdk_name="currenciesList",
description="List currencies supported by LNbits exchange-rate conversion.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_list(
self, request: EmptyRequest
) -> CurrencyListResponse:
from lnbits.utils.exchange_rates import allowed_currencies
return CurrencyListResponse(currencies=allowed_currencies())
@extension_api_method(
method_id="utils.currencies.rate",
namespace="utils",
name="Get currency rate",
host_name="utils_currencies_rate",
sdk_name="currenciesRate",
description="Get sats-per-fiat and BTC price for a currency.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_rate(
self, request: CurrencyRateRequest
) -> CurrencyRateResponse:
from lnbits.utils.exchange_rates import get_fiat_rate_and_price_satoshis
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
return CurrencyRateResponse(rate=rate, price=price)
@extension_api_method(
method_id="utils.currencies.convert",
namespace="utils",
name="Convert currency amount",
host_name="utils_currencies_convert",
sdk_name="currenciesConvert",
description="Convert between sats, BTC, and supported fiat currencies.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_convert(
self, request: CurrencyConvertRequest
) -> CurrencyConvertResponse:
from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis,
satoshis_amount_as_fiat,
)
from_currency = request.from_currency
if from_currency == "sats":
from_currency = "sat"
amounts: list[tuple[str, float]] = []
if from_currency == "sat":
sats = int(request.amount)
amounts.append(("BTC", sats / 100_000_000))
amounts.append(("sats", sats))
for currency in request.to.split(","):
currency = currency.strip()
if currency:
amounts.append(
(
currency.upper(),
await satoshis_amount_as_fiat(sats, currency),
)
)
else:
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
amounts.append((from_currency.upper(), request.amount))
amounts.append(("sats", sats))
amounts.append(("BTC", sats / 100_000_000))
return CurrencyConvertResponse(amounts=amounts)
@extension_api_method(
method_id="utils.currencies.fiat_to_sats",
namespace="utils",
name="Convert fiat to sats",
host_name="utils_currencies_fiat_to_sats",
sdk_name="currenciesFiatToSats",
description="Convert a fiat amount to sats.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_fiat_to_sats(
self, request: FiatToSatsRequest
) -> FiatToSatsResponse:
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
return FiatToSatsResponse(
amount_sat=await fiat_amount_as_satoshis(
request.amount,
request.currency,
)
)
@extension_api_method(
method_id="utils.currencies.sats_to_fiat",
namespace="utils",
name="Convert sats to fiat",
host_name="utils_currencies_sats_to_fiat",
sdk_name="currenciesSatsToFiat",
description="Convert a sats amount to fiat.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_currencies_sats_to_fiat(
self, request: SatsToFiatRequest
) -> SatsToFiatResponse:
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
return SatsToFiatResponse(
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
)
@extension_api_method(
method_id="utils.server.health",
namespace="utils",
name="Server health",
host_name="utils_server_health",
sdk_name="serverHealth",
description="Return basic public LNbits server health data.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_server_health(self, request: EmptyRequest) -> ServerHealthResponse:
from lnbits.settings import settings
return ServerHealthResponse(
server_time=int(time.time()),
up_time=settings.lnbits_server_up_time,
)
@extension_api_method(
method_id="utils.lightning.decode_invoice",
namespace="utils",
name="Decode Lightning invoice",
host_name="utils_lightning_decode_invoice",
sdk_name="lightningDecodeInvoice",
description="Decode a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_decode_invoice(
self, request: Bolt11Request
) -> DecodeInvoiceResponse:
invoice = _decode_bolt11(request.bolt11)
return _decoded_invoice_response(invoice)
@extension_api_method(
method_id="utils.lightning.validate_invoice",
namespace="utils",
name="Validate Lightning invoice",
host_name="utils_lightning_validate_invoice",
sdk_name="lightningValidateInvoice",
description="Validate whether a string is a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_validate_invoice(
self, request: Bolt11Request
) -> ValidateInvoiceResponse:
try:
_decode_bolt11(request.bolt11)
return ValidateInvoiceResponse(valid=True)
except Exception as exc:
return ValidateInvoiceResponse(valid=False, error=str(exc))
@extension_api_method(
method_id="utils.lightning.invoice_payment_hash",
namespace="utils",
name="Get Lightning invoice payment hash",
host_name="utils_lightning_invoice_payment_hash",
sdk_name="lightningInvoicePaymentHash",
description="Get the payment hash from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_payment_hash(
self, request: Bolt11Request
) -> InvoicePaymentHashResponse:
return InvoicePaymentHashResponse(
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
)
@extension_api_method(
method_id="utils.lightning.invoice_amount_msat",
namespace="utils",
name="Get Lightning invoice amount",
host_name="utils_lightning_invoice_amount_msat",
sdk_name="lightningInvoiceAmountMsat",
description="Get the amount in msat from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_amount_msat(
self, request: Bolt11Request
) -> InvoiceAmountMsatResponse:
return InvoiceAmountMsatResponse(
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_expiry",
namespace="utils",
name="Get Lightning invoice expiry",
host_name="utils_lightning_invoice_expiry",
sdk_name="lightningInvoiceExpiry",
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_expiry(
self, request: Bolt11Request
) -> InvoiceExpiryResponse:
return InvoiceExpiryResponse(
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
)
@extension_api_method(
method_id="utils.lightning.invoice_memo",
namespace="utils",
name="Get Lightning invoice memo",
host_name="utils_lightning_invoice_memo",
sdk_name="lightningInvoiceMemo",
description="Get the memo from a BOLT11 Lightning invoice.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_invoice_memo(
self, request: Bolt11Request
) -> InvoiceMemoResponse:
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
@extension_api_method(
method_id="utils.lightning.verify_preimage",
namespace="utils",
name="Verify Lightning preimage",
host_name="utils_lightning_verify_preimage",
sdk_name="lightningVerifyPreimage",
description="Verify that a preimage matches a payment hash.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_verify_preimage(
self, request: VerifyPreimageRequest
) -> VerifyPreimageResponse:
from lnbits.utils.crypto import verify_preimage
return VerifyPreimageResponse(
valid=verify_preimage(request.preimage, request.payment_hash)
)
@extension_api_method(
method_id="utils.lightning.random_secret_and_hash",
namespace="utils",
name="Random Lightning secret and hash",
host_name="utils_lightning_random_secret_and_hash",
sdk_name="lightningRandomSecretAndHash",
description="Create a random secret and matching SHA256 hash.",
required_permission="utils.basic",
require_auth=False,
)
async def utils_lightning_random_secret_and_hash(
self, request: RandomSecretAndHashRequest
) -> RandomSecretAndHashResponse:
from lnbits.utils.crypto import random_secret_and_hash
secret, payment_hash = random_secret_and_hash(request.length)
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
@staticmethod @staticmethod
def _permission_data( def _permission_data(
permissions: Iterable[Any], permissions: Iterable[Any],
@@ -549,6 +846,55 @@ class ExtensionAPI:
return table, wallet_field return table, wallet_field
def _decode_bolt11(payment_request: str) -> Any:
from lnbits import bolt11
return bolt11.decode(payment_request)
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
return DecodeInvoiceResponse(
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
amount_msat=_invoice_amount_msat(invoice),
expiry=_invoice_expiry(invoice),
expires_at=_invoice_expires_at(invoice),
memo=_invoice_memo(invoice),
)
def _invoice_amount_msat(invoice: Any) -> int | None:
amount_msat = getattr(invoice, "amount_msat", None)
if amount_msat is None:
return None
return int(amount_msat)
def _invoice_expiry(invoice: Any) -> int | None:
expiry = getattr(invoice, "expiry", None)
if expiry is None:
return None
return int(expiry)
def _invoice_expires_at(invoice: Any) -> int | None:
expiry_date = getattr(invoice, "expiry_date", None)
if isinstance(expiry_date, datetime):
return int(expiry_date.timestamp())
date = getattr(invoice, "date", None)
expiry = getattr(invoice, "expiry", None)
if isinstance(date, datetime) and expiry is not None:
return int(date.timestamp() + int(expiry))
if isinstance(date, (int, float)) and expiry is not None:
return int(date + int(expiry))
return None
def _invoice_memo(invoice: Any) -> str | None:
memo = getattr(invoice, "description", None)
return str(memo) if memo is not None else None
def list_extension_api_methods( def list_extension_api_methods(
api_cls: type[ExtensionAPI] = ExtensionAPI, api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> list[ExtensionAPIMethod]: ) -> list[ExtensionAPIMethod]:
+103 -3
View File
@@ -74,9 +74,8 @@ class StorageDeleteResponse(BaseModel):
class CreateInvoiceRequest(BaseModel): class CreateInvoiceRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128) wallet_id: str = Field(..., min_length=1, max_length=128)
amount_sat: int = Field(..., gt=0) amount: float = Field(..., gt=0)
# todo: bridge for extensions to select currencies currency: str = Field("sat", min_length=1, max_length=8)
currency: str | None = Field(..., min_length=1, max_length=8)
memo: str = Field(..., max_length=512) memo: str = Field(..., max_length=512)
tag: str = Field(..., min_length=1, max_length=64) tag: str = Field(..., min_length=1, max_length=64)
extra: dict[str, str] = Field(default_factory=dict) extra: dict[str, str] = Field(default_factory=dict)
@@ -163,6 +162,107 @@ class ExtensionApiRequest(BaseModel):
return values return values
class CurrencyListResponse(BaseModel):
currencies: list[str] = Field(default_factory=list)
class CurrencyRateRequest(BaseModel):
currency: str = Field(..., min_length=1, max_length=8)
class CurrencyRateResponse(BaseModel):
rate: float
price: float
class CurrencyConvertRequest(BaseModel):
amount: float = Field(..., gt=0)
from_currency: str = Field(..., alias="from", min_length=1, max_length=8)
to: str = Field(..., min_length=1, max_length=256)
class Config:
allow_population_by_field_name = True
class CurrencyConvertResponse(BaseModel):
amounts: list[tuple[str, float]] = Field(default_factory=list)
class FiatToSatsRequest(BaseModel):
amount: float = Field(..., gt=0)
currency: str = Field(..., min_length=1, max_length=8)
class FiatToSatsResponse(BaseModel):
amount_sat: int
class SatsToFiatRequest(BaseModel):
amount: float = Field(..., gt=0)
currency: str = Field(..., min_length=1, max_length=8)
class SatsToFiatResponse(BaseModel):
amount: float
class ServerHealthResponse(BaseModel):
server_time: int
up_time: str
class Bolt11Request(BaseModel):
bolt11: str = Field(..., min_length=1, max_length=8192)
class DecodeInvoiceResponse(BaseModel):
valid: bool = True
payment_hash: str | None = None
amount_msat: int | None = None
expiry: int | None = None
expires_at: int | None = None
memo: str | None = None
class ValidateInvoiceResponse(BaseModel):
valid: bool
error: str | None = None
class InvoicePaymentHashResponse(BaseModel):
payment_hash: str
class InvoiceAmountMsatResponse(BaseModel):
amount_msat: int | None = None
class InvoiceExpiryResponse(BaseModel):
expires_at: int | None = None
class InvoiceMemoResponse(BaseModel):
memo: str | None = None
class VerifyPreimageRequest(BaseModel):
preimage: str = Field(..., min_length=64, max_length=64)
payment_hash: str = Field(..., min_length=64, max_length=64)
class VerifyPreimageResponse(BaseModel):
valid: bool
class RandomSecretAndHashRequest(BaseModel):
length: int = Field(32, ge=16, le=64)
class RandomSecretAndHashResponse(BaseModel):
secret: str
hash: str
class RandomIdRequest(BaseModel): class RandomIdRequest(BaseModel):
prefix: str = Field(..., min_length=1, max_length=32) prefix: str = Field(..., min_length=1, max_length=32)
+3
View File
@@ -533,6 +533,9 @@ window.localisation.en = {
extension_permission_http_request_desc: extension_permission_http_request_desc:
'Make HTTP requests to approved external hosts.', 'Make HTTP requests to approved external hosts.',
extension_permission_http_request_hosts: 'Allowed hosts', extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_utils_basic: 'Use basic LNbits utilities',
extension_permission_utils_basic_desc:
'Use public currency conversion, server health, and Lightning invoice helper functions.',
extension_permission_payments_watch: 'Watch payments', extension_permission_payments_watch: 'Watch payments',
extension_permission_wallet_create_invoice: 'Create invoices', extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public: extension_permission_wallet_create_invoice_public: