Compare commits

..
Author SHA1 Message Date
alan 1d8db72517 chore: make bundle [skip ci] 2026-07-14 08:51:37 +00:00
dni 424a558a10 fulcrum port 2026-07-14 10:40:45 +02:00
dni 610db9b9b8 while running 2026-07-13 09:37:19 +02:00
dni 3571f96979 fix: revoluts test timestamp exceeded 2026-07-13 09:09:42 +02:00
dni d754efd1e0 add into api.js 2026-07-13 09:09:42 +02:00
dni e9943edd2b add utxos endpoint 2026-07-13 09:09:42 +02:00
dni 4337e62c10 fix lint 2026-07-13 09:09:42 +02:00
dni 4dc2c779a2 add service 2026-07-13 09:09:42 +02:00
dni 3f28d7a31a fix only have address updates sent if tracked 2026-07-13 09:09:42 +02:00
dni fe02f54021 electrum server select 2026-07-13 09:09:42 +02:00
dni 65d809a66c type 2026-07-13 09:09:42 +02:00
dni c3ac6b7a55 add settings to frontend and more embit 2026-07-13 09:09:42 +02:00
dni e1c427ddf7 add listeners 2026-07-13 09:09:42 +02:00
dni 062246b577 trackers 2026-07-13 09:09:42 +02:00
dni 5514e35e18 addresstracker 2026-07-13 09:09:42 +02:00
dni e61dab83a9 relay_ws_queue 2026-07-13 09:09:42 +02:00
dni da2f4abe10 blocktracker 2026-07-13 09:09:42 +02:00
dni e35204c253 fixup! 2026-07-13 09:09:42 +02:00
dni b2380a8dc4 address tracker 2026-07-13 09:09:42 +02:00
dni 1e4f19fd1c fix closing 2026-07-13 09:09:42 +02:00
dni 0a1a578988 fixes 2026-07-13 09:09:42 +02:00
dni 04e748c089 add back tx tracking and clean blockexplorer api 2026-07-13 09:09:42 +02:00
dni fbea97d187 remove track transaction 2026-07-13 09:09:42 +02:00
dni 93e33f6cf9 feat: task manager onchain listeners 2026-07-13 09:09:42 +02:00
dni 1e3ab2d6d0 mypy 2026-07-13 09:09:42 +02:00
dni 5452de5b60 fix 2026-07-13 09:09:42 +02:00
dni c76cb627d8 fix pyright 2026-07-13 09:09:42 +02:00
dni 824bc14f26 prettier 2026-07-13 09:09:42 +02:00
dni c466d85ae7 add languages 2026-07-13 09:09:42 +02:00
dni 2941157658 add history error 2026-07-13 09:09:42 +02:00
dni d0bc63d114 add to package 2026-07-13 09:09:42 +02:00
dni 2c422d3fd0 fix explorer 2026-07-13 09:09:42 +02:00
dni 69c9be1993 prettier 2026-07-13 09:09:42 +02:00
dni f78e82954f format 2026-07-13 09:09:42 +02:00
dni 5d9f13e71e fixup! 2026-07-13 09:09:42 +02:00
dni 46f19148a8 add optional public access setting 2026-07-13 09:09:42 +02:00
dni 2a96512e55 move into logged in template 2026-07-13 09:09:42 +02:00
dni d7797cc303 fix jinja 2026-07-13 09:09:41 +02:00
dni 843afd33e5 dynamically update blockexplorer compoenent with websockets 2026-07-13 09:09:41 +02:00
dni e3ce6b9fef add websocket endpoints 2026-07-13 09:09:41 +02:00
dni e63effbcb3 add blocks 2026-07-13 09:09:41 +02:00
dni 55b97b6e4f ruff and prettier 2026-07-13 09:09:41 +02:00
dni d559d5d278 fix pyright 2026-07-13 09:09:41 +02:00
dni 69c72e1d86 fix ruff 2026-07-13 09:09:41 +02:00
dni 730d1e2aaf feat: adds blockexplorer api, frontend via electrum
- settings
- frontend page component
- api
- models

models
2026-07-13 09:09:41 +02:00
Vlad StanandGitHub cabb58f8fe chore: make bundle (#4045) 2026-07-08 16:59:28 +03:00
aac04efd0e Merge commit from fork
* fix: don't show unsafe html in lnurlp notifications

* fix: lnurlp success aes decryption

* regenerate bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-07-08 16:49:43 +03:00
fd9009f760 feat: create TaskManager singleton class (#3775)
Co-authored-by: dadofsambonzuki <dadofsambonzuki@users.noreply.github.com>
2026-07-08 14:07:38 +02:00
dni ⚡andGitHub 6ab413775a chore: update to v1.5.6 (#4044) 2026-07-08 13:54:35 +02:00
7eb9965205 feat: keep funding source on default reset, force seed backup (#4040)
Co-authored-by: alan <alan@lnbits.com>
2026-07-08 13:54:11 +02:00
4634ad5a5a feat: use lnbits price aggregator to save loads of request (#4026)
Co-authored-by: alan <alan@lnbits.com>
2026-07-06 09:21:55 +02:00
dni ⚡andGitHub 648aaa17c5 CI: appimage upload-urls with gh upload command (#4036) 2026-07-03 12:35:14 +01:00
blackcoffeexbtandGitHub 7db5c986b3 NWC funding source: Reduce polling (#4008) 2026-07-02 13:00:49 +01:00
99 changed files with 4861 additions and 4922 deletions
+1 -14
View File
@@ -7,10 +7,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
workflow_dispatch:
inputs:
@@ -18,10 +14,6 @@ on:
description: 'The tag name for the release'
required: true
type: string
upload_url:
description: 'The upload URL for the release'
required: true
type: string
jobs:
build-linux-package:
@@ -113,11 +105,6 @@ jobs:
shell: bash
- name: Upload Linux Release Asset
uses: actions/upload-release-asset@v1
with:
upload_url: ${{ inputs.upload_url }}
asset_path: ${{ env.APPIMAGE_NAME }}
asset_name: ${{ env.APPIMAGE_NAME }}
asset_content_type: application/octet-stream
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
-11
View File
@@ -12,8 +12,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
@@ -22,14 +20,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -74,4 +64,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
-11
View File
@@ -13,8 +13,6 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -23,14 +21,6 @@ jobs:
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
@@ -85,4 +75,3 @@ jobs:
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+52 -59
View File
@@ -23,36 +23,35 @@ from lnbits.core.crud import (
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.extensions.events import dispatch_wasm_invoice_paid
from lnbits.core.extensions.loader import (
is_wasm_extension_dir,
is_wasm_extension_id,
)
from lnbits.core.extensions.routes import register_wasm_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
)
from lnbits.core.services.notifications import (
dispatch_payment_notification,
enqueue_admin_notification,
process_next_notification,
)
from lnbits.core.services.payments import (
check_pending_payments,
fundingsource_invoice_producer,
)
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
purge_audit_data,
run_by_the_minute_tasks,
wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages,
notify_server_status,
process_next_audit_entry,
refresh_extension_cache,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
from lnbits.settings import settings
from lnbits.tasks import (
cancel_all_tasks,
create_permanent_task,
register_invoice_listener,
)
from lnbits.utils.cache import cache
from lnbits.utils.logger import (
configure_logger,
@@ -75,7 +74,7 @@ from .middleware import (
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
from .task_manager import task_manager
async def startup(app: FastAPI):
@@ -139,7 +138,7 @@ async def shutdown():
settings.lnbits_running = False
# shutdown event
cancel_all_tasks()
task_manager.cancel_all_tasks()
# wait a bit to allow them to finish, so that cleanup can run without problems
await asyncio.sleep(0.1)
@@ -172,7 +171,6 @@ def create_app() -> FastAPI:
# Allow registering new extensions routes without direct access to the `app` object
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app)
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
@@ -317,9 +315,8 @@ async def build_all_installed_extensions_list( # noqa: C901
installed_extensions.append(ext_info)
await create_installed_extension(ext_info)
if not is_wasm_extension_dir(ext_dir):
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
current_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, current_version)
except Exception as e:
logger.warning(e)
@@ -420,13 +417,6 @@ def register_new_ext_routes(app: FastAPI) -> Callable:
return register_new_ext_routes_fn
def register_new_wasm_ext_routes(app: FastAPI) -> Callable:
def register_new_wasm_ext_routes_fn(ext_id: str):
register_wasm_extension(app, ext_id)
return register_new_wasm_ext_routes_fn
def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_new_ratelimiter_fn():
limiter = Limiter(
@@ -480,46 +470,49 @@ async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
try:
if is_wasm_extension_id(ext.code):
register_wasm_extension(app, ext.code)
continue
register_ext_routes(app, ext)
register_ext_tasks(ext)
except Exception as exc:
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
await update_installed_extension_state(ext_id=ext.code, active=False)
def register_async_tasks() -> None:
task_manager.init()
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
# listen to all incoming payments and dispatch payment notifications
# note: should be the first in task list for a bit quicker notifications
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
create_permanent_task(
run_interval(
settings.lnbits_funding_source_pending_interval_seconds,
check_pending_payments,
)
# periodic tasks
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
task_manager.create_permanent_task(
check_pending_payments,
interval=settings.lnbits_funding_source_pending_interval_seconds,
)
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
task_manager.create_permanent_task(
collect_exchange_rates_data,
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
)
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
task_manager.create_permanent_task(
check_server_balance_against_node,
interval=60 * settings.lnbits_watchdog_interval_minutes,
)
task_manager.create_permanent_task(
notify_server_status,
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
)
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
# core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core")
async def dispatch_extension_invoice_paid(payment) -> None:
await dispatch_wasm_invoice_paid(payment)
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data)
create_permanent_task(collect_exchange_rates_data)
# permanent tasks run in a loop, will be restarted if they fail
task_manager.create_permanent_task(fundingsource_invoice_producer)
task_manager.create_permanent_task(process_next_notification)
task_manager.create_permanent_task(process_next_audit_entry)
# server logs for websocket
if settings.lnbits_admin_ui:
server_log_task = initialize_server_websocket_logger()
create_permanent_task(server_log_task)
task_manager.create_permanent_task(
server_log_task, name="server_websocket_logger"
)
+2
View File
@@ -6,6 +6,7 @@ from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.blockexplorer_api import blockexplorer_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
@@ -48,6 +49,7 @@ def init_core_routers(app: FastAPI):
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
app.include_router(blockexplorer_router)
__all__ = ["core_app", "core_app_extra", "db"]
+15 -10
View File
@@ -8,11 +8,18 @@ from lnbits.db import dict_to_model
from lnbits.settings import (
AdminSettings,
EditableSettings,
FundingSourcesSettings,
SettingsField,
SuperSettings,
settings,
)
RESET_PRESERVED_SETTINGS = (
"lnbits_webpush_pubkey",
"lnbits_webpush_privkey",
*FundingSourcesSettings.__fields__,
)
async def get_super_settings() -> SuperSettings | None:
data = await get_settings_by_tag("core")
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
async def reset_core_settings() -> None:
await db.execute(
"""
DELETE FROM system_settings WHERE tag = 'core'
AND id NOT IN (
'super_user',
'lnbits_webpush_pubkey',
'lnbits_webpush_privkey'
)
""",
)
core_settings = await get_settings_by_tag("core") or {}
super_user = await get_settings_field("super_user")
await delete_admin_settings()
if super_user:
await set_settings_field("super_user", super_user.value)
for field in RESET_PRESERVED_SETTINGS:
if field in core_settings:
await set_settings_field(field, core_settings[field])
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
-28
View File
@@ -1,28 +0,0 @@
"""Extension runtime contracts."""
from .api import (
ExtensionAPI,
ExtensionAPIMethod,
extension_api_contract,
extension_api_method,
get_extension_api_method,
list_extension_api_methods,
)
from .loader import WasmExtension, load_wasm_extension
from .routes import register_wasm_extension
from .runtime import ExtensionAPIHost
from .wasm import invoke_wasm_extension_export
__all__ = [
"ExtensionAPI",
"ExtensionAPIHost",
"ExtensionAPIMethod",
"WasmExtension",
"extension_api_contract",
"extension_api_method",
"get_extension_api_method",
"invoke_wasm_extension_export",
"list_extension_api_methods",
"load_wasm_extension",
"register_wasm_extension",
]
-659
View File
@@ -1,659 +0,0 @@
from __future__ import annotations
import inspect
import json
import logging
import secrets
import time
from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import dataclass
from functools import wraps
from typing import Any, TypeVar, cast, get_type_hints
from pydantic import BaseModel
from lnbits.helpers import sha256s
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
CreateInvoiceResponse,
EmptyRequest,
ExtensionApiRequest,
HttpRequest,
HttpResponse,
ListUserWalletsResponse,
LogRequest,
LogResponse,
NowResponse,
RandomIdRequest,
RandomIdResponse,
StorageDeleteRequest,
StorageDeleteResponse,
StorageGetRequest,
StorageGetResponse,
StoragePaginatedRequest,
StoragePaginatedResponse,
StorageSetRequest,
StorageSetResponse,
UserWalletSummary,
)
from .storage import (
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
logger = logging.getLogger("lnbits.extensions")
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
@dataclass(frozen=True)
class ExtensionAPIMethodExport:
method_id: str
namespace: str
name: str
host_name: str
sdk_name: str
description: str
required_permission: str | None = None
require_auth: bool = True
@dataclass(frozen=True)
class ExtensionAPIMethod:
method_id: str
namespace: str
name: str
python_name: str
host_name: str
sdk_name: str
description: str
request_model: type[BaseModel]
response_model: type[BaseModel]
required_permission: str | None = None
require_auth: bool = True
@property
def sdk_qualified_name(self) -> str:
return f"{self.namespace}.{self.sdk_name}"
def extension_api_method(
*,
method_id: str,
namespace: str,
name: str,
host_name: str,
sdk_name: str,
description: str,
required_permission: str | None = None,
require_auth: bool = True,
) -> Callable[
[Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]],
Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]],
]:
export = ExtensionAPIMethodExport(
method_id=method_id,
namespace=namespace,
name=name,
host_name=host_name,
sdk_name=sdk_name,
description=description,
required_permission=required_permission,
require_auth=require_auth,
)
def decorator(
function: Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]],
) -> Callable[[ExtensionAPI, _RequestModel], Awaitable[_ResponseModel]]:
@wraps(function)
async def wrapper(self: ExtensionAPI, request: _RequestModel) -> _ResponseModel:
if require_auth and not self.has_authenticated_context():
raise PermissionError(
f"Extension API method '{method_id}' requires authentication."
)
self.require_permission(required_permission)
return await function(self, request)
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
return wrapper
return decorator
class ExtensionAPI:
def __init__(
self,
extension_id: str,
permissions: Iterable[Any],
*,
user_id: str | None = None,
context: str = "user",
owner_id: str | None = None,
) -> None:
self.extension_id = extension_id
self.permissions, self.permission_policies = self._permission_data(permissions)
self.user_id = user_id
self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_id
self._uuid = secrets.token_urlsafe(12).replace("-", "_")
def __repr__(self) -> str:
return (
"ExtensionAPI("
f"extension_id={self.extension_id!r}, "
f"context={self.context!r}, "
f"_uuid={self._uuid!r}"
")"
)
def require_permission(self, permission: str | None) -> None:
if permission and permission not in self.permissions:
raise PermissionError(
f"Extension '{self.extension_id}' is missing permission '{permission}'."
)
def has_authenticated_context(self) -> bool:
return bool(self.user_id) or self.context == "event"
def _require_owner_id(self) -> str:
if not self.owner_id:
raise PermissionError("Extension API method requires an owner context.")
return self.owner_id
@extension_api_method(
method_id="storage.get",
namespace="storage",
name="Get storage row",
host_name="storage_get",
sdk_name="get",
description="Read one row from an extension storage table.",
required_permission="ext.storage.read",
require_auth=True,
)
async def storage_get(self, request: StorageGetRequest) -> StorageGetResponse:
row = await storage_get_row(
self.extension_id,
request.table,
request.id,
self._require_owner_id(),
)
return StorageGetResponse(data_json=json.dumps(row) if row else None)
@extension_api_method(
method_id="storage.get_public",
namespace="storage",
name="Get public storage row",
host_name="storage_get_public",
sdk_name="getPublic",
description="Read one public row from an extension storage table.",
required_permission="ext.storage.read_public",
require_auth=False,
)
async def storage_get_public(
self, request: StorageGetRequest
) -> StorageGetResponse:
public_fields = self._public_storage_fields(request.table)
row = await storage_get_public_row(self.extension_id, request.table, request.id)
if not row:
return StorageGetResponse()
public_row = {
field_name: value
for field_name, value in row.items()
if field_name in public_fields
}
return StorageGetResponse(data_json=json.dumps(public_row))
@extension_api_method(
method_id="storage.set",
namespace="storage",
name="Set storage row",
host_name="storage_set",
sdk_name="set",
description="Create or update one row in an extension storage table.",
required_permission="ext.storage.write",
require_auth=True,
)
async def storage_set(self, request: StorageSetRequest) -> StorageSetResponse:
await storage_set_row(
self.extension_id,
request.table,
request.data,
self._require_owner_id(),
)
return StorageSetResponse()
@extension_api_method(
method_id="storage.get_paginated",
namespace="storage",
name="Get paginated storage rows",
host_name="storage_get_paginated",
sdk_name="getPaginated",
description="Get filtered, searched, sorted, paginated storage rows.",
required_permission="ext.storage.read",
require_auth=True,
)
async def storage_get_paginated(
self, request: StoragePaginatedRequest
) -> StoragePaginatedResponse:
page = await storage_get_paginated_rows(
self.extension_id,
request.table,
request.filters,
owner_id=self._require_owner_id(),
search=request.search,
search_fields=request.search_fields,
sort_by=request.sort_by,
descending=request.descending,
limit=request.limit,
offset=request.offset,
)
return StoragePaginatedResponse(
rows_json=json.dumps(page["data"]),
total=page["total"],
)
@extension_api_method(
method_id="storage.delete",
namespace="storage",
name="Delete storage row",
host_name="storage_delete",
sdk_name="delete",
description="Delete one row from an extension storage table.",
required_permission="ext.storage.write",
require_auth=True,
)
async def storage_delete(
self, request: StorageDeleteRequest
) -> StorageDeleteResponse:
await storage_delete_row(
self.extension_id,
request.table,
request.id,
self._require_owner_id(),
)
return StorageDeleteResponse()
@extension_api_method(
method_id="wallet.create_invoice",
namespace="wallet",
name="Create invoice",
host_name="create_invoice",
sdk_name="createInvoice",
description="Create an incoming Lightning invoice for an allowed wallet.",
required_permission="wallet.create_invoice",
require_auth=True,
)
async def wallet_create_invoice(
self, request: CreateInvoiceRequest
) -> CreateInvoiceResponse:
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.payments import create_payment_request
if self.user_id:
wallet = await get_wallet(request.wallet_id)
if wallet is None or wallet.user != self.user_id:
raise PermissionError(
"Creating an invoice for this wallet requires an "
"authenticated user context."
)
else:
pass
# todo: security stuff here
payment = await create_payment_request(
request.wallet_id,
CreateInvoice(
amount=request.amount_sat,
unit=request.currency or "sat",
memo=request.memo,
extra=request.extra,
extension=self.extension_id,
),
)
return CreateInvoiceResponse(
payment_hash=payment.payment_hash,
payment_request=payment.payment_request or payment.bolt11,
checking_id=payment.checking_id,
)
@extension_api_method(
method_id="wallet.create_invoice_public",
namespace="wallet",
name="Create public invoice",
host_name="create_invoice_public",
sdk_name="createInvoicePublic",
description="Create a public incoming Lightning invoice.",
required_permission="wallet.create_invoice_public",
require_auth=False,
)
async def wallet_create_invoice_public(
self, request: CreateInvoicePublicRequest
) -> CreateInvoiceResponse:
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.payments import create_payment_request
table, wallet_field = self._public_invoice_wallet_source()
row = await storage_get_public_row(self.extension_id, table, request.source_id)
if not row:
raise PermissionError("Public invoice source was not found.")
wallet_id = row.get(wallet_field)
if not isinstance(wallet_id, str) or not wallet_id:
raise PermissionError("Public invoice source has no valid wallet.")
payment = await create_payment_request(
wallet_id,
CreateInvoice(
amount=request.amount,
unit=request.currency,
memo=request.memo,
extra={
"tag": self.extension_id,
"source_id": request.source_id,
f"extra_{self.extension_id}": request.extra,
},
extension=self.extension_id,
),
)
return CreateInvoiceResponse(
payment_hash=payment.payment_hash,
payment_request=payment.payment_request or payment.bolt11,
checking_id=payment.checking_id,
)
@extension_api_method(
method_id="wallet.list_user_wallets",
namespace="wallet",
name="List user wallets",
host_name="list_user_wallets",
sdk_name="listUserWallets",
description="List wallets available to the authenticated extension user.",
required_permission="wallet.list",
)
async def wallet_list_user_wallets(
self, request: EmptyRequest
) -> ListUserWalletsResponse:
if not self.user_id:
raise PermissionError(
"Listing user wallets requires an authenticated user context."
)
from lnbits.core.crud.wallets import get_wallets
user_wallets = await get_wallets(self.user_id)
if user_wallets is None:
raise PermissionError(
"Listing user wallets requires an authenticated user context."
)
return ListUserWalletsResponse(
wallets=[
UserWalletSummary(id=w.id, name=w.name, currency=w.currency)
for w in user_wallets
]
)
@extension_api_method(
method_id="http.request",
namespace="http",
name="HTTP request",
host_name="http_request",
sdk_name="request",
description="Make an outbound HTTP request to an allowed host.",
required_permission="http.request",
require_auth=True,
)
async def http_request(self, request: HttpRequest) -> HttpResponse:
from .http_client import send_extension_http_request
policy = self.permission_policies.get("http.request") or {}
return await send_extension_http_request(self.extension_id, policy, request)
@extension_api_method(
method_id="extension.api.request",
namespace="extension",
name="Extension API request",
host_name="extension_api_request",
sdk_name="request",
description="Call an allowed installed extension API.",
required_permission="extension.api.request",
require_auth=True,
)
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
from .extension_client import send_extension_api_request
policy = self.permission_policies.get("extension.api.request") or {}
return await send_extension_api_request(
self.extension_id,
policy,
self.user_id,
request,
)
@extension_api_method(
method_id="system.random_id",
namespace="system",
name="Random ID",
host_name="random_id",
sdk_name="id",
description="Create a random extension-local identifier.",
require_auth=False,
)
async def system_random_id(self, request: RandomIdRequest) -> RandomIdResponse:
return RandomIdResponse(
id=f"{request.prefix}_{secrets.token_urlsafe(12).replace('-', '_')}"
)
@extension_api_method(
method_id="system.now",
namespace="system",
name="Current timestamp",
host_name="now",
sdk_name="now",
description="Return the current Unix timestamp.",
require_auth=False,
)
async def system_now(self, request: EmptyRequest) -> NowResponse:
return NowResponse(timestamp=int(time.time()))
@extension_api_method(
method_id="system.log",
namespace="system",
name="Log message",
host_name="log",
sdk_name="log",
description="Write a bounded message to the extension log.",
require_auth=False,
)
async def system_log(self, request: LogRequest) -> LogResponse:
log = getattr(logger, request.level)
log("extension:%s %s", self.extension_id, request.message)
return LogResponse()
@staticmethod
def _permission_data(
permissions: Iterable[Any],
) -> tuple[set[str], dict[str, dict[str, Any]]]:
permission_ids: set[str] = set()
policies: dict[str, dict[str, Any]] = {}
for permission in permissions:
if isinstance(permission, str):
permission_ids.add(permission)
continue
permission_id: str | None = None
policy: Any = None
if isinstance(permission, Mapping):
permission_id = permission.get("id") # type: ignore[assignment]
policy = permission.get("policy")
else:
permission_id = getattr(permission, "id", None)
policy = getattr(permission, "policy", None)
if not permission_id:
continue
permission_ids.add(permission_id)
if isinstance(policy, dict):
policies[permission_id] = policy
return permission_ids, policies
def _public_storage_fields(self, table: str) -> set[str]:
policy = self.permission_policies.get("ext.storage.read_public") or {}
tables = policy.get("tables")
if not isinstance(tables, list):
raise PermissionError(
"Public storage reads require a tables policy for "
"'ext.storage.read_public'."
)
for table_policy in tables:
if not isinstance(table_policy, dict):
continue
if table_policy.get("table_name") != table:
continue
public_fields = table_policy.get("public_fields")
if not isinstance(public_fields, list) or not all(
isinstance(field, str) and field for field in public_fields
):
raise PermissionError(
f"Public storage table '{table}' has no valid public fields."
)
return set(public_fields)
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
def _public_invoice_wallet_source(self) -> tuple[str, str]:
policy = self.permission_policies.get("wallet.create_invoice_public") or {}
table = policy.get("table")
wallet_field = policy.get("wallet_field")
if not isinstance(table, str) or not table:
raise PermissionError(
"Public invoice creation requires a storage table policy."
)
if not isinstance(wallet_field, str) or not wallet_field:
raise PermissionError(
"Public invoice creation requires a wallet field policy."
)
return table, wallet_field
def list_extension_api_methods(
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> list[ExtensionAPIMethod]:
methods: list[ExtensionAPIMethod] = []
for python_name, function in inspect.getmembers(api_cls, inspect.isfunction):
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
if not export:
continue
request_model, response_model = _get_method_models(function)
methods.append(
ExtensionAPIMethod(
method_id=export.method_id,
namespace=export.namespace,
name=export.name,
python_name=python_name,
host_name=export.host_name,
sdk_name=export.sdk_name,
description=export.description,
request_model=request_model,
response_model=response_model,
required_permission=export.required_permission,
require_auth=export.require_auth,
)
)
return sorted(methods, key=lambda method: method.method_id)
def extension_api_permission_ids(
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> set[str]:
return {
method.required_permission
for method in list_extension_api_methods(api_cls)
if method.required_permission
}
def get_extension_api_method(
method_id: str,
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> ExtensionAPIMethod:
for method in list_extension_api_methods(api_cls):
if method.method_id == method_id:
return method
raise KeyError(f"Unknown extension API method '{method_id}'.")
def extension_api_contract(
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> dict[str, object]:
return {
"version": 1,
"methods": [
{
"id": method.method_id,
"namespace": method.namespace,
"name": method.name,
"python_name": method.python_name,
"host_name": method.host_name,
"sdk_name": method.sdk_name,
"sdk_qualified_name": method.sdk_qualified_name,
"description": method.description,
"required_permission": method.required_permission,
"require_auth": method.require_auth,
"request_schema": method.request_model.schema(
ref_template="#/definitions/{model}"
),
"response_schema": method.response_model.schema(
ref_template="#/definitions/{model}"
),
}
for method in list_extension_api_methods(api_cls)
],
}
def _get_method_models(
function: Callable[..., object],
) -> tuple[type[BaseModel], type[BaseModel]]:
signature = inspect.signature(function)
request_parameters = [
parameter
for parameter in signature.parameters.values()
if parameter.name != "self"
]
if len(request_parameters) != 1:
raise TypeError(
f"Extension API method '{function.__name__}' must accept one request model."
)
hints = get_type_hints(function)
request_model = hints.get(request_parameters[0].name)
response_model = hints.get("return")
if not _is_pydantic_model(request_model):
raise TypeError(
f"Extension API method '{function.__name__}' request must be a BaseModel."
)
if not _is_pydantic_model(response_model):
raise TypeError(
f"Extension API method '{function.__name__}' response must be a BaseModel."
)
return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model)
def _is_pydantic_model(value: object) -> bool:
return isinstance(value, type) and issubclass(value, BaseModel)
-113
View File
@@ -1,113 +0,0 @@
import json
from typing import Any
from loguru import logger
from lnbits.core.db import core_app_extra
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
extension_id = _payment_extension_id(payment)
if not extension_id:
return
extension = core_app_extra.wasm_extension_registry.get(extension_id)
if not extension:
return
export_name = _wasm_invoice_paid_export(extension.config)
if not export_name:
return
if not _is_wasm_event_export(extension, export_name):
logger.warning(
f"WASM extension '{extension.id}' declares invalid onInvoicePaid "
f"export '{export_name}'."
)
return
try:
from lnbits.core.extensions.wasm import invoke_wasm_extension_export
await invoke_wasm_extension_export(
extension.id,
export_name,
_wasm_invoice_paid_payload(payment),
context="event",
owner_id=await _wasm_invoice_paid_owner_id(extension, payment),
)
except Exception as exc:
logger.warning(
f"WASM extension '{extension.id}' failed to handle paid invoice "
f"'{payment.payment_hash}': {exc!s}"
)
def _payment_extension_id(payment: Any) -> str | None:
if isinstance(payment.extension, str) and payment.extension:
return payment.extension
extra = payment.extra or {}
tag = extra.get("tag") or payment.tag
return tag if isinstance(tag, str) and tag else None
async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None:
source_id = _payment_source_id(payment)
source_table = _wasm_public_invoice_source_table(extension.config)
if not source_id or not source_table:
return None
from lnbits.core.extensions.storage import storage_get_row_owner_id
return await storage_get_row_owner_id(extension.id, source_table, source_id)
def _payment_source_id(payment: Any) -> str | None:
extra = payment.extra or {}
source_id = extra.get("source_id")
return source_id if isinstance(source_id, str) and source_id else None
def _wasm_public_invoice_source_table(config: dict[str, Any]) -> str | None:
permissions = config.get("permissions") or []
for permission in permissions:
if not isinstance(permission, dict):
continue
if permission.get("id") != "wallet.create_invoice_public":
continue
policy = permission.get("policy") or {}
table = policy.get("table")
return table if isinstance(table, str) and table else None
return None
def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None:
events = config.get("events") or {}
export_name = events.get("onInvoicePaid")
return export_name if isinstance(export_name, str) and export_name else None
def _is_wasm_event_export(extension: Any, export_name: str) -> bool:
for export in extension.exports:
if export.get("name") == export_name:
return export.get("visibility") == "event"
return False
def _wasm_invoice_paid_payload(payment: Any) -> dict[str, Any]:
return {
"checkingId": payment.checking_id,
"paymentHash": payment.payment_hash,
"walletId": payment.wallet_id,
"amount": payment.amount,
"fee": payment.fee,
"bolt11": payment.bolt11,
"memo": payment.memo,
"pending": payment.pending,
"status": payment.status,
"tag": payment.tag,
"extension": payment.extension,
"extra": payment.extra or {},
"payment": json.loads(payment.json()),
}
-201
View File
@@ -1,201 +0,0 @@
from __future__ import annotations
import posixpath
import re
from typing import Any
from urllib.parse import unquote, urlsplit, urlunsplit
import httpx
from lnbits.core.crud.extensions import (
get_installed_extension,
get_user_active_extensions_ids,
)
from lnbits.core.crud.wallets import get_wallets
from lnbits.settings import settings
from .models import ExtensionApiRequest, HttpResponse
EXTENSION_API_TIMEOUT_SECONDS = 10.0
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
_READ_METHODS = {"GET", "HEAD"}
_WRITE_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_FORBIDDEN_RESPONSE_HEADERS = {
"connection",
"content-length",
"set-cookie",
"transfer-encoding",
}
async def send_extension_api_request(
caller_extension_id: str,
policy: dict[str, Any],
user_id: str | None,
request: ExtensionApiRequest,
) -> HttpResponse:
if not user_id:
raise PermissionError("Extension API requests require authentication.")
target_extension_id = _target_extension_id(request.extension_id)
access = _target_extension_access(policy, target_extension_id)
_require_method_access(caller_extension_id, target_extension_id, access, request)
await _require_enabled_extension(target_extension_id, user_id)
api_key = await _user_api_key(user_id, request.method)
path = _extension_api_path(request.path)
body = request.body.encode() if request.body is not None else b""
if len(body) > 65_536:
raise ValueError("Extension API request body is too large.")
url = f"http://{settings.host}:{settings.port}/{target_extension_id}{path}"
try:
async with httpx.AsyncClient(
follow_redirects=False,
timeout=EXTENSION_API_TIMEOUT_SECONDS,
trust_env=False,
) as client:
async with client.stream(
request.method,
url,
headers={"X-API-KEY": api_key},
content=body,
) as response:
response_body = await _read_limited_response(response)
return HttpResponse(
status_code=response.status_code,
headers=_response_headers(dict(response.headers)),
body=response_body.decode(response.encoding or "utf-8", "replace"),
)
except httpx.RequestError as exc:
raise ValueError("Extension API request failed.") from exc
def _target_extension_id(extension_id: str) -> str:
target = extension_id.strip()
if not target or not _EXTENSION_ID_RE.match(target):
raise PermissionError("Extension API request has an invalid target extension.")
return target
def _target_extension_access(
policy: dict[str, Any], target_extension_id: str
) -> set[str]:
extensions = policy.get("extensions")
if not isinstance(extensions, list) or not extensions:
raise PermissionError(
"Extension API requests require a non-empty extensions policy."
)
for extension in extensions:
if isinstance(extension, str):
extension_id = extension
access = ["read"]
elif isinstance(extension, dict):
extension_id = extension.get("id")
access = extension.get("access")
else:
continue
if extension_id != target_extension_id:
continue
if not isinstance(access, list):
raise PermissionError(
f"Extension API target '{target_extension_id}' has no access policy."
)
clean_access = {
item
for item in access
if isinstance(item, str) and item in {"read", "write"}
}
if clean_access:
return clean_access
break
raise PermissionError(
f"Extension API target '{target_extension_id}' is not allowed."
)
def _require_method_access(
caller_extension_id: str,
target_extension_id: str,
access: set[str],
request: ExtensionApiRequest,
) -> None:
if request.method in _READ_METHODS:
required_access = "read"
elif request.method in _WRITE_METHODS:
required_access = "write"
else:
raise PermissionError("Extension API request method is not allowed.")
if required_access not in access:
raise PermissionError(
f"Extension '{caller_extension_id}' cannot {required_access} "
f"extension '{target_extension_id}'."
)
async def _require_enabled_extension(target_extension_id: str, user_id: str) -> None:
extension = await get_installed_extension(target_extension_id)
if not extension or not extension.active:
raise PermissionError(
f"Target extension '{target_extension_id}' is not installed or enabled."
)
active_extensions = await get_user_active_extensions_ids(user_id)
if target_extension_id not in active_extensions:
raise PermissionError(
f"Target extension '{target_extension_id}' is not active for this user."
)
async def _user_api_key(user_id: str, method: str) -> str:
wallets = await get_wallets(user_id)
if not wallets:
raise PermissionError("Extension API request requires a user wallet.")
wallet = wallets[0]
return wallet.inkey if method in _READ_METHODS else wallet.adminkey
def _extension_api_path(path: str) -> str:
parts = urlsplit(path)
if parts.scheme or parts.netloc:
raise PermissionError("Extension API request path must be relative.")
if parts.fragment:
raise PermissionError("Extension API request path cannot include a fragment.")
if not parts.path.startswith("/api/"):
raise PermissionError("Extension API request path must start with '/api/'.")
decoded_path = unquote(parts.path)
path_parts = decoded_path.split("/")
if any(part == ".." for part in path_parts):
raise PermissionError("Extension API request path cannot traverse directories.")
normalized = posixpath.normpath(decoded_path)
if normalized != decoded_path.rstrip("/") or not normalized.startswith("/api/"):
raise PermissionError("Extension API request path is invalid.")
return urlunsplit(("", "", parts.path, parts.query, ""))
async def _read_limited_response(response: httpx.Response) -> bytes:
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
raise ValueError("Extension API response is too large.")
chunks.append(chunk)
return b"".join(chunks)
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
return {
key: value
for key, value in headers.items()
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
}
-183
View File
@@ -1,183 +0,0 @@
from __future__ import annotations
import ipaddress
import socket
from typing import Any
from urllib.parse import urlparse
import httpx
from .models import HttpRequest, HttpResponse
HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
HTTP_MAX_RESPONSE_BYTES = 262_144
_FORBIDDEN_REQUEST_HEADERS = {
"connection",
"content-length",
"cookie",
"host",
"proxy-authorization",
"transfer-encoding",
}
_FORBIDDEN_RESPONSE_HEADERS = {
"connection",
"content-length",
"set-cookie",
"transfer-encoding",
}
async def send_extension_http_request(
extension_id: str,
policy: dict[str, Any],
request: HttpRequest,
) -> HttpResponse:
allowed_origins = _allowed_origins(policy)
origin = _request_origin(request.url)
if origin not in allowed_origins:
raise PermissionError(
f"Extension '{extension_id}' is not allowed to request '{origin}'."
)
await _reject_internal_host(request.url)
headers = _request_headers(request.headers)
body = request.body.encode() if request.body is not None else b""
if len(body) > 65_536:
raise ValueError("HTTP request body is too large.")
try:
async with httpx.AsyncClient(
follow_redirects=False,
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
trust_env=False,
) as client:
async with client.stream(
request.method,
request.url,
headers=headers,
content=body,
) as response:
response_body = await _read_limited_response(response)
return HttpResponse(
status_code=response.status_code,
headers=_response_headers(dict(response.headers)),
body=response_body.decode(response.encoding or "utf-8", "replace"),
)
except httpx.RequestError as exc:
raise ValueError("HTTP request failed.") from exc
def _allowed_origins(policy: dict[str, Any]) -> set[str]:
hosts = policy.get("hosts")
if not isinstance(hosts, list) or not hosts:
raise PermissionError("HTTP requests require a non-empty hosts policy.")
origins: set[str] = set()
for host in hosts:
if not isinstance(host, str) or not host:
continue
origins.add(_request_origin(host))
if not origins:
raise PermissionError("HTTP requests require at least one valid host.")
return origins
def _request_origin(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme != "https":
raise PermissionError("HTTP requests require https URLs.")
if parsed.username or parsed.password:
raise PermissionError("HTTP requests cannot include credentials in URLs.")
if not parsed.hostname:
raise PermissionError("HTTP requests require a hostname.")
hostname = parsed.hostname.lower()
port = _url_port(parsed)
if port is None or port == 443:
return f"https://{hostname}"
return f"https://{hostname}:{port}"
def _url_port(parsed: Any) -> int | None:
try:
return parsed.port
except ValueError as exc:
raise PermissionError("HTTP request URL has an invalid port.") from exc
async def _reject_internal_host(url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
raise PermissionError("HTTP requests require a hostname.")
if hostname == "localhost" or hostname.endswith(".localhost"):
raise PermissionError("HTTP requests cannot target localhost.")
try:
address = ipaddress.ip_address(hostname)
_reject_internal_address(address)
return
except ValueError:
pass
for address in await _resolve_host(hostname):
_reject_internal_address(address)
async def _resolve_host(
hostname: str,
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
import asyncio
def resolve() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
try:
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise PermissionError("HTTP request host could not be resolved.") from exc
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos:
sockaddr = info[4]
addresses.append(ipaddress.ip_address(sockaddr[0]))
return addresses
return await asyncio.to_thread(resolve)
def _reject_internal_address(
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
) -> None:
if not address.is_global:
raise PermissionError("HTTP requests cannot target internal network addresses.")
def _request_headers(headers: dict[str, str]) -> dict[str, str]:
clean: dict[str, str] = {}
for key, value in headers.items():
header = key.strip()
if not header:
continue
if header.lower() in _FORBIDDEN_REQUEST_HEADERS:
continue
clean[header] = value
return clean
async def _read_limited_response(response: httpx.Response) -> bytes:
chunks: list[bytes] = []
size = 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > HTTP_MAX_RESPONSE_BYTES:
raise ValueError("HTTP response is too large.")
chunks.append(chunk)
return b"".join(chunks)
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
return {
key: value
for key, value in headers.items()
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
}
-100
View File
@@ -1,100 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from lnbits.settings import settings
@dataclass(frozen=True)
class WasmExtension:
id: str
name: str
version: str
root_path: Path
module_path: Path
wit_path: Path | None
world: str
host_api: str
exports: list[dict[str, Any]]
config: dict[str, Any]
def is_wasm_extension_id(ext_id: str) -> bool:
config = load_wasm_extension_config(ext_id)
return bool(config and config.get("extension_type") == "wasm")
def is_wasm_extension_dir(ext_dir: Path) -> bool:
config = _load_json(ext_dir / "config.json")
return bool(config and config.get("extension_type") == "wasm")
def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | None:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
return _load_json(ext_dir / "config.json")
def load_wasm_extension(ext_id: str) -> WasmExtension:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
config = load_wasm_extension_config(ext_id)
if not config:
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
if config.get("extension_type") != "wasm":
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
wasm_config = config.get("wasm") or {}
module_path = _extension_path(ext_dir, wasm_config.get("module"))
wit_path = _optional_extension_path(ext_dir, wasm_config.get("wit"))
_check_wasm_module(module_path)
if wit_path and not wit_path.is_file():
raise FileNotFoundError(f"WIT file not found: {wit_path}")
return WasmExtension(
id=config.get("id") or ext_id,
name=config.get("name") or ext_id,
version=config.get("version") or "0.0",
root_path=ext_dir,
module_path=module_path,
wit_path=wit_path,
world=wasm_config.get("world") or "",
host_api=wasm_config.get("host_api") or "lnbits.core.extensions.ExtensionAPI",
exports=wasm_config.get("exports") or [],
config=config,
)
def _load_json(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
with path.open("r", encoding="utf-8") as config_file:
value = json.load(config_file)
if not isinstance(value, dict):
raise ValueError(f"Expected JSON object in '{path}'.")
return value
def _extension_path(ext_dir: Path, value: Any) -> Path:
if not isinstance(value, str) or not value:
raise ValueError(f"Missing relative path for extension '{ext_dir.name}'.")
path = (ext_dir / value).resolve()
if ext_dir.resolve() not in path.parents:
raise ValueError(f"Extension path escapes extension root: {value}")
return path
def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None:
if value is None:
return None
return _extension_path(ext_dir, value)
def _check_wasm_module(path: Path) -> None:
if not path.is_file():
raise FileNotFoundError(f"WASM module not found: {path}")
with path.open("rb") as wasm_file:
magic = wasm_file.read(4)
if magic != b"\0asm":
raise ValueError(f"Invalid WASM module: {path}")
-184
View File
@@ -1,184 +0,0 @@
import json
from typing import Any, Literal
from pydantic import BaseModel, Field, root_validator
class EmptyRequest(BaseModel):
pass
class StorageGetRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
id: str = Field(..., min_length=1, max_length=512)
class StorageGetResponse(BaseModel):
data_json: str | None = None
class StorageSetRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
data: dict[str, Any] = Field(default_factory=dict)
@root_validator(pre=True)
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
data_json = values.get("data_json")
if data_json is not None and "data" not in values:
values["data"] = json.loads(data_json)
return values
class StorageSetResponse(BaseModel):
ok: bool = True
class StoragePaginatedRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
filters: dict[str, Any] = Field(default_factory=dict)
search: str | None = Field(None, max_length=256)
search_fields: list[str] = Field(default_factory=list)
sort_by: str | None = Field(None, min_length=1, max_length=128)
descending: bool = False
limit: int = Field(25, ge=1, le=1000)
offset: int = Field(0, ge=0)
@root_validator(pre=True)
def parse_json_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
filters_json = values.get("filters_json")
if filters_json is not None and "filters" not in values:
values["filters"] = json.loads(filters_json)
search_fields_json = values.get("search_fields_json")
if search_fields_json is not None and "search_fields" not in values:
values["search_fields"] = json.loads(search_fields_json)
if values.get("sort_by") == "":
values["sort_by"] = None
return values
class StoragePaginatedResponse(BaseModel):
rows_json: str = "[]"
total: int = 0
class StorageDeleteRequest(BaseModel):
table: str = Field(..., min_length=1, max_length=128)
id: str = Field(..., min_length=1, max_length=512)
class StorageDeleteResponse(BaseModel):
ok: bool = True
class CreateInvoiceRequest(BaseModel):
wallet_id: str = Field(..., min_length=1, max_length=128)
amount_sat: int = Field(..., gt=0)
# todo: bridge for extensions to select currencies
currency: str | None = Field(..., min_length=1, max_length=8)
memo: str = Field(..., max_length=512)
tag: str = Field(..., min_length=1, max_length=64)
extra: dict[str, str] = Field(default_factory=dict)
class CreateInvoicePublicRequest(BaseModel):
source_id: str = Field(..., min_length=1, max_length=512)
amount: float = Field(..., gt=0)
currency: str = Field(..., min_length=1, max_length=8)
memo: str = Field("", max_length=512)
extra: dict[str, Any] = Field(default_factory=dict)
@root_validator
def validate_extra_size(cls, values: dict[str, Any]) -> dict[str, Any]:
extra = values.get("extra") or {}
try:
encoded = json.dumps(extra, separators=(",", ":"))
except TypeError as exc:
raise ValueError("extra must be JSON serializable.") from exc
if len(encoded.encode()) > 4096:
raise ValueError("extra must not exceed 4096 bytes.")
values["extra"] = extra
return values
class CreateInvoiceResponse(BaseModel):
payment_hash: str
payment_request: str
checking_id: str
class UserWalletSummary(BaseModel):
id: str
name: str
currency: str | None = None
class ListUserWalletsResponse(BaseModel):
wallets: list[UserWalletSummary] = Field(default_factory=list)
class HttpRequest(BaseModel):
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
url: str = Field(..., min_length=1, max_length=2048)
headers: dict[str, str] = Field(default_factory=dict)
body: str | None = Field(None, max_length=65536)
@root_validator(pre=True)
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
method = values.get("method")
if isinstance(method, str):
values["method"] = method.upper()
return values
@root_validator
def validate_headers_size(cls, values: dict[str, Any]) -> dict[str, Any]:
headers = values.get("headers") or {}
if len(headers) > 32:
raise ValueError("headers must not contain more than 32 entries.")
for key, value in headers.items():
if len(key) > 128 or len(value) > 4096:
raise ValueError("headers are too large.")
values["headers"] = headers
return values
class HttpResponse(BaseModel):
status_code: int
headers: dict[str, str] = Field(default_factory=dict)
body: str = ""
class ExtensionApiRequest(BaseModel):
extension_id: str = Field(..., min_length=1, max_length=128)
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
path: str = Field(..., min_length=1, max_length=2048)
body: str | None = Field(None, max_length=65536)
@root_validator(pre=True)
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
method = values.get("method")
if isinstance(method, str):
values["method"] = method.upper()
return values
class RandomIdRequest(BaseModel):
prefix: str = Field(..., min_length=1, max_length=32)
class RandomIdResponse(BaseModel):
id: str
class NowResponse(BaseModel):
timestamp: int
class LogRequest(BaseModel):
level: Literal["debug", "info", "warning", "error"] = "info"
message: str = Field(..., min_length=1, max_length=2048)
class LogResponse(BaseModel):
ok: bool = True
-635
View File
@@ -1,635 +0,0 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Annotated, Any, NoReturn
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from loguru import logger
from pydantic import UUID4
from starlette.staticfiles import PathLike as StaticFilesPathLike
from starlette.types import Scope
from lnbits.core.crud import get_user_from_account
from lnbits.core.db import core_app_extra
from lnbits.core.models import Account
from lnbits.decorators import (
check_access_token,
check_account_exists,
optional_user_id,
)
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.utils.cache import cache
from .loader import WasmExtension, load_wasm_extension
from .wasm import invoke_wasm_extension_export, warm_wasm_extension
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
WASM_EXTENSION_CORE_STATIC_ASSETS = {
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
"material-icons-v50.woff2": (
"static/fonts/material-icons-v50.woff2",
"font/woff2",
),
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
"quasar.umd.prod.js": (
"static/vendor/quasar.umd.prod.js",
"text/javascript; charset=utf-8",
),
"qrcode.vue.browser.js": (
"static/vendor/qrcode.vue.browser.js",
"text/javascript; charset=utf-8",
),
"vue.global.prod.js": (
"static/vendor/vue.global.prod.js",
"text/javascript; charset=utf-8",
),
}
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
"material-icons.css": (
"""
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('./material-icons-v50.woff2') format('woff2');
}
""",
"text/css; charset=utf-8",
)
}
WASM_EXTENSION_STATIC_MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".png": "image/png",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
}
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
class GuardedWasmExtensionStaticFiles(StaticFiles):
async def get_response(self, path: str, scope: Scope) -> Response:
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
return _wasm_extension_core_asset_response(path)
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
raise HTTPException(status_code=404)
return await super().get_response(path, scope)
def file_response(
self,
full_path: StaticFilesPathLike,
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
suffix = Path(full_path).suffix.lower()
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
_reject_html_like_wasm_static_asset(Path(full_path))
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
loaded = load_wasm_extension(ext_id)
warm_wasm_extension(loaded)
_mount_wasm_extension_static(app, loaded)
_register_wasm_extension_ui_routes(app, loaded)
_register_wasm_extension_api_routes(app, loaded)
core_app_extra.wasm_extension_registry.register(loaded)
settings.activate_extension_paths(ext_id, "", [])
logger.info(
f"Loaded WASM extension '{loaded.id}' "
f"({loaded.module_path.stat().st_size} bytes)."
)
return loaded
def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
static_path = extension.root_path / "static"
mount_path = f"/ext-assets/{extension.id}"
if any(getattr(route, "path", None) == mount_path for route in app.routes):
return
app.mount(
mount_path,
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
name=f"{extension.id}-static",
)
def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
entrypoint = _wasm_extension_entrypoint(
extension, route_config.get("entrypoint")
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
path_params = route_config.get("path_params") or {}
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
frame_path,
auth,
path_params,
)
def _register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("api_routes") or []:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: dict[str, Any],
) -> None:
method = _wasm_extension_api_method(extension, route_config.get("method"))
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
path_params = route_config.get("path_params") or {}
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if _has_route(app, route_path, method):
return
async def invoke_wasm_api_request(
request: Request, account: Account | None = None
) -> dict[str, Any]:
try:
payload = await _read_api_payload(request, path_params)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload,
user=account,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def invoke_private_wasm_extension_export(
request: Request,
account: Account = Depends(check_account_exists),
) -> dict[str, Any]:
return await invoke_wasm_api_request(request, account)
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
return await invoke_wasm_api_request(request)
app.add_api_route(
route_path,
(
invoke_public_wasm_extension_export
if auth == "public"
else invoke_private_wasm_extension_export
),
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
include_in_schema=False,
)
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
if request.method in {"POST", "PUT", "PATCH"}:
payload.update(await _read_json_object(request))
return payload
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return {}
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value
def _read_api_path_params(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
for key, value in request.path_params.items():
target = path_params.get(key) or _snake_to_camel(key)
payload[target] = value
return payload
def _read_api_query_params(request: Request) -> dict[str, Any]:
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
if not isinstance(export_name, str) or not export_name:
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
for export in extension.exports:
if export.get("name") != export_name:
continue
if export.get("visibility") in {"public", "authenticated"}:
return export_name
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
if not isinstance(method, str):
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
method = method.upper()
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
return method
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
if path == "/":
return f"/api/v1/ext/{extension.id}"
return f"/api/v1/ext/{extension.id}{path}"
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
for route in app.routes:
if getattr(route, "path", None) != route_path:
continue
methods = getattr(route, "methods", set()) or set()
if method in methods:
return True
return False
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
frame_path: str,
auth: str,
path_params: dict[str, str],
) -> None:
if _has_route(app, route_path, "GET"):
return
async def serve_private_wasm_extension_page(
request: Request,
account: Account = Depends(check_account_exists),
) -> Any:
user = await get_user_from_account(account)
return _wasm_extension_wrapper_response(
request,
extension,
frame_path,
auth,
path_params,
user.json() if user else None,
account.id,
)
async def serve_public_wasm_extension_page(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> Any:
return _wasm_extension_wrapper_response(
request,
extension,
frame_path,
auth,
path_params,
None,
user_id,
)
app.add_api_route(
route_path,
(
serve_public_wasm_extension_page
if auth == "public"
else serve_private_wasm_extension_page
),
methods=["GET"],
name=f"{extension.id}:{route_path}",
include_in_schema=False,
)
def _add_wasm_extension_frame_route(
app: FastAPI,
extension: WasmExtension,
frame_path: str,
entrypoint: Path,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> FileResponse:
_consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
response = FileResponse(entrypoint)
response.headers["Content-Security-Policy"] = _wasm_extension_frame_csp(
request, extension
)
response.headers["Cache-Control"] = "no-store"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
# Extension access goes through the parent bridge.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), "
"clipboard-read=(), usb=()"
)
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
app.add_api_route(
frame_path,
serve_wasm_extension_frame,
methods=["GET"],
name=f"{extension.id}:frame:{frame_path}",
include_in_schema=False,
)
def _wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
frame_path: str,
auth: str,
path_params: dict[str, str],
user_json: str | None,
user_id: str | None,
) -> Any:
public = auth == "public"
response = template_renderer().TemplateResponse(
request,
"wasm_extension.html",
{
"extension": extension,
"frame_url": _wasm_extension_frame_url(extension, frame_path, user_id),
"bridge": {
"extensionId": extension.id,
"public": public,
"routeParams": _read_api_path_params(request, path_params),
"query": _read_api_query_params(request),
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
},
"public": public,
"user": user_json,
},
)
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
return response
def _wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str:
origin = str(request.base_url).rstrip("/")
extension_assets = f"{origin}/ext-assets/{extension.id}/"
return (
"sandbox allow-scripts; "
"default-src 'none'; "
f"script-src {extension_assets}; "
"script-src-attr 'none'; "
f"style-src {extension_assets}; "
"style-src-attr 'none'; "
f"img-src {extension_assets} data:; "
f"font-src {extension_assets}; "
"connect-src 'none'; "
"form-action 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"frame-src 'none'; "
"worker-src 'none'; "
"media-src 'none'; "
"manifest-src 'none'; "
"frame-ancestors 'self'"
)
def _wasm_extension_frame_url(
extension: WasmExtension, frame_path: str, user_id: str | None
) -> str:
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
return f"{frame_path}?frame_token={token}"
def _create_wasm_extension_frame_token(
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> str:
token = uuid4().hex
cache.set(
_wasm_extension_frame_token_cache_key(token),
{
"extension_id": extension.id,
"frame_path": frame_path,
"user_id": user_id,
},
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
)
return token
def _consume_wasm_extension_frame_token(
request: Request,
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> None:
token = request.query_params.get("frame_token")
if not token:
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
cache_key = _wasm_extension_frame_token_cache_key(token)
token_data = cache.get(cache_key)
if (
not isinstance(token_data, dict)
or token_data.get("extension_id") != extension.id
or token_data.get("frame_path") != frame_path
):
_raise_wasm_extension_frame_not_found(
extension, frame_path, "unknown or expired"
)
token_user_id = token_data.get("user_id")
if token_user_id and token_user_id != user_id:
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
cache.pop(cache_key)
def _wasm_extension_frame_token_cache_key(token: str) -> str:
return f"wasm-frame-token:{token}"
def _raise_wasm_extension_frame_not_found(
extension: WasmExtension,
frame_path: str,
reason: str,
) -> NoReturn:
logger.warning(
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
)
raise HTTPException(status_code=404, detail="Not found")
def _wasm_extension_bridge_api_routes(
extension: WasmExtension,
public: bool,
) -> list[dict[str, str]]:
routes: list[dict[str, str]] = []
for route_config in extension.config.get("api_routes") or []:
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if public and auth != "public":
continue
method = _wasm_extension_api_method(extension, route_config.get("method"))
path = _wasm_extension_api_path(extension, route_config.get("path"))
_wasm_extension_api_export(extension, route_config.get("export"))
routes.append(
{
"method": method,
"path": path,
"pattern": _path_template_pattern(path),
}
)
return routes
def _path_template_pattern(path: str) -> str:
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
return f"^{pattern}$"
async def _optional_wasm_user_id(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
try:
return await optional_user_id(request, access_token, usr)
except HTTPException:
return None
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
if auth in {"public", "user"}:
return auth
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
if path == "/":
return "/ext"
return f"/ext{path}"
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
if not isinstance(entrypoint, str) or not entrypoint:
raise ValueError(
f"Invalid route entrypoint for WASM extension '{extension.id}'."
)
if entrypoint.startswith("/"):
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be a "
"relative extension path."
)
path = (extension.root_path / entrypoint).resolve()
root_path = extension.root_path.resolve()
if path != root_path and root_path not in path.parents:
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
static_path = (extension.root_path / "static").resolve()
if path == static_path or static_path in path.parents:
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must not be "
"inside the static asset directory."
)
if path.suffix.lower() != ".html":
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be "
"an HTML file."
)
if not path.is_file():
raise FileNotFoundError(f"Route entrypoint not found: {path}")
return path
def _reject_html_like_wasm_static_asset(path: Path) -> None:
with path.open("rb") as asset_file:
prefix = asset_file.read(512).lstrip().lower()
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
raise HTTPException(status_code=404)
def _wasm_extension_core_asset_response(path: str) -> Response:
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
if not asset_name or "/" in asset_name or "\\" in asset_name:
raise HTTPException(status_code=404)
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
if generated_asset:
content, content_type = generated_asset
response = Response(content=content, media_type=content_type)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
if not asset_config:
raise HTTPException(status_code=404)
relative_path, content_type = asset_config
asset_path = Path(settings.lnbits_path, relative_path)
if not asset_path.is_file():
raise HTTPException(status_code=404)
response = FileResponse(asset_path)
response.headers["Content-Type"] = content_type
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
-120
View File
@@ -1,120 +0,0 @@
from __future__ import annotations
import inspect
import re
from collections.abc import Awaitable, Callable, Mapping
from typing import Any
from pydantic import BaseModel
from .api import ExtensionAPI, ExtensionAPIMethod, list_extension_api_methods
HostImport = Callable[..., Awaitable[dict[str, Any]]]
class ExtensionAPIHost:
def __init__(
self,
api: ExtensionAPI,
*,
api_cls: type[ExtensionAPI] = ExtensionAPI,
) -> None:
self.api = api
self.methods = list_extension_api_methods(api_cls)
self._methods_by_host_name = self._index_methods(self.methods)
async def invoke(
self,
host_name: str,
payload: Mapping[str, Any] | BaseModel | None = None,
) -> dict[str, Any]:
method = self._require_method(host_name)
request = self._request_model(method, payload)
handler = getattr(self.api, method.python_name)
response = handler(request)
if inspect.isawaitable(response):
response = await response
return self._response_payload(method, response)
def imports(self) -> dict[str, HostImport]:
return {
_snake_to_camel(method.host_name): self._make_import(method)
for method in self.methods
}
def import_object(self) -> dict[str, dict[str, HostImport]]:
return {"lnbits:extension/host": self.imports()}
def _make_import(self, method: ExtensionAPIMethod) -> HostImport:
async def host_import(
payload: Mapping[str, Any] | BaseModel | None = None,
) -> dict[str, Any]:
return await self.invoke(method.host_name, payload)
return host_import
def _require_method(self, host_name: str) -> ExtensionAPIMethod:
method = self._methods_by_host_name.get(host_name)
if not method:
raise KeyError(f"Unknown extension host function '{host_name}'.")
return method
@staticmethod
def _index_methods(
methods: list[ExtensionAPIMethod],
) -> dict[str, ExtensionAPIMethod]:
index: dict[str, ExtensionAPIMethod] = {}
for method in methods:
for host_name in {
method.host_name,
_snake_to_camel(method.host_name),
method.host_name.replace("_", "-"),
}:
index[host_name] = method
return index
@staticmethod
def _request_model(
method: ExtensionAPIMethod,
payload: Mapping[str, Any] | BaseModel | None,
) -> BaseModel:
if isinstance(payload, method.request_model):
return payload
if isinstance(payload, BaseModel):
payload = payload.dict()
if payload is None:
payload = {}
if not isinstance(payload, Mapping):
raise TypeError(
f"Host function '{method.host_name}' expects an object payload."
)
data = {_to_snake(key): value for key, value in payload.items()}
if isinstance(data.get("extra"), list):
data["extra"] = dict(data["extra"])
if isinstance(data.get("headers"), list):
data["headers"] = dict(data["headers"])
return method.request_model.parse_obj(data)
@staticmethod
def _response_payload(
method: ExtensionAPIMethod,
response: Any,
) -> dict[str, Any]:
if not isinstance(response, method.response_model):
response = method.response_model.parse_obj(response)
payload = response.dict()
if method.method_id in {"http.request", "extension.api.request"} and isinstance(
payload.get("headers"), Mapping
):
payload["headers"] = list(payload["headers"].items())
return {_snake_to_camel(key): value for key, value in payload.items()}
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _to_snake(value: str) -> str:
value = value.replace("-", "_")
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
-627
View File
@@ -1,627 +0,0 @@
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from loguru import logger
from lnbits.core.crud import update_migration_version
from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import POSTGRES, SQLITE, Connection, Database
from lnbits.settings import settings
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
OWNER_ID_FIELD = "__lnbits_owner_id__"
async def storage_get_row(
ext_id: str,
table: str,
row_id: str,
owner_id: str,
) -> dict[str, Any] | None:
table_schema = _load_table_schema(ext_id, table)
query = f"""
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
row = await conn.fetchone(query, {"id": row_id, "owner_id": owner_id})
return _row_from_db(table_schema, row) if row else None
async def storage_get_public_row(
ext_id: str,
table: str,
row_id: str,
) -> dict[str, Any] | None:
table_schema = _load_table_schema(ext_id, table)
query = f"""
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
row = await conn.fetchone(query, {"id": row_id})
return _row_from_db(table_schema, row) if row else None
async def storage_get_row_owner_id(
ext_id: str,
table: str,
row_id: str,
) -> str | None:
_load_table_schema(ext_id, table)
query = f"""
SELECT {OWNER_ID_FIELD} FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
row = await conn.fetchone(query, {"id": row_id})
owner_id = row[OWNER_ID_FIELD] if row else None
return owner_id if isinstance(owner_id, str) and owner_id else None
async def storage_set_row(
ext_id: str,
table: str,
data: dict[str, Any],
owner_id: str,
) -> None:
table_schema = _load_table_schema(ext_id, table)
clean_data = _data_to_db(table_schema, data, require_id=True)
clean_data[OWNER_ID_FIELD] = owner_id
columns = list(clean_data.keys())
placeholders = [f":{column}" for column in columns]
updates = [
f"{column} = excluded.{column}"
for column in columns
if column not in ("id", OWNER_ID_FIELD)
]
conflict_sql = (
"DO UPDATE SET "
+ ", ".join(updates)
+ f" WHERE {OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if updates
else "DO NOTHING"
)
query = f"""
INSERT INTO {_table_ref_for_schema(ext_id, table)}
({", ".join(columns)})
VALUES
({", ".join(placeholders)})
ON CONFLICT (id) {conflict_sql}
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
await conn.execute(query, clean_data)
async def storage_get_paginated_rows(
ext_id: str,
table: str,
filters: dict[str, Any],
*,
owner_id: str,
search: str | None,
search_fields: list[str],
sort_by: str | None,
descending: bool,
limit: int,
offset: int,
) -> dict[str, Any]:
table_schema = _load_table_schema(ext_id, table)
where_sql, values = _where_sql(table_schema, filters, search, search_fields)
where_sql = _append_owner_where_sql(where_sql)
values[OWNER_ID_FIELD] = owner_id
order_sql = _order_sql(table_schema, sort_by, descending)
count_values = dict(values)
values.update({"limit": min(limit, 1000), "offset": offset})
table_ref = _table_ref_for_schema(ext_id, table)
rows_query = f"""
SELECT * FROM {table_ref}
{where_sql}
{order_sql}
LIMIT :limit
OFFSET :offset
""" # noqa: S608
count_query = f"""
SELECT COUNT(*) AS count FROM {table_ref}
{where_sql}
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
rows = await conn.fetchall(rows_query, values)
count_row = await conn.fetchone(count_query, count_values)
return {
"data": [_row_from_db(table_schema, row) for row in rows],
"total": int(count_row["count"]) if count_row else 0,
}
async def storage_delete_row(
ext_id: str,
table: str,
row_id: str,
owner_id: str,
) -> None:
_load_table_schema(ext_id, table)
query = f"""
DELETE FROM {_table_ref_for_schema(ext_id, table)}
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
""" # noqa: S608
async with Database(f"ext_{ext_id}").connect() as conn:
await conn.execute(query, {"id": row_id, "owner_id": owner_id})
async def migrate_wasm_extension_database(
ext: InstallableExtension,
current_version: DbVersion | None = None,
) -> None:
migrations_dir = ext.ext_dir / "storage" / "migrations"
migration_files = _migration_files(migrations_dir)
if not migration_files:
logger.debug(f"No storage migrations for WASM extension '{ext.id}'.")
return
ext_db = Database(f"ext_{ext.id}")
async with ext_db.connect() as conn:
for version, path in migration_files:
if current_version and version <= current_version.version:
continue
logger.debug(f"running WASM storage migration {ext.id}.{version}")
print(f"running migration {ext.id}.{version}")
await _run_storage_migration(conn, path)
await _update_wasm_migration_version(conn, ext.id, version)
def _migration_files(migrations_dir: Path) -> list[tuple[int, Path]]:
if not migrations_dir.is_dir():
return []
files: list[tuple[int, Path]] = []
for path in migrations_dir.glob("*.json"):
match = _MIGRATION_FILE_RE.match(path.name)
if not match:
raise ValueError(f"Invalid WASM storage migration filename: {path.name}")
files.append((int(match.group(1)), path))
return sorted(files)
async def _run_storage_migration(db: Connection, path: Path) -> None:
migration = _load_json(path)
operations = migration.get("operations")
if not isinstance(operations, list):
raise ValueError(f"WASM storage migration '{path}' has no operations list.")
for operation in operations:
if not isinstance(operation, dict):
raise ValueError(f"WASM storage migration '{path}' has invalid operation.")
sql = _operation_sql(db, operation)
await db.execute(sql)
def _operation_sql(db: Connection, operation: dict[str, Any]) -> str:
op = operation.get("op")
if op == "create_table":
return _create_table_sql(db, operation)
if op == "add_field":
return _add_field_sql(db, operation)
if op == "create_index":
return _create_index_sql(db, operation)
raise ValueError(f"Unsupported WASM storage migration operation: {op}")
def _create_table_sql(db: Connection, operation: dict[str, Any]) -> str:
table = _require_identifier(operation, "table")
fields = _require_fields(operation)
if not any(field.get("name") == "id" for field in fields):
raise ValueError(f"WASM storage table '{table}' must define an id field.")
if any(field.get("name") == OWNER_ID_FIELD for field in fields):
raise ValueError(
f"WASM storage table '{table}' defines reserved field '{OWNER_ID_FIELD}'."
)
columns = [
_column_sql(db, field, primary_key=field.get("name") == "id")
for field in fields
]
columns.append(f"{OWNER_ID_FIELD} TEXT NOT NULL")
return f"""
CREATE TABLE IF NOT EXISTS {_table_ref(db, table)} (
{", ".join(columns)}
);
"""
def _add_field_sql(db: Connection, operation: dict[str, Any]) -> str:
table = _require_identifier(operation, "table")
field = _field_from_add_field_operation(operation)
if field["name"] == OWNER_ID_FIELD:
raise ValueError(
f"WASM storage table '{table}' cannot add reserved field "
f"'{OWNER_ID_FIELD}'."
)
return f"""
ALTER TABLE {_table_ref(db, table)}
ADD COLUMN {_column_sql(db, field)};
"""
def _create_index_sql(db: Connection, operation: dict[str, Any]) -> str:
table = _require_identifier(operation, "table")
name = _require_identifier(operation, "name")
field = _require_identifier(operation, "field")
if field == OWNER_ID_FIELD:
raise ValueError(
f"WASM storage table '{table}' cannot index reserved field "
f"'{OWNER_ID_FIELD}'."
)
if db.type == SQLITE and db.schema:
return f"""
CREATE INDEX IF NOT EXISTS {_schema_ref(db, name)}
ON {table} ({field});
"""
return f"""
CREATE INDEX IF NOT EXISTS {name}
ON {_table_ref(db, table)} ({field});
"""
def _column_sql(
db: Connection,
field: dict[str, Any],
*,
primary_key: bool = False,
) -> str:
name = _require_identifier(field, "name")
column_type = _field_type_sql(db, field)
parts = [name, column_type]
if primary_key:
parts.append("PRIMARY KEY")
elif not field.get("nullable", False):
parts.append("NOT NULL")
if "default" in field:
parts.append(f"DEFAULT {_default_sql(field['default'])}")
return " ".join(parts)
def _field_type_sql(db: Connection, field: dict[str, Any]) -> str:
if field.get("list") is True:
return "TEXT"
field_type = field.get("type")
if field_type == "string":
return "TEXT"
if field_type == "integer":
return db.big_int
if field_type == "number":
return "DOUBLE PRECISION" if db.type == POSTGRES else "REAL"
if field_type == "boolean":
return "BOOLEAN"
if field_type == "datetime":
return "TIMESTAMP"
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]:
schema = _load_storage_schema(ext_id)
tables = schema.get("tables")
if not isinstance(tables, dict):
raise ValueError(f"WASM extension '{ext_id}' has no storage tables schema.")
_require_identifier({"table": table}, "table")
table_schema = tables.get(table)
if not isinstance(table_schema, dict):
raise ValueError(f"WASM extension '{ext_id}' has no storage table '{table}'.")
fields = table_schema.get("fields")
if not isinstance(fields, list) or not fields:
raise ValueError(f"WASM storage table '{table}' has no fields schema.")
for field in fields:
if not isinstance(field, dict):
raise ValueError(f"WASM storage table '{table}' has invalid field schema.")
_require_identifier(field, "name")
if field["name"] == OWNER_ID_FIELD:
raise ValueError(
f"WASM storage table '{table}' defines reserved field "
f"'{OWNER_ID_FIELD}'."
)
return table_schema
def _load_storage_schema(ext_id: str) -> dict[str, Any]:
schema_path = (
Path(settings.lnbits_extensions_path)
/ "extensions"
/ ext_id
/ "storage"
/ "schema.json"
)
if not schema_path.is_file():
raise ValueError(f"WASM extension '{ext_id}' has no storage schema.")
return _load_json(schema_path)
def _data_to_db(
table_schema: dict[str, Any],
data: dict[str, Any],
*,
require_id: bool,
) -> dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("WASM storage row data must be an object.")
if require_id and not data.get("id"):
raise ValueError("WASM storage row data must include an id.")
_reject_reserved_owner_field(data, "row")
fields = _fields_by_name(table_schema)
unknown_fields = sorted(set(data) - set(fields))
if unknown_fields:
raise ValueError(
"WASM storage row has unknown fields: " + ", ".join(unknown_fields)
)
return {
field_name: _value_to_db(fields[field_name], value)
for field_name, value in data.items()
}
def _filters_to_db(
table_schema: dict[str, Any],
filters: dict[str, Any],
) -> dict[str, Any]:
if not isinstance(filters, dict):
raise ValueError("WASM storage filters must be an object.")
_reject_reserved_owner_field(filters, "filters")
fields = _fields_by_name(table_schema)
unknown_fields = sorted(set(filters) - set(fields))
if unknown_fields:
raise ValueError(
"WASM storage filters have unknown fields: " + ", ".join(unknown_fields)
)
return {
field_name: _value_to_db(fields[field_name], value)
for field_name, value in filters.items()
}
def _where_sql(
table_schema: dict[str, Any],
filters: dict[str, Any],
search: str | None,
search_fields: list[str],
) -> tuple[str, dict[str, Any]]:
clean_filters = _filters_to_db(table_schema, filters)
clauses = [f"{field} = :filter_{field}" for field in clean_filters]
values = {f"filter_{field}": value for field, value in clean_filters.items()}
clean_search = search.strip().lower() if search else ""
if clean_search:
fields = _fields_by_name(table_schema)
invalid_fields = sorted(set(search_fields) - set(fields))
if invalid_fields:
raise ValueError(
"WASM storage search has unknown fields: " + ", ".join(invalid_fields)
)
if search_fields:
search_clause = " OR ".join(
f"LOWER(CAST({field} AS TEXT)) LIKE :search" for field in search_fields
)
clauses.append(f"({search_clause})")
values["search"] = f"%{clean_search}%"
return ("WHERE " + " AND ".join(clauses), values) if clauses else ("", values)
def _append_owner_where_sql(where_sql: str) -> str:
owner_clause = f"{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
if where_sql:
return f"{where_sql} AND {owner_clause}"
return f"WHERE {owner_clause}"
def _order_sql(
table_schema: dict[str, Any],
sort_by: str | None,
descending: bool,
) -> str:
if not sort_by:
return ""
fields = _fields_by_name(table_schema)
if sort_by not in fields:
raise ValueError(f"WASM storage sort field is unknown: {sort_by}")
direction = "DESC" if descending else "ASC"
return f"ORDER BY {sort_by} {direction}"
def _row_from_db(
table_schema: dict[str, Any],
row: dict[str, Any],
) -> dict[str, Any]:
fields = _fields_by_name(table_schema)
return {
field_name: _value_from_db(fields[field_name], value)
for field_name, value in dict(row).items()
if field_name in fields
}
def _fields_by_name(table_schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
fields = table_schema.get("fields")
if not isinstance(fields, list):
raise ValueError("WASM storage table schema fields must be a list.")
return {field["name"]: field for field in fields}
def _reject_reserved_owner_field(data: dict[str, Any], value_name: str) -> None:
if OWNER_ID_FIELD in data:
raise ValueError(f"WASM storage {value_name} includes a reserved owner field.")
def _value_to_db(field: dict[str, Any], value: Any) -> Any: # noqa: C901
if value is None:
if field.get("nullable", False):
return None
raise ValueError(f"WASM storage field '{field['name']}' cannot be null.")
if field.get("list") is True:
if not isinstance(value, list):
raise ValueError(f"WASM storage field '{field['name']}' must be a list.")
return json.dumps(value)
field_type = field.get("type")
if field_type == "string":
if not isinstance(value, str):
raise ValueError(f"WASM storage field '{field['name']}' must be a string.")
return value
if field_type == "integer":
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"WASM storage field '{field['name']}' must be an integer."
)
return value
if field_type == "number":
if isinstance(value, bool) or not isinstance(value, int | float):
raise ValueError(f"WASM storage field '{field['name']}' must be a number.")
return value
if field_type == "boolean":
if not isinstance(value, bool):
raise ValueError(f"WASM storage field '{field['name']}' must be a boolean.")
return value
if field_type == "datetime":
if isinstance(value, int | float):
return datetime.fromtimestamp(value, tz=timezone.utc)
if isinstance(value, datetime):
return value
raise ValueError(
f"WASM storage field '{field['name']}' must be a Unix timestamp."
)
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
def _value_from_db(field: dict[str, Any], value: Any) -> Any:
if value is None:
return None
if field.get("list") is True:
if isinstance(value, str):
return json.loads(value)
return value
field_type = field.get("type")
if field_type == "boolean":
return bool(value)
if field_type == "datetime":
if isinstance(value, datetime):
return int(value.replace(tzinfo=timezone.utc).timestamp())
if isinstance(value, int | float):
return int(value)
if isinstance(value, str):
try:
return int(datetime.fromisoformat(value).timestamp())
except ValueError:
return value
return value
def _default_sql(value: Any) -> str:
if value is None:
return "NULL"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int | float):
return str(value)
if isinstance(value, str):
return _quote_sql_string(value)
if isinstance(value, list | dict):
return _quote_sql_string(json.dumps(value))
raise ValueError(f"Unsupported WASM storage default value: {value}")
def _field_from_add_field_operation(operation: dict[str, Any]) -> dict[str, Any]:
field = {
"name": operation.get("field"),
"type": operation.get("type"),
}
for key in ("default", "list", "nullable"):
if key in operation:
field[key] = operation[key]
return field
def _require_fields(operation: dict[str, Any]) -> list[dict[str, Any]]:
fields = operation.get("fields")
if not isinstance(fields, list) or not fields:
raise ValueError("WASM storage create_table operation requires fields.")
if not all(isinstance(field, dict) for field in fields):
raise ValueError("WASM storage fields must be objects.")
return fields
def _require_identifier(data: dict[str, Any], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not _SQL_IDENTIFIER_RE.match(value):
raise ValueError(f"Invalid WASM storage SQL identifier for '{key}': {value}")
return value
def _table_ref(db: Connection, table: str) -> str:
if db.schema:
return f"{_schema_ref(db, table)}"
return table
def _table_ref_for_schema(ext_id: str, table: str) -> str:
_require_identifier({"schema": ext_id}, "schema")
_require_identifier({"table": table}, "table")
return f"{ext_id}.{table}"
def _schema_ref(db: Connection, name: str) -> str:
if not db.schema:
return name
if not _SQL_IDENTIFIER_RE.match(db.schema):
raise ValueError(f"Invalid WASM extension storage schema: {db.schema}")
return f"{db.schema}.{name}"
def _quote_sql_string(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _load_json(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as json_file:
data = json.load(json_file)
if not isinstance(data, dict):
raise ValueError(f"WASM storage migration '{path}' must be a JSON object.")
return data
async def _update_wasm_migration_version(
db: Connection,
ext_id: str,
version: int,
) -> None:
if db.schema is None:
await update_migration_version(db, ext_id, version)
else:
async with core_db.connect() as conn:
await update_migration_version(conn, ext_id, version)
-223
View File
@@ -1,223 +0,0 @@
from __future__ import annotations
import asyncio
import json
import re
from collections.abc import Mapping
from functools import lru_cache
from typing import Any
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
from .api import ExtensionAPI, list_extension_api_methods
from .loader import WasmExtension
from .runtime import ExtensionAPIHost
async def invoke_wasm_extension_export(
ext_id: str,
export_name: str,
payload: Mapping[str, Any] | None = None,
*,
user: Any | None = None,
context: str = "user",
owner_id: str | None = None,
) -> dict[str, Any]:
extension = _get_registered_extension(ext_id)
permissions = await _extension_permissions(extension)
api = ExtensionAPI(
extension.id,
permissions,
user_id=_user_id(user),
context=context,
owner_id=owner_id,
)
event_loop = asyncio.get_running_loop()
return await asyncio.to_thread(
_invoke_wasm_extension_export_sync,
extension,
export_name,
payload or {},
api,
event_loop,
)
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
def _invoke_wasm_extension_export_sync(
extension: WasmExtension,
export_name: str,
payload: Mapping[str, Any],
api: ExtensionAPI,
event_loop: asyncio.AbstractEventLoop,
) -> dict[str, Any]:
try:
from wasmtime import Store, WasiConfig, component
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
engine = _wasm_engine()
store = Store(engine)
store.set_wasi(WasiConfig())
linker = component.Linker(engine)
linker.add_wasip2()
_add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
wasm_component = _wasm_component(extension)
instance = linker.instantiate(store, wasm_component)
function = instance.get_func(store, export_name)
if not function:
raise KeyError(
f"WASM extension '{extension.id}' has no export '{export_name}'."
)
result = function(store, json.dumps(payload))
function.post_return(store)
return _parse_wasm_export_result(extension, result)
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
try:
from wasmtime import Config, Engine
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
config = Config()
config.wasm_component_model = True
return Engine(config)
def _wasm_component(extension: WasmExtension) -> Any:
stat = extension.module_path.stat()
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(_wasm_engine(), module_path)
def _add_extension_host_imports(
linker: Any,
api_host: ExtensionAPIHost,
event_loop: asyncio.AbstractEventLoop,
) -> None:
with linker.root() as root:
with root.add_instance("lnbits:extension/host") as host:
for method in list_extension_api_methods():
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(api_host, method.host_name, event_loop),
)
def _make_host_import(
api_host: ExtensionAPIHost,
host_name: str,
event_loop: asyncio.AbstractEventLoop,
) -> Any:
def host_import(_store: Any, request: Any = None) -> Any:
payload = _component_payload_to_dict(request)
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name, payload), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return host_import
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
if isinstance(value, Mapping):
return dict(value)
raise TypeError("WASM host function payload must be a record.")
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
from wasmtime import component
record = component.Record()
for key, item in value.items():
setattr(record, _camel_to_kebab(key), _to_component_value(item))
return record
def _to_component_value(value: Any) -> Any:
if isinstance(value, Mapping):
return _dict_to_component_record(value)
if isinstance(value, list):
return [_to_component_value(item) for item in value]
return value
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
if isinstance(value, bytes):
value = value.decode()
if not isinstance(value, str):
return {"ok": True, "data": value}
max_response_bytes = (
(extension.config.get("wasm") or {})
.get("resource_limits", {})
.get("max_response_bytes")
)
if isinstance(max_response_bytes, int):
response_size = len(value.encode())
if response_size > max_response_bytes:
raise ValueError(
f"WASM extension response is too large: {response_size} bytes."
)
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
return {"ok": True, "data": parsed}
def _get_registered_extension(ext_id: str) -> WasmExtension:
extension = core_app_extra.wasm_extension_registry.get(ext_id)
if extension:
return extension
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
async def _extension_permissions(extension: WasmExtension) -> list[Any]:
installed_extension = await get_installed_extension(extension.id)
if not installed_extension:
return []
return installed_extension.permissions
def _user_id(user: Any | None) -> str | None:
return getattr(user, "id", None) if user else None
def _camel_to_kebab(value: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
-5
View File
@@ -13,8 +13,6 @@ from lnbits.core.crud import (
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.extensions.loader import is_wasm_extension_id
from lnbits.core.extensions.storage import migrate_wasm_extension_database
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
@@ -24,9 +22,6 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
if is_wasm_extension_id(ext.id):
await migrate_wasm_extension_database(ext, current_version)
return
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
-9
View File
@@ -815,12 +815,3 @@ async def m045_add_external_id_to_payments(db: Connection):
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
async def m046_add_permissions_to_installed_extensions(db: Connection):
"""
Adds granted permissions to installed extensions.
"""
await db.execute(
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
)
-2
View File
@@ -7,7 +7,6 @@ from .misc import (
CoreAppExtra,
DbVersion,
SimpleStatus,
WasmExtensionRegistry,
)
from .payments import (
CancelInvoice,
@@ -103,6 +102,5 @@ __all__ = [
"Wallet",
"WalletInfo",
"WalletTypeInfo",
"WasmExtensionRegistry",
"WebPushSubscription",
]
+5 -32
View File
@@ -6,7 +6,6 @@ import json
import os
import shutil
import zipfile
from asyncio.tasks import create_task
from pathlib import Path
from typing import Any
@@ -21,6 +20,7 @@ from lnbits.helpers import (
version_parse,
)
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import cache
@@ -77,13 +77,6 @@ class GitHubRepo(BaseModel):
default_branch: str
class ExtensionPermission(BaseModel):
id: str
label: str | None = None
description: str | None = None
policy: dict[str, Any] | None = None
class ExtensionConfig(BaseModel):
name: str
short_description: str
@@ -91,8 +84,6 @@ class ExtensionConfig(BaseModel):
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
extension_type: str | None = None
permissions: list[ExtensionPermission] = []
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -153,7 +144,6 @@ class UserExtension(BaseModel):
class Extension(BaseModel):
code: str
is_valid: bool
is_wasm: bool = False
name: str | None = None
short_description: str | None = None
tile: str | None = None
@@ -177,7 +167,6 @@ class Extension(BaseModel):
return Extension(
code=ext_info.id,
is_valid=True,
is_wasm=ext_info.is_wasm,
name=ext_info.name,
short_description=ext_info.short_description,
tile=ext_info.icon,
@@ -359,7 +348,6 @@ class InstallableExtension(BaseModel):
icon: str | None = None
stars: int = 0
meta: ExtensionMeta | None = None
permissions: list[ExtensionPermission] = []
@property
def hash(self) -> str:
@@ -412,18 +400,6 @@ class InstallableExtension(BaseModel):
return False
return self.meta.pay_to_enable.required is True
@property
def is_wasm(self) -> bool:
config_path = Path(self.ext_dir, "config.json")
if not config_path.is_file():
return False
try:
with open(config_path, encoding="utf-8") as json_file:
config_json = json.load(json_file)
except Exception:
return False
return config_json.get("extension_type") == "wasm"
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
@@ -634,11 +610,6 @@ class InstallableExtension(BaseModel):
version=version,
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
permissions=[
ExtensionPermission.parse_obj(permission)
for permission in config_json.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
],
meta=ExtensionMeta(
installed_release=ExtensionRelease(
name=ext_id,
@@ -671,7 +642,10 @@ class InstallableExtension(BaseModel):
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
task_manager.create_task(
cls._refresh_installable_extensions_cache(),
"refresh_installable_extensions_cache",
)
extension_list = cache_value.value # type: ignore
return extension_list
@@ -829,7 +803,6 @@ class CreateExtension(BaseModel):
version: str
cost_sats: int | None = 0
payment_hash: str | None = None
permissions: list[ExtensionPermission] = []
class ExtensionDetailsRequest(BaseModel):
+1 -27
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from collections.abc import Callable
from pydantic import BaseModel
@@ -10,34 +9,9 @@ def _do_nothing(*_):
pass
async def _do_nothing_async(_: Any) -> None:
pass
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_wasm_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
dispatch_extension_invoice_paid: Callable[[Any], Awaitable[None]] = (
_do_nothing_async
)
def __init__(self) -> None:
self.wasm_extension_registry = WasmExtensionRegistry()
class WasmExtensionRegistry:
def __init__(self) -> None:
self._extensions: dict[str, Any] = {}
def register(self, extension: Any) -> None:
self._extensions[extension.id] = extension
def get(self, ext_id: str) -> Any | None:
return self._extensions.get(ext_id)
def list(self) -> list[Any]:
return list(self._extensions.values())
class ConversionData(BaseModel):
+12
View File
@@ -1,3 +1,10 @@
from .blockexplorer import (
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
)
from .fiat_providers import check_fiat_status
from .funding_source import (
get_balance_delta,
@@ -56,7 +63,12 @@ __all__ = [
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_fee_estimates",
"fetch_lnurl_pay_request",
"fetch_onchain_balance",
"fetch_recent_blocks",
"fetch_tip",
"fetch_transaction",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
+97
View File
@@ -0,0 +1,97 @@
import asyncio
from lnbits.settings import settings
from lnbits.task_manager import OnchainAddressEvent
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
Balance,
BlockHeader,
BlockInfo,
ElectrumClient,
FeeResponse,
Transaction,
network_from_name,
parse_block_header,
parse_raw_tx,
scripthash_from_address,
)
def _client() -> ElectrumClient:
return ElectrumClient(
settings.lnbits_blockexplorer_electrum_url,
network=network_from_name(settings.lnbits_blockexplorer_network),
)
async def fetch_recent_blocks(count: int = 5) -> list[BlockInfo]:
async with _client() as c:
tip = await c.get_tip()
start = max(0, tip.height - count + 1)
headers = await c.get_block_headers(start, tip.height - start + 1)
raw = bytes.fromhex(headers.hex)
blocks = [
parse_block_header(raw[i * 80 : (i + 1) * 80].hex(), start + i)
for i in range(headers.count)
]
return list(reversed(blocks))
async def fetch_tip() -> BlockHeader:
async with _client() as c:
return await c.get_tip()
async def fetch_fee_estimates() -> FeeResponse:
async with _client() as c:
estimates_raw = await asyncio.gather(
c.estimate_fee(1),
c.estimate_fee(3),
c.estimate_fee(6),
c.estimate_fee(144),
)
histogram = await c.fee_histogram()
estimates = {
str(blocks): fee
for blocks, fee in zip([1, 3, 6, 144], estimates_raw, strict=False)
if fee >= 0
}
return FeeResponse(estimates=estimates, histogram=histogram)
async def fetch_transaction(txid: str) -> Transaction:
async with _client() as c:
raw_hex = await c.get_transaction(txid)
return parse_raw_tx(raw_hex, network=c.network)
async def fetch_onchain_balance(onchain_address: str) -> AddressResponse:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
balance_res, history_res = await asyncio.gather(
client.get_balance(scripthash),
client.get_history(scripthash),
return_exceptions=True,
)
if isinstance(balance_res, BaseException):
raise balance_res
history = [] if isinstance(history_res, BaseException) else history_res
history_error = str(history_res) if isinstance(history_res, BaseException) else None
return AddressResponse(
balance=balance_res, history=history, history_error=history_error
)
async def fetch_utxos(onchain_address: str) -> list[UTXO]:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
return await client.listunspent(scripthash)
def address_event_to_response(event: OnchainAddressEvent) -> AddressResponse:
return AddressResponse(
balance=Balance(confirmed=event.confirmed, unconfirmed=event.unconfirmed),
history=event.history,
history_error=event.history_error,
)
+4 -111
View File
@@ -1,10 +1,5 @@
import asyncio
import importlib
import json
import zipfile
from collections.abc import Iterable
from pathlib import PurePosixPath
from typing import Any
from loguru import logger
@@ -21,23 +16,15 @@ from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.extensions.api import extension_api_permission_ids
from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings
from ..models.extensions import (
Extension,
ExtensionMeta,
ExtensionPermission,
InstallableExtension,
)
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension(
ext_info: InstallableExtension,
skip_download: bool | None = False,
granted_permissions: list[ExtensionPermission] | None = None,
ext_info: InstallableExtension, skip_download: bool | None = False
) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
@@ -57,11 +44,6 @@ async def install_extension(
if not skip_download:
await ext_info.download_archive()
extension_config = _load_extension_archive_config(ext_info)
ext_info.permissions = _validate_extension_permissions(
ext_info, granted_permissions, extension_config
)
ext_info.extract_archive()
db_version = await get_db_version(ext_info.id)
@@ -75,99 +57,15 @@ async def install_extension(
await update_installed_extension(ext_info)
extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension and not extension.is_wasm:
if extension.is_upgrade_extension:
# call stop while the old routes are still active
await stop_extension_background_work(ext_info.id)
if not extension.is_wasm:
await start_extension_background_work(ext_info.id)
await start_extension_background_work(ext_info.id)
return extension
def validate_extension_permissions(
ext_id: str,
permissions: Iterable[ExtensionPermission],
*,
strict: bool = True,
) -> list[ExtensionPermission]:
known_permission_ids = extension_api_permission_ids()
normalized_permissions: list[ExtensionPermission] = []
unknown_ids: list[str] = []
for permission in permissions:
if permission.id not in known_permission_ids:
unknown_ids.append(permission.id)
if strict:
continue
normalized_permissions.append(permission.copy(update={"label": None}))
if unknown_ids and strict:
raise ValueError(
f"Extension '{ext_id}' requests unknown permissions: "
+ ", ".join(sorted(set(unknown_ids)))
)
return normalized_permissions
def _validate_extension_permissions(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
extension_config: dict[str, Any],
) -> list[ExtensionPermission]:
if extension_config.get("extension_type") != "wasm":
return []
requested_permissions = validate_extension_permissions(
ext_info.id,
[
ExtensionPermission.parse_obj(permission)
for permission in extension_config.get("permissions") or []
if isinstance(permission, dict) and permission.get("id")
],
)
if not requested_permissions:
return []
if granted_permissions is None:
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
requested_ids = {permission.id for permission in requested_permissions}
granted_ids = {permission.id for permission in granted_permissions}
if requested_ids != granted_ids:
raise ValueError(
f"Extension '{ext_info.id}' was not granted all requested permissions."
)
return requested_permissions
def _load_extension_archive_config(ext_info: InstallableExtension) -> dict[str, Any]:
if not ext_info.zip_path.is_file():
return {}
try:
with zipfile.ZipFile(ext_info.zip_path, "r") as archive:
config_name = _archive_config_name(archive.namelist())
if not config_name:
return {}
with archive.open(config_name) as config_file:
config = json.load(config_file)
except Exception as exc:
raise ValueError(f"Cannot read extension config for '{ext_info.id}'.") from exc
return config if isinstance(config, dict) else {}
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)
if len(path.parts) == 2 and path.name == "config.json":
return name
return None
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
@@ -189,11 +87,6 @@ async def uninstall_extension(ext_id: str):
async def activate_extension(ext: Extension):
if ext.is_wasm:
core_app_extra.register_new_wasm_ext_routes(ext.code)
await update_installed_extension_state(ext_id=ext.code, active=True)
return
core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True)
await start_extension_background_work(ext.code)
+2 -5
View File
@@ -20,6 +20,7 @@ from lnbits.fiat.base import (
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings
from lnbits.task_manager import task_manager
async def handle_fiat_payment_confirmation(
@@ -60,11 +61,7 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
payment.status = PaymentState.SUCCESS.value
await update_payment(payment)
await handle_fiat_payment_confirmation(payment)
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(payment)
return fiat_status
+2
View File
@@ -66,6 +66,8 @@ async def check_server_balance_against_node():
async def check_balance_delta_changed():
if settings.notification_balance_delta_threshold_sats <= 0:
return
status = await get_balance_delta()
if settings.latest_balance_delta_sats is None:
settings.latest_balance_delta_sats = status.delta_sats
+9
View File
@@ -237,6 +237,15 @@ async def send_email(
return False
async def dispatch_payment_notification(payment: Payment) -> None:
"""
This worker dispatches the payment notifications.
"""
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
+22 -11
View File
@@ -19,6 +19,7 @@ from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
@@ -516,9 +517,7 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(payment)
async def check_wallet_limits(
@@ -789,10 +788,8 @@ async def _pay_internal_invoice(
) # notify the receiver
# notify receiver asynchronously (extension listeners)
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
task_manager.internal_invoice_queue.put_nowait(internal_payment)
return payment
@@ -829,16 +826,15 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
task = task_manager.create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
f"fundingsource_pay_invoice_{checking_id}",
)
# make sure a hold invoice or deferred payment is not blocking the server
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
try:
payment_response = await asyncio.wait_for(task, timeout=wait_time)
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
except asyncio.TimeoutError:
# return pending payment on timeout
logger.debug(
@@ -1108,3 +1104,18 @@ async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment
payment = await update_payment(payment)
return payment
async def fundingsource_invoice_producer() -> None:
"""
will collect all invoices that come directly from the backend wallet.
Called registered in the app startup sequence and run by taskmanager.
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
task_manager.invoice_queue.put_nowait(payment)
+46 -125
View File
@@ -2,78 +2,43 @@ import asyncio
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud import create_audit_entry
from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.db import core_app_extra
from lnbits.core.models.audit import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
get_balance_delta,
)
from lnbits.core.services.funding_source import get_balance_delta
from lnbits.core.services.notifications import (
enqueue_admin_notification,
process_next_notification,
send_payment_notification,
)
from lnbits.db import Filters
from lnbits.settings import settings
from lnbits.utils.cache import cache
from lnbits.utils.exchange_rates import btc_rates
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def run_by_the_minute_tasks() -> None:
minute_counter = 0
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
if settings.notification_balance_delta_threshold_sats > 0:
try:
# runs by default every minute, the delta should not change that often
await check_balance_delta_changed()
except Exception as ex:
logger.error(ex)
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
try:
await check_server_balance_against_node()
except Exception as ex:
logger.error(ex)
if minute_counter % status_minutes == 0:
try:
await _notify_server_status()
except Exception as ex:
logger.error(ex)
if minute_counter % 60 == 0:
try:
# initialize the list of all extensions
await InstallableExtension.get_installable_extensions(
post_refresh_cache=True
)
except Exception as ex:
logger.error(ex)
minute_counter += 1
await asyncio.sleep(60)
async def process_next_audit_entry() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
data = await audit_queue.get()
await create_audit_entry(data)
async def _notify_server_status() -> None:
async def refresh_extension_cache() -> None:
# only refreshes every 10 minutes
await InstallableExtension.get_installable_extensions()
async def notify_server_status() -> None:
accounts = await get_accounts(filters=Filters(limit=0))
wallets_count = await get_wallets_count()
payments = await get_payments_status_count()
status = await get_balance_delta()
values = {
"up_time": settings.lnbits_server_up_time,
@@ -90,82 +55,38 @@ async def _notify_server_status() -> None:
enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
while settings.lnbits_running:
payment = await invoice_paid_queue.get()
logger.trace("received invoice paid event")
# payment notification
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
await core_app_extra.dispatch_extension_invoice_paid(payment)
async def wait_for_audit_data() -> None:
"""
Waits for audit entries to be pushed to the queue.
Then it inserts the entries into the DB.
"""
while settings.lnbits_running:
data = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
logger.warning(ex)
await asyncio.sleep(3)
async def wait_notification_messages() -> None:
while settings.lnbits_running:
try:
await process_next_notification()
except Exception as ex:
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
async def purge_audit_data() -> None:
"""
Remove audit entries which have passed their retention period.
"""
while settings.lnbits_running:
try:
await delete_expired_audit_entries()
except Exception as ex:
logger.warning(ex)
# clean every hour
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data() -> None:
"""
Collect exchange rates data. Used for monitoring only.
"""
while settings.lnbits_running:
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
if sleep_time > 0:
try:
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
currency = settings.lnbits_default_accounting_currency or "USD"
max_history_size = settings.lnbits_exchange_history_size
try:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
cache.set(
f"btc-price-{currency}",
price,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(
{"Aggregator": price}, max_history_size
)
else:
sleep_time = 60
await asyncio.sleep(sleep_time)
rates = await btc_rates(currency)
if rates:
rates_values = [r[1] for r in rates]
lnbits_rate = sum(rates_values) / len(rates_values)
rates.append(("LNbits", lnbits_rate))
cache.set(
f"btc-price-{currency}",
lnbits_rate,
expiry=settings.lnbits_exchange_rate_cache_seconds,
)
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
except Exception as ex:
logger.warning(ex)
+4 -5
View File
@@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
from lnbits.tasks import invoice_listeners
from lnbits.task_manager import PublicTask, task_manager
from .. import core_app_extra
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
@@ -44,11 +44,10 @@ async def api_auditor():
name="Monitor",
description="show the current listeners and other monitoring data",
dependencies=[Depends(check_admin)],
response_model=list[PublicTask],
)
async def api_monitor():
return {
"invoice_listeners": list(invoice_listeners.keys()),
}
async def api_monitor() -> list[PublicTask]:
return task_manager.get_public_tasks()
@admin_router.get(
+170
View File
@@ -0,0 +1,170 @@
import asyncio
from http import HTTPStatus
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from pydantic.types import UUID4
from lnbits.core.services.blockexplorer import (
address_event_to_response,
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
fetch_utxos,
)
from lnbits.decorators import check_access_token, check_user_exists
from lnbits.settings import settings
from lnbits.task_manager import (
OnchainAddressEvent,
OnchainTxEvent,
relay_ws_queue,
task_manager,
)
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
BlockHeader,
BlockInfo,
ElectrumError,
FeeResponse,
Transaction,
scripthash_from_address,
)
blockexplorer_router = APIRouter(
tags=["Block Explorer"],
prefix="/blockexplorer/api/v1",
)
def _check_enabled() -> None:
if not settings.lnbits_blockexplorer_enabled:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Block explorer is not enabled.",
)
async def _check_api_access(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> None:
_check_enabled()
if not settings.lnbits_blockexplorer_public_api:
await check_user_exists(r, access_token, usr)
# ---- REST ----
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
async def api_blocks() -> list[BlockInfo]:
try:
return await fetch_recent_blocks()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tip", dependencies=[Depends(_check_api_access)])
async def api_tip() -> BlockHeader:
try:
return await fetch_tip()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/fees", dependencies=[Depends(_check_api_access)])
async def api_fees() -> FeeResponse:
try:
return await fetch_fee_estimates()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tx/{txid}", dependencies=[Depends(_check_api_access)])
async def api_tx(txid: str) -> Transaction:
try:
return await fetch_transaction(txid)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get(
"/address/{address}", dependencies=[Depends(_check_api_access)]
)
async def api_address(address: str) -> AddressResponse:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_onchain_balance(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/utxos/{address}", dependencies=[Depends(_check_api_access)])
async def api_utxos(address: str) -> list[UTXO]:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_utxos(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
# ---- WebSocket ----
@blockexplorer_router.websocket("/ws/blocks")
async def ws_blocks(websocket: WebSocket) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
task_manager.register_ws_block_queue(queue)
try:
await relay_ws_queue(websocket, queue)
finally:
task_manager.unregister_ws_block_queue(queue)
@blockexplorer_router.websocket("/ws/address/{address}")
async def ws_address(websocket: WebSocket, address: str) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
try:
task_manager.register_ws_address_queue(address, queue)
except ValueError as e:
await websocket.close(code=1008, reason=str(e))
return
try:
await relay_ws_queue(websocket, queue, serialize=address_event_to_response)
finally:
task_manager.unregister_ws_address_queue(address, queue)
@blockexplorer_router.websocket("/ws/tx/{txid}")
async def ws_tx(websocket: WebSocket, txid: str) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
task_manager.register_ws_tx_queue(txid, queue)
try:
await relay_ws_queue(websocket, queue, stop_after=lambda e: e.confirmed)
finally:
task_manager.unregister_ws_tx_queue(txid, queue)
+43 -66
View File
@@ -39,7 +39,6 @@ from lnbits.core.services.extensions import (
get_valid_extensions,
install_extension,
uninstall_extension,
validate_extension_permissions,
)
from lnbits.db import Page
from lnbits.decorators import (
@@ -90,9 +89,7 @@ async def api_install_extension(data: CreateExtension):
)
try:
extension = await install_extension(
ext_info, granted_permissions=data.permissions
)
extension = await install_extension(ext_info)
except Exception as exc:
logger.warning(exc)
@@ -462,19 +459,11 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
if not config:
return {}
permissions = validate_extension_permissions(config.name, config.permissions)
return {
"min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(),
"warning": config.warning,
"extension_type": config.extension_type,
"permissions": [dict(permission) for permission in permissions],
}
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
@@ -546,10 +535,9 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
)
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
installed_exts_by_id = {e.id: e for e in installed_exts}
for e in installable_exts:
installed_ext = installed_exts_by_id.get(e.id)
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
@@ -570,58 +558,47 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
extension_data = []
for ext in installable_exts:
installed_ext = installed_exts_by_id.get(ext.id)
permissions = (
validate_extension_permissions(
installed_ext.id, installed_ext.permissions, strict=False
)
if installed_ext
else []
)
extension_data.append(
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"isWasm": installed_ext.is_wasm if installed_ext else ext.is_wasm,
"permissions": [dict(permission) for permission in permissions],
"inProgress": False,
"selectedForUpdate": False,
}
)
extension_data = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"categories": ext.meta.categories if ext.meta else [],
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
"paidFeatures": ext.meta.paid_features if ext.meta else False,
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
}
for ext in installable_exts
]
return extension_data
+1
View File
@@ -185,6 +185,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/blockexplorer")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
+1 -10
View File
@@ -448,7 +448,7 @@ async def _check_user_access(r: Request, user_id: str, conn: Connection | None =
async def _check_user_extension_access(
user_id: str, path: str, conn: Connection | None = None
):
ext_id = _extension_id_from_request_path(path)
ext_id = path_segments(path)[0]
status = await check_user_extension_access(user_id, ext_id, conn=conn)
if not status.success:
raise HTTPException(
@@ -457,15 +457,6 @@ async def _check_user_extension_access(
)
def _extension_id_from_request_path(path: str) -> str:
segments = path_segments(path)
if len(segments) >= 2 and segments[0] == "ext":
return segments[1]
if len(segments) >= 4 and segments[:3] == ["api", "v1", "ext"]:
return segments[3]
return segments[0]
async def _get_account_from_token(
access_token: str, path: str, method: str, conn: Connection | None = None
) -> Account | None:
+20 -1
View File
@@ -362,6 +362,8 @@ class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_price_aggregator_enabled: bool = Field(default=True)
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -587,6 +589,7 @@ class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
class AlbyFundingSource(LNbitsSettings):
@@ -611,6 +614,7 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
@@ -648,6 +652,7 @@ class BoltzFundingSource(LNbitsSettings):
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
class StrikeFundingSource(LNbitsSettings):
@@ -840,6 +845,16 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False)
class BlockExplorerSettings(LNbitsSettings):
lnbits_blockexplorer_enabled: bool = Field(default=False)
lnbits_blockexplorer_public_api: bool = Field(default=False)
lnbits_blockexplorer_electrum_url: str = Field(
default="ssl://electrum.blockstream.info:50002"
)
# one of: main, test, regtest, signet (see embit.networks.NETWORKS)
lnbits_blockexplorer_network: str = Field(default="main")
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105
@@ -1013,6 +1028,7 @@ class EditableSettings(
LightningSettings,
WebPushSettings,
NodeUISettings,
BlockExplorerSettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
@@ -1072,11 +1088,12 @@ class EnvSettings(LNbitsSettings):
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
task_heart_beat_verbose: bool = Field(default=False)
task_heart_beat_interval: int = Field(default=30)
@property
def has_default_extension_path(self) -> bool:
@@ -1262,6 +1279,7 @@ class PublicSettings(BaseModel):
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_block_explorer: bool = Field(alias="showBlockExplorer")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
@@ -1329,6 +1347,7 @@ class PublicSettings(BaseModel):
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+44 -1
View File
@@ -824,5 +824,48 @@ window.localisation.br = {
payment_labels_updated: 'Rótulos de pagamento atualizados',
color: 'Cor',
sort: 'Ordenar',
sort_by: 'Ordenar por'
sort_by: 'Ordenar por',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos usuários explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem transações demais)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}
+40 -1
View File
@@ -419,5 +419,44 @@ window.localisation.cn = {
http_request_methods: 'HTTP请求方法',
http_response_codes: 'HTTP响应代码',
request_details: '请求详情',
http_request_details: 'HTTP请求详细信息'
http_request_details: 'HTTP请求详细信息',
block_explorer: '区块浏览器',
enable_block_explorer: '启用区块浏览器',
block_explorer_desc: '允许用户通过 Electrum 浏览比特币交易和地址。',
blockexplorer_public_api: '公开 API 访问',
blockexplorer_public_api_desc: '允许对区块浏览器 API 端点的未认证访问。',
electrum_server_url: 'Electrum 服务器 URL',
electrum_server_url_hint:
'例如 ssl://electrum.blockstream.info:50002 或 tcp://localhost:50001',
blockexplorer_search_label: '按 TXID 或地址搜索',
blockexplorer_search_hint: '64位十六进制 = 交易 · 其他 = 比特币地址',
recent_blocks: '最新区块',
chain_tip: '链尖',
block_height: '区块高度',
block_fee: '区块手续费',
fee_estimates: '手续费估算',
confirmed_balance: '已确认余额',
unconfirmed_balance: '未确认余额',
transaction_history: '交易历史',
coinbase: 'Coinbase',
inputs: '输入',
outputs: '输出',
confirmations: '确认数',
confirmed: '已确认',
unconfirmed: '未确认',
history_unavailable: '交易历史不可用(地址交易过多)',
address: '地址',
block_number: '区块 #{height}',
block_diff: '难度 {value}',
block_hash: '哈希',
previous_block: '上一区块',
merkle_root: 'Merkle 根',
version: '版本',
bits: 'Bits',
difficulty: '难度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '虚拟大小',
weight: '权重',
n_block_fee: '{n} 区块手续费'
}
+44 -1
View File
@@ -439,5 +439,48 @@ window.localisation.cs = {
http_request_methods: 'Metody HTTP požadavků',
http_response_codes: 'Kódy HTTP odpovědí',
request_details: 'Podrobnosti žádosti',
http_request_details: 'Podrobnosti HTTP žádosti'
http_request_details: 'Podrobnosti HTTP žádosti',
block_explorer: 'Průzkumník bloků',
enable_block_explorer: 'Povolit průzkumník bloků',
block_explorer_desc:
'Umožňuje uživatelům procházet bitcoinové transakce a adresy přes Electrum.',
blockexplorer_public_api: 'Veřejný přístup k API',
blockexplorer_public_api_desc:
'Povolit neověřený přístup k API koncovým bodům průzkumníku bloků.',
electrum_server_url: 'URL Electrum serveru',
electrum_server_url_hint:
'např. ssl://electrum.blockstream.info:50002 nebo tcp://localhost:50001',
blockexplorer_search_label: 'Hledat podle TXID nebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakce · cokoli jiného = bitcoinová adresa',
recent_blocks: 'Nedávné bloky',
chain_tip: 'Vrchol řetězu',
block_height: 'Výška bloku',
block_fee: 'poplatek bloku',
fee_estimates: 'Odhady poplatků',
confirmed_balance: 'Potvrzený zůstatek',
unconfirmed_balance: 'Nepotvrzený zůstatek',
transaction_history: 'Historie transakcí',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrzení',
confirmed: 'Potvrzeno',
unconfirmed: 'Nepotvrzeno',
history_unavailable:
'Historie transakcí nedostupná (adresa má příliš mnoho transakcí)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Předchozí blok',
merkle_root: 'Merkle kořen',
version: 'Verze',
bits: 'Bity',
difficulty: 'Obtížnost',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuální velikost',
weight: 'Váha',
n_block_fee: 'poplatek {n} bloků'
}
+44 -1
View File
@@ -452,5 +452,48 @@ window.localisation.de = {
http_request_methods: 'HTTP-Anfragemethoden',
http_response_codes: 'HTTP-Antwortcodes',
request_details: 'Anfragedetails',
http_request_details: 'HTTP-Anfragedetails'
http_request_details: 'HTTP-Anfragedetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer aktivieren',
block_explorer_desc:
'Ermöglicht Nutzern das Durchsuchen von Bitcoin-Transaktionen und -Adressen über Electrum.',
blockexplorer_public_api: 'Öffentlicher API-Zugang',
blockexplorer_public_api_desc:
'Nicht-authentifizierten Zugriff auf die Block-Explorer-API-Endpunkte erlauben.',
electrum_server_url: 'Electrum-Server-URL',
electrum_server_url_hint:
'z.B. ssl://electrum.blockstream.info:50002 oder tcp://localhost:50001',
blockexplorer_search_label: 'Nach TXID oder Adresse suchen',
blockexplorer_search_hint:
'64-Zeichen-Hex = Transaktion · Alles andere = Bitcoin-Adresse',
recent_blocks: 'Aktuelle Blöcke',
chain_tip: 'Kettenspitze',
block_height: 'Blockhöhe',
block_fee: 'Blockgebühr',
fee_estimates: 'Gebührenschätzungen',
confirmed_balance: 'Bestätigtes Guthaben',
unconfirmed_balance: 'Unbestätigtes Guthaben',
transaction_history: 'Transaktionsverlauf',
coinbase: 'Coinbase',
inputs: 'Eingaben',
outputs: 'Ausgaben',
confirmations: 'Bestätigungen',
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
history_unavailable:
'Transaktionsverlauf nicht verfügbar (Adresse hat zu viele Transaktionen)',
address: 'Adresse',
block_number: 'Block #{height}',
block_diff: 'Schw. {value}',
block_hash: 'Hash',
previous_block: 'Vorheriger Block',
merkle_root: 'Merkle-Wurzel',
version: 'Version',
bits: 'Bits',
difficulty: 'Schwierigkeit',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuelle Größe',
weight: 'Gewicht',
n_block_fee: '{n}-Block-Gebühr'
}
+51 -22
View File
@@ -519,27 +519,6 @@ window.localisation.en = {
admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.',
extension_permissions_title: 'Grant extension permissions',
extension_permissions_request: 'This extension requests these permissions:',
extension_permissions_grant_install: 'Grant and install',
extension_permission_ext_storage_read: 'Read extension storage',
extension_permission_ext_storage_read_public: 'Read public extension storage',
extension_permission_ext_storage_write: 'Write extension storage',
extension_permission_extension_api_request: 'Use other extensions',
extension_permission_extension_api_request_desc:
'Call approved installed extensions using your account permissions.',
extension_permission_extension_api_request_extensions: 'Allowed extensions',
extension_permission_http_request: 'Connect to external websites',
extension_permission_http_request_desc:
'Make HTTP requests to approved external hosts.',
extension_permission_http_request_hosts: 'Allowed hosts',
extension_permission_payments_watch: 'Watch payments',
extension_permission_wallet_create_invoice: 'Create invoices',
extension_permission_wallet_create_invoice_public:
'Create Lightning invoices from public pages',
extension_permission_wallet_create_invoice_public_desc:
'Create incoming Lightning invoices from public pages.',
extension_permission_wallet_list: 'List wallets',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
@@ -862,5 +841,55 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated',
color: 'Color',
sort: 'Sort',
sort_by: 'Sort by'
sort_by: 'Sort by',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Enable Block Explorer',
block_explorer_desc:
'Allow users to explore Bitcoin transactions and addresses via Electrum.',
blockexplorer_public_api: 'Public API Access',
blockexplorer_public_api_desc:
'Allow unauthenticated access to the block explorer API endpoints.',
electrum_compatible_server: 'Electrum compatible server',
electrum_server_url: 'Electrum Server URL',
electrum_server_url_hint:
'Choose a public Electrum server or enter your own.',
electrum_server_url_custom: 'Custom Electrum Server URL',
view_public_electrum_servers: 'View public Electrum servers',
blockexplorer_network: 'Bitcoin Network',
blockexplorer_network_hint:
'The network the Electrum server is connected to, used to render addresses correctly.',
blockexplorer_search_label: 'Search by TXID or Address',
blockexplorer_search_hint:
'64-char hex = transaction · anything else = Bitcoin address',
recent_blocks: 'Recent Blocks',
chain_tip: 'Chain Tip',
block_height: 'Block Height',
block_fee: 'block fee',
fee_estimates: 'Fee Estimates',
confirmed_balance: 'Confirmed Balance',
unconfirmed_balance: 'Unconfirmed Balance',
transaction_history: 'Transaction History',
coinbase: 'Coinbase',
inputs: 'Inputs',
outputs: 'Outputs',
confirmations: 'Confirmations',
confirmed: 'Confirmed',
unconfirmed: 'Unconfirmed',
no_transactions: 'No transactions found',
history_unavailable:
'Transaction history unavailable (address has too many transactions)',
address: 'Address',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block fee'
}
+44 -1
View File
@@ -453,5 +453,48 @@ window.localisation.es = {
http_request_methods: 'Métodos de solicitud HTTP',
http_response_codes: 'Códigos de Respuesta HTTP',
request_details: 'Detalles de la solicitud',
http_request_details: 'Detalles de la Solicitud HTTP'
http_request_details: 'Detalles de la Solicitud HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activar Block Explorer',
block_explorer_desc:
'Permite a los usuarios explorar transacciones y direcciones de Bitcoin a través de Electrum.',
blockexplorer_public_api: 'Acceso a la API pública',
blockexplorer_public_api_desc:
'Permitir acceso no autenticado a los endpoints de la API del explorador de bloques.',
electrum_server_url: 'URL del servidor Electrum',
electrum_server_url_hint:
'p.ej. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Buscar por TXID o dirección',
blockexplorer_search_hint:
'Hex de 64 caracteres = transacción · cualquier otra cosa = dirección Bitcoin',
recent_blocks: 'Bloques recientes',
chain_tip: 'Punta de cadena',
block_height: 'Altura de bloque',
block_fee: 'tarifa de bloque',
fee_estimates: 'Estimaciones de tarifa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo no confirmado',
transaction_history: 'Historial de transacciones',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Salidas',
confirmations: 'Confirmaciones',
confirmed: 'Confirmado',
unconfirmed: 'No confirmado',
history_unavailable:
'Historial de transacciones no disponible (la dirección tiene demasiadas transacciones)',
address: 'Dirección',
block_number: 'Bloque #{height}',
block_diff: 'dif {value}',
block_hash: 'Hash',
previous_block: 'Bloque anterior',
merkle_root: 'Raíz de Merkle',
version: 'Versión',
bits: 'Bits',
difficulty: 'Dificultad',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamaño virtual',
weight: 'Peso',
n_block_fee: 'tarifa {n} bloques'
}
+44 -1
View File
@@ -647,5 +647,48 @@ window.localisation.fi = {
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
'The user will be redirected to this URL after the payment is successful',
block_explorer: 'Lohkoselain',
enable_block_explorer: 'Ota lohkoselain käyttöön',
block_explorer_desc:
'Salli käyttäjien tutkia Bitcoin-transaktioita ja -osoitteita Electrumin kautta.',
blockexplorer_public_api: 'Julkinen API-pääsy',
blockexplorer_public_api_desc:
'Salli todentamaton pääsy lohkoselain API-päätteisiin.',
electrum_server_url: 'Electrum-palvelimen URL',
electrum_server_url_hint:
'esim. ssl://electrum.blockstream.info:50002 tai tcp://localhost:50001',
blockexplorer_search_label: 'Hae TXID:llä tai osoitteella',
blockexplorer_search_hint:
'64 merkin heksa = transaktio · muu = Bitcoin-osoite',
recent_blocks: 'Viimeisimmät lohkot',
chain_tip: 'Ketjun kärki',
block_height: 'Lohkokorkeus',
block_fee: 'lohkomaksu',
fee_estimates: 'Maksuarviot',
confirmed_balance: 'Vahvistettu saldo',
unconfirmed_balance: 'Vahvistamaton saldo',
transaction_history: 'Tapahtumahistoria',
coinbase: 'Coinbase',
inputs: 'Syötteet',
outputs: 'Tulosteet',
confirmations: 'Vahvistukset',
confirmed: 'Vahvistettu',
unconfirmed: 'Vahvistamaton',
history_unavailable:
'Tapahtumahistoria ei saatavilla (osoitteella on liikaa tapahtumia)',
address: 'Osoite',
block_number: 'Lohko #{height}',
block_diff: 'vaikeus {value}',
block_hash: 'Hash',
previous_block: 'Edellinen lohko',
merkle_root: 'Merkle-juuri',
version: 'Versio',
bits: 'Bitit',
difficulty: 'Vaikeus',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuaalikoko',
weight: 'Paino',
n_block_fee: '{n} lohkon maksu'
}
+44 -1
View File
@@ -456,5 +456,48 @@ window.localisation.fr = {
http_request_methods: 'Méthodes de requête HTTP',
http_response_codes: 'Codes de réponse HTTP',
request_details: 'Détails de la demande',
http_request_details: 'Détails de la requête HTTP'
http_request_details: 'Détails de la requête HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activer le Block Explorer',
block_explorer_desc:
"Permet aux utilisateurs d'explorer les transactions et adresses Bitcoin via Electrum.",
blockexplorer_public_api: 'Accès API public',
blockexplorer_public_api_desc:
"Autoriser l'accès non authentifié aux endpoints de l'API de l'explorateur de blocs.",
electrum_server_url: 'URL du serveur Electrum',
electrum_server_url_hint:
'p.ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Rechercher par TXID ou adresse',
blockexplorer_search_hint:
'Hex 64 caractères = transaction · autre chose = adresse Bitcoin',
recent_blocks: 'Blocs récents',
chain_tip: 'Sommet de chaîne',
block_height: 'Hauteur de bloc',
block_fee: 'frais de bloc',
fee_estimates: 'Estimations de frais',
confirmed_balance: 'Solde confirmé',
unconfirmed_balance: 'Solde non confirmé',
transaction_history: 'Historique des transactions',
coinbase: 'Coinbase',
inputs: 'Entrées',
outputs: 'Sorties',
confirmations: 'Confirmations',
confirmed: 'Confirmé',
unconfirmed: 'Non confirmé',
history_unavailable:
'Historique des transactions indisponible (adresse avec trop de transactions)',
address: 'Adresse',
block_number: 'Bloc #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloc précédent',
merkle_root: 'Racine de Merkle',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulté',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Taille virtuelle',
weight: 'Poids',
n_block_fee: 'frais {n} blocs'
}
+44 -1
View File
@@ -450,5 +450,48 @@ window.localisation.it = {
http_request_methods: 'Metodi di richiesta HTTP',
http_response_codes: 'Codici di risposta HTTP',
request_details: 'Dettagli della richiesta',
http_request_details: 'Dettagli della richiesta HTTP'
http_request_details: 'Dettagli della richiesta HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Abilita Block Explorer',
block_explorer_desc:
'Consenti agli utenti di esplorare transazioni e indirizzi Bitcoin tramite Electrum.',
blockexplorer_public_api: 'Accesso API pubblico',
blockexplorer_public_api_desc:
"Consenti accesso non autenticato agli endpoint API dell'esploratore di blocchi.",
electrum_server_url: 'URL server Electrum',
electrum_server_url_hint:
'es. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Cerca per TXID o indirizzo',
blockexplorer_search_hint:
'Hex 64 caratteri = transazione · altro = indirizzo Bitcoin',
recent_blocks: 'Blocchi recenti',
chain_tip: 'Punta della catena',
block_height: 'Altezza blocco',
block_fee: 'commissione blocco',
fee_estimates: 'Stime delle commissioni',
confirmed_balance: 'Saldo confermato',
unconfirmed_balance: 'Saldo non confermato',
transaction_history: 'Storico transazioni',
coinbase: 'Coinbase',
inputs: 'Input',
outputs: 'Output',
confirmations: 'Conferme',
confirmed: 'Confermato',
unconfirmed: 'Non confermato',
history_unavailable:
"Storico transazioni non disponibile (l'indirizzo ha troppe transazioni)",
address: 'Indirizzo',
block_number: 'Blocco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Blocco precedente',
merkle_root: 'Radice di Merkle',
version: 'Versione',
bits: 'Bit',
difficulty: 'Difficoltà',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Dimensione virtuale',
weight: 'Peso',
n_block_fee: 'commissione {n} blocchi'
}
+44 -1
View File
@@ -440,5 +440,48 @@ window.localisation.jp = {
http_request_methods: 'HTTPリクエストメソッド',
http_response_codes: 'HTTPレスポンスコード',
request_details: 'リクエストの詳細',
http_request_details: 'HTTPリクエストの詳細'
http_request_details: 'HTTPリクエストの詳細',
block_explorer: 'ブロックエクスプローラー',
enable_block_explorer: 'ブロックエクスプローラーを有効化',
block_explorer_desc:
'Electrumを介してビットコインのトランザクションとアドレスを探索できます。',
blockexplorer_public_api: 'パブリックAPIアクセス',
blockexplorer_public_api_desc:
'ブロックエクスプローラーAPIエンドポイントへの非認証アクセスを許可します。',
electrum_server_url: 'ElectrumサーバーURL',
electrum_server_url_hint:
'例: ssl://electrum.blockstream.info:50002 または tcp://localhost:50001',
blockexplorer_search_label: 'TXIDまたはアドレスで検索',
blockexplorer_search_hint:
'64文字の16進数 = トランザクション · それ以外 = ビットコインアドレス',
recent_blocks: '最新ブロック',
chain_tip: 'チェーン先端',
block_height: 'ブロック高さ',
block_fee: 'ブロック手数料',
fee_estimates: '手数料見積もり',
confirmed_balance: '確認済み残高',
unconfirmed_balance: '未確認残高',
transaction_history: 'トランザクション履歴',
coinbase: 'コインベース',
inputs: 'インプット',
outputs: 'アウトプット',
confirmations: '確認数',
confirmed: '確認済み',
unconfirmed: '未確認',
history_unavailable:
'トランザクション履歴が取得できません(アドレスのトランザクションが多すぎます)',
address: 'アドレス',
block_number: 'ブロック #{height}',
block_diff: 'diff {value}',
block_hash: 'ハッシュ',
previous_block: '前のブロック',
merkle_root: 'マークルルート',
version: 'バージョン',
bits: 'Bits',
difficulty: '難易度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '仮想サイズ',
weight: '重量',
n_block_fee: '{n}ブロック手数料'
}
+43 -1
View File
@@ -435,5 +435,47 @@ window.localisation.kr = {
http_request_methods: 'HTTP 요청 메서드',
http_response_codes: 'HTTP 응답 코드',
request_details: '요청 세부사항',
http_request_details: 'HTTP 요청 세부사항'
http_request_details: 'HTTP 요청 세부사항',
block_explorer: '블록 탐색기',
enable_block_explorer: '블록 탐색기 활성화',
block_explorer_desc:
'Electrum을 통해 비트코인 거래 및 주소를 탐색할 수 있습니다.',
blockexplorer_public_api: '공개 API 접근',
blockexplorer_public_api_desc:
'블록 탐색기 API 엔드포인트에 대한 비인증 접근을 허용합니다.',
electrum_server_url: 'Electrum 서버 URL',
electrum_server_url_hint:
'예: ssl://electrum.blockstream.info:50002 또는 tcp://localhost:50001',
blockexplorer_search_label: 'TXID 또는 주소로 검색',
blockexplorer_search_hint: '64자 16진수 = 거래 · 그 외 = 비트코인 주소',
recent_blocks: '최근 블록',
chain_tip: '체인 끝',
block_height: '블록 높이',
block_fee: '블록 수수료',
fee_estimates: '수수료 추정',
confirmed_balance: '확인된 잔액',
unconfirmed_balance: '미확인 잔액',
transaction_history: '거래 내역',
coinbase: 'Coinbase',
inputs: '입력',
outputs: '출력',
confirmations: '확인 수',
confirmed: '확인됨',
unconfirmed: '미확인',
history_unavailable:
'거래 내역을 불러올 수 없습니다 (주소의 거래가 너무 많음)',
address: '주소',
block_number: '블록 #{height}',
block_diff: 'diff {value}',
block_hash: '해시',
previous_block: '이전 블록',
merkle_root: '머클 루트',
version: '버전',
bits: 'Bits',
difficulty: '난이도',
nonce: 'Nonce',
txid: 'TXID',
vsize: '가상 크기',
weight: '무게',
n_block_fee: '{n}블록 수수료'
}
+44 -1
View File
@@ -450,5 +450,48 @@ window.localisation.nl = {
http_request_methods: 'HTTP-aanvraagmethoden',
http_response_codes: 'HTTP-responscodes',
request_details: 'Aanvraagdetails',
http_request_details: 'HTTP-verzoekdetails'
http_request_details: 'HTTP-verzoekdetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer inschakelen',
block_explorer_desc:
'Laat gebruikers Bitcoin-transacties en -adressen verkennen via Electrum.',
blockexplorer_public_api: 'Publieke API-toegang',
blockexplorer_public_api_desc:
'Niet-geauthenticeerde toegang tot de block explorer API-eindpunten toestaan.',
electrum_server_url: 'Electrum-server-URL',
electrum_server_url_hint:
'bijv. ssl://electrum.blockstream.info:50002 of tcp://localhost:50001',
blockexplorer_search_label: 'Zoeken op TXID of adres',
blockexplorer_search_hint:
'64-karakter hex = transactie · alles anders = Bitcoin-adres',
recent_blocks: 'Recente blokken',
chain_tip: 'Kettingtop',
block_height: 'Blokhoogte',
block_fee: 'blokvergoeding',
fee_estimates: 'Vergoedingsschattingen',
confirmed_balance: 'Bevestigd saldo',
unconfirmed_balance: 'Onbevestigd saldo',
transaction_history: 'Transactiegeschiedenis',
coinbase: 'Coinbase',
inputs: 'Invoer',
outputs: 'Uitvoer',
confirmations: 'Bevestigingen',
confirmed: 'Bevestigd',
unconfirmed: 'Onbevestigd',
history_unavailable:
'Transactiegeschiedenis niet beschikbaar (adres heeft te veel transacties)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'moeil. {value}',
block_hash: 'Hash',
previous_block: 'Vorig blok',
merkle_root: 'Merkle-wortel',
version: 'Versie',
bits: 'Bits',
difficulty: 'Moeilijkheid',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuele grootte',
weight: 'Gewicht',
n_block_fee: '{n}-blok vergoeding'
}
+44 -1
View File
@@ -441,5 +441,48 @@ window.localisation.pi = {
http_request_methods: 'HTTP Request Methods',
http_response_codes: 'HTTP Response Codes',
request_details: 'Request Details',
http_request_details: 'HTTP Request Details'
http_request_details: 'HTTP Request Details',
block_explorer: 'Treasure Map',
enable_block_explorer: 'Hoist the Treasure Map',
block_explorer_desc:
"Let scallywags spy on Bitcoin doubloons an' addresses via Electrum.",
blockexplorer_public_api: 'Open Seas API',
blockexplorer_public_api_desc:
'Allow any landlubber access to the block explorer API ports.',
electrum_server_url: 'Electrum Port URL',
electrum_server_url_hint:
'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
blockexplorer_search_label: 'Search by TXID or Port',
blockexplorer_search_hint:
'64-char hex = plunder · anything else = Bitcoin port',
recent_blocks: 'Recent Plunder',
chain_tip: "Tip o' the Anchor Chain",
block_height: 'Plunder Height',
block_fee: 'plunder fee',
fee_estimates: 'Booty Estimates',
confirmed_balance: 'Confirmed Booty',
unconfirmed_balance: 'Unconfirmed Booty',
transaction_history: 'Plunder History',
coinbase: 'Coinbase',
inputs: 'Inbound Plunder',
outputs: 'Outbound Plunder',
confirmations: 'Confirmations, arr',
confirmed: 'Confirmed, arr',
unconfirmed: 'Unconfirmed, arr',
history_unavailable:
'Plunder history lost at sea (too many transactions, matey!)',
address: 'Port',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Plunder Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block booty'
}
+44 -1
View File
@@ -444,5 +444,48 @@ window.localisation.pl = {
http_request_methods: 'Metody żądań HTTP',
http_response_codes: 'Kody Odpowiedzi HTTP',
request_details: 'Szczegóły żądania',
http_request_details: 'Szczegóły żądania HTTP'
http_request_details: 'Szczegóły żądania HTTP',
block_explorer: 'Przeglądarka bloków',
enable_block_explorer: 'Włącz przeglądarkę bloków',
block_explorer_desc:
'Umożliwia użytkownikom przeglądanie transakcji i adresów Bitcoin przez Electrum.',
blockexplorer_public_api: 'Publiczny dostęp do API',
blockexplorer_public_api_desc:
'Zezwól na nieuwierzytelniony dostęp do punktów końcowych API przeglądarki bloków.',
electrum_server_url: 'URL serwera Electrum',
electrum_server_url_hint:
'np. ssl://electrum.blockstream.info:50002 lub tcp://localhost:50001',
blockexplorer_search_label: 'Szukaj po TXID lub adresie',
blockexplorer_search_hint:
'64-znakowy hex = transakcja · cokolwiek innego = adres Bitcoin',
recent_blocks: 'Ostatnie bloki',
chain_tip: 'Wierzchołek łańcucha',
block_height: 'Wysokość bloku',
block_fee: 'opłata bloku',
fee_estimates: 'Szacunki opłat',
confirmed_balance: 'Potwierdzony saldo',
unconfirmed_balance: 'Niepotwierdzony saldo',
transaction_history: 'Historia transakcji',
coinbase: 'Coinbase',
inputs: 'Wejścia',
outputs: 'Wyjścia',
confirmations: 'Potwierdzenia',
confirmed: 'Potwierdzone',
unconfirmed: 'Niepotwierdzone',
history_unavailable:
'Historia transakcji niedostępna (adres ma zbyt wiele transakcji)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'trud. {value}',
block_hash: 'Hash',
previous_block: 'Poprzedni blok',
merkle_root: 'Korzeń Merkle',
version: 'Wersja',
bits: 'Bity',
difficulty: 'Trudność',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Rozmiar wirtualny',
weight: 'Waga',
n_block_fee: 'opłata {n} bloków'
}
+44 -1
View File
@@ -446,5 +446,48 @@ window.localisation.pt = {
http_request_methods: 'Métodos de Requisição HTTP',
http_response_codes: 'Códigos de Resposta HTTP',
request_details: 'Detalhes da solicitação',
http_request_details: 'Detalhes da Solicitação HTTP'
http_request_details: 'Detalhes da Solicitação HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos utilizadores explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem demasiadas transações)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}
+44 -1
View File
@@ -444,5 +444,48 @@ window.localisation.sk = {
http_request_methods: 'Metódy HTTP žiadostí',
http_response_codes: 'Kódy odpovedí HTTP',
request_details: 'Podrobnosti žiadosti',
http_request_details: 'Podrobnosti požiadavky HTTP'
http_request_details: 'Podrobnosti požiadavky HTTP',
block_explorer: 'Prehliadač blokov',
enable_block_explorer: 'Povoliť prehliadač blokov',
block_explorer_desc:
'Umožňuje používateľom prehliadať bitcoinové transakcie a adresy cez Electrum.',
blockexplorer_public_api: 'Verejný prístup k API',
blockexplorer_public_api_desc:
'Povoliť neoverený prístup k API koncovým bodom prieskumníka blokov.',
electrum_server_url: 'URL Electrum servera',
electrum_server_url_hint:
'napr. ssl://electrum.blockstream.info:50002 alebo tcp://localhost:50001',
blockexplorer_search_label: 'Hľadať podľa TXID alebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakcia · čokoľvek iné = bitcoinová adresa',
recent_blocks: 'Nedávne bloky',
chain_tip: 'Vrchol reťaze',
block_height: 'Výška bloku',
block_fee: 'poplatok bloku',
fee_estimates: 'Odhady poplatkov',
confirmed_balance: 'Potvrdený zostatok',
unconfirmed_balance: 'Nepotvrdený zostatok',
transaction_history: 'História transakcií',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrdenia',
confirmed: 'Potvrdené',
unconfirmed: 'Nepotvrdené',
history_unavailable:
'História transakcií nedostupná (adresa má príliš veľa transakcií)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Predchádzajúci blok',
merkle_root: 'Merkle koreň',
version: 'Verzia',
bits: 'Bity',
difficulty: 'Obťažnosť',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuálna veľkosť',
weight: 'Váha',
n_block_fee: 'poplatok {n} blokov'
}
+44 -1
View File
@@ -442,5 +442,48 @@ window.localisation.we = {
http_request_methods: 'Dulliau Cais HTTP',
http_response_codes: 'Codau Ymateb HTTP',
request_details: 'Manylion y Cais',
http_request_details: 'Manylion Cais HTTP'
http_request_details: 'Manylion Cais HTTP',
block_explorer: 'Archwiliwr Bloc',
enable_block_explorer: "Galluogi'r Archwiliwr Bloc",
block_explorer_desc:
'Caniatáu i ddefnyddwyr archwilio trafodion a chyfeiriadau Bitcoin drwy Electrum.',
blockexplorer_public_api: 'Mynediad API Cyhoeddus',
blockexplorer_public_api_desc:
'Caniatáu mynediad heb ddilysu i bwyntiau terfyn API yr archwiliwr bloc.',
electrum_server_url: 'URL Gweinydd Electrum',
electrum_server_url_hint:
'e.e. ssl://electrum.blockstream.info:50002 neu tcp://localhost:50001',
blockexplorer_search_label: 'Chwilio yn ôl TXID neu Gyfeiriad',
blockexplorer_search_hint:
'Hex 64 nod = trafodiad · unrhyw beth arall = cyfeiriad Bitcoin',
recent_blocks: 'Blociau Diweddar',
chain_tip: 'Blaen y Gadwyn',
block_height: 'Uchder Bloc',
block_fee: 'ffi bloc',
fee_estimates: 'Amcangyfrifon Ffi',
confirmed_balance: 'Balans Cadarnhawyd',
unconfirmed_balance: 'Balans Heb ei Gadarnhau',
transaction_history: 'Hanes Trafodion',
coinbase: 'Coinbase',
inputs: 'Mewnbynnau',
outputs: 'Allbynnau',
confirmations: 'Cadarnhadau',
confirmed: 'Cadarnhawyd',
unconfirmed: 'Heb ei Gadarnhau',
history_unavailable:
'Hanes trafodion ar goll (mae cyfeiriad â gormod o drafodion)',
address: 'Cyfeiriad',
block_number: 'Bloc #{height}',
block_diff: 'anhawster {value}',
block_hash: 'Hash',
previous_block: 'Bloc Blaenorol',
merkle_root: 'Gwreiddyn Merkle',
version: 'Fersiwn',
bits: 'Bits',
difficulty: 'Anhawster',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Maint Rhithwir',
weight: 'Pwysau',
n_block_fee: 'ffi {n} bloc'
}
+9
View File
@@ -193,5 +193,14 @@ window._lnbitsApi = {
return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError)
},
getBlockexplorerAddress(address) {
return this.request('get', `/blockexplorer/api/v1/address/${address}`)
},
getBlockexplorerTransaction(txid) {
return this.request('get', `/blockexplorer/api/v1/tx/${txid}`)
},
getBlockexplorerUtxos(address) {
return this.request('get', `/blockexplorer/api/v1/utxos/${address}`)
}
}
@@ -0,0 +1,40 @@
window.app.component('lnbits-admin-blockexplorer', {
props: ['form-data'],
template: '#lnbits-admin-blockexplorer',
data() {
return {
electrumServers: [
'ssl://fulcrum.lnbits.com:50002',
'ssl://mainnet.nunchuk.io:52002',
'ssl://fulcrum.grey.pw:50002',
'ssl://electrum2.bluewallet.io:443',
'ssl://electrum.acinq.co:50002',
'ssl://electrum.blockstream.info:50002',
'ssl://bitcoin.mullvad.net:5010'
]
}
},
computed: {
electrumServerOptions() {
return [...this.electrumServers, 'Custom']
},
electrumServerPreset: {
get() {
return this.electrumServers.includes(
this.formData.lnbits_blockexplorer_electrum_url
)
? this.formData.lnbits_blockexplorer_electrum_url
: 'Custom'
},
set(value) {
if (value === 'Custom') {
if (this.electrumServerPreset !== 'Custom') {
this.formData.lnbits_blockexplorer_electrum_url = ''
}
return
}
this.formData.lnbits_blockexplorer_electrum_url = value
}
}
}
})
@@ -62,12 +62,6 @@ window.app.component('lnbits-admin-exchange-providers', {
mounted() {
this.getExchangeRateHistory()
},
created() {
const hash = window.location.hash.replace('#', '')
if (hash === 'exchange_providers') {
this.showExchangeProvidersTab(hash)
}
},
methods: {
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
@@ -127,18 +121,21 @@ window.app.component('lnbits-admin-exchange-providers', {
this.exchangeData.showTickerConversion = true
},
initExchangeChart(data) {
if (this.exchangeRatesChart) {
this.exchangeRatesChart.destroy()
this.exchangeRatesChart = null
}
const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
)
const exchanges = [
...this.formData.lnbits_exchange_rate_providers,
{name: 'LNbits'}
]
const exchanges = this.formData.lnbits_price_aggregator_enabled
? [{name: 'Aggregator'}]
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
const datasets = exchanges.map(exchange => ({
label: exchange.name,
data: data.map(d => d.rates[exchange.name]),
pointStyle: true,
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
tension: 0.4
}))
this.exchangeRatesChart = new Chart(
@@ -148,7 +145,11 @@ window.app.component('lnbits-admin-exchange-providers', {
options: {
plugins: {
legend: {
display: false
display: true
},
title: {
display: true,
text: 'Bitcoin Price History'
}
}
},
@@ -0,0 +1,151 @@
window.app.component('lnbits-admin-funding-seed-backup', {
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding-seed-backup',
data() {
return {
dialog: {
show: false,
step: 1,
seed: '',
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: ''
}
}
},
watch: {
active(isActive) {
if (isActive) {
this.openIfRequired()
}
},
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
const source = this.seedBackupSource(walletClass)
if (previousWalletClass && source && this.formData[source.seedField]) {
this.formData[source.confirmField] = false
}
this.openIfRequired()
},
'formData.boltz_mnemonic'() {
this.formData.boltz_mnemonic_backup_confirmed =
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
? this.settings.boltz_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.phoenixd_mnemonic'() {
this.formData.phoenixd_mnemonic_backup_confirmed =
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
? this.settings.phoenixd_mnemonic_backup_confirmed
: false
this.openIfRequired()
},
'formData.spark_l2_mnemonic'() {
this.formData.spark_l2_mnemonic_backup_confirmed =
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
? this.settings.spark_l2_mnemonic_backup_confirmed
: false
this.openIfRequired()
}
},
computed: {
seedWords() {
return this.dialog.seed
.split(/\s+/)
.filter(Boolean)
.map((word, index) => ({index, word}))
}
},
created() {
this.openIfRequired()
},
methods: {
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
if (walletClass === 'BoltzWallet') {
return {
seedField: 'boltz_mnemonic',
confirmField: 'boltz_mnemonic_backup_confirmed'
}
}
if (walletClass === 'PhoenixdWallet') {
return {
seedField: 'phoenixd_mnemonic',
confirmField: 'phoenixd_mnemonic_backup_confirmed'
}
}
if (walletClass === 'SparkL2Wallet') {
return {
seedField: 'spark_l2_mnemonic',
confirmField: 'spark_l2_mnemonic_backup_confirmed'
}
}
},
openIfRequired() {
if (!this.active || !this.isSuperUser) return
const source = this.seedBackupSource()
if (!source) return
const seed = (this.formData[source.seedField] || '').trim()
const confirmed = this.formData[source.confirmField]
if (!seed || confirmed || this.dialog.show) return
this.dialog = {
show: true,
step: 1,
seed,
visible: false,
challenge: [],
answers: {},
error: '',
confirmField: source.confirmField
}
},
prepareChallenge() {
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
const count = Math.min(4, words.length)
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
this.dialog.challenge = indexes
.sort((a, b) => a - b)
.map(index => ({index, word: words[index]}))
this.dialog.answers = {}
this.dialog.error = ''
this.dialog.step = 2
},
submitChallenge() {
const isValid = this.dialog.challenge.every(({index, word}) => {
const answer = this.dialog.answers[index] || ''
return answer.trim().toLowerCase() === word.toLowerCase()
})
if (!isValid) {
this.dialog.error =
'One or more words are incorrect. Check your backup and try again.'
return
}
const field = this.dialog.confirmField
LNbits.api
.request(
'PATCH',
'/admin/api/v1/settings',
this.g.user.wallets[0].adminkey,
{
[field]: true
}
)
.then(() => {
this.formData[field] = true
this.settings[field] = true
this.dialog.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Seed backup confirmed',
icon: 'check'
})
})
.catch(LNbits.utils.notifyApiError)
}
}
})
@@ -1,5 +1,5 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
props: ['active', 'is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
data() {
return {
@@ -35,18 +35,6 @@ window.app.component('lnbits-manage-extension-list', {
.toLocaleLowerCase()
.includes(this.searchTerm.toLocaleLowerCase())
})
},
extensionUrl(extension) {
if (extension.is_wasm) {
return `/ext/${extension.code}`
}
return `/${extension.code}/`
},
extensionActive(extension) {
const extensionPath = extension.is_wasm
? `/ext/${extension.code}`
: `/${extension.code}`
return this.$route.path.startsWith(extensionPath)
}
},
async created() {
+5
View File
@@ -66,6 +66,11 @@ const routes = [
name: 'NodePublic',
component: PageNodePublic
},
{
path: '/blockexplorer',
name: 'BlockExplorer',
component: PageBlockExplorer
},
{
path: '/payments',
name: 'Payments',
+185
View File
@@ -0,0 +1,185 @@
window.PageBlockExplorer = {
template: '#page-blockexplorer',
data() {
return {
query: '',
loading: false,
tip: null,
fees: null,
blocks: [],
selectedBlock: null,
blockDialog: false,
txResult: null,
txStatus: null,
addressResult: null,
currentAddress: ''
}
},
computed: {
feeList() {
if (!this.fees || !this.fees.estimates) return []
return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({
label: this.$t('n_block_fee', {n: blocks}),
rate: (rate * 100000).toFixed(1) + ' sat/vB'
}))
},
formattedBlocks() {
const now = Math.floor(Date.now() / 1000)
return this.blocks.map(b => ({
...b,
shortHash: b.hash.slice(0, 8) + '...' + b.hash.slice(-4),
timeAgo: this._timeAgo(now - b.timestamp),
utcTime: new Date(b.timestamp * 1000).toUTCString(),
difficulty: this._difficulty(b.bits)
}))
}
},
async created() {
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
this._blockWsActive = true
this._connectBlocksWs()
},
beforeUnmount() {
this._blockWsActive = false
if (this._blockWs) this._blockWs.close()
if (this._searchWs) this._searchWs.close()
},
methods: {
_wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/blockexplorer/api/v1${path}`
},
_connectBlocksWs() {
const ws = new WebSocket(this._wsUrl('/ws/blocks'))
ws.onmessage = e => {
const block = JSON.parse(e.data)
const rest = this.blocks.filter(b => b.height !== block.height)
this.blocks = [block, ...rest].slice(0, 5)
}
ws.onerror = () => ws.close()
ws.onclose = () => {
if (this._blockWsActive) setTimeout(() => this._connectBlocksWs(), 5000)
}
this._blockWs = ws
},
_connectSearchWs(path, onMessage) {
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
const ws = new WebSocket(this._wsUrl(path))
ws.onmessage = e => {
try {
onMessage(JSON.parse(e.data))
} catch (_) {}
}
ws.onerror = () => ws.close()
this._searchWs = ws
},
_timeAgo(seconds) {
if (seconds < 60) return seconds + 's ago'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
return Math.floor(seconds / 3600) + 'h ago'
},
_difficulty(bitsHex) {
const exp = parseInt(bitsHex.slice(0, 2), 16)
const mantissa = parseInt(bitsHex.slice(2), 16)
const diff1 = 0xffff * Math.pow(2, 208)
const target = mantissa * Math.pow(2, 8 * (exp - 3))
const d = diff1 / target
if (d >= 1e12) return (d / 1e12).toFixed(2) + 'T'
if (d >= 1e9) return (d / 1e9).toFixed(2) + 'G'
if (d >= 1e6) return (d / 1e6).toFixed(2) + 'M'
return d.toFixed(0)
},
openBlock(b) {
this.selectedBlock = b
this.blockDialog = true
},
async loadBlocks() {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/blocks'
)
this.blocks = r.data
} catch (_) {}
},
async loadTip() {
try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tip')
this.tip = r.data
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async loadFees() {
try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/fees')
this.fees = r.data
} catch (_) {}
},
clearResult() {
this.txResult = null
this.txStatus = null
this.addressResult = null
this.query = ''
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
},
async search() {
const q = this.query.trim()
if (!q) return
this.txResult = null
this.txStatus = null
this.addressResult = null
this.loading = true
try {
if (/^[0-9a-fA-F]{64}$/.test(q)) {
await this.loadTx(q)
} else {
await this.loadAddress(q)
}
} finally {
this.loading = false
}
},
async loadTx(txid) {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/tx/' + txid
)
this.txResult = r.data
this.txStatus = null
this.addressResult = null
this.query = txid
this._connectSearchWs(`/ws/tx/${txid}`, data => {
if (!data.error) this.txStatus = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async loadAddress(address) {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/address/' + address
)
this.addressResult = r.data
this.txResult = null
this.txStatus = null
this.currentAddress = address
this.query = address
this._connectSearchWs(`/ws/address/${address}`, data => {
if (!data.error) this.addressResult = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}
+1 -132
View File
@@ -24,11 +24,6 @@ window.PageExtensions = {
selectedExtensionDetails: null,
selectedExtensionRepos: null,
selectedRelease: null,
permissionGrant: {
show: false,
permissions: [],
resolve: null
},
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null,
@@ -137,12 +132,6 @@ window.PageExtensions = {
// the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs()
const grantedPermissions =
await this.resolveExtensionPermissionGrant(release)
if (grantedPermissions === null) {
return
}
this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false
release.payment_hash =
@@ -154,8 +143,7 @@ window.PageExtensions = {
archive: release.archive,
source_repo: release.source_repo,
payment_hash: release.payment_hash,
version: release.version,
permissions: grantedPermissions
version: release.version
})
.then(response => {
this.selectedExtension.inProgress = false
@@ -418,10 +406,6 @@ window.PageExtensions = {
},
async payAndInstall(release) {
try {
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
return
}
this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false
const paymentInfo = await this.requestPaymentForInstall(
@@ -467,10 +451,6 @@ window.PageExtensions = {
}
},
async showInstallQRCode(release) {
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
return
}
this.selectedRelease = release
try {
@@ -633,9 +613,6 @@ window.PageExtensions = {
return ''
},
extensionOpenUrl(extension) {
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
},
async getGitHubReleaseDetails(release) {
if (!release.is_github_release || release.loaded) {
return
@@ -651,8 +628,6 @@ window.PageExtensions = {
release.is_version_compatible = data.is_version_compatible
release.min_lnbits_version = data.min_lnbits_version
release.warning = data.warning
release.extension_type = data.extension_type
release.permissions = data.permissions || []
} catch (error) {
console.warn(error)
release.error = error
@@ -661,105 +636,6 @@ window.PageExtensions = {
release.inProgress = false
}
},
async resolveExtensionPermissionGrant(release) {
const permissions = this.extensionPermissionsForRelease(release)
if (
!this.releaseRequiresPermissionGrant(release) ||
!permissions.length
) {
return []
}
if (release.grantedPermissions) {
return release.grantedPermissions
}
const grantedPermissions =
await this.confirmExtensionPermissions(permissions)
if (!grantedPermissions) {
return null
}
release.grantedPermissions = grantedPermissions
return grantedPermissions
},
extensionPermissionsForRelease(release) {
return release.permissions || this.selectedExtension?.permissions || []
},
releaseRequiresPermissionGrant(release) {
return (
release.extension_type === 'wasm' ||
this.selectedExtension?.isWasm === true
)
},
confirmExtensionPermissions(permissions) {
return new Promise(resolve => {
this.selectedRelease = null
this.permissionGrant = {
show: true,
permissions,
resolve
}
this.showManageExtensionDialog = true
})
},
grantExtensionPermissions() {
this.resolveExtensionPermissionDialog(this.permissionGrant.permissions)
},
cancelExtensionPermissions() {
this.resolveExtensionPermissionDialog(null)
},
onManageExtensionDialogHide() {
if (this.permissionGrant.show) {
this.resolveExtensionPermissionDialog(null)
}
},
resolveExtensionPermissionDialog(grantedPermissions) {
const resolve = this.permissionGrant.resolve
this.permissionGrant = {
show: false,
permissions: [],
resolve: null
}
this.showManageExtensionDialog = false
if (resolve) {
resolve(grantedPermissions)
}
},
permissionI18nKey(permission) {
return `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
},
permissionLabel(permission) {
const key = this.permissionI18nKey(permission)
const label = this.$t(key)
return label === key ? permission.id : label
},
permissionDescription(permission) {
const key = `${this.permissionI18nKey(permission)}_desc`
const description = this.$t(key)
return description === key ? permission.description : description
},
permissionPolicyDetails(permission) {
if (permission.id === 'http.request') {
const hosts = permission.policy?.hosts
if (!Array.isArray(hosts) || !hosts.length) return ''
return `${this.$t('extension_permission_http_request_hosts')}: ${hosts.join(', ')}`
}
if (permission.id === 'extension.api.request') {
const extensions = permission.policy?.extensions
if (!Array.isArray(extensions) || !extensions.length) return ''
const targets = extensions
.map(extension => {
if (typeof extension === 'string') return `${extension} (read)`
if (!extension?.id) return null
const access = Array.isArray(extension.access)
? extension.access.join(', ')
: 'read'
return `${extension.id} (${access})`
})
.filter(Boolean)
if (!targets.length) return ''
return `${this.$t('extension_permission_extension_api_request_extensions')}: ${targets.join(', ')}`
}
return ''
},
async selectAllUpdatableExtensionss() {
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
},
@@ -770,13 +646,6 @@ window.PageExtensions = {
if (!ext.selectedForUpdate) {
continue
}
if (ext.isWasm) {
Quasar.Notify.create({
type: 'warning',
message: `Skipping ${ext.id}; this extension update requires permission approval.`
})
continue
}
ext.inProgress = true
await LNbits.api.request('POST', `/api/v1/extension`, null, {
ext_id: ext.id,
+33 -12
View File
@@ -414,12 +414,19 @@ window.PageWallet = {
switch (action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
message: action.url,
caption: action.description,
html: true,
html: false,
type: 'positive',
timeout: 0,
closeBtn: true
closeBtn: true,
actions: [
{
label: 'Open link',
color: 'white',
handler: () => this.utils.openUrlInNewTab(action.url)
}
]
})
break
case 'message':
@@ -431,15 +438,29 @@ window.PageWallet = {
})
break
case 'aes':
this.utils.decryptLnurlPayAES(action, response.data.preimage)
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
this.utils
.decryptLnurlPayAES(action, response.data.preimage)
.then(value => {
Quasar.Notify.create({
message: value,
caption: action.description,
html: false,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
.catch(error => {
Quasar.Notify.create({
message: action.description || 'Payment successful.',
caption: 'Could not decrypt success action.',
html: false,
type: 'warning',
timeout: 0,
closeBtn: true
})
})
break
}
}
})
+22
View File
@@ -365,5 +365,27 @@ window._lnbitsUtils = {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
},
validateBrowsableUrl(urlString, allowLoopback = false) {
const url = new URL(urlString)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol')
}
if (!allowLoopback) {
const host = url.hostname
if (
host === 'localhost' ||
host === '[::1]' ||
host === '::1' ||
host.startsWith('127.') ||
host.startsWith('::ffff:127.')
) {
throw new Error('Loopback addresses are not allowed')
}
}
},
openUrlInNewTab(urlString, allowLoopback = false) {
this.validateBrowsableUrl(urlString, allowLoopback)
window.open(urlString, '_blank', 'noopener,noreferrer')
}
}
+3
View File
@@ -58,6 +58,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
@@ -70,6 +71,8 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/admin/lnbits-admin-blockexplorer.js",
"js/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",
+555
View File
@@ -0,0 +1,555 @@
import asyncio
import traceback
import uuid
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from typing import TypeVar
from fastapi import WebSocket
from loguru import logger
from pydantic import BaseModel
from lnbits.core.models import Payment
from lnbits.settings import settings
from lnbits.utils.electrum import (
AddressTracker,
BlockInfo,
BlockTracker,
OnchainAddressEvent,
OnchainTxEvent,
TransactionTracker,
scripthash_from_address,
)
class PublicTask(BaseModel):
"""Public model used to expose task information via the API."""
name: str
created_at: datetime
class Task:
"""Model used on the backend to keep track of background tasks."""
coro: Coroutine
name: str
created_at: datetime
task: asyncio.Task
invoice_queue: asyncio.Queue[Payment] | None = None
onchain_address_queue: asyncio.Queue[OnchainAddressEvent] | None = None
onchain_tx_queue: asyncio.Queue[OnchainTxEvent] | None = None
block_queue: asyncio.Queue[BlockInfo] | None = None
def __init__(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
) -> None:
self.coro = coro
self.name = name or f"task_{uuid.uuid4()}"
self.created_at = datetime.now(timezone.utc)
self.task = asyncio.create_task(self.coro, name=self.name)
self.invoice_queue = invoice_queue
self.onchain_address_queue = onchain_address_queue
self.onchain_tx_queue = onchain_tx_queue
self.block_queue = block_queue
class TaskManager:
"""Singleton class to manage background tasks."""
ONCHAIN_ADDRESS_LISTENER_SUFFIX = "_onchain_address_listener"
tasks: list[Task] = []
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
_address_tracker: "AddressTracker | None" = None
_block_tracker: "BlockTracker | None" = None
_tx_trackers: dict[str, "TransactionTracker"] = {}
_tracked_addresses_by_listener: dict[str, set[str]] = {}
def init(self) -> None:
self.create_permanent_task(
func=self._heart_beat,
interval=settings.task_heart_beat_interval,
)
self.create_permanent_task(self._invoice_listener_consumer)
self.create_permanent_task(self._internal_invoice_listener_consumer)
def get_task(self, name: str) -> Task | None:
"""Get a running task by name."""
for task in self.tasks:
if task.name == name:
return task
return None
def get_public_tasks(self) -> list[PublicTask]:
"""Get a list of public tasks."""
return [PublicTask(name=t.name, created_at=t.created_at) for t in self.tasks]
def cancel_task(self, task: Task) -> None:
"""Cancel a running task."""
self.tasks.remove(task)
try:
task.task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{task.name}`: {exc!s}")
def cancel_all_tasks(self) -> None:
"""Cancel all running tasks."""
for task in list(self.tasks):
self.cancel_task(task)
def create_task(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
) -> Task:
"""Create a task. If a task with the same name exists, it will be cancelled."""
if name:
task = self.get_task(name)
if task:
self.cancel_task(task)
task = Task(
coro=coro,
name=name,
invoice_queue=invoice_queue,
onchain_address_queue=onchain_address_queue,
onchain_tx_queue=onchain_tx_queue,
block_queue=block_queue,
)
self.tasks.append(task)
return task
def create_permanent_task(
self,
func: Callable[[], Coroutine],
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
name: str | None = None,
interval: int = 0,
) -> Task:
"""Create a task that runs forever and restarts on failure."""
async def wrapper():
while settings.lnbits_running:
await self._catch_everything_and_restart(func)
if interval > 0:
await asyncio.sleep(interval)
return self.create_task(
coro=wrapper(),
name=name or func.__name__,
invoice_queue=invoice_queue,
onchain_address_queue=onchain_address_queue,
onchain_tx_queue=onchain_tx_queue,
block_queue=block_queue,
)
def register_invoice_listener(
self,
func: Callable[[Payment], Coroutine],
name: str | None = None,
) -> Task:
"""
A method intended for extensions to call when they want to be notified about
incoming payments. Will call provided Coroutine with the updated payment.
"""
name = f"{name or uuid.uuid4()}_invoice_listener"
queue: asyncio.Queue[Payment] = asyncio.Queue()
return self.create_permanent_task(
self._invoice_listener_worker(func, queue),
name=name,
invoice_queue=queue,
)
def register_onchain_listener(
self,
func: Callable[[OnchainAddressEvent], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for onchain address events. Only dispatches events
for addresses tracked under the same `name` via track_address, e.g. an
extension registering as "ext_satspay" only sees events for addresses
it tracked with that same name. Defaults to the shared "core" listener
if no name is given.
"""
name = name or "core"
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
return self.create_permanent_task(
self._onchain_address_listener_worker(func, queue),
name=f"{name}{self.ONCHAIN_ADDRESS_LISTENER_SUFFIX}",
onchain_address_queue=queue,
)
def register_onchain_tx_listener(
self,
func: Callable[[OnchainTxEvent], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for onchain transaction events dispatched for any
transaction currently tracked via register_ws_tx_queue.
Will call the provided coroutine with an OnchainTxEvent on each update.
"""
name = f"{name or uuid.uuid4()}_onchain_tx_listener"
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
return self.create_permanent_task(
self._onchain_tx_listener_worker(func, queue),
name=name,
onchain_tx_queue=queue,
)
def register_block_listener(
self,
func: Callable[[BlockInfo], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for new block events dispatched while the shared
block tracker is running (i.e. while a websocket or other consumer has
requested block updates via register_ws_block_queue).
Will call the provided coroutine with a BlockInfo on each new block.
"""
name = f"{name or uuid.uuid4()}_block_listener"
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
return self.create_permanent_task(
self._block_listener_worker(func, queue),
name=name,
block_queue=queue,
)
def track_address(self, address: str, name: str) -> None:
"""Start tracking a Bitcoin address via Electrum (ref-counted).
`name` identifies the listener (see register_onchain_listener) that
should receive events for this address.
"""
self._get_address_tracker().add(address)
self._tracked_addresses_by_listener.setdefault(name, set()).add(address)
def untrack_address(self, address: str, name: str) -> None:
"""Decrement ref count; remove from shared tracker when last caller leaves."""
if self._address_tracker:
self._address_tracker.remove(address)
tracked = self._tracked_addresses_by_listener.get(name)
if tracked:
tracked.discard(address)
if not tracked:
self._tracked_addresses_by_listener.pop(name, None)
def _get_address_tracker(self) -> "AddressTracker":
if self._address_tracker is None:
self._address_tracker = AddressTracker(
settings.lnbits_blockexplorer_electrum_url
)
if not self.get_task("address_tracker"):
self.create_task(
self._address_tracker.run(
self._dispatch_onchain_event,
lambda: settings.lnbits_running,
),
name="address_tracker",
)
return self._address_tracker
def register_ws_address_queue(
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
) -> None:
"""Register a per-connection queue for a watched address.
Raises ValueError if the address is invalid.
"""
scripthash_from_address(address)
self._get_address_tracker().register_queue(address, queue)
def unregister_ws_address_queue(
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
) -> None:
"""Deregister a per-connection queue and decrement the address ref count."""
if self._address_tracker:
self._address_tracker.unregister_queue(address, queue)
def register_ws_tx_queue(
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
) -> None:
"""Register a per-connection queue for a watched transaction."""
self._get_tx_tracker(txid).register_queue(queue)
def unregister_ws_tx_queue(
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
) -> None:
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
tracker = self._tx_trackers.get(txid)
if not tracker:
return
tracker.unregister_queue(queue)
if not tracker.has_queues():
self._tx_trackers.pop(txid, None)
task = self.get_task(f"ws_tx_{txid}")
if task:
self.cancel_task(task)
def _get_tx_tracker(self, txid: str) -> "TransactionTracker":
tracker = self._tx_trackers.get(txid)
if tracker is None:
tracker = TransactionTracker(settings.lnbits_blockexplorer_electrum_url)
self._tx_trackers[txid] = tracker
if not self.get_task(f"ws_tx_{txid}"):
self.create_task(
self._transaction_tracker_dispatch(txid, tracker),
name=f"ws_tx_{txid}",
)
return tracker
def register_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
"""Register a per-connection queue for new block events."""
if self._block_tracker is None:
self._block_tracker = BlockTracker(
settings.lnbits_blockexplorer_electrum_url
)
tracker = self._block_tracker
was_empty = not tracker.has_queues()
tracker.register_queue(queue)
if was_empty:
self.create_task(
tracker.run(
self._dispatch_block_event,
lambda: tracker.has_queues() and settings.lnbits_running,
),
name="block_tracker",
)
def unregister_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
if not self._block_tracker:
return
self._block_tracker.unregister_queue(queue)
if not self._block_tracker.has_queues():
task = self.get_task("block_tracker")
if task:
self.cancel_task(task)
def track_transaction(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine],
) -> Task:
"""Track a transaction until confirmed, calling callback on each change."""
return self.create_task(
self._transaction_tracker(txid, callback),
name=f"onchain_tx_{txid}",
)
async def _heart_beat(self) -> None:
"""A heartbeat that removes done tasks logs the number of tasks."""
for task in self.tasks:
state = task.task._state if task.task else "NOT RUNNING"
if settings.task_heart_beat_verbose:
logger.debug(
f"Task Manager: `{task.name}` state: `{state}` "
f"created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}`"
)
if task.task and task.task.done():
logger.debug(f"Task Manager: task `{task.name}` is done.")
self.cancel_task(task)
invoice_listeners = sum(1 for task in self.tasks if task.invoice_queue)
onchain_listeners = sum(
1
for task in self.tasks
if task.onchain_address_queue or task.onchain_tx_queue or task.block_queue
)
other_tasks = len(self.tasks) - invoice_listeners - onchain_listeners
logger.debug(
f"Task Manager: {other_tasks} tasks, "
f"{invoice_listeners} invoice listeners, "
f"{onchain_listeners} onchain listeners."
)
async def _catch_everything_and_restart(
self,
func: Callable[[], Coroutine],
restart_interval: int = 5,
) -> None:
"""Catches all exceptions from a function and restarts it after 5 seconds."""
while settings.lnbits_running:
try:
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
if not settings.lnbits_running:
return
logger.error(f"exception in background task `{func.__name__}`:", exc)
logger.error(traceback.format_exc())
logger.info(
f"`{func.__name__}` restarts in {restart_interval} seconds."
)
await asyncio.sleep(restart_interval)
def _invoice_listener_worker(
self, func: Callable[[Payment], Coroutine], queue: asyncio.Queue[Payment]
) -> Callable:
async def wrapper() -> None:
payment: Payment = await queue.get()
await func(payment)
return wrapper
def _onchain_address_listener_worker(
self,
func: Callable[[OnchainAddressEvent], Coroutine],
queue: asyncio.Queue[OnchainAddressEvent],
) -> Callable:
async def wrapper() -> None:
event: OnchainAddressEvent = await queue.get()
await func(event)
return wrapper
def _onchain_tx_listener_worker(
self,
func: Callable[[OnchainTxEvent], Coroutine],
queue: asyncio.Queue[OnchainTxEvent],
) -> Callable:
async def wrapper() -> None:
event: OnchainTxEvent = await queue.get()
await func(event)
return wrapper
def _block_listener_worker(
self,
func: Callable[[BlockInfo], Coroutine],
queue: asyncio.Queue[BlockInfo],
) -> Callable:
async def wrapper() -> None:
event: BlockInfo = await queue.get()
await func(event)
return wrapper
def _invoice_dispatcher(self, payment: Payment) -> None:
"""Dispatches a payment to all registered invoice listeners."""
for task in self.tasks:
if not task.invoice_queue:
continue
logger.debug(f"Enqueing payment to task {task.name}")
task.invoice_queue.put_nowait(payment)
async def _dispatch_onchain_event(self, event: OnchainAddressEvent) -> None:
"""Dispatches an onchain address event to listeners tracking that
address under their own name (see track_address).
Per-address WS queue fan-out is handled by AddressTracker itself.
"""
for task in self.tasks:
if not task.onchain_address_queue:
continue
if not task.name.endswith(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX):
continue
name = task.name[: -len(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX)]
if event.address in self._tracked_addresses_by_listener.get(name, ()):
task.onchain_address_queue.put_nowait(event)
async def _dispatch_onchain_tx_event(self, event: OnchainTxEvent) -> None:
"""Dispatches an onchain tx event to registered listeners.
Per-tx WS queue fan-out is handled by TransactionTracker itself.
"""
for task in self.tasks:
if task.onchain_tx_queue:
task.onchain_tx_queue.put_nowait(event)
async def _dispatch_block_event(self, event: BlockInfo) -> None:
"""Dispatches a new block event to registered listeners.
Per-connection WS queue fan-out is handled by BlockTracker itself.
"""
for task in self.tasks:
if task.block_queue:
task.block_queue.put_nowait(event)
async def _invoice_listener_consumer(self) -> None:
payment = await self.invoice_queue.get()
logger.info(f"got a payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _internal_invoice_listener_consumer(self) -> None:
payment = await self.internal_invoice_queue.get()
logger.info(f"got an internal payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _transaction_tracker(
self, txid: str, callback: Callable[[OnchainTxEvent], Coroutine]
) -> None:
await TransactionTracker(settings.lnbits_blockexplorer_electrum_url).track(
txid,
callback,
lambda: settings.lnbits_running,
)
async def _transaction_tracker_dispatch(
self, txid: str, tracker: "TransactionTracker"
) -> None:
await tracker.track(
txid,
self._dispatch_onchain_tx_event,
lambda: tracker.has_queues() and settings.lnbits_running,
)
T = TypeVar("T", bound=BaseModel)
async def relay_ws_queue(
websocket: WebSocket,
queue: "asyncio.Queue[T]",
serialize: Callable[[T], BaseModel] = lambda e: e,
stop_after: Callable[[T], bool] = lambda _: False,
) -> None:
"""
Pumps events from `queue` to `websocket` as JSON until the client
disconnects, sending fails, or `stop_after` returns True for an event.
Shared by the blockexplorer address/tx/block websocket endpoints.
"""
try:
while settings.lnbits_running:
recv_task = asyncio.create_task(websocket.receive())
event_task = asyncio.create_task(queue.get())
done, pending = await asyncio.wait(
[recv_task, event_task], return_when=asyncio.FIRST_COMPLETED
)
for t in pending:
t.cancel()
disconnect = recv_task in done and (
recv_task.result().get("type") == "websocket.disconnect"
)
if disconnect:
break
if event_task in done:
event = event_task.result()
try:
await websocket.send_json(serialize(event).dict())
except Exception as exc:
logger.debug(f"ws relay send error: {exc}")
break
if stop_after(event):
break
except Exception as exc:
logger.debug(f"ws relay error: {exc}")
task_manager = TaskManager()
+33 -120
View File
@@ -1,146 +1,83 @@
import asyncio
import traceback
import uuid
from collections.abc import Callable, Coroutine
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services.payments import (
get_standalone_payment,
update_invoice_from_paid_invoices_stream,
)
from lnbits.core.services.payments import get_standalone_payment
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
tasks: list[asyncio.Task] = []
unique_tasks: dict[str, asyncio.Task] = {}
from lnbits.task_manager import task_manager
# DEPRECATED: use task_manager.create_task instead.
def create_task(coro: Coroutine) -> asyncio.Task:
task = asyncio.create_task(coro)
tasks.append(task)
return task
logger.debug("DEPRECATED: use task_manager.create_task instead.")
return task_manager.create_task(coro).task
# DEPRECATED: use task_manager.create_task with `name` kwarg.
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
if unique_tasks.get(name):
logger.warning(f"task `{name}` already exists, cancelling it")
try:
unique_tasks[name].cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
task = asyncio.create_task(coro)
unique_tasks[name] = task
return task
logger.debug("DEPRECATED: use task_manager.create_task instead.")
return task_manager.create_task(coro, name=name).task
# DEPRECATED: use task_manager.create_permanent_task instead.
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
return create_task(catch_everything_and_restart(func))
logger.debug("DEPRECATED: use task_manager.create_permanent_task instead.")
return task_manager.create_permanent_task(func).task
# DEPRECATED: use task_manager.create_permanent_task with `name` argument instead.
def create_permanent_unique_task(
name: str, coro: Callable[[], Coroutine]
) -> asyncio.Task:
return create_unique_task(name, catch_everything_and_restart(coro, name))
def cancel_all_tasks() -> None:
for task in tasks:
try:
task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task: {exc!s}")
for name, task in unique_tasks.items():
try:
task.cancel()
except Exception as exc:
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
# DEPRECATED don't use this, use task_manager.create_permanent_task instead.
async def catch_everything_and_restart(
func: Callable[[], Coroutine],
name: str = "unnamed",
) -> Coroutine:
try:
return await func()
except asyncio.CancelledError:
raise # because we must pass this up
except Exception as exc:
logger.error(f"exception in background task `{name}`:", exc)
logger.error(traceback.format_exc())
logger.error("will restart the task in 5 seconds.")
await asyncio.sleep(5)
return await catch_everything_and_restart(func, name)
) -> None:
_ = name
return await task_manager._catch_everything_and_restart(func)
invoice_listeners: dict[str, asyncio.Queue] = {}
# TODO: name should not be optional
# some extensions still dont use a name, but they should
def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None):
"""
A method intended for extensions (and core/tasks.py) to call when they want to be
notified about new invoice payments incoming. Will emit all incoming payments.
DEPRECATED: use task_manager.register_invoice_listener instead,
which also allows to pass a callback instead of a queue.
This method will still work but it is not recommended for new code.
"""
if not name:
# fallback to a random name if extension didn't provide one
name = f"no_name_{str(uuid.uuid4())[:8]}"
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
name = f"forward_{name or str(uuid.uuid4())[:8]}"
if invoice_listeners.get(name):
logger.warning(f"invoice listener `{name}` already exists, replacing it")
# here we just forwarding the payments to the provided queue
async def forward_queue(payment: Payment):
send_chan.put_nowait(payment)
logger.trace(f"registering invoice listener `{name}`")
invoice_listeners[name] = send_chan
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
task_manager.register_invoice_listener(forward_queue, name=name)
async def internal_invoice_queue_put(checking_id: str) -> None:
"""
DEPRECATED: use task_manager.internal_invoice_queue instead,
A method to call when it wants to notify about an internal invoice payment.
"""
await internal_invoice_queue.put(checking_id)
async def internal_invoice_listener() -> None:
"""
internal_invoice_queue will be filled directly in core/services.py
after the payment was deemed to be settled internally.
Called by the app startup sequence.
"""
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await get_standalone_payment(checking_id, incoming=True)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
async def invoice_listener() -> None:
"""
invoice_listener will collect all invoices that come directly
from the backend wallet.
Called by the app startup sequence.
"""
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_from_paid_invoices_stream(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"internal_invoice_queue_put: payment {checking_id} not found")
return
await task_manager.internal_invoice_queue.put(payment)
# DEPRECATED use task_manager.register_invoice_listener(coro, name="myext")
def wait_for_paid_invoices(
invoice_listener_name: str,
func: Callable[[Payment], Coroutine],
) -> Callable[[], Coroutine]:
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
async def wrapper() -> None:
invoice_queue: asyncio.Queue = asyncio.Queue()
@@ -150,27 +87,3 @@ def wait_for_paid_invoices(
await func(payment)
return wrapper
def run_interval(
interval_seconds: int,
func: Callable[[], Coroutine],
) -> Callable[[], Coroutine]:
"""Run a function at a specified interval in seconds, while the server is running"""
async def wrapper() -> None:
while settings.lnbits_running:
try:
await func()
except Exception as e:
logger.error(f"Error occurred in interval task: {e}")
logger.warning(traceback.format_exc())
await asyncio.sleep(interval_seconds)
return wrapper
async def invoice_callback_dispatcher(payment: Payment):
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
+18 -1
View File
@@ -1,4 +1,5 @@
{% include('components/admin/funding.vue') %} {%
{% include('components/admin/funding_seed_backup.vue') %} {%
include('components/admin/funding.vue') %} {%
include('components/admin/funding_sources.vue') %} {%
include('components/admin/fiat_providers.vue') %} {%
include('components/admin/exchange_providers.vue') %} {%
@@ -10,6 +11,7 @@ include('components/admin/extensions.vue') %} {%
include('components/admin/assets-config.vue') %} {%
include('components/admin/notifications.vue') %} {%
include('components/admin/server.vue') %} {%
include('components/admin/blockexplorer.vue') %} {%
include('components/lnbits-qrcode.vue') %} {%
include('components/lnbits-qrcode-scanner.vue') %} {%
include('components/lnbits-disclaimer.vue') %} {%
@@ -96,6 +98,21 @@ include('components/lnbits-error.vue') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="g.settings.showBlockExplorer" to="/blockexplorer">
<q-item-section side>
<q-icon
name="travel_explore"
:color="isActive('/blockexplorer') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('block_explorer')"></q-item-label>
</q-item-section>
<q-item-section side v-show="isActive('/blockexplorer')">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
</div>
<q-item to="/payments">
<q-item-section side>
@@ -0,0 +1,90 @@
<template id="lnbits-admin-blockexplorer">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('block_explorer')"></span>
</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_blockexplorer_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('enable_block_explorer')"></q-item-label>
<q-item-label
caption
v-text="$t('block_explorer_desc')"
></q-item-label>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_blockexplorer_public_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label
v-text="$t('blockexplorer_public_api')"
></q-item-label>
<q-item-label
caption
v-text="$t('blockexplorer_public_api_desc')"
></q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('electrum_compatible_server')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-select
filled
v-model="electrumServerPreset"
:options="electrumServerOptions"
:label="$t('electrum_server_url')"
:hint="$t('electrum_server_url_hint')"
></q-select>
</div>
<div class="col-12 col-md-4" v-if="electrumServerPreset === 'Custom'">
<q-input
filled
v-model="formData.lnbits_blockexplorer_electrum_url"
:label="$t('electrum_server_url_custom')"
:hint="$t('electrum_server_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-select
filled
v-model="formData.lnbits_blockexplorer_network"
:options="['main', 'test', 'regtest', 'signet']"
:label="$t('blockexplorer_network')"
:hint="$t('blockexplorer_network_hint')"
></q-select>
</div>
<div class="col-12">
<a
href="https://1209k.com/bitcoin-eye/ele.php?chain=btc"
target="_blank"
rel="noopener noreferrer"
>
<span v-text="$t('view_public_electrum_servers')"></span>
</a>
</div>
</div>
</q-card-section>
</template>
@@ -1,7 +1,46 @@
<template id="lnbits-admin-exchange-providers">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
<p class="q-mb-md text-caption text-grey">
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
LNbits team. Aggregates prices from multiple exchanges and returns a median,
no API keys required.
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
>price.lnbits.com</a
>
&mdash;
<a
href="https://github.com/lnbits/lnbits-price-aggregator"
target="_blank"
rel="noopener"
>GitHub</a
>
</p>
<div class="row q-mb-md items-start">
<div class="col-auto q-mr-md q-mt-sm">
<q-toggle
v-model="formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
label="Use Price Aggregator"
>
</q-toggle>
</div>
<div class="col-12 col-md-7">
<q-input
filled
v-model="formData.lnbits_price_aggregator_url"
type="text"
label="Price Aggregator URL"
hint="Fetch BTC price from this aggregator instead of individual providers below."
:disable="!formData.lnbits_price_aggregator_enabled"
@update:model-value="formData.touch = null"
>
</q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
<div class="row">
<div class="col-12 col-md-8">
@@ -53,6 +92,11 @@
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('exchange_providers')"></span>
</h6>
<div class="row q-mt-md">
<div class="col-6">
<q-btn
@@ -60,6 +104,7 @@
label="Add Exchange Provider"
color="primary"
class="q-mb-md"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
@@ -70,12 +115,20 @@
:label="$t('reset_defaults')"
color="primary"
class="float-right"
:disable="formData.lnbits_price_aggregator_enabled"
>
</q-btn>
</div>
</div>
<div class="overflow-auto">
<div
class="overflow-auto"
:style="
formData.lnbits_price_aggregator_enabled
? 'opacity:0.4;pointer-events:none'
: ''
"
>
<q-table
row-key="name"
:rows="formData.lnbits_exchange_rate_providers"
@@ -301,5 +301,11 @@
</div>
</div>
</div>
<lnbits-admin-funding-seed-backup
:active="active"
:is-super-user="isSuperUser"
:form-data="formData"
:settings="settings"
></lnbits-admin-funding-seed-backup>
</q-card-section>
</template>
@@ -0,0 +1,136 @@
<template id="lnbits-admin-funding-seed-backup">
<q-dialog v-model="dialog.show">
<q-card style="width: 760px; max-width: 95vw; border-radius: 8px">
<q-card-section class="q-pb-md">
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_one"
:color="dialog.step === 1 ? 'primary' : 'grey-9'"
text-color="white"
label="Backup"
></q-chip>
</div>
<div class="col-6">
<q-chip
square
class="full-width"
icon="looks_two"
:color="dialog.step === 2 ? 'primary' : 'grey-9'"
text-color="white"
label="Verify"
></q-chip>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="dialog.step === 1">
<div class="row items-center justify-between q-mb-md">
<div>
<div
class="text-subtitle1"
v-text="`${seedWords.length}-word recovery phrase`"
></div>
<div
class="text-caption text-grey-5"
v-text="'Write these words down in order.'"
></div>
</div>
<q-btn
outline
no-caps
color="primary"
:icon="dialog.visible ? 'visibility_off' : 'visibility'"
:label="dialog.visible ? 'Hide words' : 'Show words'"
@click="dialog.visible = !dialog.visible"
></q-btn>
</div>
<div class="row q-col-gutter-sm">
<div
class="col-4 col-md-3"
v-for="word in seedWords"
:key="word.index"
>
<div
class="row items-center no-wrap rounded-borders"
style="
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.035);
"
>
<div
class="text-caption text-grey-5 text-center"
style="
width: 42px;
border-right: 1px solid rgba(255, 255, 255, 0.1);
"
v-text="word.index + 1"
></div>
<div
class="text-body2 text-weight-medium q-px-sm"
style="min-width: 0; overflow-wrap: anywhere"
v-text="dialog.visible ? word.word : '••••••'"
></div>
</div>
</div>
</div>
<div class="row justify-end q-mt-lg">
<q-btn
color="primary"
no-caps
label="I have written it down"
@click="prepareChallenge"
></q-btn>
</div>
</q-card-section>
<q-card-section v-if="dialog.step === 2">
<div class="q-mb-md">
<div class="text-subtitle1" v-text="'Confirm your backup'"></div>
<div
class="text-caption text-grey-5"
v-text="
'Enter the requested words from your written recovery phrase.'
"
></div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-12 col-sm-6"
v-for="word in dialog.challenge"
:key="word.index"
>
<q-input
v-model.trim="dialog.answers[word.index]"
filled
:label="`Word ${word.index + 1}`"
></q-input>
</div>
</div>
<div
class="text-negative q-mt-sm"
v-if="dialog.error"
v-text="dialog.error"
></div>
<div class="row justify-between q-mt-lg">
<q-btn flat no-caps label="Back" @click="dialog.step = 1"></q-btn>
<q-btn
color="primary"
icon="check"
no-caps
label="Confirm backup"
@click="submitChallenge"
></q-btn>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
@@ -18,18 +18,13 @@
<q-item
v-for="extension in userExtensions"
clickable
:active="extensionActive(extension)"
:active="$route.path.startsWith('/' + extension.code)"
tag="a"
:to="extensionUrl(extension)"
:to="'/' + extension.code + '/'"
>
<q-item-section side>
<q-avatar size="md">
<img
v-if="extension.tile"
:src="extension.tile"
style="width: 20px; height: 20px; object-fit: contain"
/>
<q-icon v-else name="extension" size="20px"></q-icon>
<q-img :src="extension.tile" style="max-width: 20px"></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
@@ -37,7 +32,10 @@
><span v-text="extension.name"></span>
</q-item-label>
</q-item-section>
<q-item-section side v-show="extensionActive(extension)">
<q-item-section
side
v-show="$route.path.startsWith('/' + extension.code)"
>
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
@@ -6,6 +6,9 @@
:content-inset-level="0.5"
>
<q-card-section>
<q-banner dense rounded class="bg-warning text-black q-mb-md">
These keys should be kept safe, sharing them could risk losing funds.
</q-banner>
<q-list>
<q-item dense class="q-pa-none">
<q-item-section>
+1 -1
View File
@@ -4,4 +4,4 @@ include('pages/users.vue') %} {% include('pages/admin.vue') %} {%
include('pages/account.vue') %} {% include('pages/extensions_builder.vue') %} {%
include('pages/extensions.vue') %} {% include('pages/first-install.vue') %} {%
include('pages/home.vue') %} {% include('pages/wallet.vue') %} {%
include('pages/error.vue') %}
include('pages/error.vue') %} {% include('pages/blockexplorer.vue') %}
+13 -1
View File
@@ -88,7 +88,8 @@
{value: 'notifications', label: $t('notifications')},
{value: 'audit', label: $t('audit')},
{value: 'assets-config', label: $t('assets')},
{value: 'site_customisation', label: $t('site_customisation')}
{value: 'site_customisation', label: $t('site_customisation')},
{value: 'blockexplorer', label: $t('block_explorer')}
]"
option-value="value"
option-label="label"
@@ -183,6 +184,13 @@
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
<q-tab
name="blockexplorer"
icon="travel_explore"
:label="$q.screen.gt.sm ? $t('block_explorer') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('block_explorer')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
@@ -199,6 +207,7 @@
>
<q-tab-panel name="funding">
<lnbits-admin-funding
:active="tab === 'funding'"
:is-super-user="isSuperUser"
:settings="settings"
:form-data="formData"
@@ -234,6 +243,9 @@
<q-tab-panel name="assets-config">
<lnbits-admin-assets-config :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="blockexplorer">
<lnbits-admin-blockexplorer :form-data="formData" />
</q-tab-panel>
</q-tab-panels>
</q-scroll-area>
</q-form>
+385
View File
@@ -0,0 +1,385 @@
<template id="page-blockexplorer">
<div class="row q-col-gutter-md">
<!-- Left column: blocks + search + results -->
<div class="col-12 col-md-7 q-gutter-y-md">
<!-- Recent blocks (mempool-style squares) -->
<q-card v-if="blocks.length">
<q-card-section>
<div
class="text-subtitle1 q-mb-md"
v-text="$t('recent_blocks')"
></div>
<div class="row q-gutter-sm">
<q-card
v-for="b in formattedBlocks"
:key="b.height"
flat
class="bg-primary text-white cursor-pointer"
v-ripple
@click="openBlock(b)"
>
<q-card-section class="q-pa-sm">
<div
class="text-subtitle1 text-weight-bold"
v-text="'#' + b.height.toLocaleString()"
></div>
<div class="text-caption q-mt-xs" v-text="b.timeAgo"></div>
<div class="text-caption q-mt-sm">
<code v-text="b.shortHash"></code>
</div>
<div
class="text-caption"
v-text="$t('block_diff', {value: b.difficulty})"
></div>
</q-card-section>
</q-card>
</div>
</q-card-section>
</q-card>
<!-- Block detail dialog -->
<q-dialog v-model="blockDialog">
<q-card v-if="selectedBlock">
<q-card-section class="bg-primary text-white q-pb-sm">
<div
class="text-h6"
v-text="
$t('block_number', {
height: selectedBlock.height.toLocaleString()
})
"
></div>
<div class="text-caption" v-text="selectedBlock.utcTime"></div>
</q-card-section>
<q-card-section>
<div class="q-mb-md">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('block_hash')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.hash"
></code>
</div>
<div class="q-mb-md">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('previous_block')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.prev_hash"
></code>
</div>
<div class="q-mb-lg">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('merkle_root')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.merkle_root"
></code>
</div>
<div class="row q-col-gutter-md">
<div class="col-6 col-sm-3">
<div
class="text-caption text-grey"
v-text="$t('version')"
></div>
<div v-text="'0x' + selectedBlock.version.toString(16)"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey" v-text="$t('bits')"></div>
<div v-text="selectedBlock.bits"></div>
</div>
<div class="col-6 col-sm-3">
<div
class="text-caption text-grey"
v-text="$t('difficulty')"
></div>
<div v-text="selectedBlock.difficulty"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey" v-text="$t('nonce')"></div>
<div v-text="selectedBlock.nonce.toLocaleString()"></div>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat v-close-popup v-text="$t('close')"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-card>
<q-card-section>
<div class="text-h6" v-text="$t('block_explorer')"></div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
filled
v-model="query"
:label="$t('blockexplorer_search_label')"
:hint="$t('blockexplorer_search_hint')"
@keyup.enter="search"
>
<template v-slot:append>
<q-btn
flat
round
dense
icon="search"
:loading="loading"
@click="search"
/>
</template>
</q-input>
</q-card-section>
</q-card>
<!-- Transaction result -->
<q-card v-if="txResult">
<q-card-section>
<div class="row items-center justify-between q-mb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="$t('transaction')"></div>
<q-badge
v-if="txStatus"
:color="txStatus.confirmed ? 'positive' : 'orange'"
:label="
txStatus.confirmed ? $t('confirmed') : $t('unconfirmed')
"
></q-badge>
<q-spinner v-if="!txStatus" size="1em" color="grey" />
</div>
<q-btn flat round dense icon="close" @click="clearResult" />
</div>
<div class="q-mb-sm">
<span
class="text-caption text-grey"
v-text="$t('txid') + ': '"
></span>
<code class="text-caption be-wrap" v-text="txResult.txid"></code>
</div>
<div class="row q-col-gutter-md q-mb-sm">
<div class="col-auto" v-if="txStatus && txStatus.height">
<div
class="text-caption text-grey"
v-text="$t('block_height')"
></div>
<div v-text="txStatus.height.toLocaleString()"></div>
</div>
<div class="col-auto" v-if="txStatus && txStatus.fee !== null">
<div class="text-caption text-grey" v-text="$t('fee')"></div>
<div v-text="txStatus.fee + ' sat'"></div>
</div>
<div class="col-auto" v-if="txResult.vsize || txResult.size">
<div class="text-caption text-grey" v-text="$t('vsize')"></div>
<div v-text="(txResult.vsize || txResult.size) + ' vB'"></div>
</div>
<div class="col-auto" v-if="txResult.weight">
<div class="text-caption text-grey" v-text="$t('weight')"></div>
<div v-text="txResult.weight + ' WU'"></div>
</div>
</div>
<q-expansion-item
icon="login"
:label="$t('inputs') + ' (' + txResult.vin.length + ')'"
dense
class="q-mb-xs"
>
<q-list dense separator>
<q-item v-for="(vin, i) in txResult.vin" :key="i">
<q-item-section>
<q-item-label
v-if="vin.coinbase"
class="text-grey"
v-text="$t('coinbase')"
>
</q-item-label>
<q-item-label v-else class="be-wrap">
<a
href="#"
@click.prevent="loadTx(vin.txid)"
class="text-primary"
v-text="vin.txid + ':' + vin.vout"
></a>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item
icon="logout"
:label="$t('outputs') + ' (' + txResult.vout.length + ')'"
dense
>
<q-list dense separator>
<q-item v-for="(vout, i) in txResult.vout" :key="i">
<q-item-section>
<q-item-label>
<template
v-if="vout.scriptPubKey && vout.scriptPubKey.address"
>
<a
href="#"
@click.prevent="loadAddress(vout.scriptPubKey.address)"
class="text-primary"
v-text="vout.scriptPubKey.address"
></a>
</template>
<template
v-else-if="
vout.scriptPubKey &&
vout.scriptPubKey.type === 'nulldata'
"
>
<span class="text-grey">OP_RETURN</span>
</template>
<template v-else-if="vout.scriptPubKey">
<span v-text="vout.scriptPubKey.type"></span>
</template>
</q-item-label>
<q-item-label
caption
v-text="vout.value + ' BTC'"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
</q-card-section>
</q-card>
<!-- Address result -->
<q-card v-if="addressResult">
<q-card-section>
<div class="row items-center justify-between q-mb-xs">
<div class="text-subtitle1" v-text="$t('address')"></div>
<q-btn flat round dense icon="close" @click="clearResult" />
</div>
<div class="text-caption q-mb-sm">
<code class="be-wrap" v-text="currentAddress"></code>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-auto">
<div
class="text-caption text-grey"
v-text="$t('confirmed_balance')"
></div>
<div
v-text="
addressResult.balance.confirmed.toLocaleString() + ' sat'
"
></div>
</div>
<div
class="col-auto"
v-if="addressResult.balance.unconfirmed !== 0"
>
<div
class="text-caption text-grey"
v-text="$t('unconfirmed_balance')"
></div>
<div
v-text="
addressResult.balance.unconfirmed.toLocaleString() + ' sat'
"
></div>
</div>
</div>
<div
class="text-subtitle2 q-mb-xs"
v-text="
$t('transaction_history') +
' (' +
addressResult.history.length +
')'
"
></div>
<q-list dense separator>
<q-item
v-for="h in addressResult.history"
:key="h.tx_hash"
clickable
v-ripple
@click="loadTx(h.tx_hash)"
>
<q-item-section>
<q-item-label
class="text-primary be-wrap"
v-text="h.tx_hash"
></q-item-label>
<q-item-label
caption
v-text="
h.height > 0
? $t('block_height') + ': ' + h.height.toLocaleString()
: $t('unconfirmed')
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" color="grey-5"></q-icon>
</q-item-section>
</q-item>
</q-list>
<div
v-if="addressResult.history_error"
class="text-warning q-mt-sm text-caption"
v-text="$t('history_unavailable')"
></div>
<div
v-else-if="addressResult.history.length === 0"
class="text-grey q-mt-sm"
v-text="$t('no_transactions')"
></div>
</q-card-section>
</q-card>
</div>
<!-- Right column: chain tip + fees -->
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card v-if="tip">
<q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div>
<div class="text-caption text-grey" v-text="$t('block_height')"></div>
<div
class="text-h6 q-mb-md"
v-text="tip.height.toLocaleString()"
></div>
<template v-if="feeList.length">
<div
class="text-subtitle2 q-mb-sm"
v-text="$t('fee_estimates')"
></div>
<q-list dense>
<q-item v-for="f in feeList" :key="f.label" class="q-px-none">
<q-item-section>
<q-item-label
class="text-caption text-grey"
v-text="f.label"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label
class="text-body2"
v-text="f.rate"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
</q-card-section>
</q-card>
</div>
</div>
</template>
<style>
.be-wrap {
word-break: break-all;
}
</style>
+3 -55
View File
@@ -314,7 +314,7 @@
flat
color="primary"
type="a"
:href="extensionOpenUrl(extension)"
:href="extension.id + '/'"
:label="$t('open')"
></q-btn>
<q-btn
@@ -455,60 +455,8 @@
</q-card>
</q-dialog>
<q-dialog
v-model="showManageExtensionDialog"
position="top"
@hide="onManageExtensionDialogHide"
>
<q-card v-if="permissionGrant.show" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="$t('extension_permissions_title')"></div>
<div
class="text-body2 q-mt-sm"
v-text="$t('extension_permissions_request')"
></div>
</q-card-section>
<q-list bordered separator class="q-mt-md">
<q-item
v-for="permission of permissionGrant.permissions"
:key="permission.id"
>
<q-item-section>
<q-item-label>
<li><strong v-text="permissionLabel(permission)"></strong></li>
</q-item-label>
<q-item-label
v-if="permissionDescription(permission)"
caption
v-text="permissionDescription(permission)"
></q-item-label>
<q-item-label
v-if="permissionPolicyDetails(permission)"
caption
v-text="permissionPolicyDetails(permission)"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
<div class="row q-mt-lg">
<q-btn
flat
color="grey"
v-text="$t('cancel')"
@click="cancelExtensionPermissions"
></q-btn>
<q-btn
flat
color="primary"
class="q-ml-auto"
v-text="$t('extension_permissions_grant_install')"
@click="grantExtensionPermissions"
></q-btn>
</div>
</q-card>
<q-card v-else-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-dialog v-model="showManageExtensionDialog" position="top">
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div v-if="selectedRelease.paymentRequest">
<lnbits-qrcode
-351
View File
@@ -1,351 +0,0 @@
{% extends "base.html" %} {% block styles %}
<style>
.wasm-extension-frame {
border: 0;
display: block;
height: calc(100vh - 56px);
min-height: calc(100vh - 56px);
width: 100%;
}
</style>
{% endblock %} {% block page_container %}
<q-page-container>
<!-- # todo:revisit this -->
<q-page
v-if="$route.path.startsWith('{{ normalize_path(request.path) }}')"
class="q-pa-none"
>
<iframe
id="lnbits-wasm-extension-frame"
class="wasm-extension-frame"
data-frame-url="{{ frame_url }}"
title="{{ extension.name }}"
sandbox="allow-scripts"
allow="clipboard-write"
referrerpolicy="no-referrer"
></iframe>
</q-page>
<q-page v-else class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
<lnbits-wallet-new v-if="g.user && !g.isPublicPage"></lnbits-wallet-new>
<lnbits-header-wallets
v-if="g.user && !g.isPublicPage"
></lnbits-header-wallets>
<router-view :key="$route.path"></router-view>
</q-page>
</q-page-container>
{% endblock %} {% block scripts %}
<script>
;(() => {
const bridge = {{ bridge | tojson | safe }}
let bridgePort = null
const allowedPaymentHashes = new Set()
const paymentSubscriptions = new Map()
function extensionFrameWindow() {
return extensionFrame()?.contentWindow
}
function extensionFrame() {
return document.getElementById('lnbits-wasm-extension-frame')
}
function loadExtensionFrame() {
const frame = extensionFrame()
if (!frame) {
closeBridgePort()
return
}
if (frame.dataset.loaded === 'true') return
frame.dataset.loaded = 'true'
frame.src = frame.dataset.frameUrl
}
function startExtensionFrameLoader() {
loadExtensionFrame()
window.router?.afterEach(() => {
window.setTimeout(loadExtensionFrame)
})
}
function sendResponse(reply, id, payload) {
reply({
type: 'lnbits-extension:response',
id,
...payload
})
}
function allowedApiRoute(method, path) {
let url
try {
url = new URL(path, window.location.origin)
} catch (_error) {
return false
}
if (url.origin !== window.location.origin) return false
method = String(method || 'GET').toUpperCase()
return bridge.apiRoutes.some(route => {
return (
route.method === method &&
new RegExp(route.pattern).test(url.pathname)
)
})
}
async function callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
if (!allowedApiRoute(method, path)) {
throw new Error('Extension API route is not allowed.')
}
const options = {
method,
headers: {},
credentials: 'same-origin'
}
if (message.body !== undefined && message.body !== null) {
options.headers['content-type'] = 'application/json'
options.body = JSON.stringify(message.body)
}
const response = await fetch(path, options)
const text = await response.text()
let data = text
if (text) {
try {
data = JSON.parse(text)
} catch (_error) {
data = text
}
}
if (!response.ok) {
throw new Error(
typeof data === 'object' && data.detail ? data.detail : text
)
}
rememberPaymentHashes(data)
return data
}
function notify(message) {
const level = ['positive', 'negative', 'warning', 'info'].includes(
message.level
)
? message.level
: 'info'
if (window.Quasar?.Notify) {
window.Quasar.Notify.create({
color: level,
message: String(message.message || '')
})
}
}
function rememberPaymentHashes(value) {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
value.forEach(rememberPaymentHashes)
return
}
for (const [key, item] of Object.entries(value)) {
if (
['paymentHash', 'payment_hash'].includes(key) &&
isPaymentHash(item)
) {
allowedPaymentHashes.add(item)
}
rememberPaymentHashes(item)
}
}
function isPaymentHash(value) {
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
}
function websocketUrl(path) {
const url = new URL(window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = path
url.search = ''
url.hash = ''
return url.toString()
}
function sendBridgeEvent(message) {
if (!bridgePort) return
bridgePort.postMessage({
type: 'lnbits-extension:event',
...message
})
}
function closePaymentSubscription(subscriptionId) {
const subscription = paymentSubscriptions.get(subscriptionId)
if (!subscription) return
paymentSubscriptions.delete(subscriptionId)
try {
subscription.socket.close()
} catch (_error) {}
}
function closePaymentSubscriptions() {
for (const subscriptionId of Array.from(paymentSubscriptions.keys())) {
closePaymentSubscription(subscriptionId)
}
}
function closeBridgePort() {
closePaymentSubscriptions()
bridgePort?.close()
bridgePort = null
}
function subscribePayment(message) {
const subscriptionId = String(message.subscriptionId || '')
const paymentHash = String(message.paymentHash || '')
if (!subscriptionId || !isPaymentHash(paymentHash)) {
throw new Error('Invalid payment subscription.')
}
if (!allowedPaymentHashes.has(paymentHash)) {
throw new Error('Payment subscription is not allowed.')
}
closePaymentSubscription(subscriptionId)
const socket = new WebSocket(
websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
)
paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
socket.addEventListener('message', event => {
let data = event.data
try {
data = JSON.parse(event.data)
} catch (_error) {}
sendBridgeEvent({
event: 'payment.update',
subscriptionId,
paymentHash,
data
})
if (
data &&
typeof data === 'object' &&
(data.pending === false ||
['success', 'settled', 'paid'].includes(String(data.status || '')))
) {
sendBridgeEvent({
event: 'payment.settled',
subscriptionId,
paymentHash,
data
})
closePaymentSubscription(subscriptionId)
}
})
socket.addEventListener('error', () => {
sendBridgeEvent({
event: 'payment.error',
subscriptionId,
paymentHash
})
closePaymentSubscription(subscriptionId)
})
socket.addEventListener('close', () => {
paymentSubscriptions.delete(subscriptionId)
})
}
async function handleBridgeRequest(message, reply) {
if (!message || message.type !== 'lnbits-extension:request') return
try {
if (message.action === 'context') {
sendResponse(reply, message.id, {
ok: true,
data: {
extensionId: bridge.extensionId,
public: bridge.public,
routeParams: bridge.routeParams,
query: bridge.query
}
})
return
}
if (message.action === 'api') {
sendResponse(reply, message.id, {
ok: true,
data: await callApi(message)
})
return
}
if (message.action === 'ui.notify') {
notify(message)
sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'payment.subscribe') {
subscribePayment(message)
sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
if (message.action === 'payment.unsubscribe') {
closePaymentSubscription(String(message.subscriptionId || ''))
sendResponse(reply, message.id, {
ok: true,
data: {ok: true}
})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
sendResponse(reply, message.id, {
ok: false,
error: error instanceof Error ? error.message : String(error)
})
}
}
window.addEventListener('message', event => {
if (event.source !== extensionFrameWindow()) return
const message = event.data
if (!message || message.type !== 'lnbits-extension:connect') return
const port = event.ports?.[0]
if (!port) return
closeBridgePort()
bridgePort = port
bridgePort.addEventListener('message', portEvent => {
handleBridgeRequest(portEvent.data, response => {
port.postMessage(response)
})
})
bridgePort.start()
bridgePort.postMessage({
type: 'lnbits-extension:connected',
id: message.id
})
})
window.addEventListener('load', startExtensionFrameLoader)
})()
</script>
{% endblock %}
+6 -17
View File
@@ -1,13 +1,8 @@
from __future__ import annotations
import asyncio
from time import time
from typing import Any, NamedTuple
from loguru import logger
from lnbits.settings import settings
class Cached(NamedTuple):
value: Any
@@ -22,8 +17,7 @@ class Cache:
Small caching utility providing simple get/set interface (very much like redis)
"""
def __init__(self, interval: float = 10) -> None:
self.interval = interval
def __init__(self) -> None:
self._values: dict[Any, Cached] = {}
def value(self, key: str) -> Cached | None:
@@ -59,16 +53,11 @@ class Cache:
self.set(key, value, expiry=expiry)
return value
async def invalidate_forever(self):
while settings.lnbits_running:
try:
await asyncio.sleep(self.interval)
ts = time()
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
except Exception:
logger.error("Error invalidating cache")
async def invalidate_cache(self):
ts = time()
expired = [k for k, v in self._values.items() if v.expiry < ts]
for k in expired:
self._values.pop(k)
cache = Cache()
+625 -5
View File
@@ -11,13 +11,29 @@ import hashlib
import itertools
import json
import ssl
from collections.abc import Callable
import struct
from collections.abc import Callable, Coroutine
from typing import Any
from urllib.parse import urlparse
from embit.networks import NETWORKS
from embit.script import Script
from embit.transaction import Transaction as EmbitTransaction
from loguru import logger
from pydantic import BaseModel
DEFAULT_NETWORK = NETWORKS["main"]
def network_from_name(name: str) -> dict:
"""Look up an embit network dict (see embit.networks.NETWORKS) by name."""
try:
return NETWORKS[name]
except KeyError as exc:
raise ValueError(
f"Unknown network {name!r}, expected one of {list(NETWORKS)}"
) from exc
class ElectrumError(Exception):
pass
@@ -28,6 +44,47 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
def address_to_scriptpubkey(address: str) -> bytes:
"""Convert a Bitcoin address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR) to scriptPubKey."""
try:
script = Script.from_address(address)
except Exception as exc:
raise ValueError(f"Invalid address: {address!r}") from exc
if script is None:
raise ValueError(f"Invalid address: {address!r}")
return script.data
def scripthash_from_address(address: str) -> str:
return scripthash_from_scriptpubkey(address_to_scriptpubkey(address))
_SCRIPT_TYPE_NAMES = {
"p2pkh": "pubkeyhash",
"p2sh": "scripthash",
"p2wpkh": "witness_v0_keyhash",
"p2wsh": "witness_v0_scripthash",
"p2tr": "witness_v1_taproot",
}
def _scriptpubkey_info(spk: bytes, network: dict) -> tuple[str, str | None]:
"""Return (type, address_or_None) for a scriptPubKey."""
n = len(spk)
# P2PK (not classified by embit)
if n in (35, 67) and spk[-1] == 0xAC:
return "pubkey", None
# OP_RETURN (not classified by embit)
if n >= 1 and spk[0] == 0x6A:
return "nulldata", None
script = Script(spk)
script_type = script.script_type()
if script_type is None:
return "nonstandard", None
return _SCRIPT_TYPE_NAMES[script_type], script.address(network)
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
@@ -105,6 +162,145 @@ class ServerFeatures(BaseModel):
hosts: dict[str, Any] = {}
class ScriptSig(BaseModel):
hex: str
class ScriptPubKey(BaseModel):
hex: str
type: str
address: str | None = None
class TxInput(BaseModel):
txid: str | None = None
vout: int | None = None
scriptSig: ScriptSig | None = None # noqa: N815
sequence: int
coinbase: str | None = None
class TxOutput(BaseModel):
value: float
n: int
scriptPubKey: ScriptPubKey # noqa: N815
class Transaction(BaseModel):
txid: str
version: int
locktime: int
vin: list[TxInput]
vout: list[TxOutput]
size: int
vsize: int
weight: int
hex: str
class FeeResponse(BaseModel):
estimates: dict[str, float]
histogram: list[FeeHistogramEntry]
class AddressResponse(BaseModel):
balance: Balance
history: list[HistoryEntry]
history_error: str | None = None
class BlockInfo(BaseModel):
height: int
hash: str
timestamp: int
version: int
bits: str
nonce: int
prev_hash: str
merkle_root: str
def parse_block_header(header_hex: str, height: int) -> BlockInfo:
"""Parse an 80-byte block header hex string into a BlockInfo model."""
data = bytes.fromhex(header_hex)
version = struct.unpack_from("<I", data, 0)[0]
prev_hash = data[4:36][::-1].hex()
merkle_root = data[36:68][::-1].hex()
timestamp = struct.unpack_from("<I", data, 68)[0]
bits = format(struct.unpack_from("<I", data, 72)[0], "08x")
nonce = struct.unpack_from("<I", data, 76)[0]
block_hash = hashlib.sha256(hashlib.sha256(data).digest()).digest()[::-1].hex()
return BlockInfo(
height=height,
hash=block_hash,
timestamp=timestamp,
version=version,
bits=bits,
nonce=nonce,
prev_hash=prev_hash,
merkle_root=merkle_root,
)
def parse_raw_tx(hex_str: str, network: dict | None = None) -> Transaction:
"""Parse a raw transaction hex string into a Transaction model."""
network = network or DEFAULT_NETWORK
data = bytes.fromhex(hex_str)
tx = EmbitTransaction.parse(data)
vin: list[TxInput] = []
for inp in tx.vin:
if inp.txid == b"\x00" * 32 and inp.vout == 0xFFFFFFFF:
vin.append(
TxInput(sequence=inp.sequence, coinbase=inp.script_sig.data.hex())
)
else:
vin.append(
TxInput(
txid=inp.txid.hex(),
vout=inp.vout,
scriptSig=ScriptSig(hex=inp.script_sig.data.hex()),
sequence=inp.sequence,
)
)
vout: list[TxOutput] = []
for n_out, out in enumerate(tx.vout):
spk_type, address = _scriptpubkey_info(out.script_pubkey.data, network)
vout.append(
TxOutput(
value=round(out.value / 1e8, 8),
n=n_out,
scriptPubKey=ScriptPubKey(
hex=out.script_pubkey.data.hex(), type=spk_type, address=address
),
)
)
if tx.is_segwit:
# base (non-witness) size = full size minus the segwit marker/flag
# (2 bytes) and each input's witness stack
witness_bytes = sum(len(inp.witness.serialize()) for inp in tx.vin)
base_size = len(data) - 2 - witness_bytes
weight = base_size * 3 + len(data)
vsize = (weight + 3) // 4
else:
weight = len(data) * 4
vsize = len(data)
return Transaction(
txid=tx.txid().hex(),
version=tx.version,
locktime=tx.locktime,
vin=vin,
vout=vout,
size=len(data),
vsize=vsize,
weight=weight,
hex=hex_str,
)
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
@@ -135,6 +331,7 @@ class ElectrumClient:
client_name: str = "lnbits",
protocol_version: str = "1.4",
ping_interval: float = 60.0,
network: dict | None = None,
) -> None:
parsed = urlparse(url)
self.host = parsed.hostname or ""
@@ -145,6 +342,7 @@ class ElectrumClient:
self.client_name = client_name
self.protocol_version = protocol_version
self.ping_interval = ping_interval
self.network = network or DEFAULT_NETWORK
self._counter = itertools.count(1)
self._pending: dict[int, asyncio.Future[Any]] = {}
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
@@ -152,6 +350,7 @@ class ElectrumClient:
self._ping_task: asyncio.Task[None] | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self.closed: asyncio.Event = asyncio.Event()
self.server_version: str = ""
self.negotiated_protocol: str = ""
@@ -286,6 +485,7 @@ class ElectrumClient:
except Exception:
logger.exception("Electrum: recv loop error")
finally:
self.closed.set()
for fut in self._pending.values():
if not fut.done():
fut.set_exception(ElectrumError("Connection closed"))
@@ -381,10 +581,9 @@ class ElectrumClient:
"""Broadcast a raw transaction hex; returns txid on success."""
return await self._call("blockchain.transaction.broadcast", [raw_tx])
async def get_transaction(
self, txid: str, verbose: bool = False
) -> str | dict[str, Any]:
return await self._call("blockchain.transaction.get", [txid, verbose])
async def get_transaction(self, txid: str) -> str:
"""Fetch raw transaction hex by txid."""
return await self._call("blockchain.transaction.get", [txid])
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
@@ -437,3 +636,424 @@ class ElectrumClient:
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
data = await self._call("mempool.get_fee_histogram")
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
# ---------------------------------------------------------------------------
# Address tracking
# ---------------------------------------------------------------------------
class OnchainAddressEvent(BaseModel):
address: str
confirmed: int # satoshis
unconfirmed: int # satoshis
history: list[HistoryEntry] = []
history_error: str | None = None
@property
def txids(self) -> list[str]:
return [e.tx_hash for e in self.history]
class AddressTracker:
"""
Subscribes to a set of Bitcoin addresses over a single shared Electrum
connection and calls a callback on every balance/history change.
Addresses can be added/removed at runtime via :meth:`add`/:meth:`remove`,
and per-connection queues can be attached via :meth:`register_queue` for
consumers (e.g. websockets) that want events for one specific address.
Reconnects automatically on failure.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._ref_counts: dict[str, int] = {}
self._queues: dict[str, list[asyncio.Queue[OnchainAddressEvent]]] = {}
self._updated = asyncio.Event()
def add(self, address: str) -> None:
"""Start tracking an address on the shared connection (ref-counted)."""
count = self._ref_counts.get(address, 0)
self._ref_counts[address] = count + 1
if count == 0:
self._updated.set()
def remove(self, address: str) -> None:
"""Decrement ref count; drop the subscription once the last caller leaves."""
count = self._ref_counts.get(address, 0)
if count <= 1:
self._ref_counts.pop(address, None)
self._updated.set()
else:
self._ref_counts[address] = count - 1
def register_queue(
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
) -> None:
"""Register a per-connection queue to receive events for `address`."""
self._queues.setdefault(address, []).append(queue)
self.add(address)
def unregister_queue(
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
) -> None:
"""Deregister a per-connection queue for `address`."""
queues = self._queues.get(address, [])
if queue in queues:
queues.remove(queue)
if not queues:
self._queues.pop(address, None)
self.remove(address)
async def run(
self,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
await self._run_once(callback, is_active)
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(f"AddressTracker: {exc!s}, retrying in 5s")
await asyncio.sleep(5)
async def _run_once(
self,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
async with ElectrumClient(self.url) as client:
subscribed: dict[str, str] = {} # scripthash -> address
async def on_status_change(params: list[Any]) -> None:
if not params:
return
address = subscribed.get(params[0])
if address:
await self._fetch_and_dispatch(client, address, params[0], callback)
client.on("blockchain.scripthash.subscribe", on_status_change)
while is_active():
self._updated.clear()
await self._sync_subscriptions(client, subscribed, callback)
if await self._wait_for_change_or_close(client):
break # connection closed; reconnect
async def _sync_subscriptions(
self,
client: ElectrumClient,
subscribed: dict[str, str],
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
) -> None:
wanted = {a: scripthash_from_address(a) for a in self._ref_counts}
for address, scripthash in wanted.items():
if scripthash not in subscribed:
subscribed[scripthash] = address
await client.subscribe_scripthash(scripthash)
await self._fetch_and_dispatch(client, address, scripthash, callback)
still_wanted = set(wanted.values())
for scripthash, address in list(subscribed.items()):
if address not in still_wanted:
del subscribed[scripthash]
try:
await client.unsubscribe_scripthash(scripthash)
except ElectrumError:
# blockchain.scripthash.unsubscribe is part of the spec but
# many ElectrumX deployments don't implement it, returning
# "unknown method". This is expected and harmless: we've
# already dropped the scripthash from `subscribed` above,
# so if the server keeps pushing notifications for it
# anyway, on_status_change() looks it up, finds nothing,
# and drops them. Not logged since it fires on every
# untrack against these servers.
pass
async def _wait_for_change_or_close(self, client: ElectrumClient) -> bool:
"""Waits until addresses change or the connection closes; returns True
if it was the connection that closed."""
wait_task = asyncio.create_task(self._updated.wait())
closed_task = asyncio.create_task(client.closed.wait())
try:
done, _ = await asyncio.wait(
[wait_task, closed_task],
timeout=30,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for t in (wait_task, closed_task):
if not t.done():
t.cancel()
return closed_task in done
async def _fetch_and_dispatch(
self,
client: ElectrumClient,
address: str,
scripthash: str,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
) -> None:
balance_r, history_r, mempool_r = await asyncio.gather(
client.get_balance(scripthash),
client.get_history(scripthash),
client.get_mempool(scripthash),
return_exceptions=True,
)
if isinstance(balance_r, BaseException):
raise balance_r
history: list[HistoryEntry] = (
[] if isinstance(history_r, BaseException) else history_r
)
history_error: str | None = (
str(history_r) if isinstance(history_r, BaseException) else None
)
if not isinstance(mempool_r, BaseException):
seen = {e.tx_hash for e in history}
for m in mempool_r:
if m.tx_hash not in seen:
history.append(HistoryEntry(tx_hash=m.tx_hash, height=0, fee=m.fee))
event = OnchainAddressEvent(
address=address,
confirmed=balance_r.confirmed,
unconfirmed=balance_r.unconfirmed,
history=history,
history_error=history_error,
)
for q in list(self._queues.get(address, [])):
q.put_nowait(event)
await callback(event)
# ---------------------------------------------------------------------------
# Transaction tracking
# ---------------------------------------------------------------------------
class OnchainTxEvent(BaseModel):
txid: str
confirmed: bool
height: int | None = None
fee: int | None = None
def tx_watch_scripthash(tx: Transaction) -> str | None:
"""Return the scripthash of the first spendable output, used to subscribe
for confirmation notifications."""
for out in tx.vout:
if out.scriptPubKey.type != "nulldata":
return scripthash_from_scriptpubkey(bytes.fromhex(out.scriptPubKey.hex))
return None
class TransactionTracker:
"""
Subscribes to a Bitcoin transaction via Electrum and calls a callback on
each status change (unconfirmed confirmed). Stops automatically once
the transaction is confirmed or ``is_active()`` returns ``False``.
Per-connection queues can be attached via :meth:`register_queue` for
consumers (e.g. websockets) that want events for this transaction.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._queues: list[asyncio.Queue[OnchainTxEvent]] = []
def register_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
"""Register a per-connection queue to receive events for this tx."""
self._queues.append(queue)
def unregister_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
"""Deregister a per-connection queue."""
if queue in self._queues:
self._queues.remove(queue)
def has_queues(self) -> bool:
return bool(self._queues)
async def track(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
confirmed = await self._track_once(txid, callback, is_active)
if confirmed:
return
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(
f"TransactionTracker {txid[:8]}: {exc!s}, retrying in 5s"
)
await asyncio.sleep(5)
async def _track_once(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> bool:
"""One connection attempt; returns True if the tx is confirmed."""
async with ElectrumClient(self.url) as client:
try:
raw = await client.get_transaction(txid)
except ElectrumError as exc:
logger.warning(f"TransactionTracker {txid[:8]}: {exc!s}")
await asyncio.sleep(10)
return False
scripthash = tx_watch_scripthash(parse_raw_tx(raw))
confirmed_event = asyncio.Event()
async def on_change(
params: list[Any],
_sh: str | None = scripthash,
_done: asyncio.Event = confirmed_event,
) -> None:
if params and params[0] == _sh:
ev = await self._fetch_status(client, txid, _sh)
await self._dispatch(ev, callback)
if ev.confirmed:
_done.set()
if scripthash:
await client.subscribe_scripthash(scripthash, on_change)
event = await self._fetch_status(client, txid, scripthash)
await self._dispatch(event, callback)
if event.confirmed:
return True
while is_active() and not confirmed_event.is_set():
try:
await asyncio.wait_for(client.closed.wait(), timeout=30)
break # connection closed; reconnect
except asyncio.TimeoutError:
pass
return confirmed_event.is_set()
async def _dispatch(
self,
event: OnchainTxEvent,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
) -> None:
for q in list(self._queues):
q.put_nowait(event)
await callback(event)
@staticmethod
async def _fetch_status(
client: ElectrumClient, txid: str, scripthash: str | None
) -> OnchainTxEvent:
if scripthash:
try:
for entry in await client.get_history(scripthash):
if entry.tx_hash == txid:
return OnchainTxEvent(
txid=txid,
confirmed=entry.height > 0,
height=entry.height if entry.height > 0 else None,
fee=entry.fee,
)
except ElectrumError:
try:
for m in await client.get_mempool(scripthash):
if m.tx_hash == txid:
return OnchainTxEvent(txid=txid, confirmed=False, fee=m.fee)
return OnchainTxEvent(txid=txid, confirmed=True)
except ElectrumError:
pass
return OnchainTxEvent(txid=txid, confirmed=False)
# ---------------------------------------------------------------------------
# Block tracking
# ---------------------------------------------------------------------------
class BlockTracker:
"""
Subscribes to new block headers via Electrum and dispatches them to
registered queues. Per-connection queues can be attached via
:meth:`register_queue`. Reconnects automatically on failure.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._queues: list[asyncio.Queue[BlockInfo]] = []
def register_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
"""Register a per-connection queue to receive new block events."""
self._queues.append(queue)
def unregister_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
"""Deregister a per-connection queue."""
if queue in self._queues:
self._queues.remove(queue)
def has_queues(self) -> bool:
return bool(self._queues)
async def run(
self,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
await self._run_once(callback, is_active)
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(f"BlockTracker: {exc!s}, retrying in 5s")
await asyncio.sleep(5)
async def _run_once(
self,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
async with ElectrumClient(self.url) as client:
async def on_header(params: list[Any]) -> None:
h = params[0]
event = parse_block_header(h["hex"], h["height"])
await self._dispatch(event, callback)
tip = await client.subscribe_headers(on_header)
await self._dispatch(parse_block_header(tip.hex, tip.height), callback)
while is_active():
try:
await asyncio.wait_for(client.closed.wait(), timeout=30)
break # connection closed; reconnect
except asyncio.TimeoutError:
pass
async def _dispatch(
self,
event: BlockInfo,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
) -> None:
for q in list(self._queues):
q.put_nowait(event)
await callback(event)
+25
View File
@@ -289,7 +289,32 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
return apply_trimmed_mean_filter(all_rates)
async def btc_price_from_aggregator(currency: str) -> float | None:
url = settings.lnbits_price_aggregator_url.rstrip("/")
try:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
r = await client.get(f"{url}/rate/{currency.upper()}", timeout=3)
r.raise_for_status()
data = r.json()
median = data.get("rates", {}).get("median")
if median:
return float(median)
except Exception as e:
logger.warning(f"Failed to fetch price from aggregator {url}: {e}")
return None
async def btc_price(currency: str) -> float:
if (
settings.lnbits_price_aggregator_enabled
and settings.lnbits_price_aggregator_url
):
price = await btc_price_from_aggregator(currency)
if price:
return price
logger.warning("Price aggregator failed, falling back to exchange providers.")
rates = await btc_rates(currency)
if not rates:
logger.warning("Could not fetch any Bitcoin price.")
+4 -7
View File
@@ -41,19 +41,16 @@ def log_server_info():
def initialize_server_websocket_logger() -> Callable:
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
serverlog_queue: asyncio.Queue = asyncio.Queue()
async def update_websocket_serverlog():
while settings.lnbits_running:
msg = await serverlog_queue.get()
await websocket_updater(super_user_hash, msg)
logger.add(
lambda msg: serverlog_queue.put_nowait(msg),
format=Formatter().format,
)
async def update_websocket_serverlog():
msg = await serverlog_queue.get()
await websocket_updater(super_user_hash, msg)
return update_websocket_serverlog
+921 -138
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -111,6 +111,7 @@
"js/pages/users.js",
"js/pages/account.js",
"js/pages/admin.js",
"js/components/admin/lnbits-admin-funding-seed-backup.js",
"js/components/admin/lnbits-admin-funding.js",
"js/components/admin/lnbits-admin-funding-sources.js",
"js/components/admin/lnbits-admin-fiat-providers.js",
@@ -123,6 +124,8 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/admin/lnbits-admin-blockexplorer.js",
"js/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",
Generated
+4 -29
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -2147,7 +2147,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rpds-py = ">=0.25.0"
@@ -2308,7 +2308,7 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
[[package]]
name = "markdown-it-py"
@@ -4739,31 +4739,6 @@ files = [
{file = "wallycore-1.5.2.tar.gz", hash = "sha256:352b7b215046d4fd0333a82dc4b3936b749e5b9ae22a078b991a6b10712b3748"},
]
[[package]]
name = "wasmtime"
version = "46.0.1"
description = "A WebAssembly runtime powered by Wasmtime"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "wasmtime-46.0.1-py3-none-android_26_arm64_v8a.whl", hash = "sha256:cc8d52f9ad3bedc1e4de5002f7b22d7cac400be046711d177f6ce20a11eacb31"},
{file = "wasmtime-46.0.1-py3-none-android_26_x86_64.whl", hash = "sha256:f8e4b0ec402b84b856d3c6c2f96c6e6889c56e685b5cfed0a4040775c751ca38"},
{file = "wasmtime-46.0.1-py3-none-any.whl", hash = "sha256:85a092a63c20ccecb965b9aa12a19368d2e06203436d19701068efc390efa678"},
{file = "wasmtime-46.0.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9b46c546bf73ece2600403db7dc604c3ef12046ccf2fabe07d7bfaa00453ce8b"},
{file = "wasmtime-46.0.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:de1a69573a173b5171f9413bcf0b88f4fed2721ed02c842fc25de5358730ccdf"},
{file = "wasmtime-46.0.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:e53c65abe31aeeb19a3f794b6e53140d401c4c79ad91c89caefdc502ee2b10c1"},
{file = "wasmtime-46.0.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:841b53fc17eedabaa6deb1e062a04a0a8953908d540fadb4149bc55c3f6d3e50"},
{file = "wasmtime-46.0.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f295d10012b6ca6ecffa7757eed70b84ebaa2e33dc39275e7b6bed5eed5130b9"},
{file = "wasmtime-46.0.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:05fc65164b4825bf1c3c8f007df070b25accc974cb81d0bfc1c5d76fdf6f70e0"},
{file = "wasmtime-46.0.1-py3-none-win_amd64.whl", hash = "sha256:559b0753e3ea311fd16000fe51c08592a625e61ebb8640601ae7173fc516e430"},
{file = "wasmtime-46.0.1-py3-none-win_arm64.whl", hash = "sha256:967625406fde8fc3c9d795ffbb7bdde77d0a38de776e8eb7a0416ca6d811a27a"},
{file = "wasmtime-46.0.1.tar.gz", hash = "sha256:0da0388c21bc0f0e633c7a30f2b7939a657f5019258c9a3a63fd37298a0dbb8b"},
]
[package.extras]
testing = ["coverage", "pycparser", "pytest", "pytest-mypy"]
[[package]]
name = "websocket-client"
version = "1.9.0"
@@ -5148,4 +5123,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "3097673d0cd279b0bf2b8fa59c1e523273f63d430f2c68ccc268a3cf232068af"
content-hash = "7c70bdad0089089d383cf8f54c37c4473180cea175689b91322beaed1ab42e94"
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.5"
version = "1.5.6"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -53,7 +53,6 @@ dependencies = [
"greenlet~=3.3.0",
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
"wasmtime>=45.0.0",
]
[project.scripts]
+29 -1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.core.crud.settings import get_settings_field, set_settings_field
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -81,7 +82,8 @@ async def test_admin_audit_monitor_and_test_email(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
task_names = [t["name"] for t in monitor.json()]
assert any("invoice_listener" in name for name in task_names)
test_email = await client.get(
"/admin/api/v1/testemail",
@@ -150,6 +152,15 @@ async def test_admin_partial_reset_restart_and_backup(
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
await set_settings_field("lnbits_site_title", "Reset me")
await set_settings_field("lnbits_backend_wallet_class", "BoltzWallet")
await set_settings_field("boltz_mnemonic", "keep boltz seed")
await set_settings_field("boltz_mnemonic_backup_confirmed", True)
await set_settings_field("phoenixd_mnemonic", "keep phoenixd seed")
await set_settings_field("phoenixd_mnemonic_backup_confirmed", True)
await set_settings_field("spark_l2_mnemonic", "keep spark seed")
await set_settings_field("spark_l2_mnemonic_backup_confirmed", True)
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
@@ -157,4 +168,21 @@ async def test_admin_delete_settings_requires_superuser(
)
assert response.status_code == 200
assert server_restart.is_set() is True
assert await get_settings_field("lnbits_site_title") is None
backend_wallet = await get_settings_field("lnbits_backend_wallet_class")
boltz_seed = await get_settings_field("boltz_mnemonic")
boltz_confirmed = await get_settings_field("boltz_mnemonic_backup_confirmed")
phoenixd_seed = await get_settings_field("phoenixd_mnemonic")
phoenixd_confirmed = await get_settings_field("phoenixd_mnemonic_backup_confirmed")
spark_l2_seed = await get_settings_field("spark_l2_mnemonic")
spark_l2_confirmed = await get_settings_field("spark_l2_mnemonic_backup_confirmed")
assert backend_wallet and backend_wallet.value == "BoltzWallet"
assert boltz_seed and boltz_seed.value == "keep boltz seed"
assert boltz_confirmed and boltz_confirmed.value is True
assert phoenixd_seed and phoenixd_seed.value == "keep phoenixd seed"
assert phoenixd_confirmed and phoenixd_confirmed.value is True
assert spark_l2_seed and spark_l2_seed.value == "keep spark seed"
assert spark_l2_confirmed and spark_l2_confirmed.value is True
server_restart.clear()
+9 -6
View File
@@ -13,10 +13,13 @@ from lnbits.core.services import (
fee_reserve_total,
get_balance_delta,
)
from lnbits.core.services.payments import pay_invoice, update_wallet_balance
from lnbits.core.services.payments import (
pay_invoice,
update_wallet_balance,
)
from lnbits.core.services.users import create_user_account
from lnbits.exceptions import PaymentError
from lnbits.tasks import create_task, wait_for_paid_invoices
from lnbits.task_manager import task_manager
from lnbits.wallets import get_funding_source
from ..helpers import is_fake, is_regtest
@@ -160,12 +163,11 @@ async def test_create_real_invoice(
assert not payment_status["paid"]
on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
pay_real_invoice(invoice["bolt11"])
await asyncio.sleep(1)
assert on_paid_mock.call_count == 1
payment = on_paid_mock.call_args_list[0][0][0]
@@ -393,12 +395,11 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert not payment_status["paid"]
on_paid_mock = mocker.AsyncMock()
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
pay_real_invoice(invoice["bolt11"])
await asyncio.sleep(1)
assert on_paid_mock.call_count == 1
payment = on_paid_mock.call_args_list[0][0][0]
@@ -412,6 +413,8 @@ async def test_receive_real_invoice_set_pending_and_check_state(
payment_status = response.json()
assert payment_status["paid"]
assert payment
# set the incoming invoice to pending
payment.status = PaymentState.PENDING
await update_payment(payment)
+24 -14
View File
@@ -5,6 +5,7 @@ import pytest
from pytest_mock.plugin import MockerFixture
from lnbits.settings import Settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import Cache, Cached
key = "foo"
@@ -13,11 +14,10 @@ value = "bar"
@pytest.fixture
async def cache():
cache = Cache(interval=0.1)
task = asyncio.create_task(cache.invalidate_forever())
cache = Cache()
task = task_manager.create_permanent_task(cache.invalidate_cache, interval=1)
yield cache
task.cancel()
task_manager.cancel_task(task)
@pytest.mark.anyio
@@ -31,13 +31,13 @@ async def test_cache_get_set(cache):
@pytest.mark.anyio
async def test_cache_expiry(cache):
# gets expired by `get` call
cache.set(key, value, expiry=0.01)
await asyncio.sleep(0.02)
cache.set(key, value, expiry=1)
await asyncio.sleep(2)
assert not cache.get(key)
# gets expired by invalidation task
cache.set(key, value, expiry=0.1)
await asyncio.sleep(0.2)
cache.set(key, value, expiry=1)
await asyncio.sleep(2)
assert key not in cache._values
assert not cache.get(key)
@@ -94,23 +94,33 @@ async def test_cache_pop_expired_returns_default(cache):
async def test_invalidate_forever_logs_and_recovers_from_errors(
settings: Settings, mocker: MockerFixture
):
test_cache = Cache(interval=0)
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
test_cache = Cache()
original_running = settings.lnbits_running
calls = 0
async def fake_sleep(_interval):
original_invalidate = test_cache.invalidate_cache
async def fake_invalidate():
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("boom")
settings.lnbits_running = False
await original_invalidate()
mocker.patch.object(test_cache, "invalidate_cache", side_effect=fake_invalidate)
mocker.patch("lnbits.task_manager.asyncio.sleep")
logger_error = mocker.patch("lnbits.task_manager.logger.error")
bg_task = None
try:
settings.lnbits_running = True
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep)
await test_cache.invalidate_forever()
bg_task = task_manager.create_permanent_task(test_cache.invalidate_cache)
await bg_task.task
finally:
settings.lnbits_running = original_running
if bg_task:
task_manager.cancel_task(bg_task)
logger_error.assert_called_once_with("Error invalidating cache")
assert logger_error.called
assert calls == 2
+4
View File
@@ -275,6 +275,10 @@ async def test_btc_rates_skips_unsupported_and_failing_providers(
@pytest.mark.anyio
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
mocker.patch(
"lnbits.utils.exchange_rates.btc_price_from_aggregator",
AsyncMock(return_value=None),
)
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
assert await btc_price("usd") == 0.0
+18 -8
View File
@@ -1433,7 +1433,7 @@ def test_check_revolut_signature_multiple_v1_headers():
check_revolut_signature(payload, sig_header, timestamp, secret)
def test_check_revolut_signature_docs_vector():
def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
payload = (
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
b'"new_state":"completed","old_state":"pending",'
@@ -1445,9 +1445,14 @@ def test_check_revolut_signature_docs_vector():
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
check_revolut_signature(
payload, sig, timestamp, secret, tolerance_seconds=100000000
# This is a fixed vector straight from Revolut's docs, so its timestamp is
# necessarily in the past. Freeze time to it instead of growing
# tolerance_seconds indefinitely as real time marches on.
mocker.patch(
"lnbits.core.services.fiat_providers.time.time",
return_value=int(timestamp) / 1000,
)
check_revolut_signature(payload, sig, timestamp, secret)
@pytest.mark.anyio
@@ -1717,7 +1722,9 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
"lnbits.core.services.fiat_providers.get_fiat_provider",
AsyncMock(return_value=provider),
)
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
queue_put = mocker.patch(
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
)
success_status = await check_fiat_status(
Payment(
@@ -1734,7 +1741,8 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
)
assert success_status.success is True
queue_put.assert_awaited_once_with("fiat_pending")
queue_put.assert_called_once()
assert queue_put.call_args[0][0].checking_id == "fiat_pending"
await check_fiat_status(
Payment(
@@ -1749,7 +1757,7 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
extra={"fiat_checking_id": "stripe_checking_id"},
)
)
assert queue_put.await_count == 1
assert queue_put.call_count == 1
@pytest.mark.anyio
@@ -1786,7 +1794,9 @@ async def test_check_fiat_status_persists_successful_payment(
"lnbits.fiat.StripeWallet.get_invoice_status",
AsyncMock(return_value=FiatPaymentStatus(paid=True)),
)
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
queue_put = mocker.patch(
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
)
status = await check_fiat_status(payment)
@@ -1794,7 +1804,7 @@ async def test_check_fiat_status_persists_successful_payment(
assert payment.status == PaymentState.SUCCESS
updated_payment = await get_payment(payment.checking_id)
assert updated_payment.status == PaymentState.SUCCESS
queue_put.assert_awaited_once_with(payment.checking_id)
queue_put.assert_called_once_with(payment)
@pytest.mark.anyio
+17 -12
View File
@@ -12,15 +12,12 @@ from lnbits.core.crud import create_wallet, get_standalone_payment, get_wallet
from lnbits.core.crud.payments import get_payment, get_payments_paginated
from lnbits.core.models import PaymentState, Wallet
from lnbits.core.services import create_invoice, create_user_account, pay_invoice
from lnbits.core.services.payments import update_wallet_balance
from lnbits.core.services.payments import (
update_wallet_balance,
)
from lnbits.exceptions import InvoiceError, PaymentError
from lnbits.settings import Settings
from lnbits.tasks import (
create_task,
internal_invoice_listener,
internal_invoice_queue,
wait_for_paid_invoices,
)
from lnbits.task_manager import task_manager
from lnbits.wallets.base import PaymentResponse
from lnbits.wallets.fake import FakeWallet
@@ -237,24 +234,30 @@ async def test_notification_for_internal_payment(
test_name = "test_notification_for_internal_payment"
# Drain stale items left by session-scoped fixtures (e.g. update_wallet_balance)
while not internal_invoice_queue.empty():
while not task_manager.internal_invoice_queue.empty():
try:
internal_invoice_queue.get_nowait()
task_manager.internal_invoice_queue.get_nowait()
except asyncio.QueueEmpty:
break
on_paid_mock = mocker.AsyncMock()
create_task(internal_invoice_listener())
create_task(wait_for_paid_invoices(test_name, on_paid_mock)())
# create_task(internal_invoice_listener())
task_manager.register_invoice_listener(on_paid_mock, test_name)
payment = await create_invoice(
wallet_id=to_wallet.id,
amount=123,
memo=test_name,
webhook="http://test.404.lnbits.com",
)
await pay_invoice(
paid_payment = await pay_invoice(
wallet_id=to_wallet.id, payment_request=payment.bolt11, extra={"tag": "lnurlp"}
)
assert paid_payment.status == PaymentState.SUCCESS.value
assert paid_payment.bolt11 == payment.bolt11
assert paid_payment.amount == -123_000
await asyncio.sleep(1)
assert on_paid_mock.call_count == 1
@@ -264,6 +267,8 @@ async def test_notification_for_internal_payment(
assert _payment.status == PaymentState.SUCCESS.value
assert _payment.bolt11 == payment.bolt11
assert _payment.amount == 123_000
assert _payment.checking_id == payment.checking_id
updated_payment = await get_payment(_payment.checking_id)
assert (
updated_payment.webhook_status is not None
+3 -3
View File
@@ -197,8 +197,7 @@ async def test_update_wallet_balance_validates_credit_and_debit(
settings.lnbits_wallet_limit_max_balance = 0
queue_mock = mocker.patch(
"lnbits.tasks.internal_invoice_queue_put",
mocker.AsyncMock(),
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait",
)
await update_wallet_balance(wallet, 5)
@@ -212,7 +211,8 @@ async def test_update_wallet_balance_validates_credit_and_debit(
]
assert credit_payments
assert credit_payments[0].status == PaymentState.SUCCESS
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id)
queue_mock.assert_called_once()
assert queue_mock.call_args[0][0].checking_id == credit_payments[0].checking_id
@pytest.mark.anyio
+127 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import hashlib
import json
@@ -12,7 +13,7 @@ from Cryptodome.Util.Padding import pad, unpad
from websockets import ServerConnection
from websockets import serve as ws_serve
from lnbits.wallets.nwc import NWCWallet
from lnbits.wallets.nwc import NWCConnection, NWCWallet
from tests.wallets.helpers import (
WalletTest,
build_test_id,
@@ -99,6 +100,8 @@ async def handle( # noqa: C901
event,
)
await websocket.send(json.dumps(["EVENT", sub_id, event]))
elif 23195 in kinds:
assert sub_filter["authors"] == [mock_settings["service_public_key"]]
elif msg[0] == "EVENT":
event = msg[1]
decrypted_content = decrypt_content(
@@ -177,6 +180,129 @@ async def run(data: WalletTest):
await nwcwallet.cleanup()
@pytest.mark.anyio
async def test_nwc_rejects_event_from_unexpected_pubkey(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
attacker_private_key = PrivateKey()
attacker_public_key = attacker_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
try:
event = {
"kind": 23195,
"content": "{}",
"created_at": int(time.time()),
"tags": [["e", "request-event-id"]],
}
sign_event(attacker_public_key, attacker_private_key.secret.hex(), event)
with pytest.raises(Exception, match="Invalid event signature"):
await conn._on_event_message(["EVENT", "subid", event])
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_marks_pending_invoice_settled_only_once():
wallet = NWCWallet.__new__(NWCWallet)
wallet.pending_invoice_details = {"checking-id": {"checking_id": "checking-id"}}
wallet.pending_invoices = ["checking-id"]
wallet.paid_invoices_queue = asyncio.Queue(0)
wallet._mark_invoice_settled("checking-id", source="notification")
wallet._mark_invoice_settled("checking-id", source="notification")
assert wallet.paid_invoices_queue.qsize() == 1
assert await wallet.paid_invoices_queue.get() == "checking-id"
@pytest.mark.anyio
async def test_nwc_registers_notification_subscriptions(mocker):
async def _noop(*args, **kwargs):
return None
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
service_private_key = PrivateKey()
service_public_key = service_private_key.public_key.format().hex()[2:]
account_private_key = PrivateKey()
conn = NWCConnection(
service_public_key,
account_private_key.secret.hex(),
"ws://127.0.0.1:8555",
)
send_mock = mocker.patch.object(conn, "_send", mocker.AsyncMock())
try:
await conn._subscribe_to_notifications()
assert len(conn.notification_subscription_ids) == 2
assert len(conn.subscriptions) == 2
assert set(conn.subscriptions.keys()) == conn.notification_subscription_ids
assert all(
subscription["method"] == "notification_sub"
and subscription["event_id"] == subscription["sub_id"]
for subscription in conn.subscriptions.values()
)
assert send_mock.await_count == 2
finally:
await conn.close()
@pytest.mark.anyio
async def test_nwc_spreads_fallback_lookups_with_cooldown(mocker):
def _schedule_next_lookup(
invoice: dict[str, object], now: float | None = None
) -> None:
assert now is not None
invoice["next_lookup_at"] = now + 1
wallet = NWCWallet.__new__(NWCWallet)
wallet.shutdown = False
wallet.pending_invoices = ["checking-1", "checking-2"]
wallet.pending_invoice_details = {
"checking-1": {
"checking_id": "checking-1",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
"checking-2": {
"checking_id": "checking-2",
"next_lookup_at": 0.0,
"lookup_attempts": 0,
},
}
wallet.pending_invoices_lookup_cooldown = 1.0
wallet._is_shutting_down = lambda: False
wallet._payment_data_is_settled = lambda payment_data: False
wallet._cache_payment_data = lambda *args, **kwargs: None
wallet._schedule_next_lookup = _schedule_next_lookup
wallet.conn = mocker.Mock()
wallet.conn.get_info = mocker.AsyncMock()
wallet.conn.supports_method = mocker.Mock(return_value=True)
wallet.conn.call = mocker.AsyncMock(return_value={"settled_at": None})
sleep_mock = mocker.patch("lnbits.wallets.nwc.asyncio.sleep", mocker.AsyncMock())
await wallet._run_fallback_lookups(100.0)
assert wallet.conn.call.await_count == 2
sleep_mock.assert_awaited_once_with(1.0)
@pytest.mark.anyio
@pytest.mark.parametrize(
"test_data",
-310
View File
@@ -1,310 +0,0 @@
from __future__ import annotations
import argparse
from collections import defaultdict
from collections.abc import Sequence
from pathlib import Path
from types import UnionType
from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel
from lnbits.core.extensions import (
ExtensionAPI,
ExtensionAPIMethod,
get_extension_api_method,
list_extension_api_methods,
)
def generate_typescript_sdk(
api_cls: type[ExtensionAPI] | None = None,
method_ids: Sequence[str] | None = None,
) -> str:
api_cls = api_cls or ExtensionAPI
methods = _select_methods(api_cls, method_ids)
models = _collect_models(methods)
lines = [
"/* Generated by LNbits ExtensionAPI codegen. */",
"/* Do not edit by hand. */",
"",
"export type MaybePromise<T> = T | Promise<T>",
"",
]
for model in models:
lines.extend(_render_model_type(model))
lines.append("")
lines.extend(_render_method_metadata(methods))
lines.append("")
lines.extend(_render_host_type(methods))
lines.append("")
lines.extend(_render_sdk_type(methods))
lines.append("")
lines.extend(_render_create_sdk(methods))
return "\n".join(lines).rstrip() + "\n"
def write_typescript_sdk(
path: str | Path,
api_cls: type[ExtensionAPI] | None = None,
method_ids: Sequence[str] | None = None,
) -> None:
Path(path).write_text(
generate_typescript_sdk(api_cls, method_ids), encoding="utf-8"
)
def _select_methods(
api_cls: type[ExtensionAPI], method_ids: Sequence[str] | None
) -> list[ExtensionAPIMethod]:
if not method_ids:
return list_extension_api_methods(api_cls)
return [get_extension_api_method(method_id, api_cls) for method_id in method_ids]
def _collect_models(methods: Sequence[ExtensionAPIMethod]) -> list[type[BaseModel]]:
models: dict[str, type[BaseModel]] = {}
pending = [
model
for method in methods
for model in (method.request_model, method.response_model)
]
while pending:
model = pending.pop()
if model.__name__ in models:
continue
models[model.__name__] = model
for field in model.__fields__.values():
pending.extend(_nested_model_types(field.outer_type_))
return [models[name] for name in sorted(models)]
def _nested_model_types(type_: Any) -> list[type[BaseModel]]:
models: list[type[BaseModel]] = []
if _is_model_type(type_):
models.append(type_)
for arg in get_args(type_):
models.extend(_nested_model_types(arg))
return models
def _render_model_type(model: type[BaseModel]) -> list[str]:
name = _model_name(model)
fields = model.__fields__
if not fields:
return [f"export type {name} = Record<string, never>"]
lines = [f"export type {name} = {{"]
for field_name, field in fields.items():
optional = "?" if not field.required else ""
ts_type = _python_type_to_ts(field.outer_type_, field.allow_none)
lines.append(f" {_camel(field_name)}{optional}: {ts_type}")
lines.append("}")
return lines
def _python_type_to_ts(type_: Any, allow_none: bool = False) -> str:
origin = get_origin(type_)
args = get_args(type_)
if origin in (UnionType, Union):
ts = " | ".join(
_python_type_to_ts(arg) for arg in args if arg is not type(None)
)
if type(None) in args:
ts = f"{ts} | null"
return ts
if origin is Literal:
return " | ".join(_literal_to_ts(arg) for arg in args)
if _is_model_type(type_):
ts = _model_name(type_)
elif origin in (list, Sequence):
item_type = _python_type_to_ts(args[0]) if args else "unknown"
ts = f"{item_type}[]"
elif origin is dict:
key_type = _python_type_to_ts(args[0]) if args else "string"
value_type = _python_type_to_ts(args[1]) if len(args) > 1 else "unknown"
ts = (
f"Record<{key_type}, {value_type}>"
if key_type == "string"
else f"{{ [key: string]: {value_type} }}"
)
elif _is_subclass(type_, str):
ts = "string"
elif _is_subclass(type_, bool):
ts = "boolean"
elif _is_subclass(type_, int) or _is_subclass(type_, float):
ts = "number"
else:
ts = "unknown"
return f"{ts} | null" if allow_none else ts
def _literal_to_ts(value: Any) -> str:
if isinstance(value, str):
return f'"{value}"'
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return "null"
return str(value)
def _is_subclass(type_: Any, class_: type) -> bool:
try:
return isinstance(type_, type) and issubclass(type_, class_)
except TypeError:
return False
def _is_model_type(type_: Any) -> bool:
return _is_subclass(type_, BaseModel)
def _render_method_metadata(
methods: Sequence[ExtensionAPIMethod],
) -> list[str]:
lines = ["export const extensionApiMethods = ["]
for method in methods:
permission = (
f'"{method.required_permission}"' if method.required_permission else "null"
)
lines.extend(
[
" {",
f' id: "{method.method_id}",',
f' namespace: "{method.namespace}",',
f' sdkName: "{method.sdk_name}",',
f' pythonName: "{method.python_name}",',
f' hostName: "{method.host_name}",',
f' hostJsName: "{_camel(method.host_name)}",',
f" requiredPermission: {permission},",
" },",
]
)
lines.append("] as const")
return lines
def _render_host_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
lines = ["export type ExtensionHost = {"]
for method in sorted(methods, key=lambda item: item.host_name):
request = _model_name(method.request_model)
response = _model_name(method.response_model)
if _is_empty_model(method.request_model):
lines.append(f" {_camel(method.host_name)}(): MaybePromise<{response}>")
else:
lines.append(
f" {_camel(method.host_name)}"
f"(input: {request}): MaybePromise<{response}>"
)
lines.append("}")
return lines
def _render_sdk_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
namespaces = _methods_by_namespace(methods)
lines = ["export type ExtensionSdk = {"]
for namespace, namespace_methods in namespaces.items():
lines.append(f" {namespace}: {{")
for method in namespace_methods:
request = _model_name(method.request_model)
response = _model_name(method.response_model)
if _is_empty_model(method.request_model):
lines.append(f" {method.sdk_name}(): Promise<{response}>")
else:
lines.append(
f" {method.sdk_name}(input: {request}): Promise<{response}>"
)
lines.append(" }")
lines.append("}")
return lines
def _render_create_sdk(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
namespaces = _methods_by_namespace(methods)
lines = [
"export function createExtensionSdk(",
" host: ExtensionHost",
"): ExtensionSdk {",
" return {",
]
for namespace, namespace_methods in namespaces.items():
lines.append(f" {namespace}: {{")
for method in namespace_methods:
host_name = _camel(method.host_name)
if _is_empty_model(method.request_model):
signature = f"{method.sdk_name}()"
host_call = f"host.{host_name}()"
else:
signature = f"{method.sdk_name}(input)"
host_call = f"host.{host_name}(input)"
lines.extend(
[
f" async {signature} {{",
f" return {host_call}",
" },",
]
)
lines.append(" },")
lines.extend([" }", "}"])
return lines
def _methods_by_namespace(
methods: Sequence[ExtensionAPIMethod],
) -> dict[str, list[ExtensionAPIMethod]]:
namespaces: dict[str, list[ExtensionAPIMethod]] = defaultdict(list)
for method in sorted(methods, key=lambda item: (item.namespace, item.sdk_name)):
namespaces[method.namespace].append(method)
return dict(sorted(namespaces.items()))
def _model_name(model: type[BaseModel]) -> str:
return model.__name__
def _camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _is_empty_model(model: type[BaseModel]) -> bool:
return not model.__fields__
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate a TypeScript SDK from the LNbits ExtensionAPI."
)
parser.add_argument(
"--method",
action="append",
dest="method_ids",
help="ExtensionAPI method id to include. Can be passed multiple times.",
)
parser.add_argument(
"--out",
help="Output file. If omitted, the generated SDK is printed to stdout.",
)
args = parser.parse_args(argv)
sdk = generate_typescript_sdk(method_ids=args.method_ids)
if args.out:
Path(args.out).write_text(sdk, encoding="utf-8")
else:
print(sdk, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Generated
+1 -22
View File
@@ -1288,7 +1288,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.5.5"
version = "1.5.6"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1333,7 +1333,6 @@ dependencies = [
{ name = "urllib3" },
{ name = "uvicorn" },
{ name = "uvloop" },
{ name = "wasmtime" },
{ name = "websocket-client" },
{ name = "websockets" },
]
@@ -1423,7 +1422,6 @@ requires-dist = [
{ name = "uvicorn", specifier = "~=0.40.0" },
{ name = "uvloop", specifier = "~=0.22.1" },
{ name = "wallycore", marker = "extra == 'liquid'", specifier = "~=1.5.1" },
{ name = "wasmtime", specifier = ">=45.0.0" },
{ name = "websocket-client", specifier = "~=1.9.0" },
{ name = "websockets", specifier = "~=15.0.1" },
]
@@ -2860,25 +2858,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/e5/1c1d2979d074cba4f0a1516f2bf4c3ee66067b6ba3b96e5cb4460555d474/wallycore-1.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3343a1df11a7ef4572521e002f4550a0157aea2c749dcfd9be62fb5babe0d03", size = 1728038, upload-time = "2026-04-15T22:04:36.434Z" },
]
[[package]]
name = "wasmtime"
version = "45.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/ff/db9cfc61d988bc15303134bb174176a29839976876dfd18c3a12548ad291/wasmtime-45.0.0.tar.gz", hash = "sha256:2ad4bf7ca286ceea35c1e420d10b368d7f83faf9a5ffde87b4ee334a9b7f55f3", size = 128297, upload-time = "2026-05-26T17:57:39.131Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/56/7d941adba273210dcf4198266a47f472a5eeca20172005b443af71a9a3e7/wasmtime-45.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:4e843795b53e66c71313f2254731467372e5e1549227cf14accb9e2d57701c10", size = 8659052, upload-time = "2026-05-26T17:57:12.338Z" },
{ url = "https://files.pythonhosted.org/packages/3f/81/c4d81ebf3db8aa28f789a9569640f30790d5234c509a0234cd502aa2638b/wasmtime-45.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:35e713f907264e470f3bc9b592b81b8ed0f8f5651725d9f07a5d52beb0642e38", size = 9619373, upload-time = "2026-05-26T17:57:14.979Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c7/7594da7fa8a3bc5e765733ad57aac9b7b27262c4afa47521bd500e4a4574/wasmtime-45.0.0-py3-none-any.whl", hash = "sha256:6251ee5074a8b8bfaa98e6e99cb5d49d6d0f2320b3265d5aa6c2ee5df5fb4519", size = 8019034, upload-time = "2026-05-26T17:57:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/75/76/7d0e440ca03a717a97889dbb7b68f952c20ed4ffd3f59addf9553579e1d5/wasmtime-45.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:3579b0ec6d001750d66ec7089aaeee2c048f88328c82743e15f099af01b0cf84", size = 9401625, upload-time = "2026-05-26T17:57:22.149Z" },
{ url = "https://files.pythonhosted.org/packages/5b/0b/a81b5daf5adea482ecb68d9615f6a348486ab4d8e980a915d4420e57ee4d/wasmtime-45.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:31d10f25c330cebcfb364e9a357123deeec96c41725ff2bba91b705587f38a93", size = 8255954, upload-time = "2026-05-26T17:57:24.769Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8c/e9019a28e908214031310aefd78e4755221d02303190b54b2c85cb69573e/wasmtime-45.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:5d1416ec6da8cd87c29e2e9eb074358c91839c2fff971fe428c8921eaae68e73", size = 9681185, upload-time = "2026-05-26T17:57:26.641Z" },
{ url = "https://files.pythonhosted.org/packages/42/56/ed5f492bd553a31c8e28d621f8256f2c7b1a133b28f73525d96ca355891a/wasmtime-45.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a499f6ab0eebb70dca83d6a4904b743cd122f322af3abe86af08ad753533d946", size = 8582001, upload-time = "2026-05-26T17:57:28.883Z" },
{ url = "https://files.pythonhosted.org/packages/62/12/9b41740da83f51014b88181c9086de0ed75d736a5329baff7323c4fb6eff/wasmtime-45.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bef65282b7de744106a91da43e4d06ba19d2d587bc54abb83b3e757f0c4fc030", size = 8633462, upload-time = "2026-05-26T17:57:31.423Z" },
{ url = "https://files.pythonhosted.org/packages/ea/63/49d8317706a108d9ed1d4166d0fc710796da1b20e591a98a96575dec367a/wasmtime-45.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0b6ca14b4628a5d1ffa91ccf2c0f2c58fa171f126ec085d564b09d5795395dd", size = 9712524, upload-time = "2026-05-26T17:57:33.839Z" },
{ url = "https://files.pythonhosted.org/packages/20/71/8e31ea472ceb934e7261ac59a786e82cd82b4d4dcb7c870d498aa9c3c21e/wasmtime-45.0.0-py3-none-win_amd64.whl", hash = "sha256:1736a70a48f713aaf1a878514d29cc6f554213b5431e04447813a3b9b4320381", size = 8019039, upload-time = "2026-05-26T17:57:36.04Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d1/ac536e92ac95a02e137be5b6829f15b87d5eef93ace32e5ee8035155b839/wasmtime-45.0.0-py3-none-win_arm64.whl", hash = "sha256:ae9726590e6d90c6305b8b507c93468b145204d4390aa9a2e29e26babcae110e", size = 6845659, upload-time = "2026-05-26T17:57:37.696Z" },
]
[[package]]
name = "websocket-client"
version = "1.9.0"