From ee6ff7179ec57dcb1cb2a5ecb20557fba79a8ef1 Mon Sep 17 00:00:00 2001 From: Arc Date: Sun, 22 Feb 2026 01:19:19 +0000 Subject: [PATCH] bug fixes --- lnbits/core/models/extensions.py | 13 ++ lnbits/core/services/extensions.py | 16 +- lnbits/core/wasm/extension_host.py | 233 +++++++++++++++++++--- lnbits/core/wasm/runner.py | 303 ++++++++++++++++++++++++++++- lnbits/settings.py | 2 +- 5 files changed, 529 insertions(+), 38 deletions(-) diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index 5bbd94c07..1f10c7f14 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -86,6 +86,7 @@ class ExtensionConfig(BaseModel): permissions: list[ExtensionPermission] = [] extension_type: str | None = "python" public_kv_keys: list[str] = [] + public_wasm_functions: list[str] = [] def is_version_compatible(self) -> bool: return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) @@ -164,6 +165,7 @@ class Extension(BaseModel): upgrade_hash: str | None = "" extension_type: str | None = None public_kv_keys: list[str] = [] + public_wasm_functions: list[str] = [] @property def module_name(self) -> str: @@ -189,6 +191,9 @@ class Extension(BaseModel): upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "", extension_type=ext_info.meta.extension_type if ext_info.meta else None, public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [], + public_wasm_functions=ext_info.meta.public_wasm_functions + if ext_info.meta + else [], ) @@ -353,6 +358,7 @@ class ExtensionMeta(BaseModel): permissions: list[ExtensionPermission] = [] extension_type: str | None = "python" public_kv_keys: list[str] = [] + public_wasm_functions: list[str] = [] archive: str | None = None featured: bool = False paid_features: str | None = None @@ -480,6 +486,9 @@ class InstallableExtension(BaseModel): self.meta.permissions = config_json.get("permissions", []) self.meta.extension_type = config_json.get("extension_type", "python") self.meta.public_kv_keys = config_json.get("public_kv_keys", []) + self.meta.public_wasm_functions = config_json.get( + "public_wasm_functions", [] + ) if ( self.meta @@ -601,6 +610,7 @@ class InstallableExtension(BaseModel): permissions=config.permissions, extension_type=config.extension_type, public_kv_keys=config.public_kv_keys, + public_wasm_functions=config.public_wasm_functions, ), ) except Exception as e: @@ -650,6 +660,9 @@ class InstallableExtension(BaseModel): permissions=config_json.get("permissions", []), extension_type=config_json.get("extension_type", "python"), public_kv_keys=config_json.get("public_kv_keys", []), + public_wasm_functions=config_json.get( + "public_wasm_functions", [] + ), ), ) diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index 58d21f7ba..7da80f8d6 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -17,7 +17,7 @@ from lnbits.core.crud.extensions import ( update_installed_extension, ) from lnbits.core.helpers import migrate_extension_database -from lnbits.db import Connection +from lnbits.db import Connection, Database, COCKROACH, POSTGRES from lnbits.settings import settings from ..models.extensions import Extension, ExtensionMeta, InstallableExtension @@ -74,10 +74,24 @@ async def uninstall_extension(ext_id: str): extension = await get_installed_extension(ext_id) if extension: + if extension.meta and extension.meta.extension_type == "wasm": + await _purge_wasm_extension_db(ext_id) extension.clean_extension_files() await delete_installed_extension(ext_id=ext_id) +async def _purge_wasm_extension_db(ext_id: str) -> None: + cleaned = await Database.clean_ext_db_files(ext_id) + if cleaned: + return + try: + db = Database(f"ext_{ext_id}") + if db.type in {POSTGRES, COCKROACH}: + await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE") # noqa: S608 + except Exception as exc: + logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}") + + async def activate_extension(ext: Extension): core_app_extra.register_new_ext_routes(ext) await update_installed_extension_state(ext_id=ext.code, active=True) diff --git a/lnbits/core/wasm/extension_host.py b/lnbits/core/wasm/extension_host.py index d123a5518..420f99695 100644 --- a/lnbits/core/wasm/extension_host.py +++ b/lnbits/core/wasm/extension_host.py @@ -49,6 +49,17 @@ def _ensure_kv_table(db: Database, ext_id: str) -> str: return query +def _ensure_secret_kv_table(db: Database, ext_id: str) -> str: + table = _secret_kv_table_name(db, ext_id) + query = f""" + CREATE TABLE IF NOT EXISTS {table} ( + key TEXT PRIMARY KEY, + value TEXT + ); + """ + return query + + _kv_schema_cache: dict[str, dict] = {} @@ -62,6 +73,16 @@ def _kv_table_name(db: Database, ext_id: str) -> str: return table +def _secret_kv_table_name(db: Database, ext_id: str) -> str: + table = f"{ext_id}.secret_kv" if db.schema else "secret_kv" + if ( + re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", table) + is None + ): + raise ValueError("Invalid secret KV table name") + return table + + def _load_kv_schema(ext_id: str) -> dict: if ext_id in _kv_schema_cache: return _kv_schema_cache[ext_id] @@ -84,6 +105,21 @@ def _load_kv_schema(ext_id: str) -> dict: return _kv_schema_cache[ext_id] +def _load_public_wasm_functions(ext_id: str) -> list[str]: + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + return [] + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + funcs = config_json.get("public_wasm_functions", []) + return funcs if isinstance(funcs, list) else [] + except Exception: + return [] + + def _schema_for_key(schema: dict, key: str) -> dict | None: if not schema: return None @@ -154,6 +190,46 @@ async def _kv_set(db: Database, ext_id: str, key: str, value: str) -> None: ) +async def _secret_kv_get(db: Database, ext_id: str, key: str) -> str | None: + await db.execute(_ensure_secret_kv_table(db, ext_id)) + table = _secret_kv_table_name(db, ext_id) + row: dict[str, Any] | None = await db.fetchone( + f"SELECT value FROM {table} WHERE key = :key", # noqa: S608 + {"key": key}, + ) + if not row: + return None + return row.get("value") + + +async def _secret_kv_set(db: Database, ext_id: str, key: str, value: str) -> None: + await db.execute(_ensure_secret_kv_table(db, ext_id)) + table = _secret_kv_table_name(db, ext_id) + existing: dict[str, Any] | None = await db.fetchone( + f"SELECT key FROM {table} WHERE key = :key", # noqa: S608 + {"key": key}, + ) + if existing: + await db.execute( + f"UPDATE {table} SET value = :value WHERE key = :key", # noqa: S608 + {"key": key, "value": value}, + ) + else: + await db.execute( + f"INSERT INTO {table} (key, value) VALUES (:key, :value)", # noqa: S608 + {"key": key, "value": value}, + ) + + +async def _secret_kv_delete(db: Database, ext_id: str, key: str) -> None: + await db.execute(_ensure_secret_kv_table(db, ext_id)) + table = _secret_kv_table_name(db, ext_id) + await db.execute( + f"DELETE FROM {table} WHERE key = :key", # noqa: S608 + {"key": key}, + ) + + async def _require_permission(user_id: str, ext_id: str, permission: str) -> None: user_ext = await get_user_extension(user_id, ext_id) if not user_ext or not user_ext.active: @@ -189,6 +265,87 @@ def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> No _register_kv_read_routes(router, ext_id, db, ext) _register_kv_write_routes(router, ext_id, db) _register_kv_increment_route(router, ext_id, db, ext) + _register_secret_routes(router, ext_id, db) + + +def _register_secret_routes(router: APIRouter, ext_id: str, db: Database) -> None: + @router.post("/api/v1/secret/{key}") + async def api_secret_set( + key: str, payload: dict, user: User = Depends(check_user_exists) + ): + await _require_permission(user.id, ext_id, "ext.db.read_write") + _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) + value = payload.get("value") + if value is None: + raise HTTPException(400, "Missing value") + await _secret_kv_set(db, ext_id, key, str(value)) + return {"key": key} + + @router.delete("/api/v1/secret/{key}") + async def api_secret_delete(key: str, user: User = Depends(check_user_exists)): + await _require_permission(user.id, ext_id, "ext.db.read_write") + _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) + await _secret_kv_delete(db, ext_id, key) + return {"key": key} + + +def _register_public_call_routes( + router: APIRouter, ext_id: str, db: Database, ext +) -> None: + @router.post("/api/v1/public/call/{handler}") + async def api_public_wasm_call(handler: str, payload: dict): + funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions( + ext_id + ) + if handler not in funcs: + raise HTTPException(404, "Handler not public") + _check_quota("public", ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min) + + request_id = int(time.time() * 1000) % 2147483647 + raw = payload.get("raw") + value = raw if isinstance(raw, str) else json.dumps(payload) + await _kv_set(db, ext_id, f"public_request:{request_id}", value) + await _kv_set(db, ext_id, "public_request", value) + + try: + await wasm_call( + ext_id, handler, [request_id], upgrade_hash=ext.upgrade_hash + ) + except WasmExecutionError as exc: + raise HTTPException(500, str(exc)) from exc + + response = await _kv_get(db, ext_id, f"public_response:{request_id}") + if response is None: + response = await _kv_get(db, ext_id, "public_response") + if response is None: + raise HTTPException(500, "No response") + try: + data = json.loads(response) + except Exception: + return {"raw": response} + + watch = payload.get("watch") if isinstance(payload, dict) else None + if isinstance(watch, dict) and isinstance(data, dict): + payment_hash = data.get("payment_hash") + store_key = watch.get("store_key") + tag = watch.get("tag") + handler_name = watch.get("handler") or "noop" + if ( + isinstance(payment_hash, str) + and isinstance(store_key, str) + and handler_name in funcs + ): + _start_payment_watch( + ext_id, + db, + payment_hash, + handler_name, + tag if isinstance(tag, str) else None, + store_key, + ext.upgrade_hash, + ) + + return data def _register_kv_read_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None: @@ -265,40 +422,53 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> raise HTTPException(400, "Missing payment_hash") await _require_permission(user.id, ext_id, "ext.payments.watch") await _require_permission(user.id, ext_id, "ext.db.read_write") - - queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}" - invoice_queue: asyncio.Queue = asyncio.Queue() - register_invoice_listener(invoice_queue, queue_name) - - async def _watch(): - try: - while True: - payment = await invoice_queue.get() - if payment.payment_hash != payment_hash: - continue - if tag: - extra = payment.extra or {} - if extra.get("tag") != tag: - continue - if payment.pending is False: - payload_json = json.dumps(payment.dict(exclude={"preimage"})) - await _kv_set(db, ext_id, store_key, payload_json) - await wasm_call( - ext_id, - handler, - [], - upgrade_hash=ext.upgrade_hash, - ) - return - except Exception: - return - finally: - unregister_invoice_listener(queue_name) - - task = asyncio.create_task(_watch()) + task = _start_payment_watch( + ext_id, db, payment_hash, handler, tag, store_key, ext.upgrade_hash + ) return {"ok": True, "task_id": id(task)} +def _start_payment_watch( + ext_id: str, + db: Database, + payment_hash: str, + handler: str, + tag: str | None, + store_key: str, + upgrade_hash: str | None, +) -> asyncio.Task: + queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}" + invoice_queue: asyncio.Queue = asyncio.Queue() + register_invoice_listener(invoice_queue, queue_name) + + async def _watch(): + try: + while True: + payment = await invoice_queue.get() + if payment.payment_hash != payment_hash: + continue + if tag: + extra = payment.extra or {} + if extra.get("tag") != tag: + continue + if payment.pending is False: + payload_json = json.dumps(payment.dict(exclude={"preimage"})) + await _kv_set(db, ext_id, store_key, payload_json) + await wasm_call( + ext_id, + handler, + [], + upgrade_hash=upgrade_hash, + ) + return + except Exception: + return + finally: + unregister_invoice_listener(queue_name) + + return asyncio.create_task(_watch()) + + def _register_proxy_routes( router: APIRouter, app, ext_id: str, proxy_block: str ) -> None: @@ -354,6 +524,7 @@ def register_wasm_ext_routes(app, ext) -> None: _register_pages_routes(router, ext_id) _register_kv_routes(router, ext_id, db, ext) + _register_public_call_routes(router, ext_id, db, ext) _register_watch_routes(router, ext_id, db, ext) _register_proxy_routes(router, app, ext_id, proxy_block) _mount_static(app, ext_id, ext.upgrade_hash) diff --git a/lnbits/core/wasm/runner.py b/lnbits/core/wasm/runner.py index 4460454ee..277565e1e 100644 --- a/lnbits/core/wasm/runner.py +++ b/lnbits/core/wasm/runner.py @@ -2,9 +2,11 @@ import asyncio import json import re import sys +import traceback from pathlib import Path from typing import cast +import httpx from wasmtime import ( Caller, Config, @@ -18,9 +20,11 @@ from wasmtime import ( ) from lnbits.db import Database +from lnbits.core.services import websocket_updater from lnbits.settings import settings _kv_schema_cache: dict[str, dict] = {} +_http_permissions_cache: dict[str, set[tuple[str, str]]] = {} def _load_kv_schema(ext_id: str) -> dict: @@ -52,6 +56,42 @@ def _schema_for_key(schema: dict, key: str) -> dict | None: return entry if isinstance(entry, dict) else None +def _load_http_permissions(ext_id: str) -> set[tuple[str, str]]: + if ext_id in _http_permissions_cache: + return _http_permissions_cache[ext_id] + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + _http_permissions_cache[ext_id] = set() + return _http_permissions_cache[ext_id] + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + permissions = config_json.get("permissions", []) + allowed: set[tuple[str, str]] = set() + if isinstance(permissions, list): + for perm in permissions: + perm_id = perm.get("id") if isinstance(perm, dict) else None + if not isinstance(perm_id, str): + continue + if not perm_id.startswith("api."): + continue + try: + method_part, path = perm_id.split(":", 1) + method = method_part.replace("api.", "").upper() + except ValueError: + continue + if not path.startswith("/"): + continue + allowed.add((method, path)) + _http_permissions_cache[ext_id] = allowed + return allowed + except Exception: + _http_permissions_cache[ext_id] = set() + return _http_permissions_cache[ext_id] + + def _coerce_schema_value(schema_entry: dict, value: str): value_type = schema_entry.get("type", "string") if value_type == "int": @@ -83,6 +123,16 @@ def _ensure_kv_table(db: Database, ext_id: str) -> str: """ +def _ensure_secret_kv_table(db: Database, ext_id: str) -> str: + table = _secret_kv_table_name(db, ext_id) + return f""" + CREATE TABLE IF NOT EXISTS {table} ( + key TEXT PRIMARY KEY, + value TEXT + ); + """ + + def _kv_table_name(db: Database, ext_id: str) -> str: table = f"{ext_id}.kv" if db.schema else "kv" if ( @@ -93,8 +143,25 @@ def _kv_table_name(db: Database, ext_id: str) -> str: return table +def _secret_kv_table_name(db: Database, ext_id: str) -> str: + table = f"{ext_id}.secret_kv" if db.schema else "secret_kv" + if ( + re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", table) + is None + ): + raise RuntimeError("Invalid secret KV table name") + return table + + def _get_memory(caller: Caller): - memory = caller.get_export("memory") # type: ignore[attr-defined] + memory = None + if hasattr(caller, "get_export"): + memory = caller.get_export("memory") # type: ignore[attr-defined] + if memory is None: + try: + memory = caller.get("memory") + except Exception: + memory = None if memory is None: raise RuntimeError("WASM module does not export memory") return memory @@ -107,7 +174,71 @@ def _read_bytes(caller: Caller, ptr: int, length: int) -> bytes: def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None: memory = _get_memory(caller) - memory.write(caller, data, ptr) + if isinstance(data, int): + data = str(data).encode() + elif isinstance(data, str): + data = data.encode() + try: + memory.write(caller, data, ptr) + except Exception as exc: + raise RuntimeError(f"memory.write failed for type={type(data)}") from exc + + +async def _ws_publish(ext_id: str, topic: str, payload: str) -> int: + if not topic.startswith(f"{ext_id}:"): + raise RuntimeError("WS topic must be namespaced to extension") + await websocket_updater(topic, payload) + return 1 + + +def _http_request( + ext_id: str, + caller: Caller, + method_ptr: int, + method_len: int, + path_ptr: int, + path_len: int, + body_ptr: int, + body_len: int, + key_ptr: int, + key_len: int, + out_ptr: int, + out_len: int, +) -> int: + method = _read_bytes(caller, method_ptr, method_len).decode(errors="ignore").upper() + path = _read_bytes(caller, path_ptr, path_len).decode(errors="ignore") + body = _read_bytes(caller, body_ptr, body_len) + if isinstance(body, int): + body = str(body).encode() + elif isinstance(body, str): + body = body.encode() + else: + body = bytes(body) + api_key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") + + if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: + raise RuntimeError("Unsupported method") + if not path.startswith("/") or "://" in path: + raise RuntimeError("Invalid path") + + allowed = _load_http_permissions(ext_id) + if (method, path) not in allowed: + raise RuntimeError("HTTP permission denied") + + base_url = settings.lnbits_baseurl.rstrip("/") + headers = {"accept": "application/json"} + if body_len > 0: + headers["content-type"] = "application/json" + if api_key: + headers["x-api-key"] = api_key + + with httpx.Client(base_url=base_url) as client: + resp = client.request(method, path, headers=headers, content=body) + data = resp.content or b"" + if out_len > 0: + data = data[: max(0, out_len)] + _write_bytes(caller, out_ptr, data) + return len(data) def _db_get( @@ -182,12 +313,74 @@ def _db_set( return len(value) +def _secret_db_get( + db: Database, + ext_id: str, + caller: Caller, + key_ptr: int, + key_len: int, + out_ptr: int, + out_len: int, +) -> int: + key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") + _run(db.execute(_ensure_secret_kv_table(db, ext_id))) + table = _secret_kv_table_name(db, ext_id) + row = _run( + db.fetchone( + f"SELECT value FROM {table} WHERE key = :key", # noqa: S608 + {"key": key}, + ) + ) + if not row: + return 0 + value = str(row.get("value", "")) + data = value.encode()[: max(0, out_len)] + _write_bytes(caller, out_ptr, data) + return len(data) + + +def _secret_db_set( + db: Database, + ext_id: str, + caller: Caller, + key_ptr: int, + key_len: int, + val_ptr: int, + val_len: int, +) -> int: + key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") + value = _read_bytes(caller, val_ptr, val_len).decode(errors="ignore") + _run(db.execute(_ensure_secret_kv_table(db, ext_id))) + table = _secret_kv_table_name(db, ext_id) + row = _run( + db.fetchone( + f"SELECT key FROM {table} WHERE key = :key", # noqa: S608 + {"key": key}, + ) + ) + if row: + _run( + db.execute( + f"UPDATE {table} SET value = :value WHERE key = :key", # noqa: S608 + {"key": key, "value": value}, + ) + ) + else: + _run( + db.execute( + f"INSERT INTO {table} (key, value) VALUES (:key, :value)", # noqa: S608 + {"key": key, "value": value}, + ) + ) + return len(value) + + def _load_module(module_path: Path, ext_id: str): config = Config() - config.consume_fuel = True + config.consume_fuel = settings.lnbits_wasm_fuel > 0 engine = Engine(config) store = Store(engine) - if hasattr(store, "add_fuel"): + if settings.lnbits_wasm_fuel > 0 and hasattr(store, "add_fuel"): store.add_fuel(settings.lnbits_wasm_fuel) # type: ignore[attr-defined] module = Module.from_file(engine, str(module_path)) db = Database(f"ext_{ext_id}") @@ -202,6 +395,16 @@ def _load_module(module_path: Path, ext_id: str): ) -> int: return _db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len) + def db_secret_get( + caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int + ) -> int: + return _secret_db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len) + + def db_secret_set( + caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int + ) -> int: + return _secret_db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len) + def linker_define(linker: Linker, module: str, name: str, func: Func) -> None: try: linker.define(store, module, name, func) @@ -220,6 +423,7 @@ def _load_module(module_path: Path, ext_id: str): [ValType.i32()], ), db_get, + access_caller=True, ), ) linker_define( @@ -233,6 +437,95 @@ def _load_module(module_path: Path, ext_id: str): [ValType.i32()], ), db_set, + access_caller=True, + ), + ) + linker_define( + linker, + "host", + "db_secret_get", + Func( + store, + FuncType( + [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], + [ValType.i32()], + ), + db_secret_get, + access_caller=True, + ), + ) + linker_define( + linker, + "host", + "db_secret_set", + Func( + store, + FuncType( + [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], + [ValType.i32()], + ), + db_secret_set, + access_caller=True, + ), + ) + linker_define( + linker, + "host", + "http_request", + Func( + store, + FuncType( + [ + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ValType.i32(), + ], + [ValType.i32()], + ), + lambda caller, method_ptr, method_len, path_ptr, path_len, body_ptr, body_len, key_ptr, key_len, out_ptr, out_len: _http_request( + ext_id, + caller, + method_ptr, + method_len, + path_ptr, + path_len, + body_ptr, + body_len, + key_ptr, + key_len, + out_ptr, + out_len, + ), + access_caller=True, + ), + ) + linker_define( + linker, + "host", + "ws_publish", + Func( + store, + FuncType( + [ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], + [ValType.i32()], + ), + lambda caller, topic_ptr, topic_len, payload_ptr, payload_len: _run( + _ws_publish( + ext_id, + _read_bytes(caller, topic_ptr, topic_len) + .decode(errors="ignore"), + _read_bytes(caller, payload_ptr, payload_len) + .decode(errors="ignore"), + ) + ), + access_caller=True, ), ) @@ -268,7 +561,7 @@ def main() -> int: else: result = func(store, *int_args) # type: ignore[operator] except Exception as exc: - payload = {"ok": False, "error": str(exc)} + payload = {"ok": False, "error": f"{exc}\n{traceback.format_exc()}"} sys.stdout.write(json.dumps(payload)) return 1 diff --git a/lnbits/settings.py b/lnbits/settings.py index ebd696be0..e47228535 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -76,7 +76,7 @@ class ExtensionsSettings(LNbitsSettings): default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json" ) lnbits_wasm_timeout_seconds: float = Field(default=3.0, ge=0.1) - lnbits_wasm_fuel: int = Field(default=50_000, ge=1_000) + lnbits_wasm_fuel: int = Field(default=50_000, ge=0) lnbits_wasm_max_module_bytes: int = Field(default=1_000_000, ge=0) lnbits_wasm_max_db_ops_per_min: int = Field(default=120, ge=0)