Compare commits

...
58 Commits
Author SHA1 Message Date
dni ⚡andGitHub 15984fa49b chore: update to v1.3.0-rc4 (#3323) 2025-08-19 12:10:52 +02:00
dni ⚡andGitHub 67dc096909 feat: use pydantic mypy plugin to have improved linting of models (#3311) 2025-08-19 11:37:00 +02:00
dni ⚡andGitHub 2daddf8b74 chore: update lnurl to v0.7.3 (#3320) 2025-08-19 11:34:19 +02:00
dni ⚡andGitHub ae6cf47244 feat: lud11 disposable and storeable payRequests. (#3317) 2025-08-19 11:33:57 +02:00
dni ⚡andGitHub 1488f580ff feat: frontend component for LNURLs (#3321) 2025-08-19 11:18:02 +02:00
dni ⚡andGitHub 609c949394 chore: update to v1.3.0-rc3 (#3313) 2025-08-18 10:07:29 +02:00
dni ⚡andGitHub c24d2f24cf fix: bump lnurl its not callback_url now, its just url (#3312) 2025-08-18 09:18:11 +02:00
dni ⚡andGitHub 0ba81ec719 bug: propergate LnurlErrorResponse in lnurl service and dont checks for redundant LnurlPayActionResponse (#3314) 2025-08-14 13:37:44 +02:00
dni ⚡andGitHub 5e03707be5 fix: mispelling in logs #3306 (#3310) 2025-08-13 14:40:24 +02:00
ArcandGitHub 50f1111356 Fix: fake wallet (#3309) 2025-08-13 14:39:42 +02:00
dni ⚡andGitHub 112a0f9698 fix: lnaddresses on new lnurlscan POST endpoint (#3305) 2025-08-13 08:45:36 +02:00
ArcandGitHub 75016d1a27 fix: lnurl_decode changed to url_decode (#3303) 2025-08-04 09:25:09 +02:00
Tiago VasconcelosandGitHub 5b9f244a9f fix: prevent overflow on dialog (#3295) 2025-07-31 20:06:17 +03:00
dni ⚡andGitHub 0d2ff2e663 feat: add lud17 support for wallet parse dialog (#3289) 2025-07-23 15:27:00 +02:00
Tiago VasconcelosandGitHub bdfefc2506 [Fix] Don't allow pay for expired invoice (#3290) 2025-07-23 15:25:27 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a9f471783 build(deps): bump form-data from 4.0.0 to 4.0.4 (#3288)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 14:34:15 +03:00
dni ⚡andGitHub 793c7c45ed chore: update version to v1.3.0-rc2 (#3287) 2025-07-21 11:32:53 +03:00
dni ⚡andGitHub ff535ae065 feat: add get_pr_from_lnurl to lnurl services (#3286) 2025-07-21 10:28:14 +02:00
dni ⚡andGitHub 01fdc1defb fix: frontend withdraw error message (#3282) 2025-07-21 10:34:50 +03:00
dce2bfb440 [feat] User notifications backend (#3280)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-07-17 16:11:40 +02:00
dni ⚡andGitHub a36ab2d408 feat: update lnurl lib refactor lnurl endpoints (#3271) 2025-07-17 17:03:44 +03:00
dni ⚡andGitHub 98f732fb6f chore: update fastapi an starlette (#3277) 2025-07-17 16:41:54 +03:00
dni ⚡andGitHub 65864cb216 chore: use modern websockets instead of legacy (#3278) 2025-07-17 15:02:29 +03:00
dni ⚡andGitHub 114e7b8c3c chore: update to version v1.3.0-rc1 (#3273) 2025-07-17 14:59:12 +03:00
dni ⚡andGitHub fbfab880f3 chore: replace passlib with bcrypt (#3279) 2025-07-17 14:58:43 +03:00
dni ⚡andGitHub 133afff8ee fix: catch init fiat providers (#3281) 2025-07-17 14:58:03 +03:00
dni ⚡andGitHub 851c952c6d chore: update vue-i18n dependabot (#3276) 2025-07-17 12:23:55 +03:00
dni ⚡andGitHub 8b29148085 fix: frontend bgimagechoice can be null (#3270) 2025-07-15 12:36:39 +03:00
dni ⚡ ad48092549 chore: bundle 2025-07-15 11:16:42 +02:00
ee449ca612 chore: udate v1.2.1 fi.js (#3268)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:19 +02:00
Vlad Stananddni ⚡ 73f45ae859 feat: better server start logging (#3266) 2025-07-15 11:15:18 +02:00
Vlad Stananddni ⚡ 094a9fc1a1 [feat] UI for user notifications (#3267) 2025-07-15 11:15:17 +02:00
dni ⚡ 5e9919c5c5 feat: toggle holdinvoice depending on fundingsource Feature (#3262) 2025-07-15 11:15:16 +02:00
f91c933919 feat: HodlInvoices (Rebase) (#2869)
Co-authored-by: pseudozach <git@pseudozach.com>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:15:14 +02:00
dni ⚡ 6e9f451419 feat: introduce self.features to wallets, refactor feature nodemanager (#3260) 2025-07-15 11:15:13 +02:00
dni ⚡ 256a8098c6 chore: get rid of mypy notes and typecheck more functions (#3257) 2025-07-15 11:15:12 +02:00
Arcanddni ⚡ 1a02095758 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:15:10 +02:00
Tiago Vasconcelosanddni ⚡ ec5b7493b3 fix: default theme settings (#3256) 2025-07-15 11:14:33 +02:00
cdc1a6dc8f feat: Add support for the new CLNRest as a funding source (#2778)
Co-authored-by: Amperstrand <amperstrand@localhost>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:32 +02:00
Arcanddni ⚡ 7d573ef859 chore: tooltip for ad (#3255) 2025-07-15 11:14:30 +02:00
Vlad Stananddni ⚡ 4bd2a4fba9 chore: upgrade protobuf (#3252) 2025-07-15 11:14:29 +02:00
dni ⚡ 209c251fb6 fix: update and fixes for BreezWallet (#3246) 2025-07-15 11:14:28 +02:00
dni ⚡ 69a7ef74fd CI: fix using all optional dependecies (#3251) 2025-07-15 11:14:27 +02:00
dni ⚡andVlad Stan 35e2c4b0a7 fix: update success status (#3244)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:25 +02:00
dni ⚡ 2c6dce808e feat: run_interval in tasks and refactor check_pending_payments (#3245) 2025-07-15 11:14:24 +02:00
Vlad Stananddni ⚡ fa89313e6f [fix] bandit assert warnings (#3243)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-15 11:14:23 +02:00
Vlad Stananddni ⚡ 76ecf113c3 [fix] bandit sql warnings (#3242) 2025-07-15 11:14:22 +02:00
Vlad Stananddni ⚡ e0749e186e [fix] Small bandit changes (#3241) 2025-07-15 11:14:20 +02:00
0529ee2835 feat: add Breez *Liquid* SDK funding source (#2681)
Co-authored-by: Pavol Rusnak <pavol@rusnak.io>
Co-authored-by: dni  <office@dnilabs.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:19 +02:00
Vlad Stananddni ⚡ 09b91f9f79 refactor: extract create_payment_request (#3240) 2025-07-15 11:14:18 +02:00
dni ⚡andVlad Stan 90ab642dcd fix: phoenixd pending payments (#3075)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:16 +02:00
88cf1ac853 feat: Allow custom memo on pay invoice (#3236)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-07-15 11:14:15 +02:00
Vlad Stananddni ⚡ 9aa2194d94 refactor: use while settings.lnbits_running instead of while True 2025-07-15 11:14:04 +02:00
dni ⚡ ba0f7a01ef chore: reduce max complexity lint (#3230) 2025-07-15 11:14:03 +02:00
Tiago Vasconcelosanddni ⚡ 5765ea0276 [Fix] Display fee in sats (#3235) 2025-07-15 11:14:02 +02:00
Arcanddni ⚡ 88b01e6079 feat: theme fix for elsal, cyber, freedom (#3259) 2025-07-11 12:39:03 +02:00
Arcanddni ⚡ b0d0901969 refactor: theme options, includes fix bg on firefox with dialog (#3247)
Co-authored-by: dni  <office@dnilabs.com>
2025-07-11 12:39:02 +02:00
dni ⚡ 6563610b6c chore: update to version 1.2.1 2025-07-11 12:38:33 +02:00
120 changed files with 8282 additions and 8619 deletions
+21 -1
View File
@@ -40,7 +40,7 @@ PORT=5000
######################################
# which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -61,6 +61,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
SPARK_URL=http://localhost:9737/rpc
SPARK_TOKEN=myaccesstoken
#CLNRest (using runes)
CLNREST_URL=https://127.0.0.1:3010
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem
# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}'
CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune
CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune
CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune
#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max'
#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08
# CoreLightningWallet
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
@@ -153,6 +167,12 @@ BREEZ_GREENLIGHT_DEVICE_KEY="/path/to/breezsdk/device.pem" # or BASE64/HEXSTRIN
BREEZ_GREENLIGHT_DEVICE_CERT="/path/to/breezsdk/device.crt" # or BASE64/HEXSTRING
# BREEZ_USE_TRAMPOLINE=true
# BreezLiquidSdkWallet
# get your own api key here https://breez.technology/request-api-key/#contact-us-form-sdk
# or keep the api key empty to use the LNbits key for referrals (API key is not a secret)
# BREEZ_LIQUID_API_KEY=""
BREEZ_LIQUID_SEED="MNEMONIC SEED PHRASE"
# BREEZ_LIQUID_FEE_OFFSET_SAT=50
# Google OAuth Config
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
+1 -3
View File
@@ -43,9 +43,7 @@ runs:
shell: bash
run: |
poetry env use python${{ inputs.python-version }}
poetry install
# needed for conv tests
poetry add psycopg2-binary
poetry install --all-extras
- name: Use Node.js ${{ inputs.node-version }}
if: ${{ (inputs.npm == 'true') }}
+31
View File
@@ -12,6 +12,23 @@ A backend wallet can be configured using the following LNbits environment variab
You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md).
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet**
- `CLNREST_URL`: `https://127.0.0.1:3010`
- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file)
- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file)
- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune`
- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune`
- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune`
- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' `
- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08)
### CoreLightning
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet**
@@ -19,6 +36,8 @@ You can [compare the LNbits compatible Lightning Network funding sources here](w
### CoreLightning REST
This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST)
- `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet**
- `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/
- `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex
@@ -135,6 +154,18 @@ A Greenlight invite code or Greenlight partner certificate/key can be used to re
- `BREEZ_GREENLIGHT_DEVICE_KEY`: /path/to/breezsdk/device.pem or Base64/Hex
- `BREEZ_GREENLIGHT_DEVICE_CERT`: /path/to/breezsdk/device.crt or Base64/Hex
### Breez Liquid SDK
This funding source leverages the [Breez SDK - Liquid](https://sdk-doc-liquid.breez.technology/) to manage all Lightning payments via submarine swaps on the Liquid network. To get started, simply provide a mnemonic seed phrase. The easiest way to generate one is by using a liquid wallet, such as [Blockstream Green](https://blockstream.com/green/). Once generated, you can copy the seed to your environment variable or enter it in the admin UI.
- `LNBITS_BACKEND_WALLET_CLASS`: **BreezLiquidSdkWallet**
- `BREEZ_LIQUID_SEED`: ...
Each submarine swap incurs service and on-chain fees. To account for these, you may need to increase the reserve fee in the admin UI by navigating to **Settings -> Funding**, or by setting the following environment variables:
- `LNBITS_RESERVE_FEE_MIN`: ...
- `LNBITS_RESERVE_FEE_PERCENT`: ...
### Cliche Wallet
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
+15 -13
View File
@@ -4,6 +4,7 @@ import importlib
import os
import shutil
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, Optional
@@ -26,7 +27,8 @@ from lnbits.core.crud.extensions import create_installed_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_notification
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.core.services.payments import check_pending_payments
from lnbits.core.tasks import (
audit_queue,
collect_exchange_rates_data,
@@ -65,16 +67,13 @@ from .middleware import (
add_ip_block_middleware,
add_ratelimit_middleware,
)
from .tasks import (
check_pending_payments,
internal_invoice_listener,
invoice_listener,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
async def startup(app: FastAPI):
logger.info(f"Starting LNbits Version: {settings.version}")
start = time.perf_counter()
settings.lnbits_running = True
# wait till migration is done
await migrate_databases()
@@ -105,7 +104,7 @@ async def startup(app: FastAPI):
# initialize tasks
register_async_tasks()
enqueue_notification(
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server started.",
@@ -113,10 +112,13 @@ async def startup(app: FastAPI):
},
)
end = time.perf_counter()
logger.success(f"LNbits started in {end - start:.2f} seconds.")
async def shutdown():
logger.warning("LNbits shutting down...")
enqueue_notification(
enqueue_admin_notification(
NotificationType.server_start_stop,
{
"message": "LNbits server shutting down...",
@@ -277,7 +279,7 @@ async def check_installed_extensions(app: FastAPI):
logger.info(f"{ext.id} ({ext.installed_version})")
async def build_all_installed_extensions_list(
async def build_all_installed_extensions_list( # noqa: C901
include_deactivated: Optional[bool] = True,
) -> list[InstallableExtension]:
"""
@@ -451,7 +453,7 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix)
async def check_and_register_extensions(app: FastAPI):
async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
try:
@@ -461,12 +463,12 @@ async def check_and_register_extensions(app: FastAPI):
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
def register_async_tasks():
def register_async_tasks() -> None:
create_permanent_task(wait_for_audit_data)
create_permanent_task(wait_notification_messages)
create_permanent_task(check_pending_payments)
create_permanent_task(run_interval(30 * 60, check_pending_payments))
create_permanent_task(invoice_listener)
create_permanent_task(internal_invoice_listener)
create_permanent_task(cache.invalidate_forever)
+7 -7
View File
@@ -320,9 +320,8 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
assert (
ext.meta and ext.meta.installed_release
), f"Extension {ext.id} has no installed_release"
if not ext.meta or not ext.meta.installed_release:
raise ValueError(f"Extension {ext.id} has no installed_release")
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@@ -353,7 +352,7 @@ async def extensions_list():
help="Admin user ID (must have permissions to install extensions).",
)
@coro
async def extensions_update(
async def extensions_update( # noqa: C901
extension: Optional[str] = None,
all_extensions: Optional[bool] = False,
repo_index: Optional[str] = None,
@@ -608,9 +607,10 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert (
installed_ext.meta and installed_ext.meta.installed_release
), "Cannot find previously installed release. Please uninstall first."
if not installed_ext.meta or not installed_ext.meta.installed_release:
raise ValueError(
"Cannot find previously installed release. Please uninstall first."
)
release = await _select_release(extension, repo_index, source_repo)
if not release:
+2
View File
@@ -11,6 +11,7 @@ from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.tinyurl_api import tinyurl_router
@@ -42,6 +43,7 @@ def init_core_routers(app: FastAPI):
app.include_router(users_router)
app.include_router(audit_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
__all__ = ["core_app", "core_app_extra", "db"]
+9 -3
View File
@@ -30,10 +30,11 @@ async def delete_expired_audit_entries(
conn: Optional[Connection] = None,
):
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE from audit
WHERE delete_at < {db.timestamp_now}
""",
""", # noqa: S608
)
@@ -48,13 +49,16 @@ async def get_count_stats(
filters = Filters()
clause = filters.where()
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count({field}) as total
FROM audit
{clause}
GROUP BY {field}
ORDER BY {field}
""",
""", # noqa: S608
values=filters.values(),
model=AuditCountStat,
)
@@ -70,13 +74,15 @@ async def get_long_duration_stats(
filters = Filters()
clause = filters.where()
long_duration_paths = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
query=f"""
SELECT path as field, max(duration) as total FROM audit
{clause}
GROUP BY path
ORDER BY total DESC
LIMIT 5
""",
""", # noqa: S608
values=filters.values(),
model=AuditCountStat,
)
+7 -3
View File
@@ -50,7 +50,8 @@ async def drop_extension_db(ext_id: str, conn: Optional[Connection] = None) -> N
{"id": ext_id},
)
# Check that 'ext_id' is a valid extension id and not a malicious string
assert row, f"Extension '{ext_id}' db version cannot be found"
if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
@@ -78,10 +79,13 @@ async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None,
) -> list[InstallableExtension]:
where = "WHERE active = :active" if active is not None else ""
query = "SELECT * FROM installed_extensions"
if active is not None:
query += " WHERE active = :active"
values = {"active": active} if active is not None else {}
all_extensions = await (conn or db).fetchall(
f"SELECT * FROM installed_extensions {where}",
query,
values,
model=InstallableExtension,
)
+40 -20
View File
@@ -50,11 +50,13 @@ async def get_standalone_payment(
clause = f"({clause}) AND wallet_id = :wallet_id"
row = await (conn or db).fetchone(
# This query is safe from SQL injection
# The `clause` is constructed from sanitized inputs
f"""
SELECT * FROM apipayments
WHERE {clause}
ORDER BY amount LIMIT 1
""",
""", # noqa: S608
values,
Payment,
)
@@ -80,19 +82,25 @@ async def get_latest_payments_by_extension(
ext_name: str, ext_id: str, limit: int = 5
) -> list[Payment]:
return await db.fetchall(
# This query is safe from SQL injection
# The limtit is an integer and not user input
f"""
SELECT * FROM apipayments
WHERE status = '{PaymentState.SUCCESS}'
WHERE status = :status
AND extra LIKE :ext_name
AND extra LIKE :ext_id
ORDER BY time DESC LIMIT {int(limit)}
""",
{"ext_name": f"%{ext_name}%", "ext_id": f"%{ext_id}%"},
""", # noqa: S608
{
"status": f"{PaymentState.SUCCESS}",
"ext_name": f"%{ext_name}%",
"ext_id": f"%{ext_id}%",
},
Payment,
)
async def get_payments_paginated(
async def get_payments_paginated( # noqa: C901
*,
wallet_id: Optional[str] = None,
user_id: Optional[str] = None,
@@ -227,21 +235,23 @@ async def delete_expired_invoices(
# first we delete all invoices older than one month
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
WHERE status = :status AND amount > 0
AND time < {db.timestamp_placeholder("delta")}
""",
{"delta": int(time() - 2592000)},
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
)
# then we delete all invoices whose expiry date is in the past
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM apipayments
WHERE status = '{PaymentState.PENDING}' AND amount > 0
WHERE status = :status AND amount > 0
AND expiry < {db.timestamp_placeholder("now")}
""",
{"now": int(time())},
""", # noqa: S608
{"status": f"{PaymentState.PENDING}", "now": int(time())},
)
@@ -254,7 +264,8 @@ async def create_payment(
# we don't allow the creation of the same invoice twice
# note: this can be removed if the db uniqueness constraints are set appropriately
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
if previous_payment is not None:
raise ValueError("Payment already exists")
extra = data.extra or {}
payment = Payment(
@@ -268,7 +279,7 @@ async def create_payment(
preimage=data.preimage,
expiry=data.expiry,
webhook=data.webhook,
fee=data.fee,
fee=-abs(data.fee),
tag=extra.get("tag", None),
extra=extra,
)
@@ -321,16 +332,20 @@ async def get_payments_history(
)
"""
]
clause = filters.where(where)
transactions: list[dict] = await db.fetchall(
# This query is safe from SQL injection:
# - `date_trunc` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
f"""
SELECT {date_trunc} date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) income,
SUM(CASE WHEN amount < 0 THEN abs(amount) + abs(fee) ELSE 0 END) spending
FROM apipayments
{filters.where(where)}
{clause}
GROUP BY date
ORDER BY date DESC
""",
""", # noqa: S608
filters.values(values),
)
if wallet_id:
@@ -376,13 +391,16 @@ async def get_payment_count_stats(
clause = filters.where(extra_stmts)
data = await (conn or db).fetchall(
# SQL injection vectors safety:
# - `field` is a static string, not user input
# - `clause` is generated from filters, which are validated and sanitized
query=f"""
SELECT {field} as field, count(*) as total
FROM apipayments
{clause}
GROUP BY {field}
ORDER BY {field}
""",
""", # noqa: S608
values=filters.values(),
model=PaymentCountStat,
)
@@ -468,6 +486,8 @@ async def get_wallets_stats(
clauses = filters.where(where_stmts)
data = await (conn or db).fetchall(
# This query is safe from SQL injection
# The `clauses` is constructed from sanitized inputs
query=f"""
SELECT apipayments.wallet_id,
MAX(wallets.name) AS wallet_name,
@@ -479,7 +499,7 @@ async def get_wallets_stats(
{clauses}
GROUP BY apipayments.wallet_id
ORDER BY payments_count
""",
""", # noqa: S608
values=filters.values(),
model=PaymentWalletStats,
)
@@ -504,11 +524,11 @@ async def check_internal(
otherwise None
"""
return await (conn or db).fetchone(
f"""
"""
SELECT * FROM apipayments
WHERE payment_hash = :hash AND status = '{PaymentState.PENDING}' AND amount > 0
WHERE payment_hash = :hash AND status = :status AND amount > 0
""",
{"hash": payment_hash},
{"status": f"{PaymentState.PENDING}", "hash": payment_hash},
Payment,
)
+4 -2
View File
@@ -56,7 +56,8 @@ async def update_admin_settings(
async def update_super_user(super_user: str) -> SuperSettings:
await set_settings_field("super_user", super_user)
settings = await get_super_settings()
assert settings, "updated super_user settings could not be retrieved"
if not settings:
raise ValueError("updated super_user settings could not be retrieved")
return settings
@@ -86,7 +87,8 @@ async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSet
await set_settings_field(key, value)
settings = await get_super_settings()
assert settings, "created admin settings could not be retrieved"
if not settings:
raise ValueError("created admin settings could not be retrieved")
return settings
+2 -1
View File
@@ -110,6 +110,7 @@ async def delete_accounts_no_wallets(
) -> None:
delta = int(time()) - time_delta
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
DELETE FROM accounts
WHERE NOT EXISTS (
@@ -118,7 +119,7 @@ async def delete_accounts_no_wallets(
(updated_at is null AND created_at < :delta)
OR updated_at < {db.timestamp_placeholder("delta")}
)
""",
""", # noqa: S608
{"delta": delta},
)
+27 -22
View File
@@ -33,7 +33,7 @@ async def create_wallet(
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
) -> Wallet:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet
@@ -48,11 +48,12 @@ async def delete_wallet(
) -> None:
now = int(time())
await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = :deleted, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet AND "user" = :user
""",
""", # noqa: S608
{"wallet": wallet_id, "user": user_id, "deleted": deleted, "now": now},
)
@@ -71,11 +72,12 @@ async def delete_wallet_by_id(
) -> Optional[int]:
now = int(time())
result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets
SET deleted = true, updated_at = {db.timestamp_placeholder('now')}
WHERE id = :wallet
""",
""", # noqa: S608
{"wallet": wallet_id, "now": now},
)
return result.rowcount
@@ -107,14 +109,16 @@ async def delete_unused_wallets(
async def get_wallet(
wallet_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> Optional[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE id = :wallet {where}
""",
query,
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
@@ -123,14 +127,16 @@ async def get_wallet(
async def get_wallets(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[Wallet]:
where = "AND deleted = :deleted" if deleted is not None else ""
query = """
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchall(
f"""
SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) AS balance_msat FROM wallets
WHERE "user" = :user {where}
""",
query,
{"user": user_id, "deleted": deleted},
Wallet,
)
@@ -162,12 +168,11 @@ async def get_wallets_paginated(
async def get_wallets_ids(
user_id: str, deleted: Optional[bool] = None, conn: Optional[Connection] = None
) -> list[str]:
where = "AND deleted = :deleted" if deleted is not None else ""
query = """SELECT id FROM wallets WHERE "user" = :user"""
if deleted is not None:
query += "AND deleted = :deleted"
result: list[dict] = await (conn or db).fetchall(
f"""
SELECT id FROM wallets
WHERE "user" = :user {where}
""",
query,
{"user": user_id, "deleted": deleted},
)
return [row["id"] for row in result]
+2 -1
View File
@@ -37,7 +37,8 @@ async def create_webpush_subscription(
{"endpoint": endpoint, "user": user, "data": data, "host": host},
)
subscription = await get_webpush_subscription(endpoint, user)
assert subscription, "Newly created webpush subscription couldn't be retrieved"
if not subscription:
raise ValueError("Newly created webpush subscription couldn't be retrieved")
return subscription
+28 -8
View File
@@ -214,6 +214,7 @@ async def m007_set_invoice_expiries(db: Connection):
"""
try:
result = await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
SELECT bolt11, checking_id
FROM apipayments
@@ -222,7 +223,7 @@ async def m007_set_invoice_expiries(db: Connection):
AND bolt11 IS NOT NULL
AND expiry IS NULL
AND time < {db.timestamp_now}
"""
""" # noqa: S608
)
rows = result.mappings().all()
if len(rows):
@@ -242,13 +243,15 @@ async def m007_set_invoice_expiries(db: Connection):
f" {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments SET expiry = {db.timestamp_placeholder('expiry')}
WHERE checking_id = :checking_id AND amount > 0
""",
""", # noqa: S608
{"expiry": expiration_date, "checking_id": checking_id},
)
except Exception:
except Exception as exc:
logger.debug(exc)
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -371,7 +374,8 @@ async def m014_set_deleted_wallets(db: Connection):
"wallet": row.get("id"),
},
)
except Exception:
except Exception as exc:
logger.debug(exc)
continue
except OperationalError:
# this is necessary now because it may be the case that this migration will
@@ -454,17 +458,19 @@ async def m017_add_timestamp_columns_to_accounts_and_wallets(db: Connection):
# set all to now where they are null
now = int(time())
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE wallets SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
""", # noqa: S608
{"now": now},
)
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE accounts SET created_at = {db.timestamp_placeholder('now')}
WHERE created_at IS NULL
""",
""", # noqa: S608
{"now": now},
)
@@ -616,7 +622,12 @@ async def m027_update_apipayments_data(db: Connection):
logger.info(f"Updating {offset} to {offset+limit}")
result = await db.execute(
f"SELECT * FROM apipayments ORDER BY time LIMIT {limit} OFFSET {offset}"
# Limit and Offset safe from SQL injection
# since they are integers and are not user input
f"""
SELECT * FROM apipayments
ORDER BY time LIMIT {int(limit)} OFFSET {int(offset)}
""" # noqa: S608
)
payments = result.mappings().all()
logger.info(f"Payments count: {len(payments)}")
@@ -629,11 +640,12 @@ async def m027_update_apipayments_data(db: Connection):
tag = extra.get("tag")
tsph = db.timestamp_placeholder("created_at")
await db.execute(
# Timestamp placeholder is safe from SQL injection (not user input)
f"""
UPDATE apipayments
SET tag = :tag, created_at = {tsph}, updated_at = {tsph}
WHERE checking_id = :checking_id
""",
""", # noqa: S608
{
"tag": tag,
"created_at": created_at,
@@ -723,3 +735,11 @@ async def m032_add_external_id_to_accounts(db: Connection):
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_add_stored_paylinks_to_wallet(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)
+7 -4
View File
@@ -1,5 +1,5 @@
from .audit import AuditEntry, AuditFilters
from .lnurl import CreateLnurl, CreateLnurlAuth, PayLnurlWData
from .lnurl import CreateLnurlPayment, CreateLnurlWithdraw
from .misc import (
BalanceDelta,
Callback,
@@ -9,6 +9,7 @@ from .misc import (
SimpleStatus,
)
from .payments import (
CancelInvoice,
CreateInvoice,
CreatePayment,
DecodePayment,
@@ -23,6 +24,7 @@ from .payments import (
PaymentsStatusCount,
PaymentState,
PaymentWalletStats,
SettleInvoice,
)
from .tinyurl import TinyURL
from .users import (
@@ -57,11 +59,12 @@ __all__ = [
"BalanceDelta",
"BaseWallet",
"Callback",
"CancelInvoice",
"ConversionData",
"CoreAppExtra",
"CreateInvoice",
"CreateLnurl",
"CreateLnurlAuth",
"CreateLnurlPayment",
"CreateLnurlWithdraw",
"CreatePayment",
"CreateUser",
"CreateWallet",
@@ -72,7 +75,6 @@ __all__ = [
"LoginUsernamePassword",
"LoginUsr",
"PayInvoice",
"PayLnurlWData",
"Payment",
"PaymentCountField",
"PaymentCountStat",
@@ -85,6 +87,7 @@ __all__ = [
"PaymentsStatusCount",
"RegisterUser",
"ResetUserPassword",
"SettleInvoice",
"SimpleStatus",
"TinyURL",
"UpdateBalance",
+4 -4
View File
@@ -211,7 +211,8 @@ class ExtensionRelease(BaseModel):
self, amount: int | None = None
) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
if not url:
raise ValueError("Missing URL for payment info.")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
@@ -376,9 +377,8 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
assert (
self.meta and self.meta.installed_release
), "installed_release is none."
if not self.meta or not self.meta.installed_release:
raise ValueError("No installed release.")
self._restore_payment_info()
+21 -9
View File
@@ -1,20 +1,32 @@
from time import time
from typing import Optional
from pydantic import BaseModel
from lnurl import LnAddress, Lnurl, LnurlPayResponse
from pydantic import BaseModel, Field
class CreateLnurl(BaseModel):
description_hash: str
callback: str
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
internal_memo: Optional[str] = None
class CreateLnurlAuth(BaseModel):
callback: str
class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl
class PayLnurlWData(BaseModel):
lnurl_w: str
class LnurlScan(BaseModel):
lnurl: Lnurl | LnAddress
class StoredPayLink(BaseModel):
lnurl: str
label: str
last_used: int = Field(default_factory=lambda: int(time()))
class StoredPayLinks(BaseModel):
links: list[StoredPayLink] = []
+3
View File
@@ -2,6 +2,8 @@ from enum import Enum
from pydantic import BaseModel
from lnbits.core.models.users import UserNotifications
class NotificationType(Enum):
server_status = "server_status"
@@ -18,6 +20,7 @@ class NotificationType(Enum):
class NotificationMessage(BaseModel):
message_type: NotificationType
values: dict
user_notifications: UserNotifications | None = None
NOTIFICATION_TEMPLATES = {
+42 -1
View File
@@ -5,6 +5,7 @@ from enum import Enum
from typing import Literal
from fastapi import Query
from lnurl import LnurlWithdrawResponse
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel
@@ -252,13 +253,25 @@ class CreateInvoice(BaseModel):
memo: str | None = Query(None, max_length=640)
description_hash: str | None = None
unhashed_description: str | None = None
payment_hash: str | None = Query(
None,
description="The payment hash of the hold invoice.",
min_length=64,
max_length=64,
)
expiry: int | None = None
extra: dict | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_callback: str | None = None
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
@validator("payment_hash")
def check_hex(cls, v):
if v:
_ = bytes.fromhex(v)
return v
@validator("unit")
@classmethod
def unit_is_from_allowed_currencies(cls, v):
@@ -272,3 +285,31 @@ class PaymentsStatusCount(BaseModel):
outgoing: int = 0
failed: int = 0
pending: int = 0
class SettleInvoice(BaseModel):
preimage: str = Field(
...,
description="The preimage of the payment hash to settle the invoice.",
min_length=64,
max_length=64,
)
@validator("preimage")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
class CancelInvoice(BaseModel):
payment_hash: str = Field(
...,
description="The payment hash of the invoice to cancel.",
min_length=64,
max_length=64,
)
@validator("payment_hash")
def check_hex(cls, v):
_ = bytes.fromhex(v)
return v
+18 -5
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw
from fastapi import Query
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from lnbits.core.models.misc import SimpleItem
@@ -20,6 +20,15 @@ from lnbits.settings import settings
from .wallets import Wallet
class UserNotifications(BaseModel):
nostr_identifier: str | None = None
telegram_chat_id: str | None = None
email_address: str | None = None
excluded_wallets: list[str] = []
outgoing_payments_sats: int = 0
incoming_payments_sats: int = 0
class UserExtra(BaseModel):
email_verified: bool | None = False
first_name: str | None = None
@@ -35,6 +44,8 @@ class UserExtra(BaseModel):
# how many wallets are shown in the user interface
visible_wallet_count: int | None = 10
notifications: UserNotifications = UserNotifications()
class EndpointAccess(BaseModel):
path: str
@@ -120,16 +131,18 @@ class Account(BaseModel):
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
salt = gensalt()
hashed_pw = hashpw(password.encode(), salt)
if not hashed_pw:
raise ValueError("Password hashing failed.")
self.password_hash = hashed_pw.decode()
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
return checkpw(password.encode(), self.password_hash.encode())
def validate_fields(self):
if self.username and not is_valid_username(self.username):
+3 -12
View File
@@ -1,17 +1,15 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from ecdsa import SECP256k1, SigningKey
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -41,6 +39,7 @@ class Wallet(BaseModel):
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
@property
def balance(self) -> int:
@@ -58,14 +57,6 @@ class Wallet(BaseModel):
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: str | None = None
+11 -5
View File
@@ -2,20 +2,23 @@ from .funding_source import (
get_balance_delta,
switch_to_voidwallet,
)
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification, send_payment_notification
from .lnurl import fetch_lnurl_pay_request, get_pr_from_lnurl
from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
cancel_hold_invoice,
check_transaction_status,
check_wallet_limits,
create_fiat_invoice,
create_invoice,
create_payment_request,
create_wallet_invoice,
fee_reserve,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
service_fee,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
update_wallet_balance,
@@ -35,25 +38,28 @@ from .websockets import websocket_manager, websocket_updater
__all__ = [
"calculate_fiat_amounts",
"cancel_hold_invoice",
"check_admin_settings",
"check_transaction_status",
"check_wallet_limits",
"check_webpush_settings",
"create_fiat_invoice",
"create_invoice",
"create_payment_request",
"create_user_account",
"create_user_account_no_ckeck",
"create_wallet_invoice",
"enqueue_notification",
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_lnurl_pay_request",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",
"pay_invoice",
"perform_lnurlauth",
"redeem_lnurl_withdraw",
"send_payment_notification",
"service_fee",
"settle_hold_invoice",
"switch_to_voidwallet",
"update_cached_settings",
"update_pending_payment",
+7 -7
View File
@@ -26,10 +26,11 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
if ext_info.meta.installed_release:
assert (
ext_info.meta.installed_release.is_version_compatible
), "Incompatible extension version"
if (
ext_info.meta.installed_release
and not ext_info.meta.installed_release.is_version_compatible
):
raise ValueError("Incompatible extension version")
installed_ext = await get_installed_extension(ext_info.id)
if installed_ext and installed_ext.meta:
@@ -93,9 +94,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
old_module = importlib.import_module(ext.module_name)
stop_fn_name = f"{ext_id}_stop"
assert hasattr(
old_module, stop_fn_name
), f"No stop function found for '{ext.module_name}'."
if not hasattr(old_module, stop_fn_name):
raise ValueError(f"No stop function found for '{ext.module_name}'.")
stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
+5
View File
@@ -176,6 +176,11 @@ async def test_connection(provider: str) -> SimpleStatus:
This function should be called when setting up or testing the Stripe integration.
"""
fiat_provider = await get_fiat_provider(provider)
if not fiat_provider:
return SimpleStatus(
success=False,
message=f"Fiat provider '{provider}' not found.",
)
status = await fiat_provider.status()
if status.error_message:
return SimpleStatus(
+3 -3
View File
@@ -1,7 +1,7 @@
from loguru import logger
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification
from lnbits.core.services.notifications import enqueue_admin_notification
from lnbits.settings import settings
from lnbits.wallets import get_funding_source, set_funding_source
@@ -51,7 +51,7 @@ async def check_server_balance_against_node():
f"Balance delta reached: {status.delta_sats} sats."
f" Switch to void wallet: {use_voidwallet}."
)
enqueue_notification(
enqueue_admin_notification(
NotificationType.watchdog_check,
{
"delta_sats": status.delta_sats,
@@ -71,7 +71,7 @@ async def check_balance_delta_changed():
settings.latest_balance_delta_sats = status.delta_sats
return
if status.delta_sats != settings.latest_balance_delta_sats:
enqueue_notification(
enqueue_admin_notification(
NotificationType.balance_delta,
{
"delta_sats": status.delta_sats,
+127 -147
View File
@@ -1,158 +1,138 @@
import asyncio
import json
from io import BytesIO
from typing import Optional
from urllib.parse import parse_qs, urlparse
from time import time
import httpx
from fastapi import Depends
from lnurl import (
LnAddress,
Lnurl,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
execute_pay_request,
handle,
)
from loguru import logger
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
)
from lnbits.helpers import check_callback_url, url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.settings import settings
from .payments import create_invoice
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
check_callback_url(str(lnurl))
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
async def get_pr_from_lnurl(
lnurl: str, amount_msat: int, comment: str | None = None
) -> str:
res = await handle(lnurl, user_agent=settings.user_agent, timeout=10)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(res["callback"])
await client.get(res["callback"], params=params)
except Exception:
pass
res2 = await execute_pay_request(
res,
msat=amount_msat,
comment=comment,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res2, LnurlErrorResponse):
raise LnurlResponseException(res2.reason)
return res2.pr
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
async def fetch_lnurl_pay_request(
data: CreateLnurlPayment, wallet: Wallet | None = None
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
"""
Pay an LNURL payment request.
optional `wallet` is used to store the pay link in the wallet's stored links.
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
check_callback_url(callback)
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
raises `LnurlResponseException` if pay request fails
"""
if not data.res and data.lnurl:
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
data.res = res
if not data.res:
raise LnurlResponseException("No LNURL pay request provided.")
if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
amount = round(data.amount / 1000, 2)
amount_msat = await fiat_amount_as_satoshis(amount, data.unit)
amount_msat *= 1000
else:
amount_msat = data.amount
res2 = await execute_pay_request(
data.res,
msat=amount_msat,
comment=data.comment,
user_agent=settings.user_agent,
timeout=10,
)
if wallet:
await store_paylink(data.res, res2, wallet, data.lnurl)
return data.res, res2
async def store_paylink(
res: LnurlPayResponse,
res2: LnurlPayActionResponse,
wallet: Wallet,
lnurl: LnAddress | Lnurl | None = None,
) -> None:
if res2.disposable is not False:
return # do not store disposable LNURL pay links
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
stored_paylink = None
# If we have only a LnurlPayResponse, we can use its lnaddress
# because the lnurl is not available.
if not lnurl:
for _data in res.metadata.list():
if _data[0] == "text/identifier":
stored_paylink = StoredPayLink(
lnurl=LnAddress(_data[1]), label=res.metadata.text
)
if not stored_paylink:
logger.warning(
"No lnaddress found in metadata for LNURL pay link. "
"Skipping storage."
)
return # skip if lnaddress not found in metadata
else:
if isinstance(lnurl, Lnurl):
_lnurl = str(lnurl.lud17 or lnurl.bech32)
else:
_lnurl = str(lnurl)
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
# update last_used if its already stored
for pl in wallet.stored_paylinks.links:
if pl.lnurl == stored_paylink.lnurl:
pl.last_used = int(time())
await update_wallet(wallet)
logger.debug(
"Updated last used time for LNURL "
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
)
return
# if not already stored, append it
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
wallet.stored_paylinks.links.append(stored_paylink)
await update_wallet(wallet)
logger.debug(
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
)
+152 -74
View File
@@ -16,23 +16,25 @@ from lnbits.core.crud import (
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.crud.users import get_user
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
NotificationMessage,
NotificationType,
)
from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
notifications_queue: asyncio.Queue = asyncio.Queue()
notifications_queue: asyncio.Queue[NotificationMessage] = asyncio.Queue()
def enqueue_notification(message_type: NotificationType, values: dict) -> None:
if not is_message_type_enabled(message_type):
def enqueue_admin_notification(message_type: NotificationType, values: dict) -> None:
if not _is_message_type_enabled(message_type):
return
try:
notifications_queue.put_nowait(
@@ -42,62 +44,125 @@ def enqueue_notification(message_type: NotificationType, values: dict) -> None:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification():
notification_message: NotificationMessage = await notifications_queue.get()
def enqueue_user_notification(
message_type: NotificationType, values: dict, user_notifications: UserNotifications
) -> None:
try:
notifications_queue.put_nowait(
NotificationMessage(
message_type=message_type,
values=values,
user_notifications=user_notifications,
)
)
except Exception as e:
logger.error(f"Error enqueuing notification: {e}")
async def process_next_notification() -> None:
notification_message = await notifications_queue.get()
message_type, text = _notification_message_to_text(notification_message)
await send_notification(text, message_type)
user_notifications = notification_message.user_notifications
if user_notifications:
await send_user_notification(user_notifications, text, message_type)
else:
await send_admin_notification(text, message_type)
async def send_admin_notification(
message: str,
message_type: Optional[str] = None,
) -> None:
return await send_notification(
settings.lnbits_telegram_notifications_chat_id,
settings.lnbits_nostr_notifications_identifiers,
settings.lnbits_email_notifications_to_emails,
message,
message_type,
)
async def send_user_notification(
user_notifications: UserNotifications,
message: str,
message_type: Optional[str] = None,
) -> None:
email_address = (
[user_notifications.email_address] if user_notifications.email_address else []
)
nostr_identifiers = (
[user_notifications.nostr_identifier]
if user_notifications.nostr_identifier
else []
)
return await send_notification(
user_notifications.telegram_chat_id,
nostr_identifiers,
email_address,
message,
message_type,
)
async def send_notification(
telegram_chat_id: str | None,
nostr_identifiers: list[str] | None,
email_addresses: list[str] | None,
message: str,
message_type: Optional[str] = None,
) -> None:
try:
if settings.lnbits_telegram_notifications_enabled:
await send_telegram_notification(message)
if telegram_chat_id and settings.is_telegram_notifications_configured():
await send_telegram_notification(telegram_chat_id, message)
logger.debug(f"Sent telegram notification: {message_type}")
except Exception as e:
logger.error(f"Error sending telegram notification {message_type}: {e}")
try:
if settings.lnbits_nostr_notifications_enabled:
await send_nostr_notification(message)
if nostr_identifiers and settings.is_nostr_notifications_configured():
await send_nostr_notifications(nostr_identifiers, message)
logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}")
try:
if settings.lnbits_email_notifications_enabled:
await send_email_notification(message)
if email_addresses and settings.lnbits_email_notifications_enabled:
await send_email_notification(email_addresses, message)
logger.debug(f"Sent email notification: {message_type}")
except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notification(message: str) -> dict:
for i in settings.lnbits_nostr_notifications_identifiers:
async def send_nostr_notifications(identifiers: list[str], message: str) -> list[str]:
success_sent: list[str] = []
for identifier in identifiers:
try:
identifier = await fetch_nip5_details(i)
user_pubkey = identifier[0]
relays = identifier[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
await send_nostr_notification(identifier, message)
success_sent.append(identifier)
except Exception as e:
logger.warning(f"Error notifying identifier {i}: {e}")
return {"status": "ok"}
logger.warning(f"Error notifying identifier {identifier}: {e}")
return success_sent
async def send_telegram_notification(message: str) -> dict:
async def send_nostr_notification(identifier: str, message: str):
nip5 = await fetch_nip5_details(identifier)
user_pubkey = nip5[0]
relays = nip5[1]
server_private_key = normalize_private_key(
settings.lnbits_nostr_notifications_private_key
)
await send_nostr_dm(
server_private_key,
user_pubkey,
message,
relays,
)
async def send_telegram_notification(chat_id: str, message: str) -> dict:
return await send_telegram_message(
settings.lnbits_telegram_notifications_access_token,
settings.lnbits_telegram_notifications_chat_id,
chat_id,
message,
)
@@ -112,7 +177,7 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
async def send_email_notification(
message: str, subject: str = "LNbits Notification"
to_emails: list[str], message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
@@ -123,7 +188,7 @@ async def send_email_notification(
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
to_emails,
subject,
message,
)
@@ -163,41 +228,6 @@ async def send_email(
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
@@ -231,7 +261,7 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
except Exception as e:
logger.error(f"Error sending websocket payment notification {e!s}")
try:
send_chat_payment_notification(wallet, payment)
await send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error(f"Error sending chat payment notification {e!s}")
try:
@@ -268,7 +298,7 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
)
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
@@ -283,10 +313,23 @@ def send_chat_payment_notification(wallet: Wallet, payment: Payment):
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_notification(NotificationType.outgoing_payment, values)
else:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
enqueue_admin_notification(NotificationType.outgoing_payment, values)
elif amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_admin_notification(NotificationType.incoming_payment, values)
user = await get_user(wallet.user)
user_notifications = user.extra.notifications if user else None
if user_notifications and wallet.id not in user_notifications.excluded_wallets:
out_limit = user_notifications.outgoing_payments_sats
in_limit = user_notifications.incoming_payments_sats
if payment.is_out and (amount_sats >= out_limit):
enqueue_user_notification(
NotificationType.outgoing_payment, values, user_notifications
)
elif amount_sats >= in_limit:
enqueue_user_notification(
NotificationType.incoming_payment, values, user_notifications
)
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
@@ -330,3 +373,38 @@ async def send_push_notification(subscription, title, body, url=""):
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
def _is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
if message_type == NotificationType.settings_update:
return settings.lnbits_notification_settings_update
if message_type == NotificationType.watchdog_check:
return settings.lnbits_notification_watchdog
if message_type == NotificationType.balance_delta:
return settings.notification_balance_delta_changed
if message_type == NotificationType.server_start_stop:
return settings.lnbits_notification_server_start_stop
if message_type == NotificationType.server_status:
return settings.lnbits_notification_server_status_hours > 0
if message_type == NotificationType.incoming_payment:
return settings.lnbits_notification_incoming_payment_amount_sats > 0
if message_type == NotificationType.outgoing_payment:
return settings.lnbits_notification_outgoing_payment_amount_sats > 0
return False
def _notification_message_to_text(
notification_message: NotificationMessage,
) -> tuple[str, str]:
message_type = notification_message.message_type.value
meesage_value = NOTIFICATION_TEMPLATES.get(message_type, message_type)
try:
text = meesage_value.format(**notification_message.values)
except Exception as e:
logger.warning(f"Error formatting notification message: {e}")
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
+180 -72
View File
@@ -1,13 +1,13 @@
import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from bolt11 import Bolt11, MilliSatoshi, Tags
from bolt11 import decode as bolt11_decode
from bolt11 import encode as bolt11_encode
from lnurl import LnurlErrorResponse, LnurlSuccessResponse
from lnurl import execute_withdraw as lnurl_withdraw
from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
@@ -16,15 +16,16 @@ from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
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.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash
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
from lnbits.wallets.base import (
InvoiceResponse,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@@ -66,7 +67,8 @@ async def pay_invoice(
if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat)
assert invoice.amount_msat
if not invoice.amount_msat:
raise ValueError("Missig invoice amount.")
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
amount_msat = invoice.amount_msat
@@ -89,16 +91,24 @@ async def pay_invoice(
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
service_fee_memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(payment, service_fee_memo, new_conn)
await _credit_service_fee_wallet(wallet, payment, new_conn)
return payment
async def create_payment_request(
wallet_id: str, invoice_data: CreateInvoice
) -> Payment:
"""
Create a lightning invoice or a fiat payment request.
"""
if invoice_data.fiat_provider:
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
async def create_fiat_invoice(
wallet_id: str, invoice_data: CreateInvoice, conn: Optional[Connection] = None
):
@@ -122,6 +132,9 @@ async def create_fiat_invoice(
internal_payment = await create_wallet_invoice(wallet_id, invoice_data)
fiat_provider = await get_fiat_provider(fiat_provider_name)
if not fiat_provider:
raise InvoiceError("No fiat provider found.", status="failed")
fiat_invoice = await fiat_provider.create_invoice(
amount=invoice_data.amount,
payment_hash=internal_payment.payment_hash,
@@ -155,8 +168,8 @@ async def create_fiat_invoice(
async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
description_hash = b""
unhashed_description = b""
description_hash = None
unhashed_description = None
memo = data.memo or settings.lnbits_site_title
if data.description_hash or data.unhashed_description:
if data.description_hash:
@@ -187,30 +200,29 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
extra=data.extra,
webhook=data.webhook,
internal=data.internal,
payment_hash=data.payment_hash,
)
# lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
check_callback_url(data.lnurl_callback)
r = await client.get(
data.lnurl_callback,
params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
payment.extra["lnurl_response"] = resp["reason"]
else:
payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
payment.extra["lnurl_response"] = False
if data.lnurl_withdraw:
try:
check_callback_url(data.lnurl_withdraw.callback)
res = await lnurl_withdraw(
data.lnurl_withdraw,
payment.bolt11,
user_agent=settings.user_agent,
timeout=10,
)
if isinstance(res, LnurlErrorResponse):
payment.extra["lnurl_response"] = res.reason
payment.status = "failed"
elif isinstance(res, LnurlSuccessResponse):
payment.extra["lnurl_response"] = True
payment.status = "success"
except Exception as exc:
payment.extra["lnurl_response"] = str(exc)
payment.status = "failed"
# updating to payment here would run into a race condition
# with the payment listeners and they will overwrite each other
return payment
@@ -227,6 +239,7 @@ async def create_invoice(
extra: Optional[dict] = None,
webhook: Optional[str] = None,
internal: Optional[bool] = False,
payment_hash: str | None = None,
conn: Optional[Connection] = None,
) -> Payment:
if not amount > 0:
@@ -260,38 +273,54 @@ async def create_invoice(
status="failed",
)
payment_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if payment_hash:
try:
invoice_response = await funding_source.create_hold_invoice(
amount=amount_sat,
memo=invoice_memo,
payment_hash=payment_hash,
description_hash=description_hash,
)
extra["hold_invoice"] = True
except UnsupportedError as exc:
raise InvoiceError(
"Hold invoices are not supported by the funding source.",
status="failed",
) from exc
else:
invoice_response = await funding_source.create_invoice(
amount=amount_sat,
memo=invoice_memo,
description_hash=description_hash,
unhashed_description=unhashed_description,
expiry=expiry or settings.lightning_invoice_expiry,
)
if (
not payment_response.ok
or not payment_response.payment_request
or not payment_response.checking_id
not invoice_response.ok
or not invoice_response.payment_request
or not invoice_response.checking_id
):
raise InvoiceError(
message=payment_response.error_message or "unexpected backend error.",
message=invoice_response.error_message or "unexpected backend error.",
status="pending",
)
invoice = bolt11_decode(payment_response.payment_request)
invoice = bolt11_decode(invoice_response.payment_request)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
bolt11=payment_response.payment_request,
bolt11=invoice_response.payment_request,
payment_hash=invoice.payment_hash,
preimage=payment_response.preimage,
preimage=invoice_response.preimage,
amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
webhook=webhook,
fee=invoice_response.fee_msat or 0,
)
payment = await create_payment(
checking_id=payment_response.checking_id,
checking_id=invoice_response.checking_id,
data=create_payment_model,
conn=conn,
)
@@ -309,17 +338,45 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> bool:
async def update_pending_payment(payment: Payment) -> Payment:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
return True
if status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
return True
return False
elif status.success:
payment = await update_payment_success_status(payment, status)
return payment
async def check_pending_payments():
"""
check_pending_payments is called during startup to check for pending payments with
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
return
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
exclude_uncheckable=True,
)
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
payment = await update_pending_payment(payment)
prefix = f"payment ({i+1} / {count})"
logger.debug(f"{prefix} {payment.status} {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
@@ -709,19 +766,20 @@ async def _pay_external_invoice(
)
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
service_fee_msat = service_fee(amount_msat, internal=False)
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
# 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:
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
payment_response = await asyncio.wait_for(task, wait_time)
payment_response = await asyncio.wait_for(task, timeout=wait_time)
except asyncio.TimeoutError:
# return pending payment on timeout
logger.debug(f"payment timeout, {checking_id} is still pending")
logger.debug(
f"payment timeout after {wait_time}s, {checking_id} is still pending"
)
return payment
# payment failed
@@ -735,18 +793,28 @@ async def _pay_external_invoice(
message = payment_response.error_message or "without an error message."
raise PaymentError(f"Payment failed: {message}", status="failed")
# payment.ok can be True (paid) or None (pending)!
payment.status = (
PaymentState.SUCCESS if payment_response.ok is True else PaymentState.PENDING
)
payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment_response.preimage
await update_payment(payment, payment_response.checking_id, conn=conn)
payment.checking_id = payment_response.checking_id
if payment.success:
if payment_response.success:
payment = await update_payment_success_status(
payment, payment_response, conn=conn
)
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id
return payment
async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Optional[Connection] = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
await update_payment(payment, conn=conn)
return payment
@@ -780,8 +848,7 @@ async def _verify_external_payment(
if status.success:
# payment was successful on the fundingsource
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
await update_payment_success_status(payment, status, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
@@ -834,12 +901,16 @@ def _validate_payment_request(
async def _credit_service_fee_wallet(
payment: Payment, memo: str, conn: Optional[Connection] = None
wallet: Wallet, payment: Payment, conn: Optional[Connection] = None
):
service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if not settings.lnbits_service_fee_wallet or not service_fee_msat:
return
memo = f"""
Service fee for payment of {abs(payment.sat)} sats.
Wallet: '{wallet.name}' ({wallet.id})."""
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
bolt11=payment.bolt11,
@@ -897,3 +968,40 @@ async def _check_fiat_invoice_limits(
f"The amount exceeds the '{fiat_provider_name}'"
"faucet wallet balance.",
)
async def settle_hold_invoice(payment: Payment, preimage: str) -> InvoiceResponse:
if verify_preimage(preimage, payment.payment_hash) is False:
raise InvoiceError("Invalid preimage.", status="failed")
funding_source = get_funding_source()
response = await funding_source.settle_hold_invoice(preimage=preimage)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.preimage = preimage
payment.extra["hold_invoice_settled"] = True
await update_payment(payment)
return response
async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
funding_source = get_funding_source()
response = await funding_source.cancel_hold_invoice(
payment_hash=payment.payment_hash
)
if not response.ok:
raise InvoiceError(
response.error_message or "Unexpected backend error.", status="failed"
)
payment.status = PaymentState.FAILED
payment.extra["hold_invoice_cancelled"] = True
await update_payment(payment)
return response
+2 -1
View File
@@ -19,7 +19,8 @@ async def check_webpush_settings():
vapid = Vapid()
vapid.generate_keys()
privkey = vapid.private_pem()
assert vapid.public_key, "VAPID public key does not exist"
if not vapid.public_key:
raise ValueError("VAPID public key does not exist")
pubkey = b64urlencode(
vapid.public_key.public_bytes(
serialization.Encoding.X962,
+2 -1
View File
@@ -80,7 +80,8 @@ async def create_user_account_no_ckeck(
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account)
assert user, "Cannot find user for account."
if not user:
raise ValueError("Cannot find user for account.")
return user
+13 -13
View File
@@ -13,7 +13,7 @@ from lnbits.core.crud.audit import delete_expired_audit_entries
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.models import AuditEntry
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 (
@@ -22,7 +22,7 @@ from lnbits.core.services.funding_source import (
get_balance_delta,
)
from lnbits.core.services.notifications import (
enqueue_notification,
enqueue_admin_notification,
process_next_notification,
send_payment_notification,
)
@@ -31,10 +31,10 @@ from lnbits.settings import settings
from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue = asyncio.Queue()
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
async def run_by_the_minute_tasks():
async def run_by_the_minute_tasks() -> None:
minute_counter = 0
while settings.lnbits_running:
status_minutes = settings.lnbits_notification_server_status_hours * 60
@@ -69,7 +69,7 @@ async def run_by_the_minute_tasks():
await asyncio.sleep(60)
async def _notify_server_status():
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()
@@ -87,10 +87,10 @@ async def _notify_server_status():
"lnbits_balance_sats": status.lnbits_balance_sats,
"node_balance_sats": status.node_balance_sats,
}
enqueue_notification(NotificationType.server_status, values)
enqueue_admin_notification(NotificationType.server_status, values)
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
"""
This worker dispatches events to all extensions and dispatches webhooks.
"""
@@ -103,13 +103,13 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
await send_payment_notification(wallet, payment)
async def wait_for_audit_data():
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: AuditEntry = await audit_queue.get()
data = await audit_queue.get()
try:
await create_audit_entry(data)
except Exception as ex:
@@ -117,17 +117,17 @@ async def wait_for_audit_data():
await asyncio.sleep(3)
async def wait_notification_messages():
async def wait_notification_messages() -> None:
while settings.lnbits_running:
try:
await process_next_notification()
except Exception as ex:
logger.log("error", ex)
logger.warning("Payment notification error", ex)
await asyncio.sleep(3)
async def purge_audit_data():
async def purge_audit_data() -> None:
"""
Remove audit entries which have passed their retention period.
"""
@@ -141,7 +141,7 @@ async def purge_audit_data():
await asyncio.sleep(60 * 60)
async def collect_exchange_rates_data():
async def collect_exchange_rates_data() -> None:
"""
Collect exchange rates data. Used for monitoring only.
"""
+10 -8
View File
@@ -145,7 +145,9 @@
type="text"
:label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')"
>
><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
</q-input>
<q-toggle
v-model="formData.lnbits_ad_space_enabled"
@@ -154,9 +156,9 @@
<br />
</div>
</div>
<p><span v-text="$t('ui_default_theme')"></span></p>
<div class="row q-col-gutter-md">
<div class="col">
<div class="row q-col-gutter-md q-my-md">
<div class="col-12" v-text="$t('ui_default_theme')"></div>
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_border"
:options="globalBorderOptions"
@@ -165,7 +167,7 @@
>
</q-select>
</div>
<div class="col">
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_theme"
:options="lnbits_theme_options"
@@ -174,7 +176,7 @@
>
</q-select>
</div>
<div class="col">
<div class="col-12 col-sm-6 col-lg-3">
<q-select
v-model="formData.lnbits_default_reaction"
:options="reactionOptions"
@@ -183,7 +185,7 @@
>
</q-select>
</div>
<div class="col">
<div class="col-12 col-sm-6 col-lg-3">
<q-input
type="text"
v-model="formData.lnbits_default_bgimage"
@@ -193,7 +195,7 @@
>
</q-input>
</div>
<div class="col">
<div class="col-12 col-sm-6 col-lg-3">
<q-toggle
type="bool"
v-model="formData.lnbits_default_gradient"
+184 -72
View File
@@ -7,38 +7,71 @@
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div>
<div class="q-gutter-y-md">
<q-tabs v-model="tab" align="left">
<q-tab
name="user"
:label="$t('account_settings')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="theme"
:label="$t('look_and_feel')"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="api_acls"
:label="$t('access_control_list')"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
<div class="q-pa-sm">
<div class="row items-center justify-between q-gutter-xs">
<div class="col">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md">
<div v-if="user" class="col-md-12 col-lg-6 q-gutter-y-md">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-tab-panels v-model="tab">
<q-splitter>
<template v-slot:before>
<q-tabs v-model="tab" vertical active-color="primary">
<q-tab
name="user"
icon="person"
:label="$q.screen.gt.sm ? $t('account_settings') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('account_settings')"></span
></q-tooltip>
</q-tab>
<q-tab
name="notifications"
icon="notifications"
:label="$q.screen.gt.sm ? $t('notifications') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('notifications')"></span
></q-tooltip>
</q-tab>
<q-tab
name="theme"
icon="palette"
:label="$q.screen.gt.sm ? $t('look_and_feel') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('look_and_feel')"></span
></q-tooltip>
</q-tab>
<q-tab
name="api_acls"
icon="lock"
:label="$q.screen.gt.sm ? $t('access_control_list') : ''"
@update="val => tab = val.name"
>
<q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('access_control_list')"></span
></q-tooltip>
</q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-scroll-area style="height: 80vh">
<q-tab-panels v-if="user" v-model="tab">
<q-tab-panel name="user">
<div v-if="credentialsData.show">
<q-card-section>
@@ -293,9 +326,6 @@
</q-card-section>
<q-separator></q-separator>
<q-card-section>
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('change_password')"
@@ -368,89 +398,89 @@
</div>
<div class="col-8">
<q-btn
v-if="g.allowedThemes.includes('classic')"
v-if="allowedThemes.includes('classic')"
dense
flat
@click="themeChoiceFunc('classic')"
@click="changeTheme('classic')"
icon="circle"
color="deep-purple"
size="md"
><q-tooltip>classic</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('bitcoin')"
v-if="allowedThemes.includes('bitcoin')"
dense
flat
@click="themeChoiceFunc('bitcoin')"
@click="changeTheme('bitcoin')"
icon="circle"
color="deep-orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('mint')"
v-if="allowedThemes.includes('mint')"
dense
flat
@click="themeChoiceFunc('mint')"
@click="changeTheme('mint')"
icon="circle"
color="green"
size="md"
><q-tooltip>mint</q-tooltip> </q-btn
><q-btn
v-if="g.allowedThemes.includes('autumn')"
v-if="allowedThemes.includes('autumn')"
dense
flat
@click="themeChoiceFunc('autumn')"
@click="changeTheme('autumn')"
icon="circle"
color="brown"
size="md"
><q-tooltip>autumn</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('monochrome')"
v-if="allowedThemes.includes('monochrome')"
dense
flat
@click="themeChoiceFunc('monochrome')"
@click="changeTheme('monochrome')"
icon="circle"
color="grey"
size="md"
><q-tooltip>monochrome</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('salvador')"
v-if="allowedThemes.includes('salvador')"
dense
flat
@click="themeChoiceFunc('salvador')"
@click="changeTheme('salvador')"
icon="circle"
color="blue-10"
size="md"
><q-tooltip>elSalvador</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('freedom')"
v-if="allowedThemes.includes('freedom')"
dense
flat
@click="themeChoiceFunc('freedom')"
@click="changeTheme('freedom')"
icon="circle"
color="pink-13"
size="md"
><q-tooltip>freedom</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('cyber')"
v-if="allowedThemes.includes('cyber')"
dense
flat
@click="themeChoiceFunc('cyber')"
@click="changeTheme('cyber')"
icon="circle"
color="light-green-9"
size="md"
><q-tooltip>cyber</q-tooltip>
</q-btn>
<q-btn
v-if="g.allowedThemes.includes('flamingo')"
v-if="allowedThemes.includes('flamingo')"
dense
flat
@click="themeChoiceFunc('flamingo')"
@click="changeTheme('flamingo')"
icon="circle"
color="pink-3"
size="md"
@@ -464,9 +494,9 @@
</div>
<div class="col-8">
<q-input
v-model="bgimageSelection"
v-model="bgimageChoice"
:label="$t('background_image')"
@update:model-value="bgimageChoiceFunc"
@update:model-value="applyBackgroundImage"
>
<q-tooltip
><span v-text="$t('background_image')"></span
@@ -479,19 +509,18 @@
<span v-text="$t('gradient_background')"></span>
</div>
<div class="col-8">
<q-btn
<q-toggle
dense
flat
round
@click="toggleGradient"
icon="gradient"
size="sm"
v-model="gradientChoice"
@update:model-value="applyGradient"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
></q-tooltip>
</q-btn>
</q-toggle>
</div>
</div>
@@ -500,10 +529,11 @@
<span v-text="$t('toggle_darkmode')"></span>
</div>
<div class="col-8">
<q-btn
<q-toggle
dense
flat
round
v-model="darkChoice"
@click="toggleDarkMode"
:icon="($q.dark.isActive) ? 'brightness_3' : 'wb_sunny'"
size="sm"
@@ -511,7 +541,7 @@
<q-tooltip
><span v-text="$t('toggle_darkmode')"></span
></q-tooltip>
</q-btn>
</q-toggle>
</div>
</div>
<div class="row q-mb-md">
@@ -520,7 +550,7 @@
</div>
<div class="col-8">
<q-select
v-model="borderSelection"
v-model="borderChoice"
:options="borderOptions"
label="Borders"
@update:model-value="applyBorder"
@@ -548,7 +578,7 @@
</div>
<div class="col-8">
<q-select
v-model="reactionSelection"
v-model="reactionChoice"
:options="reactionOptions"
label="Reactions"
@update:model-value="reactionChoiceFunc"
@@ -559,12 +589,101 @@
</q-select>
</div>
</div>
<q-separator></q-separator>
<div class="row q-mb-md q-mt-md">
<q-btn @click="updateAccount" unelevated color="primary">
<span v-text="$t('update_account')"></span>
</q-btn>
</div>
</q-tab-panel>
<q-tab-panel name="notifications">
<q-card-section>
<div class="row q-mb-md">
<div class="col-4">
<span
v-text="$t('notifications_nostr_identifier')"
></span>
{%if not nostr_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.nostr_identifier"
:hint="$t('notifications_nostr_identifier_desc')"
>
</q-input>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notifications_chat_id')"></span>
{%if not telegram_configured%}
<br />
<q-badge v-text="$t('not_connected')"></q-badge>
{%endif%}
</div>
<div class="col-8">
<q-input
filled
dense
v-model="user.extra.notifications.telegram_chat_id"
:hint="$t('notifications_chat_id_desc')"
/>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_outgoing_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.outgoing_payments_sats"
:hint="$t('notification_outgoing_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('notification_incoming_payment')"></span>
</div>
<div class="col-8">
<q-input
filled
dense
type="number"
min="0"
step="1"
v-model="user.extra.notifications.incoming_payments_sats"
:hint="$t('notification_incoming_payment_desc')"
/>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('exclude_wallets')"></span>
</div>
<div class="col-8">
<q-select
filled
dense
emit-value
map-options
multiple
v-model="user.extra.notifications.excluded_wallets"
:options="g.user.walletOptions"
:label="$t('exclude_wallets')"
:hint="$t('notifications_excluded_wallets_desc')"
class="q-mt-sm"
>
</q-select>
</div>
</div>
</q-card-section>
</q-tab-panel>
<q-tab-panel name="api_acls">
<div class="row q-mb-md">
@@ -791,16 +910,9 @@
</q-card-section>
</q-tab-panel>
</q-tab-panels>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div v-else class="col-12 col-md-6 q-gutter-y-md">
<q-card>
<q-card-section>
<h4 class="q-my-none"><span v-text="$t('account')"></span></h4>
</q-card-section>
</q-scroll-area>
</template>
</q-splitter>
</q-card>
</div>
</div>
+163 -6
View File
@@ -289,9 +289,87 @@
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
group="extras"
icon="qr_code"
v-if="stored_paylinks.length > 0"
:label="$t('stored_paylinks')"
>
<q-card>
<q-card-section>
<div class="row flex" v-for="paylink in stored_paylinks">
<q-btn
dense
flat
color="primary"
icon="send"
size="xs"
@click="sendToPaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`send to: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<q-btn
dense
flat
color="secondary"
icon="content_copy"
size="xs"
@click="copyText(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`copy: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<span
v-text="paylink.label"
class="q-mr-xs q-ml-xs"
></span>
<q-btn dense flat color="primary" icon="edit" size="xs">
<q-popup-edit
@update:model-value="editPaylink()"
v-model="paylink.label"
v-slot="scope"
>
<q-input
dark
color="white"
v-model="scope.value"
dense
autofocus
counter
@keyup.enter="scope.set"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>
<span v-text="$t('edit')"></span>
</q-tooltip>
</q-btn>
<span style="flex-grow: 1"></span>
<q-btn
dense
flat
color="red"
icon="delete"
size="xs"
@click="deletePaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
<span v-text="dateFromNow(paylink.last_used)"></span>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="phone_android"
:label="$t('access_wallet_on_mobile')"
@@ -661,14 +739,48 @@
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
<q-input
v-if="has_holdinvoice"
filled
dense
v-model="receive.data.payment_hash"
:label="$t('hold_invoice_payment_hash')"
></q-input>
<q-input
filled
dense
type="textarea"
rows="2"
v-model.trim="receive.data.memo"
v-model="receive.data.memo"
:label="$t('memo')"
>
<template v-if="receive.data.internalMemo === null" v-slot:append>
<q-icon
name="add_comment"
@click.stop.prevent="receive.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip>
</template>
</q-input>
<q-input
v-if="receive.data.internalMemo !== null"
autogrow
filled
dense
v-model="receive.data.internalMemo"
class="q-mb-lg"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_receive')"
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="receive.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input>
<div v-if="g.user.fiat_providers?.length" class="q-mt-md">
<q-list bordered dense class="rounded-borders">
@@ -848,6 +960,21 @@
</div>
<q-separator></q-separator>
<h6 class="text-center" v-text="parse.invoice.description"></h6>
<q-input
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class="q-mb-lg"
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-if="parse.data.internalMemo" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer" /></template
></q-input>
<q-list separator bordered dense class="q-mb-md">
<q-expansion-item expand-separator icon="info" label="Details">
<q-list separator>
@@ -1041,7 +1168,7 @@
</p>
</div>
<div class="row">
<div class="col">
<div class="col q-mb-lg">
<q-select
filled
dense
@@ -1075,9 +1202,39 @@
filled
dense
v-model="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
:type="parse.lnurlpay.commentAllowed > 512 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
><template
v-if="parse.data.internalMemo === null"
v-slot:append
>
<q-icon
name="add_comment"
@click.stop.prevent="parse.data.internalMemo = ''"
class="cursor-pointer"
></q-icon>
<q-tooltip>
<span v-text="$t('internal_memo')"></span>
</q-tooltip> </template
></q-input>
<br />
<q-input
v-if="parse.data.internalMemo !== null"
autogrow
filled
dense
v-model="parse.data.internalMemo"
:label="$t('internal_memo')"
:hint="$t('internal_memo_hint_pay')"
class=""
:rules="[ val => !val || val.length <= 512 || 'Please use maximum 512 characters' ]"
><template v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="parse.data.internalMemo = null"
class="cursor-pointer"
/> </template
></q-input>
</div>
</div>
+13 -6
View File
@@ -16,7 +16,7 @@ from lnbits.core.models import User
from lnbits.core.models.misc import Image, SimpleStatus
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
enqueue_notification,
enqueue_admin_notification,
get_balance_delta,
update_cached_settings,
)
@@ -65,7 +65,9 @@ async def api_monitor():
)
async def api_test_email():
return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email"
settings.lnbits_email_notifications_to_emails,
"This is a LNbits test email.",
"LNbits Test Email",
)
@@ -82,10 +84,13 @@ async def api_get_settings(
status_code=HTTPStatus.OK,
)
async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
enqueue_notification(NotificationType.settings_update, {"username": user.username})
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
await update_admin_settings(data)
admin_settings = await get_admin_settings(user.super_user)
assert admin_settings, "Updated admin settings not found."
if not admin_settings:
raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict())
core_app_extra.register_new_ratelimiter()
return {"status": "Success"}
@@ -112,7 +117,9 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_notification(NotificationType.settings_update, {"username": user.username})
enqueue_admin_notification(
NotificationType.settings_update, {"username": user.username}
)
await reset_core_settings()
server_restart.set()
@@ -140,7 +147,7 @@ async def api_download_backup() -> FileResponse:
pg_backup_filename = f"{settings.lnbits_data_folder}/lnbits-database.dmp"
is_pg = db_url and db_url.startswith("postgres://")
if is_pg:
if is_pg and db_url:
p = urlparse(db_url)
command = (
f"pg_dump --host={p.hostname} "
+3 -156
View File
@@ -1,36 +1,20 @@
import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Any
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
import pyqrcode
from fastapi import (
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from lnbits.core.models import (
BaseWallet,
ConversionData,
CreateLnurlAuth,
CreateWallet,
User,
Wallet,
)
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.lnurl import decode as lnurl_decode
from lnbits.decorators import check_user_exists
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
@@ -41,7 +25,7 @@ from lnbits.utils.exchange_rates import (
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import StatusResponse
from ..services import create_user_account, perform_lnurlauth
from ..services import create_user_account
api_router = APIRouter(tags=["Core"])
@@ -92,143 +76,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan(
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) from exc
# params is what will be returned to the client
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
assert lnurlauth_key.verifying_key
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
check_callback_url(url)
try:
r = await client.get(url, timeout=5)
r.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise HTTPException(HTTPStatus.NOT_FOUND, "Not found") from exc
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": "failed to get parameters",
},
) from exc
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
) from exc
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode()
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k in ("image/jpeg;base64", "image/png;base64"):
data_uri = f"data:{k},{v}"
params.update(image=data_uri)
if k in ("text/email", "text/identifier"):
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
) from exc
return params
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@api_router.get(
"/api/v1/rate/history",
dependencies=[Depends(check_user_exists)],
+53 -28
View File
@@ -191,12 +191,14 @@ async def api_create_user_api_token(
data: ApiTokenRequest,
user: User = Depends(check_user_exists),
) -> ApiTokenResponse:
assert data.expiration_time_minutes > 0, "Expiration time must be in the future."
if not data.expiration_time_minutes > 0:
raise ValueError("Expiration time must be in the future.")
account = await get_account(user.id)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
assert account.username, "Username must be configured."
if not account.username:
raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id)
@@ -223,7 +225,8 @@ async def api_delete_user_api_token(
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
assert account.username, "Username must be configured."
if not account.username:
raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id)
@@ -318,7 +321,7 @@ async def update_pubkey(
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
raise ValueError("Invalid user ID.")
_validate_auth_timeout(payload.auth_time)
if (
@@ -326,7 +329,7 @@ async def update_pubkey(
and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
raise ValueError("Public key already in use.")
account = await get_account(user.id)
if not account:
@@ -344,7 +347,8 @@ async def update_password(
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
_validate_auth_timeout(payload.auth_time)
assert data.user_id == user.id, "Invalid user ID."
if data.user_id != user.id:
raise ValueError("Invalid user ID.")
if (
data.username
and user.username != data.username
@@ -353,12 +357,15 @@ async def update_password(
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
assert account, "Account not found."
if not account:
raise ValueError("Account not found.")
# old accounts do not have a password
if account.password_hash:
assert data.password_old, "Missing old password."
assert account.verify_password(data.password_old), "Invalid old password."
if not data.password_old:
raise ValueError("Missing old password.")
if not account.verify_password(data.password_old):
raise ValueError("Invalid old password.")
account.username = data.username
account.hash_password(data.password)
@@ -376,8 +383,10 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
HTTPStatus.FORBIDDEN, "Auth by 'Username and Password' not allowed."
)
assert data.password == data.password_repeat, "Passwords do not match."
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
if data.password != data.password_repeat:
raise ValueError("Passwords do not match.")
if not data.reset_key[:10].startswith("reset_key_"):
raise ValueError("This is not a reset key.")
try:
reset_key = base64.b64decode(data.reset_key[10:]).decode()
@@ -385,12 +394,16 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
except Exception as exc:
raise ValueError("Invalid reset key.") from exc
assert reset_data_json, "Cannot process reset key."
if not reset_data_json:
raise ValueError("Cannot process reset key.")
action, user_id, request_time = json.loads(reset_data_json)
assert action, "Missing action."
assert user_id, "Missing user ID."
assert request_time, "Missing reset time."
if not action:
raise ValueError("Missing action.")
if not user_id:
raise ValueError("Missing user ID.")
if not request_time:
raise ValueError("Missing reset time.")
_validate_auth_timeout(request_time)
@@ -557,8 +570,8 @@ def _find_auth_provider_class(provider: str) -> Callable:
provider_class = getattr(provider_module, f"{provider.title()}SSO")
if provider_class:
return provider_class
except Exception:
pass
except Exception as exc:
logger.debug(exc)
raise ValueError(f"No SSO provider found for '{provider}'.")
@@ -576,28 +589,40 @@ def _nostr_nip98_event(request: Request) -> dict:
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
assert event, "Nostr login event cannot be parsed."
if not event:
raise ValueError("Nostr login event cannot be parsed.")
if not verify_event(event):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
assert event["kind"] == 27_235, "Invalid event kind."
if not event["kind"] == 27_235:
raise ValueError("Invalid event kind.")
auth_threshold = settings.auth_credetials_update_threshold
assert (
abs(time() - event["created_at"]) < auth_threshold
), f"More than {auth_threshold} seconds have passed since the event was signed."
if not (abs(time() - event["created_at"]) < auth_threshold):
raise ValueError(
f"More than {auth_threshold} seconds have passed "
"since the event was signed."
)
_check_nostr_event_tags(event)
return event
def _check_nostr_event_tags(event: dict):
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Invalid value for tag 'method'."
if not method:
raise ValueError("Tag 'method' is missing.")
if not method.upper() == "POST":
raise ValueError("Invalid value for tag 'method'.")
url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing."
if not url:
raise ValueError("Tag 'u' for URL is missing.")
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
return event
if url not in accepted_urls:
raise ValueError(f"Invalid value for tag 'u': '{url}'.")
def _validate_auth_timeout(auth_time: Optional[int] = 0):
+64 -79
View File
@@ -170,64 +170,52 @@ async def api_enable_extension(
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated."
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
logger.info(f"Enabling extension: {ext_id}.")
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
if user.admin or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user.admin or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
if user_ext.is_paid:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
), f"Extension '{ext_id}' is missing payment wallet."
payment_status = await check_transaction_status(
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if not payment_status.paid:
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
if user_ext.is_paid:
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except HTTPException as exc:
raise exc from exc
except Exception as exc:
logger.warning(exc)
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
raise ValueError(f"Extension '{ext_id}' is missing payment wallet.")
payment_status = await check_transaction_status(
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
)
if not payment_status.paid:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to enable '{ext_id}' "),
) from exc
HTTPStatus.PAYMENT_REQUIRED,
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
@extension_router.put("/{ext_id}/disable")
@@ -243,7 +231,7 @@ async def api_disable_extension(
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
)
logger.info(f"Disabeling extension: {ext_id}.")
logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@@ -255,7 +243,8 @@ async def api_activate_extension(ext_id: str) -> SimpleStatus:
logger.info(f"Activating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
if not ext:
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
await activate_extension(ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
@@ -274,7 +263,8 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
logger.info(f"Deactivating extension: '{ext_id}'.")
ext = await get_valid_extension(ext_id)
assert ext, f"Extension '{ext_id}' doesn't exist."
if not ext:
raise ValueError(f"Extension '{ext_id}' doesn't exist.")
await deactivate_extension(ext_id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
@@ -354,40 +344,35 @@ async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
async def get_pay_to_install_invoice(
ext_id: str, data: CreateExtension
) -> ReleasePaymentInfo:
try:
assert (
ext_id == data.ext_id
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
assert data.cost_sats, "A non-zero amount must be specified."
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
if ext_id != data.ext_id:
raise ValueError(
f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
)
assert release, "Release not found."
assert release.pay_link, "Pay link not found for release."
if not data.cost_sats:
raise ValueError("A non-zero amount must be specified.")
release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version
)
if not release:
raise ValueError("Release not found.")
if not release.pay_link:
raise ValueError("Pay link not found for release.")
payment_info = await release.fetch_release_payment_info(data.cost_sats)
payment_info = await release.fetch_release_payment_info(data.cost_sats)
assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request)
if not (payment_info and payment_info.payment_request):
raise ValueError("Cannot request invoice.")
invoice = bolt11_decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing."
invoice_amount = int(invoice.amount_msat / 1000)
assert (
invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}."
assert (
payment_info.payment_hash == invoice.payment_hash
), "Wrong invoice payment hash."
if invoice.amount_msat is None:
raise ValueError("Invoic amount is missing.")
invoice_amount = int(invoice.amount_msat / 1000)
if invoice_amount != data.cost_sats:
raise ValueError(f"Wrong invoice amount: {invoice_amount}.")
if payment_info.payment_hash != invoice.payment_hash:
raise ValueError("Wrong invoice payment hash.")
return payment_info
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
) from exc
return payment_info
@extension_router.put("/{ext_id}/invoice/enable")
+6 -6
View File
@@ -7,7 +7,7 @@ from fastapi import Cookie, Depends, Query, Request
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from lnurl import url_decode
from pydantic.types import UUID4
from lnbits.core.helpers import to_valid_user_id
@@ -206,11 +206,15 @@ async def account(
request: Request,
user: User = Depends(check_user_exists),
):
nostr_configured = settings.is_nostr_notifications_configured()
telegram_configured = settings.is_telegram_notifications_configured()
return template_renderer().TemplateResponse(
request,
"core/account.html",
{
"user": user.json(),
"nostr_configured": nostr_configured,
"telegram_configured": telegram_configured,
"ajax": _is_ajax_request(request),
},
)
@@ -341,7 +345,6 @@ async def node(request: Request, user: User = Depends(check_admin)):
"node/index.html",
{
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].json(),
"ajax": _is_ajax_request(request),
@@ -361,7 +364,6 @@ async def node_public(request: Request):
request,
"node/public.html",
{
"settings": settings.dict(),
"balance": balance,
},
)
@@ -380,7 +382,6 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
"admin/index.html",
{
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
@@ -398,7 +399,6 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
{
"request": request,
"user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
"ajax": _is_ajax_request(request),
},
@@ -456,7 +456,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
if not settings.lnbits_allow_new_accounts:
return {"status": "ERROR", "reason": "New accounts are not allowed."}
lnurl = lnurl_decode(lightning)
lnurl = url_decode(lightning)
async with httpx.AsyncClient() as client:
check_callback_url(lnurl)
+175
View File
@@ -0,0 +1,175 @@
from http import HTTPStatus
from typing import Any
from fastapi import (
APIRouter,
Depends,
HTTPException,
)
from lnurl import (
LnurlResponseException,
LnurlSuccessResponse,
)
from lnurl import execute_login as lnurlauth
from lnurl import execute_withdraw as lnurl_withdraw
from lnurl import handle as lnurl_handle
from lnurl.models import (
LnurlAuthResponse,
LnurlErrorResponse,
LnurlPayResponse,
LnurlResponseModel,
LnurlWithdrawResponse,
)
from loguru import logger
from lnbits.core.models import CreateLnurlWithdraw, Payment
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from ..services import fetch_lnurl_pay_request, pay_invoice
lnurl_router = APIRouter(tags=["LNURL"])
async def _handle(lnurl: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
return res
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
deprecated=True,
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan(code: str) -> LnurlResponseModel:
res = await _handle(code)
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
check_callback_url(res.callback)
return res
@lnurl_router.post(
"/api/v1/lnurlscan",
dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse
| LnurlWithdrawResponse
| LnurlAuthResponse
| LnurlErrorResponse,
)
async def api_lnurlscan_post(scan: LnurlScan) -> LnurlResponseModel:
return await _handle(scan.lnurl)
@lnurl_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: LnurlAuthResponse, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> LnurlResponseModel:
check_callback_url(data.callback)
try:
res = await lnurlauth(
res=data,
seed=key_type.wallet.adminkey,
user_agent=settings.user_agent,
timeout=5,
)
return res
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
@lnurl_router.post("/api/v1/payments/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
"""
Pay an LNURL payment request.
Either provice `res` (LnurlPayResponse) or `lnurl` (str) in the `data` object.
"""
if not data.res and not data.lnurl:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Missing LNURL or LnurlPayResponse data.",
)
try:
res, res2 = await fetch_lnurl_pay_request(data=data, wallet=wallet.wallet)
except LnurlResponseException as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
extra: dict[str, Any] = {}
if res2.disposable is False:
extra["stored"] = True
if res2.success_action:
extra["success_action"] = res2.success_action.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=str(res2.pr),
description=res.metadata.text,
extra=extra,
)
return payment
@lnurl_router.post(
"/api/v1/payments/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK
)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: CreateLnurlWithdraw,
) -> LnurlErrorResponse | LnurlSuccessResponse:
if not lnurl_data.lnurl_w.lud17:
return LnurlErrorResponse(reason="LNURL-withdraw lud17 not provided.")
try:
url = lnurl_data.lnurl_w.lud17
res = await lnurl_handle(url, user_agent=settings.user_agent, timeout=10)
except (LnurlResponseException, Exception) as exc:
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res, LnurlWithdrawResponse):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
try:
check_callback_url(res.callback)
except ValueError as exc:
return LnurlErrorResponse(reason=f"Invalid callback URL: {exc!s}")
try:
res2 = await lnurl_withdraw(
res, payment_request, user_agent=settings.user_agent, timeout=10
)
except (LnurlResponseException, Exception) as exc:
logger.warning(exc)
return LnurlErrorResponse(reason=str(exc))
if not isinstance(res2, (LnurlSuccessResponse, LnurlErrorResponse)):
return LnurlErrorResponse(reason="Invalid LNURL-withdraw response.")
return res2
+10 -5
View File
@@ -7,8 +7,9 @@ from pydantic import BaseModel
from starlette.status import HTTP_503_SERVICE_UNAVAILABLE
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import Feature
from ...db import Filters, Page
from ...nodes.base import (
@@ -26,9 +27,13 @@ from ...nodes.base import (
from ...utils.cache import cache
def require_node():
node_class = get_node_class()
if not node_class:
def require_node() -> Node:
funding_source = get_funding_source()
if (
not funding_source.features
or Feature.nodemanager not in funding_source.features
or not funding_source.__node_cls__
):
raise HTTPException(
status_code=HTTPStatus.NOT_IMPLEMENTED,
detail="Active backend does not implement Node API",
@@ -38,7 +43,7 @@ def require_node():
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Not enabled",
)
return node_class
return funding_source.__node_cls__(funding_source)
def check_public():
+37 -147
View File
@@ -1,11 +1,7 @@
import json
import ssl
from hashlib import sha256
from http import HTTPStatus
from math import ceil
from typing import Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
Depends,
@@ -14,7 +10,7 @@ from fastapi import (
Query,
)
from fastapi.responses import JSONResponse
from loguru import logger
from lnurl import url_decode
from lnbits import bolt11
from lnbits.core.crud.payments import (
@@ -22,11 +18,10 @@ from lnbits.core.crud.payments import (
get_wallets_stats,
)
from lnbits.core.models import (
CancelInvoice,
CreateInvoice,
CreateLnurl,
DecodePayment,
KeyType,
PayLnurlWData,
Payment,
PaymentCountField,
PaymentCountStat,
@@ -34,6 +29,7 @@ from lnbits.core.models import (
PaymentFilters,
PaymentHistoryPoint,
PaymentWalletStats,
SettleInvoice,
)
from lnbits.core.models.users import User
from lnbits.db import Filters, Page
@@ -45,13 +41,10 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import (
check_callback_url,
filter_dict_keys,
generate_filter_params_openapi,
)
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from lnbits.wallets.base import InvoiceResponse
from ..crud import (
DateTrunc,
@@ -62,11 +55,12 @@ from ..crud import (
get_wallet_for_key,
)
from ..services import (
create_fiat_invoice,
create_wallet_invoice,
cancel_hold_invoice,
create_payment_request,
fee_reserve_total,
get_payments_daily_stats,
pay_invoice,
settle_hold_invoice,
update_pending_payment,
update_pending_payments,
)
@@ -121,7 +115,6 @@ async def api_payments_counting_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
@@ -142,7 +135,6 @@ async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
user: User = Depends(check_user_exists),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
@@ -163,7 +155,6 @@ async def api_payments_daily_stats(
user: User = Depends(check_user_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
):
if user.admin:
# admin user can see payments from all wallets
for_user_id = None
@@ -264,10 +255,7 @@ async def api_payments_create(
)
# If the payment is not outgoing, we can create a new invoice.
if invoice_data.fiat_provider:
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
return await create_payment_request(wallet_id, invoice_data)
@payment_router.get("/fee-reserve")
@@ -285,79 +273,6 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
check_callback_url(data.callback)
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.HTTPError, ssl.SSLError) as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return payment
# TODO: refactor this route into a public and admin one
@payment_router.get("/{payment_hash}")
async def api_payment(payment_hash, x_api_key: Optional[str] = Header(None)):
@@ -403,7 +318,7 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
payment_str = data.data
try:
if payment_str[:5] == "LNURL":
url = str(lnurl_decode(payment_str))
url = str(url_decode(payment_str))
return JSONResponse({"domain": url})
else:
invoice = bolt11.decode(payment_str)
@@ -416,57 +331,32 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
)
@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK)
async def api_payment_pay_with_nfc(
payment_request: str,
lnurl_data: PayLnurlWData,
) -> JSONResponse:
lnurl = lnurl_data.lnurl_w.lower()
@payment_router.post("/settle")
async def api_payments_settle(
data: SettleInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment_hash = sha256(bytes.fromhex(data.preimage)).hexdigest()
payment = await get_standalone_payment(
payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await settle_hold_invoice(payment, data.preimage)
# Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md
url = lnurl.replace("lnurlw://", "https://")
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
check_callback_url(url)
lnurl_req = await client.get(url, timeout=10)
if lnurl_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading LNURL request"}
)
lnurl_res = lnurl_req.json()
if lnurl_res.get("status") == "ERROR":
return JSONResponse({"success": False, "detail": lnurl_res["reason"]})
if lnurl_res.get("tag") != "withdrawRequest":
return JSONResponse(
{"success": False, "detail": "Invalid LNURL-withdraw"}
)
callback_url = lnurl_res["callback"]
k1 = lnurl_res["k1"]
callback_req = await client.get(
callback_url,
params={"k1": k1, "pr": payment_request},
timeout=10,
)
if callback_req.is_error:
return JSONResponse(
{"success": False, "detail": "Error loading callback request"}
)
callback_res = callback_req.json()
if callback_res.get("status") == "ERROR":
return JSONResponse(
{"success": False, "detail": callback_res["reason"]}
)
else:
return JSONResponse({"success": True, "detail": callback_res})
except Exception as e:
return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"})
@payment_router.post("/cancel")
async def api_payments_cancel(
data: CancelInvoice, key_type: WalletTypeInfo = Depends(require_admin_key)
) -> InvoiceResponse:
payment = await get_standalone_payment(
data.payment_hash, incoming=True, wallet_id=key_type.wallet.id
)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Payment does not exist or does not belong to this wallet.",
)
return await cancel_hold_invoice(payment)
+4 -3
View File
@@ -35,7 +35,7 @@ from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import (
create_user_account_no_ckeck,
enqueue_notification,
enqueue_admin_notification,
update_user_account,
update_user_extensions,
update_wallet_balance,
@@ -187,7 +187,8 @@ async def api_users_reset_password(user_id: str) -> str:
reset_data = ["reset", user_id, int(time.time())]
reset_data_json = json.dumps(reset_data, separators=(",", ":"), ensure_ascii=False)
reset_key = encrypt_internal_message(reset_data_json)
assert reset_key, "Cannot generate reset key."
if not reset_key:
raise ValueError("Cannot generate reset key.")
reset_key_b64 = base64.b64encode(reset_key.encode()).decode()
return f"reset_key_{reset_key_b64}"
@@ -320,7 +321,7 @@ async def api_update_balance(data: UpdateBalance) -> SimpleStatus:
if not wallet:
raise HTTPException(HTTPStatus.NOT_FOUND, "Wallet not found.")
await update_wallet_balance(wallet=wallet, amount=int(data.amount))
enqueue_notification(
enqueue_admin_notification(
NotificationType.balance_update,
{
"amount": int(data.amount),
+17 -2
View File
@@ -10,11 +10,11 @@ from fastapi import (
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
@@ -93,6 +93,21 @@ async def api_reset_wallet_keys(
return wallet
@wallet_router.put("/stored_paylinks/{wallet_id}")
async def api_put_stored_paylinks(
wallet_id: str,
data: StoredPayLinks,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> list[StoredPayLink]:
if key_info.wallet.id != wallet_id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="You cannot modify this wallet"
)
key_info.wallet.stored_paylinks.links = data.links
wallet = await update_wallet(key_info.wallet)
return wallet.stored_paylinks.links
@wallet_router.patch("")
async def api_update_wallet(
name: Optional[str] = Body(None),
+13 -9
View File
@@ -252,7 +252,7 @@ class Connection(Compat):
{clause}
{group_by_string}
) as count
""",
""", # noqa: S608
parsed_values,
)
row = result.mappings().first()
@@ -281,15 +281,19 @@ class Database(Compat):
self.schema = self.name
self.type = DB_TYPE
if DB_TYPE == SQLITE:
if self.type == POSTGRES and settings.lnbits_database_url:
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
elif self.type == COCKROACH and settings.lnbits_database_url:
database_uri = settings.lnbits_database_url.replace(
"cockroachdb://", "cockroachdb+asyncpg://"
)
else:
self.path = os.path.join(
settings.lnbits_data_folder, f"{self.name}.sqlite3"
)
database_uri = f"sqlite+aiosqlite:///{self.path}"
else:
database_uri = settings.lnbits_database_url.replace(
"postgres://", "postgresql+asyncpg://"
)
if self.name.startswith("ext_"):
self.schema = self.name[4:]
@@ -597,7 +601,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})" # noqa: S608
def update_query(
@@ -615,7 +619,7 @@ def update_query(
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
def model_to_dict(model: BaseModel) -> dict:
@@ -658,7 +662,7 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
+1 -1
View File
@@ -69,7 +69,7 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
)
def register_exception_handlers(app: FastAPI):
def register_exception_handlers(app: FastAPI): # noqa: C901
"""Register exception handlers for the FastAPI app"""
@app.exception_handler(Exception)
+11 -4
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import importlib
from enum import Enum
from loguru import logger
from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
@@ -15,7 +17,7 @@ class FiatProviderType(Enum):
stripe = "StripeWallet"
async def get_fiat_provider(name: str) -> FiatProvider:
async def get_fiat_provider(name: str) -> FiatProvider | None:
if name not in FiatProviderType.__members__:
raise ValueError(f"Fiat provider '{name}' is not supported.")
@@ -27,13 +29,18 @@ async def get_fiat_provider(name: str) -> FiatProvider:
del fiat_providers[name]
else:
return fiat_provider
fiat_providers[name] = _init_fiat_provider(FiatProviderType[name])
_provider = _init_fiat_provider(FiatProviderType[name])
if not _provider:
return None
fiat_providers[name] = _provider
return fiat_providers[name]
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider:
def _init_fiat_provider(fiat_provider: FiatProviderType) -> FiatProvider | None:
if not settings.is_fiat_provider_enabled(fiat_provider.name):
raise ValueError(f"Fiat provider '{fiat_provider.name}' not enabled.")
logger.warning(f"Fiat provider '{fiat_provider.name}' not enabled.")
return None
provider_constructor = getattr(fiat_module, fiat_provider.value)
return provider_constructor()
+9 -10
View File
@@ -16,7 +16,6 @@ from packaging import version
from pydantic.schema import field_schema
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.settings import settings
from lnbits.utils.crypto import AESCipher
@@ -83,8 +82,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"LNBITS_NODE_UI": settings.lnbits_node_ui and get_node_class() is not None,
"LNBITS_NODE_UI_AVAILABLE": get_node_class() is not None,
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
@@ -100,11 +99,8 @@ def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templa
"USE_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_DENOMINATION": (
settings.lnbits_denomination
if settings.lnbits_denomination == "FakeWallet"
else "sats"
),
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
@@ -306,8 +302,11 @@ def check_callback_url(url: str):
)
def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file:
def download_url(url: str, save_path: Path):
if not url.startswith(("http:", "https:")):
raise ValueError(f"Invalid URL: {url}. Must start with 'http' or 'https'.")
with request.urlopen(url, timeout=60) as dl_file: # noqa: S310
with open(save_path, "wb") as out_file:
out_file.write(dl_file.read())
-66
View File
@@ -1,66 +0,0 @@
from http import HTTPStatus
from typing import Callable
from fastapi import HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from lnurl import LnurlErrorResponse, decode, encode, handle
from loguru import logger
from lnbits.exceptions import InvoiceError, PaymentError
class LnurlErrorResponseHandler(APIRoute):
"""
Custom APIRoute class to handle LNURL errors.
LNURL errors always return with status 200 and
a JSON response with `status="ERROR"` and a `reason` key.
Helps to catch HTTPException and return a valid lnurl error response
Example:
withdraw_lnurl_router = APIRouter(prefix="/api/v1/lnurl")
withdraw_lnurl_router.route_class = LnurlErrorResponseHandler
"""
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def lnurl_route_handler(request: Request) -> Response:
try:
response = await original_route_handler(request)
return response
except (InvoiceError, PaymentError) as exc:
logger.debug(f"Wallet Error: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.message}"},
)
return response
except HTTPException as exc:
logger.debug(f"HTTPException: {exc}")
response = JSONResponse(
status_code=HTTPStatus.OK,
content={"status": "ERROR", "reason": f"{exc.detail}"},
)
return response
except Exception as exc:
logger.error("Unknown Error:", exc)
response = JSONResponse(
status_code=HTTPStatus.OK,
content={
"status": "ERROR",
"reason": f"UNKNOWN ERROR: {exc!s}",
},
)
return response
return lnurl_route_handler
__all__ = [
"LnurlErrorResponse",
"LnurlErrorResponseHandler",
"decode",
"encode",
"handle",
]
-1
View File
@@ -131,7 +131,6 @@ class AuditMiddleware(BaseHTTPMiddleware):
try:
response = await call_next(request)
assert response
return response
finally:
if request_details:
-15
View File
@@ -1,15 +0,0 @@
from typing import Optional
from .base import Node
def get_node_class() -> Optional[Node]:
return NODE
def set_node_class(node: Node):
global NODE
NODE = node
NODE: Optional[Node] = None
+1 -1
View File
@@ -11,12 +11,12 @@ from httpx import HTTPStatusError
from loguru import logger
from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
+53 -15
View File
@@ -142,14 +142,14 @@ class ExchangeRateProvider(BaseModel):
class InstalledExtensionsSettings(LNbitsSettings):
# installed extensions that have been deactivated
lnbits_deactivated_extensions: set[str] = Field(default=[])
lnbits_deactivated_extensions: set[str] = Field(default=set())
# upgraded extensions that require API redirects
lnbits_upgraded_extensions: dict[str, str] = Field(default={})
# list of redirects that extensions want to perform
lnbits_extensions_redirects: list[RedirectPath] = Field(default=[])
# list of all extension ids
lnbits_installed_extensions_ids: set[str] = Field(default=[])
lnbits_installed_extensions_ids: set[str] = Field(default=set())
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
@@ -201,10 +201,11 @@ class InstalledExtensionsSettings(LNbitsSettings):
if r.find_in_conflict(ext_redirect_paths)
}
assert len(existing_redirects) == 0, (
f"Cannot redirect for extension '{ext_id}'."
f" Already mapped by {existing_redirects}."
)
if len(existing_redirects) != 0:
raise ValueError(
f"Cannot redirect for extension '{ext_id}'."
f" Already mapped by {existing_redirects}."
)
self._remove_extension_redirects(ext_id)
self.lnbits_extensions_redirects += ext_redirect_paths
@@ -255,7 +256,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -265,7 +266,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
lnbits_default_gradient: bool = Field(default=True)
lnbits_default_bgimage: str = Field(default=None)
lnbits_default_bgimage: str | None = Field(default=None)
class OpsSettings(LNbitsSettings):
@@ -305,7 +306,7 @@ class FeeSettings(LNbitsSettings):
return 0
reserve_min = self.lnbits_reserve_fee_min
reserve_percent = self.lnbits_reserve_fee_percent
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
return max(int(reserve_min), int(abs(amount_msat) * reserve_percent / 100.0))
class ExchangeProvidersSettings(LNbitsSettings):
@@ -431,6 +432,18 @@ class NotificationsSettings(LNbitsSettings):
default=1_000_000, ge=0
)
def is_nostr_notifications_configured(self) -> bool:
return (
self.lnbits_nostr_notifications_enabled
and self.lnbits_nostr_notifications_private_key is not None
)
def is_telegram_notifications_configured(self) -> bool:
return (
self.lnbits_telegram_notifications_enabled
and self.lnbits_telegram_notifications_access_token is not None
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
@@ -448,6 +461,18 @@ class ClicheFundingSource(LNbitsSettings):
cliche_endpoint: str | None = Field(default=None)
class CLNRestFundingSource(LNbitsSettings):
clnrest_url: str | None = Field(default=None)
clnrest_ca: str | None = Field(default=None)
clnrest_cert: str | None = Field(default=None)
clnrest_readonly_rune: str | None = Field(default=None)
clnrest_invoice_rune: str | None = Field(default=None)
clnrest_pay_rune: str | None = Field(default=None)
clnrest_renepay_rune: str | None = Field(default=None)
clnrest_last_pay_index: str | None = Field(default=None)
clnrest_nodeid: str | None = Field(default=None)
class CoreLightningFundingSource(LNbitsSettings):
corelightning_rpc: str | None = Field(default=None)
corelightning_pay_command: str = Field(default="pay")
@@ -549,6 +574,12 @@ class BreezSdkFundingSource(LNbitsSettings):
breez_use_trampoline: bool = Field(default=True)
class BreezLiquidSdkFundingSource(LNbitsSettings):
breez_liquid_api_key: str | None = Field(default=None)
breez_liquid_seed: str | None = Field(default=None)
breez_liquid_fee_offset_sat: int = Field(default=50)
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None)
@@ -601,6 +632,7 @@ class FundingSourcesSettings(
FakeWalletFundingSource,
LNbitsFundingSource,
ClicheFundingSource,
CLNRestFundingSource,
CoreLightningFundingSource,
CoreLightningRestFundingSource,
EclairFundingSource,
@@ -618,6 +650,7 @@ class FundingSourcesSettings(
NWCFundingSource,
BreezSdkFundingSource,
StrikeFundingSource,
BreezLiquidSdkFundingSource,
):
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status
@@ -676,7 +709,7 @@ class NodeUISettings(LNbitsSettings):
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password"
username_and_password = "username-password" # noqa: S105
nostr_auth_nip98 = "nostr-auth-nip98"
google_auth = "google-auth"
github_auth = "github-auth"
@@ -696,7 +729,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600, gt=0)
auth_all_methods = [a.value for a in AuthMethods]
auth_all_methods: list[str] = [a.value for a in AuthMethods]
auth_allowed_methods: list[str] = Field(
default=[
AuthMethods.user_id_only.value,
@@ -914,7 +947,7 @@ class EnvSettings(LNbitsSettings):
class PersistenceSettings(LNbitsSettings):
lnbits_data_folder: str = Field(default="./data")
lnbits_database_url: str = Field(default=None)
lnbits_database_url: str | None = Field(default=None)
class SuperUserSettings(LNbitsSettings):
@@ -924,6 +957,8 @@ class SuperUserSettings(LNbitsSettings):
"BoltzWallet",
"BlinkWallet",
"BreezSdkWallet",
"BreezLiquidSdkWallet",
"CLNRestWallet",
"CoreLightningRestWallet",
"CoreLightningWallet",
"EclairWallet",
@@ -958,11 +993,14 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
lnbits_running: bool = Field(default=True)
# Remember the latest balance delta in order to compare with the current one
latest_balance_delta_sats: int = Field(default=None)
latest_balance_delta_sats: int = Field(default=0)
lnbits_all_extensions_ids: set[str] = Field(default=[])
lnbits_all_extensions_ids: set[str] = Field(default=set())
server_startup_time: int = Field(default=time())
server_startup_time: int = Field(default=int(time()))
has_holdinvoice: bool = Field(default=False)
has_nodemanager: bool = Field(default=False)
@property
def lnbits_server_up_time(self) -> str:
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
File diff suppressed because one or more lines are too long
+173 -572
View File
@@ -1,617 +1,218 @@
body[data-theme=classic].body--light {
background-color: #f4f4f4;
body[data-theme=classic].neon-border .q-card,
body[data-theme=classic].neon-border .q-card.q-card--dark,
body[data-theme=classic].neon-border .q-date,
body[data-theme=classic].neon-border .q-date--dark {
border: 2px solid #673ab7;
box-shadow: none;
}
body[data-theme=classic].body--light {
background-color: #f4f4f4;
body[data-theme=bitcoin].neon-border .q-card,
body[data-theme=bitcoin].neon-border .q-card.q-card--dark,
body[data-theme=bitcoin].neon-border .q-date,
body[data-theme=bitcoin].neon-border .q-date--dark {
border: 2px solid #ea611d;
box-shadow: none;
}
[data-theme=classic] .q-drawer--dark,
body[data-theme=classic].body--dark,
[data-theme=classic] .q-menu--dark {
background: #1f2234 !important;
body[data-theme=freedom].neon-border .q-card,
body[data-theme=freedom].neon-border .q-card.q-card--dark,
body[data-theme=freedom].neon-border .q-date,
body[data-theme=freedom].neon-border .q-date--dark {
border: 2px solid #e22156;
box-shadow: none;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='classic'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=classic].body--light {
background-color: #f4f4f4;
body[data-theme=cyber].neon-border .q-card,
body[data-theme=cyber].neon-border .q-card.q-card--dark,
body[data-theme=cyber].neon-border .q-date,
body[data-theme=cyber].neon-border .q-date--dark {
border: 2px solid #7cb342;
box-shadow: none;
}
[data-theme=classic] .q-card--dark,
[data-theme=classic] .q-stepper--dark {
body[data-theme=mint].neon-border .q-card,
body[data-theme=mint].neon-border .q-card.q-card--dark,
body[data-theme=mint].neon-border .q-date,
body[data-theme=mint].neon-border .q-date--dark {
border: 2px solid #3ab77d;
box-shadow: none;
}
body[data-theme=autumn].neon-border .q-card,
body[data-theme=autumn].neon-border .q-card.q-card--dark,
body[data-theme=autumn].neon-border .q-date,
body[data-theme=autumn].neon-border .q-date--dark {
border: 2px solid #b7763a;
box-shadow: none;
}
body[data-theme=flamingo].neon-border .q-card,
body[data-theme=flamingo].neon-border .q-card.q-card--dark,
body[data-theme=flamingo].neon-border .q-date,
body[data-theme=flamingo].neon-border .q-date--dark {
border: 2px solid #ff00ff;
box-shadow: none;
}
body[data-theme=monochrome].neon-border .q-card,
body[data-theme=monochrome].neon-border .q-card.q-card--dark,
body[data-theme=monochrome].neon-border .q-date,
body[data-theme=monochrome].neon-border .q-date--dark {
border: 2px solid #494949;
box-shadow: none;
}
body[data-theme=salvador].neon-border .q-card,
body[data-theme=salvador].neon-border .q-card.q-card--dark,
body[data-theme=salvador].neon-border .q-date,
body[data-theme=salvador].neon-border .q-date--dark {
border: 2px solid #1976d2;
box-shadow: none;
}
body.hard-border .q-card,
body.hard-border .q-card.q-card--dark,
body.hard-border .q-date,
body.hard-border .q-date--dark {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.2784313725);
border: none;
}
body.retro-border .q-card,
body.retro-border .q-card.q-card--dark,
body.retro-border .q-date,
body.retro-border .q-date--dark {
border: none;
box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);
}
body.no-border .q-card,
body.no-border .q-card.q-card--dark,
body.no-border .q-date,
body.no-border .q-date--dark {
border: none;
box-shadow: none;
}
body[data-theme=classic] {
--q-primary: #673ab7;
--q-secondary: #9c27b0;
--q-dark-page: #1f2234;
}
body[data-theme=classic] [data-theme=classic] .q-card--dark,
body[data-theme=classic] [data-theme=classic] .q-stepper--dark {
background: #333646 !important;
}
body[data-theme=classic].body--light {
background-color: #f4f4f4;
body[data-theme=bitcoin] {
--q-primary: #ea611d;
--q-secondary: #e56f35;
--q-dark-page: #2d293b;
}
body[data-theme=classic].body--light {
background-color: #f4f4f4;
}
[data-theme=classic] .bg-primary {
background: #673ab7 !important;
}
[data-theme=classic] .text-primary {
color: #673ab7 !important;
}
[data-theme=classic] .bg-secondary {
background: #9c27b0 !important;
}
[data-theme=classic] .text-secondary {
color: #9c27b0 !important;
}
[data-theme=classic] .bg-dark {
background: #1f2234 !important;
}
[data-theme=classic] .text-dark {
color: #1f2234 !important;
}
[data-theme=classic] .bg-info {
background: #333646 !important;
}
[data-theme=classic] .text-info {
color: #333646 !important;
}
[data-theme=classic] .bg-marginal-bg {
background: #1f2234 !important;
}
[data-theme=classic] .text-marginal-bg {
color: #1f2234 !important;
}
[data-theme=classic] .bg-marginal-text {
background: #fff !important;
}
[data-theme=classic] .text-marginal-text {
color: #fff !important;
}
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
}
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
}
[data-theme=bitcoin] .q-drawer--dark,
body[data-theme=bitcoin].body--dark,
[data-theme=bitcoin] .q-menu--dark {
background: #2d293b !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='bitcoin'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
}
[data-theme=bitcoin] .q-card--dark,
[data-theme=bitcoin] .q-stepper--dark {
body[data-theme=bitcoin] [data-theme=bitcoin] .q-card--dark,
body[data-theme=bitcoin] [data-theme=bitcoin] .q-stepper--dark {
background: #333646 !important;
}
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
body[data-theme=freedom] {
--q-primary: #e22156;
--q-secondary: #b91a45;
--q-dark-page: #462f36;
}
body[data-theme=freedom] [data-theme=freedom] .q-card--dark,
body[data-theme=freedom] [data-theme=freedom] .q-stepper--dark {
background: #47393d !important;
}
body[data-theme=bitcoin].body--light {
background-color: #f4f4f4;
body[data-theme=cyber] {
--q-primary: #7cb342;
--q-secondary: #558b2f;
--q-dark-page: #000;
}
body[data-theme=cyber] [data-theme=cyber] .q-card--dark,
body[data-theme=cyber] [data-theme=cyber] .q-stepper--dark {
background: #1f2915 !important;
}
[data-theme=bitcoin] .bg-primary {
background: #ea611d !important;
body[data-theme=mint] {
--q-primary: #3ab77d;
--q-secondary: #27b065;
--q-dark-page: #1f342b;
}
[data-theme=bitcoin] .text-primary {
color: #ea611d !important;
}
[data-theme=bitcoin] .bg-secondary {
background: #e56f35 !important;
}
[data-theme=bitcoin] .text-secondary {
color: #e56f35 !important;
}
[data-theme=bitcoin] .bg-dark {
background: #2d293b !important;
}
[data-theme=bitcoin] .text-dark {
color: #2d293b !important;
}
[data-theme=bitcoin] .bg-info {
background: #333646 !important;
}
[data-theme=bitcoin] .text-info {
color: #333646 !important;
}
[data-theme=bitcoin] .bg-marginal-bg {
background: #000000 !important;
}
[data-theme=bitcoin] .text-marginal-bg {
color: #000000 !important;
}
[data-theme=bitcoin] .bg-marginal-text {
background: #fff !important;
}
[data-theme=bitcoin] .text-marginal-text {
color: #fff !important;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
[data-theme=freedom] .q-drawer--dark,
body[data-theme=freedom].body--dark,
[data-theme=freedom] .q-menu--dark {
background: #0a0a0a !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='freedom'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
[data-theme=freedom] .q-card--dark,
[data-theme=freedom] .q-stepper--dark {
background: #1b1b1b !important;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
body[data-theme=freedom].body--light {
background-color: #f4f4f4;
}
[data-theme=freedom] .bg-primary {
background: #e22156 !important;
}
[data-theme=freedom] .text-primary {
color: #e22156 !important;
}
[data-theme=freedom] .bg-secondary {
background: #b91a45 !important;
}
[data-theme=freedom] .text-secondary {
color: #b91a45 !important;
}
[data-theme=freedom] .bg-dark {
background: #0a0a0a !important;
}
[data-theme=freedom] .text-dark {
color: #0a0a0a !important;
}
[data-theme=freedom] .bg-info {
background: #1b1b1b !important;
}
[data-theme=freedom] .text-info {
color: #1b1b1b !important;
}
[data-theme=freedom] .bg-marginal-bg {
background: #2d293b !important;
}
[data-theme=freedom] .text-marginal-bg {
color: #2d293b !important;
}
[data-theme=freedom] .bg-marginal-text {
background: #fff !important;
}
[data-theme=freedom] .text-marginal-text {
color: #fff !important;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
[data-theme=cyber] .q-drawer--dark,
body[data-theme=cyber].body--dark,
[data-theme=cyber] .q-menu--dark {
background: #0a0a0a !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='cyber'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
[data-theme=cyber] .q-card--dark,
[data-theme=cyber] .q-stepper--dark {
background: #1b1b1b !important;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
body[data-theme=cyber].body--light {
background-color: #f4f4f4;
}
[data-theme=cyber] .bg-primary {
background: #7cb342 !important;
}
[data-theme=cyber] .text-primary {
color: #7cb342 !important;
}
[data-theme=cyber] .bg-secondary {
background: #558b2f !important;
}
[data-theme=cyber] .text-secondary {
color: #558b2f !important;
}
[data-theme=cyber] .bg-dark {
background: #0a0a0a !important;
}
[data-theme=cyber] .text-dark {
color: #0a0a0a !important;
}
[data-theme=cyber] .bg-info {
background: #1b1b1b !important;
}
[data-theme=cyber] .text-info {
color: #1b1b1b !important;
}
[data-theme=cyber] .bg-marginal-bg {
background: #2d293b !important;
}
[data-theme=cyber] .text-marginal-bg {
color: #2d293b !important;
}
[data-theme=cyber] .bg-marginal-text {
background: #fff !important;
}
[data-theme=cyber] .text-marginal-text {
color: #fff !important;
}
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
[data-theme=mint] .q-drawer--dark,
body[data-theme=mint].body--dark,
[data-theme=mint] .q-menu--dark {
background: #1f342b !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='mint'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
[data-theme=mint] .q-card--dark,
[data-theme=mint] .q-stepper--dark {
body[data-theme=mint] [data-theme=mint] .q-card--dark,
body[data-theme=mint] [data-theme=mint] .q-stepper--dark {
background: #334642 !important;
}
body[data-theme=mint].body--light {
background-color: #f4f4f4;
body[data-theme=autumn] {
--q-primary: #b7763a;
--q-secondary: #b07927;
--q-dark-page: #34291f;
}
body[data-theme=mint].body--light {
background-color: #f4f4f4;
}
[data-theme=mint] .bg-primary {
background: #3ab77d !important;
}
[data-theme=mint] .text-primary {
color: #3ab77d !important;
}
[data-theme=mint] .bg-secondary {
background: #27b065 !important;
}
[data-theme=mint] .text-secondary {
color: #27b065 !important;
}
[data-theme=mint] .bg-dark {
background: #1f342b !important;
}
[data-theme=mint] .text-dark {
color: #1f342b !important;
}
[data-theme=mint] .bg-info {
background: #334642 !important;
}
[data-theme=mint] .text-info {
color: #334642 !important;
}
[data-theme=mint] .bg-marginal-bg {
background: #1f342b !important;
}
[data-theme=mint] .text-marginal-bg {
color: #1f342b !important;
}
[data-theme=mint] .bg-marginal-text {
background: #fff !important;
}
[data-theme=mint] .text-marginal-text {
color: #fff !important;
}
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
[data-theme=autumn] .q-drawer--dark,
body[data-theme=autumn].body--dark,
[data-theme=autumn] .q-menu--dark {
background: #34291f !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='autumn'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
[data-theme=autumn] .q-card--dark,
[data-theme=autumn] .q-stepper--dark {
body[data-theme=autumn] [data-theme=autumn] .q-card--dark,
body[data-theme=autumn] [data-theme=autumn] .q-stepper--dark {
background: #463f33 !important;
}
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
body[data-theme=flamingo] {
--q-primary: #ff00ff;
--q-secondary: #fda3fd;
--q-dark-page: #2f032f;
}
body[data-theme=autumn].body--light {
background-color: #f4f4f4;
}
[data-theme=autumn] .bg-primary {
background: #b7763a !important;
}
[data-theme=autumn] .text-primary {
color: #b7763a !important;
}
[data-theme=autumn] .bg-secondary {
background: #b07927 !important;
}
[data-theme=autumn] .text-secondary {
color: #b07927 !important;
}
[data-theme=autumn] .bg-dark {
background: #34291f !important;
}
[data-theme=autumn] .text-dark {
color: #34291f !important;
}
[data-theme=autumn] .bg-info {
background: #463f33 !important;
}
[data-theme=autumn] .text-info {
color: #463f33 !important;
}
[data-theme=autumn] .bg-marginal-bg {
background: #342a1f !important;
}
[data-theme=autumn] .text-marginal-bg {
color: #342a1f !important;
}
[data-theme=autumn] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
}
[data-theme=autumn] .text-marginal-text {
color: rgb(255, 255, 255) !important;
}
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
[data-theme=flamingo] .q-drawer--dark,
body[data-theme=flamingo].body--dark,
[data-theme=flamingo] .q-menu--dark {
background: #2f032f !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='flamingo'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
[data-theme=flamingo] .q-card--dark,
[data-theme=flamingo] .q-stepper--dark {
body[data-theme=flamingo] [data-theme=flamingo] .q-card--dark,
body[data-theme=flamingo] [data-theme=flamingo] .q-stepper--dark {
background: #bc23bc !important;
}
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
body[data-theme=monochrome] {
--q-primary: #494949;
--q-secondary: #6b6b6b;
--q-dark-page: #000;
}
body[data-theme=flamingo].body--light {
background-color: #f4f4f4;
}
[data-theme=flamingo] .bg-primary {
background: #ff00ff !important;
}
[data-theme=flamingo] .text-primary {
color: #ff00ff !important;
}
[data-theme=flamingo] .bg-secondary {
background: #fda3fd !important;
}
[data-theme=flamingo] .text-secondary {
color: #fda3fd !important;
}
[data-theme=flamingo] .bg-dark {
background: #2f032f !important;
}
[data-theme=flamingo] .text-dark {
color: #2f032f !important;
}
[data-theme=flamingo] .bg-info {
background: #bc23bc !important;
}
[data-theme=flamingo] .text-info {
color: #bc23bc !important;
}
[data-theme=flamingo] .bg-marginal-bg {
background: #311231 !important;
}
[data-theme=flamingo] .text-marginal-bg {
color: #311231 !important;
}
[data-theme=flamingo] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
}
[data-theme=flamingo] .text-marginal-text {
color: rgb(255, 255, 255) !important;
}
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
}
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
}
[data-theme=monochrome] .q-drawer--dark,
body[data-theme=monochrome].body--dark,
[data-theme=monochrome] .q-menu--dark {
background: #000 !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='monochrome'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
}
[data-theme=monochrome] .q-card--dark,
[data-theme=monochrome] .q-stepper--dark {
body[data-theme=monochrome] [data-theme=monochrome] .q-card--dark,
body[data-theme=monochrome] [data-theme=monochrome] .q-stepper--dark {
background: rgb(39, 39, 39) !important;
}
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
body[data-theme=salvador] {
--q-primary: #1976d2;
--q-secondary: #26a69a;
--q-dark-page: #253647;
}
body[data-theme=salvador] [data-theme=salvador] .q-card--dark,
body[data-theme=salvador] [data-theme=salvador] .q-stepper--dark {
background: #343d47 !important;
}
body[data-theme=monochrome].body--light {
background-color: #f4f4f4;
body.gradient-bg {
background-image: linear-gradient(to bottom right, var(--q-dark-page), #0a0a0a);
background-attachment: fixed;
}
[data-theme=monochrome] .bg-primary {
background: #494949 !important;
body.bg-image::before {
content: "";
position: fixed;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(8px);
background-image: var(--background);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
[data-theme=monochrome] .text-primary {
color: #494949 !important;
}
[data-theme=monochrome] .bg-secondary {
background: #6b6b6b !important;
}
[data-theme=monochrome] .text-secondary {
color: #6b6b6b !important;
}
[data-theme=monochrome] .bg-dark {
background: #000 !important;
}
[data-theme=monochrome] .text-dark {
color: #000 !important;
}
[data-theme=monochrome] .bg-info {
background: rgb(39, 39, 39) !important;
}
[data-theme=monochrome] .text-info {
color: rgb(39, 39, 39) !important;
}
[data-theme=monochrome] .bg-marginal-bg {
background: #000 !important;
}
[data-theme=monochrome] .text-marginal-bg {
color: #000 !important;
}
[data-theme=monochrome] .bg-marginal-text {
background: rgb(255, 255, 255) !important;
}
[data-theme=monochrome] .text-marginal-text {
color: rgb(255, 255, 255) !important;
body.bg-image .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */
}
[data-theme=freedom] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme=freedom] .q-header {
background: #0a0a0a !important;
}
[data-theme=cyber] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme=cyber] .q-header {
background: #0a0a0a !important;
}
[data-theme=salvador] .q-drawer--dark {
background: #242424 !important;
}
[data-theme=salvador] .q-header {
background: #0f47af !important;
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
body.body--dark .q-header,
body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
backdrop-filter: blur(6px);
}
[v-cloak] {
+27 -4
View File
@@ -49,9 +49,11 @@ window.localisation.en = {
export_to_phone_desc:
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
access_wallet_on_mobile: 'Mobile Access',
stored_paylinks: 'Stored LNURL pay links',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
@@ -101,6 +103,11 @@ window.localisation.en = {
memo: 'Memo',
date: 'Date',
path: 'Path',
internal_memo: 'Internal memo (optional)',
internal_memo_hint_receive:
"This memo is not shown to the payer but it's stored in the invoice for your reference.",
internal_memo_hint_pay:
"This memo is not shown to the payee but it's stored in the payment for your reference.",
payment_processing: 'Processing payment...',
payment_processing: 'Processing payment...',
payment_successful: 'Payment successful!',
@@ -171,7 +178,17 @@ window.localisation.en = {
extension_required_lnbits_version: 'This release requires LNbits version',
min_version: 'Minimum (included)',
max_version: 'Maximum (excluded)',
preimage: 'Preimage',
preimage_hint: 'Preimage to settle the hold invoice',
hold_invoice: 'Hold Invoice',
hold_invoice_description:
'This invoice is on hold and requires a preimage to settle.',
payment_hash: 'Payment Hash',
invoice_cancelled: 'Invoice Cancelled',
invoice_settled: 'Invoice Settled',
hold_invoice_payment_hash: 'Payment hash for hold invoice (optional)',
settle_invoice: 'Settle Invoice',
cancel_invoice: 'Cancel Invoice',
fee: 'Fee',
amount: 'Amount',
amount_limits: 'Amount Limits',
@@ -214,6 +231,9 @@ window.localisation.en = {
notifications_nostr_private_key: 'Nostr Private Key',
notifications_nostr_private_key_desc:
'Private key (hex or nsec) to sign the messages sent to Nostr',
notifications_nostr_identifier: 'Nostr Identifier',
notifications_nostr_identifier_desc:
'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to',
@@ -223,9 +243,10 @@ window.localisation.en = {
notifications_enable_telegram_desc: 'Send notfications over Telegram',
notifications_telegram_access_token: 'Access Token',
notifications_telegram_access_token_desc: 'Access token for the bot',
notifications_chat_id: 'Chat ID',
notifications_chat_id_desc: 'Chat ID to send the notifications to',
notifications_chat_id: 'Telegram Chat ID',
notifications_chat_id_desc: 'Telegram Chat ID to send the notifications to',
notifications_excluded_wallets_desc:
'Do not send notifications for these wallets',
notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notifications over email',
@@ -629,5 +650,7 @@ window.localisation.en = {
'Signing secret for the webhook. Messages will be signed with this secret.',
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',
connected: 'Connected',
not_connected: 'Not Connected'
}
+64 -7
View File
@@ -22,6 +22,7 @@ window.localisation.fi = {
active_channels: 'Aktiivisia kanavia',
connect_peer: 'Yhdistä naapuriin',
connect: 'Yhdistä',
reconnect: 'Uudista yhteys',
open_channel: 'Avaa kanava',
open: 'Avaa',
close_channel: 'Sulje kanava',
@@ -51,14 +52,18 @@ window.localisation.fi = {
'Tämä QR-koodi sisältää URL-osoitteen, jolla saa lompakkoosi täydet valtuudet. Voit lukea sen puhelimellasi ja avata sillä lompakkosi. Voit myös lisätä lompakkosi selaimella käytettäväksi PWA-sovellukseksi puhelimen aloitusruudulle. ',
access_wallet_on_mobile: 'Mobiili käyttö',
wallet: 'Lompakko:',
wallet_name: 'Lompakon nimi',
wallets: 'Lompakot',
add_wallet: 'Lisää lompakko',
add_new_wallet: 'Lisää uusi lompakko',
pin_wallet: 'Kiinnitä lompakko',
delete_wallet: 'Poista lompakko',
delete_wallet_desc:
'Lompakko poistetaan pysyvästi. Siirrä lompakosta varat ennalta muualle, sillä tämä toiminto on PERUUTTAMATON!',
rename_wallet: 'Nimeä lompakko uudelleen',
update_name: 'Tallenna',
fiat_tracking: 'Käytettävä valuutta',
fiat_providers: 'Valuutan välittäjät',
currency: 'Valuutta',
update_currency: 'Tallenna',
press_to_claim: 'Lunasta varat painamalla tästä',
@@ -122,6 +127,7 @@ window.localisation.fi = {
no_extensions: 'Laajennuksia ei ole asennettu :(',
created: 'Luotu',
search_extensions: 'Etsi laajennuksia',
search_wallets: 'Etsi lompakkoa',
extension_sources: 'Laajennuslähteet',
ext_sources_hint: 'Lähteet joista laajennuksia voi ladata',
ext_sources_label:
@@ -134,6 +140,7 @@ window.localisation.fi = {
uninstall: 'Poista',
drop_db: 'Poista tiedot',
enable: 'Ota käyttöön',
enabled: 'Käytössä',
pay_to_enable: 'Maksa ottaaksesi käyttöön',
enable_extension_details: 'Ota laajennus käyttöön tälle käyttäjälle',
disable: 'Poista käytöstä',
@@ -165,12 +172,33 @@ window.localisation.fi = {
payment_hash: 'Maksun tiiviste',
fee: 'Kulu',
amount: 'Määrä',
amount_limits: 'Määrien rajat',
amount_sats: 'Määrä (sat)',
faucest_wallet: 'Faucet Wallet',
faucest_wallet_desc_1:
'Each time a payment is confirmed by the {provider} provider funds will be subtracted from this wallet.',
faucest_wallet_desc_2:
'This helps monitor all {provider} payments and their status.',
faucest_wallet_desc_3:
'This wallet must be topped up with the amount of sats that the admin is willing to offer in exchange for the fiat currency.',
faucest_wallet_desc_4:
'If this wallet is configured, but is empty, the {provider} payments will not be processed.',
faucest_wallet_desc_5:
'This wallet can eventually get to a negative balance if parallel fiat payments are made.',
faucest_wallet_id: 'Faucet Wallet ID (optional)',
faucest_wallet_id_hint:
'Wallet ID to use for the faucet. It will be used to send the funds to the user.',
tag: 'Tunniste',
unit: 'Yksikkö',
description: 'Kuvaus',
expiry: 'Vanhenee',
webhook: 'Webhook',
webhook_url: 'Webhook URL',
webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.',
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
payment_proof: 'Maksun varmenne',
update: 'Päivitä',
update_available: 'Saatavilla on päivitys {version}-versioon!',
@@ -262,6 +290,7 @@ window.localisation.fi = {
notification_source_label:
'Lähde-URL (käytä ainoastaan LNbits:iä tai muuta luotettavaa lähdettä)',
more: 'näytä lisää',
more_count: 'näytä {count} lisää',
less: 'supista',
releases: 'Julkaisut',
watchdog: 'Watchdog',
@@ -276,7 +305,7 @@ window.localisation.fi = {
callback_url_rules: 'Callback URL -säännöt',
enter_callback_url_rule: 'Anna URL-sääntö regex-muodossa ja paina enter',
callback_url_rule_hint:
'Callback URL:it (kuten LNURL) tarkistetaan näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
'Callback URL:it (kuten LNURL) tarkistetaan kaikkien näiden sääntöjen mukaisesti. Jos sääntöjä ei ole määritetty, kaikki URL:it ovat sallittuja.',
wallet_limiter: 'Lompakon käyttörajoitin',
wallet_config: 'Wallet Config',
wallet_charts: 'Wallet Charts',
@@ -301,6 +330,9 @@ window.localisation.fi = {
login_with_user_id: 'Kirjaudu käyttäjä-ID:llä',
or: 'tai',
create_new_wallet: 'Avaa uusi lompakko',
delete_all_wallets: 'Poista kaikki lompakot',
confirm_delete_all_wallets:
'Oletko todellakin varma, että haluat poistaa käyttäjältä KAIKKI lompakot?',
login_to_account: 'Kirjaudu käyttäjänimellä',
create_account: 'Luo tili',
account_settings: 'Tilin asetukset',
@@ -309,7 +341,7 @@ window.localisation.fi = {
signin_with_nostr: 'Kirjaudu Nostr:lla',
signin_with_google: 'Kirjaudu Google-tunnuksella',
signin_with_github: 'Kirjaudu GitHub-tunnuksella',
signin_with_keycloak: 'Kirjaudu Keycloak-tunnuksella',
signin_with_custom_org: 'Kirjaudu {custom_org}-palvelulla',
username_or_email: 'Käyttäjänimi tai sähköposti',
password: 'Anna uusi salasana',
password_config: 'Salasanan määritys',
@@ -318,7 +350,10 @@ window.localisation.fi = {
change_password: 'Vaihda salasana',
update_credentials: 'Päivitä käyttöoikeustiedot',
update_pubkey: 'Päivitä julkinen avain',
nostr_pubkey_tooltip:
'Syötä tämän käyttäjän julkinen Nostr avain (hex arvona)',
set_password: 'Aseta salasana',
set_password_tooltip: 'Aseta käyttäjätunnukselle salasana',
invalid_password: 'Salasanassa tulee olla vähintään kahdeksan merkkiä',
invalid_password_repeat: 'Salasanat eivät täsmää',
reset_key_generated: 'Salasanan vaihtoavain on luotu.',
@@ -327,7 +362,8 @@ window.localisation.fi = {
register: 'Rekisteröidy',
username: 'Käyttäjänimi',
pubkey: 'Julkinen avain',
user_id: 'Käyttäjä ID',
user_id: 'Käyttäjä tunnus',
id: 'tunnus',
email: 'Sähköposti',
first_name: 'Etunimi',
last_name: 'Sukunimi',
@@ -343,6 +379,8 @@ window.localisation.fi = {
back: 'Takaisin',
logout: 'Poistu',
look_and_feel: 'Kieli ja värit',
endpoint: 'Endpoint',
api: 'API',
api_token: 'API Token',
api_tokens: 'API Tokens',
access_control_list: 'Access Control List',
@@ -354,11 +392,14 @@ window.localisation.fi = {
gradient_background: 'Gradient Background',
language: 'Kieli',
color_scheme: 'Väriteema',
visible_wallet_count: 'Näytettävien lompakkojen määrä',
admin_settings: 'Pääkäyttäjän asetukset',
extension_cost: 'Tämä laajennus edellyttää vähintään {cost} sat maksua.',
extension_paid_sats: 'Olet jo maksanut {paid_sats} satsia.',
release_details_error: 'Ei voi hakea julkaisun tietoja.',
pay_from_wallet: 'Maksa lompakosta',
pay_with: 'Maksa {provider}:lla',
select_payment_provider: 'Valitse maksun välittäjä',
wallet_required: 'Lompakko *',
show_qr: 'Näytä QR',
retry_install: 'Yritä asennusta uudelleen',
@@ -371,6 +412,9 @@ window.localisation.fi = {
'{name} -laajennuksen aktivointi edellyttää vähintään {amount} sat maksua.',
hide_empty_wallets: 'Piilota tyhjät lompakot',
recheck: 'Tarkista uudelleen',
check: 'Tarkista',
check_connection: 'Tarkista yhteys',
check_webhook: 'Tarkista Webhook',
contributors: 'Avustajat',
license: 'Lisenssi',
reset_key: 'Vaihda avain',
@@ -447,9 +491,9 @@ window.localisation.fi = {
fee_reserve_percent: 'Kuluvaraus prosentteina',
fee_reserve_msats: 'Kuluvaraus milli-sat',
reserve_fee_in_percent: 'Kuluvaraus prosentteina',
payment_wait_time: 'Maksun odotusaika',
payment_wait_time: 'Maksun odotusaika (sekuntia)',
payment_wait_time_desc:
'Kuinka pitkään maksua odotetaan saapuvaksi ennenkuin se merkitään Odotetaan -tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua tms',
'Kuinka pitkään maksua odotetaan saapuvaksi, ennen kuin se merkitään Odotetaan-tilaan. Aseta pidemmäksi käytettäessä HODL-laskuja, Boltz-palvelua, tms',
server_management: 'Palvelimen hallinta',
base_url: 'Palvelimen URL-osoite',
base_url_label: 'Palvelun staattinen pohja-URL',
@@ -474,12 +518,16 @@ window.localisation.fi = {
auth_keycloak_ci_hint:
'Varmista, että valtuutuksen palautus-URL on asetettu muotoon https://{domain}/api/v1/auth/keycloak/token',
auth_keycloak_cs_label: 'Keycloak-asiakassalasana',
auth_keycloak_custom_org_label: 'Valinnainen Keycloak-organisaatio',
auth_keycloak_custom_icon_label: 'Valinnainen Keycloak-kuvake (URL)',
currency_settings: 'Valuutta-asetukset',
allowed_currencies: 'Käytettävät valuutat',
allowed_currencies_hint: 'Valitse käytettävissä olevat fiat-valuutat',
default_account_currency: 'Tilin oletusvaluutta',
default_account_currency_hint: 'Kirjanpidon oletusvaluutta',
min_incoming_payment_amount: 'Pienin vastaanotettava maksun määrä',
min_incoming_payment_amount_desc:
'Pienin maksun määrä jolle voi luoda laskun',
max_incoming_payment_amount: 'Saapuvan maksun enimmäismäärä',
max_incoming_payment_amount_desc: 'Enimmäismäärä jonka voi laskuttaa',
max_outgoing_payment_amount: 'Lähtevän maksun enimmäismäärä',
@@ -542,6 +590,8 @@ window.localisation.fi = {
admin_users_label: 'Käyttäjätunnus',
allowed_users: 'Sallitut käyttäjät',
allowed_users_hint: 'Vain nämä käyttäjät voivat käyttää LNbitsiä',
allowed_users_hint_feature:
'Ainoastaan nämä käyttäjät voivat käyttää ominaisuutta {feature}',
allowed_users_label: 'Käyttäjätunnus',
allow_creation_user: 'Salli uusien käyttäjien luominen',
allow_creation_user_desc: 'Etusivulta on mahdollisuus luoda uusia käyttäjiä',
@@ -577,5 +627,12 @@ window.localisation.fi = {
view_column: 'Näytä lompakot rinnakkain',
filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket esimerkki'
websocket_example: 'Websocket example',
secret_key: 'Secret Key',
signing_secret: 'Signing Secret',
signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+6 -33
View File
@@ -80,6 +80,11 @@ window.AccountPageLogic = {
token_id_list: [],
allRead: false,
allWrite: false
},
notifications: {
nostr: {
identifier: ''
}
}
}
},
@@ -91,35 +96,6 @@ window.AccountPageLogic = {
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
},
toggleGradient() {
this.gradientSelection = !this.gradientChoice
this.gradientChoice = this.gradientSelection
this.$q.localStorage.set('lnbits.backgroundImage', 'none')
this.applyGradient()
if (!this.gradientChoice) {
window.location.reload()
}
},
reactionChoiceFunc() {
this.$q.localStorage.set('lnbits.reactions', this.reactionSelection)
this.reactionChoice = this.reactionSelection
},
bgimageChoiceFunc() {
this.$q.localStorage.set('lnbits.backgroundImage', this.bgimageSelection)
this.bgimageChoice = this.bgimageSelection
this.applyBackgroundImage()
},
themeChoiceFunc(newValue) {
this.changeTheme(newValue)
this.setColors()
if (this.gradientChoice) {
this.applyGradient()
}
if (this.bgimageChoice) {
this.applyBackgroundImage()
}
this.applyBorder()
},
async updateAccount() {
try {
const {data} = await LNbits.api.request(
@@ -425,11 +401,8 @@ window.AccountPageLogic = {
}
}
},
async created() {
this.borderSelection = this.borderChoice
this.reactionSelection = this.reactionChoice
this.bgimageSelection = this.bgimageChoice
this.themeSelection = this.themeChoice
try {
const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data
+69 -174
View File
@@ -19,45 +19,47 @@ window.LNbits = {
amount,
memo,
unit = 'sat',
lnurlCallback = null,
fiatProvider = null
lnurlWithdraw = null,
fiatProvider = null,
internalMemo = null,
payment_hash = null
) {
return this.request('post', '/api/v1/payments', wallet.inkey, {
const data = {
out: false,
amount: amount,
memo: memo,
lnurl_callback: lnurlCallback,
unit: unit,
fiat_provider: fiatProvider
})
lnurl_withdraw: lnurlWithdraw,
fiat_provider: fiatProvider,
payment_hash: payment_hash
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.inkey, data)
},
payInvoice(wallet, bolt11) {
return this.request('post', '/api/v1/payments', wallet.adminkey, {
payInvoice(wallet, bolt11, internalMemo = null) {
const data = {
out: true,
bolt11: bolt11
}
if (internalMemo) {
data.extra = {
internal_memo: String(internalMemo)
}
}
return this.request('post', '/api/v1/payments', wallet.adminkey, data)
},
cancelInvoice(wallet, paymentHash) {
return this.request('post', '/api/v1/payments/cancel', wallet.adminkey, {
payment_hash: paymentHash
})
},
payLnurl(
wallet,
callback,
description_hash,
amount,
description = '',
comment = '',
unit = ''
) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback,
description_hash,
amount,
comment,
description,
unit
})
},
authLnurl(wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
settleInvoice(wallet, preimage) {
return this.request('post', `/api/v1/payments/settle`, wallet.adminkey, {
preimage: preimage
})
},
createAccount(name) {
@@ -437,18 +439,6 @@ window.LNbits = {
converter.setFlavor('github')
converter.setOption('simpleLineBreaks', true)
return converter.makeHtml(text)
},
hexToRgb(hex) {
return Quasar.colors.hexToRgb(hex)
},
hexDarken(hex, percent) {
return Quasar.colors.lighten(hex, percent)
},
hexAlpha(hex, alpha) {
return Quasar.colors.changeAlpha(hex, alpha)
},
getPaletteColor(color) {
return Quasar.colors.getPaletteColor(color)
}
}
}
@@ -465,7 +455,6 @@ if (!window.g) {
fiatTracking: false,
wallets: [],
payments: [],
allowedThemes: null,
langs: [],
walletEventListeners: [],
updatePayments: false,
@@ -482,12 +471,13 @@ window.windowMixin = {
mobileSimple: true,
walletFlip: true,
showAddWalletDialog: {show: false},
borderSelection: null,
gradientSelection: null,
themeSelection: null,
reactionSelection: null,
bgimageSelection: null,
gradientSelection: false,
isUserAuthorized: false,
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
allowedThemes: WINDOW_SETTINGS['LNBITS_THEME_OPTIONS'],
walletEventListeners: [],
darkChoice: this.$q.localStorage.has('lnbits.darkMode')
? this.$q.localStorage.getItem('lnbits.darkMode')
: true,
borderChoice: this.$q.localStorage.has('lnbits.border')
? this.$q.localStorage.getItem('lnbits.border')
: USE_DEFAULT_BORDER,
@@ -503,10 +493,6 @@ window.windowMixin = {
bgimageChoice: this.$q.localStorage.has('lnbits.backgroundImage')
? this.$q.localStorage.getItem('lnbits.backgroundImage')
: USE_DEFAULT_BGIMAGE,
isUserAuthorized: false,
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
walletEventListeners: [],
backgroundImage: '',
...WINDOW_SETTINGS
}
},
@@ -612,138 +598,53 @@ window.windowMixin = {
},
changeTheme(newValue) {
document.body.setAttribute('data-theme', newValue)
if (this.themeSelection) {
this.themeChoice = newValue
this.$q.localStorage.set('lnbits.theme', newValue)
}
this.setColors()
this.$q.localStorage.set('lnbits.theme', newValue)
this.themeChoice = newValue
},
applyGradient() {
darkBgColor = this.$q.localStorage.getItem('lnbits.darkBgColor')
primaryColor = this.$q.localStorage.getItem('lnbits.primaryColor')
if (this.gradientChoice) {
document.body.classList.add('gradient-bg')
this.$q.localStorage.set('lnbits.gradientBg', true)
// Ensure dark mode is enabled when gradient background is applied
if (!this.$q.dark.isActive) {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', true)
this.toggleDarkMode()
}
const gradientStyle = `linear-gradient(to bottom right, ${LNbits.utils.hexDarken(String(primaryColor), -70)}, #0a0a0a)`
document.body.style.setProperty(
'background-image',
gradientStyle,
'important'
)
const gradientStyleCards = `background-color: ${LNbits.utils.hexAlpha(String(darkBgColor), 0.4)} !important; backdrop-filter: blur(6px);`
const style = document.createElement('style')
style.innerHTML =
`body[data-theme="${this.themeChoice}"] .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body${this.$q.dark.isActive ? '--dark' : ''} .q-header, body.body${this.$q.dark.isActive ? '--dark' : ''} .q-drawer { ${gradientStyleCards} }` +
`body[data-theme="${this.themeChoice}"].body--dark{background: ${LNbits.utils.hexDarken(String(primaryColor), -88)} !important; }` +
`[data-theme="${this.themeChoice}"] .q-card--dark{background: ${String(darkBgColor)} !important;} }`
document.head.appendChild(style)
} else {
document.body.classList.remove('gradient-bg')
this.$q.localStorage.set('lnbits.gradientBg', false)
}
},
toggleDarkMode() {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive) {
this.bgimageSelection = 'null'
this.$q.localStorage.set('lnbits.gradientBg', false)
this.$q.localStorage.set('lnbits.backgroundImage', 'null')
window.location.hash = '#theme'
window.location.reload()
}
},
applyBackgroundImage() {
if (this.bgimageSelection) {
this.$q.localStorage.set(
'lnbits.backgroundImage',
this.bgimageSelection
if (this.bgimageChoice == 'null') this.bgimageChoice = ''
if (this.bgimageChoice == '') {
document.body.classList.remove('bg-image')
} else {
document.body.classList.add('bg-image')
document.body.style.setProperty(
'--background',
`url(${this.bgimageChoice})`
)
this.bgimageChoice = this.bgimageSelection
}
if (
this.bgimageChoice &&
this.bgimageChoice !== 'null' &&
this.bgimageChoice !== 'none' &&
this.bgimageChoice !== ''
) {
this.gradientChoice = true
this.applyGradient()
const style = document.createElement('style')
style.innerHTML = `
body[data-theme="${this.themeChoice}"]::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url(${this.bgimageChoice});
background-size: cover;
filter: blur(8px);
z-index: -1;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
body[data-theme="${this.themeChoice}"] .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */
}`
document.head.appendChild(style)
}
this.$q.localStorage.set('lnbits.backgroundImage', this.bgimageChoice)
},
applyBorder() {
if (this.borderSelection) {
this.$q.localStorage.setItem('lnbits.border', this.borderSelection)
this.borderChoice = this.$q.localStorage.getItem('lnbits.border')
}
let borderStyleCSS
if (this.borderChoice == 'hard-border') {
borderStyleCSS = `box-shadow: 0 0 0 1px rgba(0,0,0,.12), 0 0 0 1px #ffffff47; border: none;`
}
if (this.borderChoice == 'neon-border') {
borderStyleCSS = `border: 2px solid ${this.$q.localStorage.getItem('lnbits.primaryColor')}; box-shadow: none;`
}
if (this.borderChoice == 'no-border') {
borderStyleCSS = `box-shadow: none; border: none;`
}
if (this.borderChoice == 'retro-border') {
borderStyleCSS = `border: none; border-color: rgba(255, 255, 255, 0.28); box-shadow: 0 1px 5px rgba(255, 255, 255, 0.2), 0 2px 2px rgba(255, 255, 255, 0.14), 0 3px 1px -2px rgba(255, 255, 255, 0.12);`
}
let style = document.createElement('style')
style.innerHTML = `
body[data-theme="${this.themeChoice}"] .q-card,
body[data-theme="${this.themeChoice}"] .q-card.q-card--dark,
body[data-theme="${this.themeChoice}"] .q-date,
body[data-theme="${this.themeChoice}"] .q-date--dark {
${borderStyleCSS}
// Remove any existing border classes
document.body.classList.forEach(cls => {
if (cls.endsWith('-border')) {
document.body.classList.remove(cls)
}
`
document.head.appendChild(style)
})
this.$q.localStorage.setItem('lnbits.border', this.borderChoice)
document.body.classList.add(this.borderChoice)
},
setColors() {
this.$q.localStorage.set(
'lnbits.primaryColor',
LNbits.utils.getPaletteColor('primary')
)
this.$q.localStorage.set(
'lnbits.secondaryColor',
LNbits.utils.getPaletteColor('secondary')
)
this.$q.localStorage.set(
'lnbits.darkBgColor',
LNbits.utils.getPaletteColor('dark')
)
document.documentElement.style.setProperty(
'--q-primary',
LNbits.utils.getPaletteColor('primary')
)
document.documentElement.style.setProperty(
'--q-secondary',
LNbits.utils.getPaletteColor('secondary')
)
toggleDarkMode() {
this.$q.dark.toggle()
this.darkChoice = this.$q.dark.isActive
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
if (!this.$q.dark.isActive) {
this.gradientChoice = false
this.applyGradient()
}
},
copyText(text, message, position) {
Quasar.copyToClipboard(text).then(() => {
@@ -808,10 +709,7 @@ window.windowMixin = {
const gradient = params.get('gradient')
const border = params.get('border')
if (
theme &&
this.g.allowedThemes.includes(theme.trim().toLowerCase())
) {
if (theme && this.allowedThemes.includes(theme.trim().toLowerCase())) {
const normalizedTheme = theme.trim().toLowerCase()
document.body.setAttribute('data-theme', normalizedTheme)
this.$q.localStorage.set('lnbits.theme', normalizedTheme)
@@ -841,8 +739,6 @@ window.windowMixin = {
window.history.replaceState(null, null, url.pathname)
}
this.setColors()
},
refreshRoute() {
const path = window.location.pathname
@@ -854,7 +750,6 @@ window.windowMixin = {
}
},
async created() {
this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
this.$q.dark.set(
this.$q.localStorage.has('lnbits.darkMode')
? this.$q.localStorage.getItem('lnbits.darkMode')
-28
View File
@@ -187,34 +187,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
}
})
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue
},
props: {
value: {
type: String,
required: true
},
options: Object
},
data() {
return {
custom: {
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
}
},
created() {
this.custom = {...this.custom, ...this.options}
}
})
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
@@ -48,6 +48,22 @@ window.app.component('lnbits-funding-sources', {
lnbits_denomination: '"sats" or 3 Letter Custom Denomination'
}
],
[
'CLNRestWallet',
'Core Lightning Rest (plugin)',
{
clnrest_url: 'Endpoint',
clnrest_ca: 'ca.pem',
clnrest_cert: 'server.pem',
clnrest_readonly_rune: 'Rune used for readonly requests',
clnrest_invoice_rune: 'Rune used for creating invoices',
clnrest_pay_rune: 'Rune used for paying invoices using pay',
clnrest_renepay_rune: 'Rune used for paying invoices using renepay',
clnrest_last_pay_index:
'Ignores any invoices paid prior to or including this index. 0 is equivalent to not specifying and negative value is invalid.',
clnrest_nodeid: 'Node id'
}
],
[
'CoreLightningWallet',
'Core Lightning',
@@ -58,7 +74,7 @@ window.app.component('lnbits-funding-sources', {
],
[
'CoreLightningRestWallet',
'Core Lightning Rest',
'Core Lightning Rest (legacy)',
{
corelightning_rest_url: 'Endpoint',
corelightning_rest_cert: 'Certificate',
@@ -219,6 +235,16 @@ window.app.component('lnbits-funding-sources', {
strike_api_endpoint: 'API Endpoint',
strike_api_key: 'API Key'
}
],
[
'BreezLiquidSdkWallet',
'Breez Liquid SDK',
{
breez_liquid_api_key: 'Breez API Key (can be empty)',
breez_liquid_seed: 'Liquid seed phrase',
breez_liquid_fee_offset_sat:
'Offset amount in sats to increase fee limit'
}
]
]
}
@@ -0,0 +1,43 @@
window.app.component('lnbits-qrcode-lnurl', {
template: '#lnbits-qrcode-lnurl',
mixins: [window.windowMixin],
props: {
url: {
required: true,
type: String
},
prefix: {
type: String,
default: 'lnurlp'
}
},
data() {
return {
tab: 'bech32',
lnurl: ''
}
},
methods: {
setLnurl() {
if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') {
this.lnurl = this.url.replace('https://', this.prefix + '://')
}
this.$emit('update:lnurl', this.lnurl)
}
},
watch: {
url() {
this.setLnurl()
},
tab() {
this.setLnurl()
}
},
created() {
this.setLnurl()
}
})
@@ -0,0 +1,27 @@
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue
},
props: {
value: {
type: String,
required: true
},
options: Object
},
data() {
return {
custom: {
margin: 3,
width: 350,
size: 350,
logo: LNBITS_QR_LOGO
}
}
},
created() {
this.custom = {...this.custom, ...this.options}
}
})
@@ -118,7 +118,13 @@ window.app.component('payment-list', {
field: row => row.extra.wallet_fiat_amount
}
],
preimage: null,
loading: false
},
hodlInvoice: {
show: false,
payment: null,
preimage: null
}
}
},
@@ -223,6 +229,35 @@ window.app.component('payment-list', {
})
.catch(LNbits.utils.notifyApiError)
},
showHoldInvoiceDialog(payment) {
this.hodlInvoice.show = true
this.hodlInvoice.preimage = ''
this.hodlInvoice.payment = payment
},
cancelHoldInvoice(payment_hash) {
LNbits.api
.cancelInvoice(this.g.wallet, payment_hash)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_cancelled')
})
})
.catch(LNbits.utils.notifyApiError)
},
settleHoldInvoice(preimage) {
LNbits.api
.settleInvoice(this.g.wallet, preimage)
.then(() => {
this.update = !this.update
Quasar.Notify.create({
type: 'positive',
message: this.$t('invoice_settled')
})
})
.catch(LNbits.utils.notifyApiError)
},
paymentTableRowKey(row) {
return row.payment_hash + row.amount
},
+17 -3
View File
@@ -8,7 +8,8 @@ window.PaymentsPageLogic = {
searchData: {
wallet_id: null,
payment_hash: null,
memo: null
memo: null,
internal_memo: null
},
statusFilters: {
success: true,
@@ -60,10 +61,10 @@ window.PaymentsPageLogic = {
sortable: false
},
{
name: 'fee',
name: 'fee_sats',
align: 'left',
label: 'Fee',
field: 'fee',
field: 'fee_sats',
sortable: true
},
@@ -82,6 +83,14 @@ window.PaymentsPageLogic = {
sortable: false,
max_length: 20
},
{
name: 'internal_memo',
align: 'left',
label: 'Internal Memo',
field: 'internal_memo',
sortable: false,
max_length: 20
},
{
name: 'wallet_id',
align: 'left',
@@ -166,6 +175,11 @@ window.PaymentsPageLogic = {
p.extra.wallet_fiat_currency
)
}
if (p.extra?.internal_memo) {
p.internal_memo = p.extra.internal_memo
}
p.fee_sats =
new Intl.NumberFormat(window.LOCALE).format(p.fee / 1000) + ' sats'
return p
})
+172 -129
View File
@@ -5,6 +5,7 @@ window.WalletPageLogic = {
origin: window.location.origin,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
stored_paylinks: [],
parse: {
show: false,
invoice: null,
@@ -14,6 +15,7 @@ window.WalletPageLogic = {
request: '',
amount: 0,
comment: '',
internalMemo: null,
unit: 'sat'
},
paymentChecker: null,
@@ -38,7 +40,9 @@ window.WalletPageLogic = {
fiatProvider: '',
data: {
amount: null,
memo: ''
memo: '',
internalMemo: null,
payment_hash: null
}
},
invoiceQrCode: '',
@@ -115,8 +119,6 @@ window.WalletPageLogic = {
isFiatPriority: false,
formattedFiatAmount: 0,
formattedExchange: null,
primaryColor: this.$q.localStorage.getItem('lnbits.primaryColor'),
secondaryColor: this.$q.localStorage.getItem('lnbits.secondaryColor'),
chartData: [],
chartDataPointCount: 0,
chartConfig: {
@@ -140,6 +142,13 @@ window.WalletPageLogic = {
},
canPay() {
if (!this.parse.invoice) return false
if (this.parse.invoice.expired) {
Quasar.Notify.create({
message: 'Invoice has expired',
color: 'negative'
})
return false
}
return this.parse.invoice.sat <= this.g.wallet.sat
},
formattedAmount() {
@@ -169,6 +178,10 @@ window.WalletPageLogic = {
}
},
methods: {
dateFromNow(unix) {
const date = new Date(unix * 1000)
return moment.utc(date).fromNow()
},
formatFiatAmount(amount, currency) {
this.update.currency = currency
this.formattedFiatAmount = LNbits.utils.formatCurrency(
@@ -199,6 +212,8 @@ window.WalletPageLogic = {
this.receive.paymentHash = null
this.receive.data.amount = null
this.receive.data.memo = null
this.receive.data.internalMemo = null
this.receive.data.payment_hash = null
this.receive.unit = this.isFiatPriority
? this.g.wallet.currency || 'sat'
: 'sat'
@@ -220,6 +235,7 @@ window.WalletPageLogic = {
window.isSecureContext && navigator.clipboard?.readText !== undefined
this.parse.data.request = ''
this.parse.data.comment = ''
this.parse.data.internalMemo = null
this.parse.data.paymentChecker = null
this.parse.camera.show = false
this.focusInput('textArea')
@@ -254,8 +270,10 @@ window.WalletPageLogic = {
this.receive.data.amount,
this.receive.data.memo,
this.receive.unit,
this.receive.lnurl && this.receive.lnurl.callback,
this.receive.fiatProvider
this.receive.lnurlWithdraw,
this.receive.fiatProvider,
this.receive.data.internalMemo,
this.receive.data.payment_hash
)
.then(response => {
this.g.updatePayments = !this.g.updatePayments
@@ -268,27 +286,29 @@ window.WalletPageLogic = {
if (!this.receive.lnurl) {
this.readNfcTag()
}
// TODO: lnurl_callback and lnurl_response
// WITHDRAW
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
if (
this.receive.lnurl &&
response.data.extra?.lnurl_response !== null
) {
if (response.data.extra.lnurl_response === false) {
response.data.extra.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
const domain = this.receive.lnurl.callback.split('/')[2]
if (typeof response.data.extra.lnurl_response === 'string') {
// failure
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
message: `${domain} lnurl-withdraw call failed.`,
caption: response.data.extra.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
} else if (response.data.extra.lnurl_response === true) {
// success
Quasar.Notify.create({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
timeout: 3000,
message: `Invoice sent to ${domain}!`,
spinner: true
})
}
@@ -337,32 +357,30 @@ window.WalletPageLogic = {
},
lnurlScan() {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.request('POST', '/api/v1/lnurlscan', this.g.wallet.adminkey, {
lnurl: this.parse.data.request
})
.then(response => {
const data = response.data
if (data.status === 'ERROR') {
Quasar.Notify.create({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
message: `lnurl scan failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
} else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
} else if (data.tag === 'withdrawRequest') {
this.parse.show = false
this.receive.show = true
this.receive.lnurlWithdraw = Object.freeze(data)
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
@@ -372,8 +390,9 @@ window.WalletPageLogic = {
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
const domain = data.callback.split('/')[2]
this.receive.lnurl = {
domain: data.domain,
domain: domain,
callback: data.callback,
fixed: data.fixed
}
@@ -388,19 +407,29 @@ window.WalletPageLogic = {
this.decodeRequest()
this.parse.camera.show = false
},
isLnurl(req) {
return (
req.toLowerCase().startsWith('lnurl1') ||
req.startsWith('lnurlp://') ||
req.startsWith('lnurlw://') ||
req.startsWith('lnurlauth://') ||
req.match(/[\w.+-~_]+@[\w.+-~_]/)
)
},
decodeRequest() {
this.parse.show = true
this.parse.data.request = this.parse.data.request.trim().toLowerCase()
let req = this.parse.data.request
this.parse.data.request = this.parse.data.request.trim()
const req = this.parse.data.request.toLowerCase()
if (req.startsWith('lightning:')) {
this.parse.data.request = req.slice(10)
this.parse.data.request = this.parse.data.request.slice(10)
} else if (req.startsWith('lnurl:')) {
this.parse.data.request = req.slice(6)
this.parse.data.request = this.parse.data.request.slice(6)
} else if (req.includes('lightning=lnurl1')) {
this.parse.data.request = req.split('lightning=')[1].split('&')[0]
this.parse.data.request = this.parse.data.request
.split('lightning=')[1]
.split('&')[0]
}
req = this.parse.data.request
if (req.startsWith('lnurl1') || req.match(/[\w.+-~_]+@[\w.+-~_]/)) {
if (this.isLnurl(this.parse.data.request)) {
this.lnurlScan()
return
}
@@ -479,7 +508,11 @@ window.WalletPageLogic = {
})
LNbits.api
.payInvoice(this.g.wallet, this.parse.data.request)
.payInvoice(
this.g.wallet,
this.parse.data.request,
this.parse.data.internalMemo
)
.then(response => {
dismissPaymentMsg()
this.updatePayments = !this.updatePayments
@@ -505,85 +538,50 @@ window.WalletPageLogic = {
})
},
payLnurl() {
const dismissPaymentMsg = Quasar.Notify.create({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api
.payLnurl(
this.g.wallet,
this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment,
this.parse.data.unit
)
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
res: this.parse.lnurlpay,
lnurl: this.parse.data.request,
unit: this.parse.data.unit,
amount: this.parse.data.amount * 1000,
comment: this.parse.data.comment,
internalMemo: this.parse.data.internalMemo
})
.then(response => {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
const extra = response.data.extra
if (extra.success_action) {
switch (extra.success_action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'message':
Quasar.Notify.create({
message: extra.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'aes':
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
extra.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
})
break
}
}
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
if (response.data.extra.success_action) {
const action = JSON.parse(response.data.extra.success_action)
switch (action.tag) {
case 'url':
Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
caption: action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'message':
Quasar.Notify.create({
message: action.message,
type: 'positive',
timeout: 0,
closeBtn: true
})
break
case 'aes':
decryptLnurlPayAES(action, response.data.preimage)
Quasar.Notify.create({
message: value,
caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
closeBtn: true
})
}
}
})
},
authLnurl() {
@@ -591,9 +589,13 @@ window.WalletPageLogic = {
timeout: 10,
message: 'Performing authentication...'
})
LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.request(
'post',
'/api/v1/lnurlauth',
wallet.adminkey,
this.parse.lnurlauth
)
.then(_ => {
dismissAuthMsg()
Quasar.Notify.create({
@@ -604,10 +606,9 @@ window.WalletPageLogic = {
this.parse.show = false
})
.catch(err => {
dismissAuthMsg()
if (err.response.data.reason) {
Quasar.Notify.create({
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
message: `Authentication failed. ${this.parse.lnurlauth.callback} says:`,
caption: err.response.data.reason,
type: 'warning',
timeout: 5000
@@ -970,11 +971,11 @@ window.WalletPageLogic = {
label: 'Balance',
data: data.map(s => s.balance),
pointStyle: false,
backgroundColor: LNbits.utils.hexAlpha(
this.primaryColor,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
),
borderColor: this.primaryColor,
borderColor: Quasar.colors.getPaletteColor('primary'),
borderWidth: 2,
fill: true,
tension: 0.7,
@@ -984,11 +985,11 @@ window.WalletPageLogic = {
label: 'Fees',
data: data.map(s => s.fee),
pointStyle: false,
backgroundColor: LNbits.utils.hexAlpha(
this.secondaryColor,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
),
borderColor: this.secondaryColor,
borderColor: Quasar.colors.getPaletteColor('secondary'),
borderWidth: 1,
fill: true,
tension: 0.7,
@@ -1029,8 +1030,8 @@ window.WalletPageLogic = {
label: 'Balance In',
borderRadius: 5,
data: data.map(s => s.balance_in),
backgroundColor: LNbits.utils.hexAlpha(
this.primaryColor,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
)
},
@@ -1038,8 +1039,8 @@ window.WalletPageLogic = {
label: 'Balance Out',
borderRadius: 5,
data: data.map(s => s.balance_out),
backgroundColor: LNbits.utils.hexAlpha(
this.secondaryColor,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
)
}
@@ -1078,16 +1079,16 @@ window.WalletPageLogic = {
{
label: 'Payments In',
data: data.map(s => s.count_in),
backgroundColor: LNbits.utils.hexAlpha(
this.primaryColor,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('primary'),
0.3
)
},
{
label: 'Payments Out',
data: data.map(s => -s.count_out),
backgroundColor: LNbits.utils.hexAlpha(
this.secondaryColor,
backgroundColor: Quasar.colors.changeAlpha(
Quasar.colors.getPaletteColor('secondary'),
0.3
)
}
@@ -1103,9 +1104,51 @@ window.WalletPageLogic = {
saveChartsPreferences() {
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
this.refreshCharts()
},
updatePaylinks() {
LNbits.api
.request(
'PUT',
`/api/v1/wallet/stored_paylinks/${this.g.wallet.id}`,
this.g.wallet.adminkey,
{
links: this.stored_paylinks
}
)
.then(() => {
Quasar.Notify.create({
message: 'Paylinks updated.',
type: 'positive',
timeout: 3500
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
sendToPaylink(lnurl) {
this.parse.data.request = lnurl
this.parse.show = true
this.lnurlScan()
},
editPaylink() {
this.$nextTick(() => {
this.updatePaylinks()
})
},
deletePaylink(lnurl) {
const links = []
this.stored_paylinks.forEach(link => {
if (link.lnurl !== lnurl) {
links.push(link)
}
})
this.stored_paylinks = links
this.updatePaylinks()
}
},
created() {
this.stored_paylinks = wallet.stored_paylinks.links
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('lightning') || urlParams.has('lnurl')) {
this.parse.data.request =
+62
View File
@@ -0,0 +1,62 @@
@use 'sass:color';
@each $theme, $colors in $themes {
body[data-theme='#{$theme}'] {
@each $name, $color in $colors {
@if $name== 'dark' {
--q-dark-page: #{$color};
}
@if $name== 'primary' {
--q-primary: #{$color};
}
@if $name== 'secondary' {
--q-secondary: #{$color};
}
@if $name== 'info' {
[data-theme='#{$theme}'] .q-card--dark,
[data-theme='#{$theme}'] .q-stepper--dark {
background: $color !important;
}
}
}
}
}
body.gradient-bg {
background-image: linear-gradient(
to bottom right,
var(--q-dark-page),
#0a0a0a
);
background-attachment: fixed;
}
body.bg-image {
&::before {
content: '';
position: fixed;
z-index: -1;
top: 0;
left: 0;
width: 100%;
height: 100%;
filter: blur(8px);
background-image: var(--background);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */
}
}
// transparent background for specific elements
body.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
.q-header,
.q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
backdrop-filter: blur(6px);
}
}
+3 -127
View File
@@ -1,130 +1,6 @@
$themes: (
'classic': (
primary: #673ab7,
secondary: #9c27b0,
dark: #1f2234,
info: #333646,
marginal-bg: #1f2234,
marginal-text: #fff
),
'bitcoin': (
primary: #ea611d,
secondary: #e56f35,
dark: #2d293b,
info: #333646,
marginal-bg: #000000,
marginal-text: #fff
),
'freedom': (
primary: #e22156,
secondary: #b91a45,
dark: #0a0a0a,
info: #1b1b1b,
marginal-bg: #2d293b,
marginal-text: #fff
),
'cyber': (
primary: #7cb342,
secondary: #558b2f,
dark: #0a0a0a,
info: #1b1b1b,
marginal-bg: #2d293b,
marginal-text: #fff
),
'mint': (
primary: #3ab77d,
secondary: #27b065,
dark: #1f342b,
info: #334642,
marginal-bg: #1f342b,
marginal-text: #fff
),
'autumn': (
primary: #b7763a,
secondary: #b07927,
dark: #34291f,
info: #463f33,
marginal-bg: #342a1f,
marginal-text: rgb(255, 255, 255)
),
'flamingo': (
primary: #ff00ff,
secondary: #fda3fd,
dark: #2f032f,
info: #bc23bc,
marginal-bg: #311231,
marginal-text: rgb(255, 255, 255)
),
'monochrome': (
primary: #494949,
secondary: #6b6b6b,
dark: #000,
info: rgb(39, 39, 39),
marginal-bg: #000,
marginal-text: rgb(255, 255, 255)
)
);
@each $theme, $colors in $themes {
@each $name, $color in $colors {
@if $name== 'dark' {
[data-theme='#{$theme}'] .q-drawer--dark,
body[data-theme='#{$theme}'].body--dark,
[data-theme='#{$theme}'] .q-menu--dark {
background: $color !important;
}
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
// set a darker body bg for all themes, when in "dark mode"
body[data-theme='#{$theme}'].body--dark {
background: scale-color($color, $lightness: -60%);
}
*/
} @else {
body[data-theme='#{$theme}'].body--light {
background-color: #f4f4f4;
}
}
@if $name== 'info' {
[data-theme='#{$theme}'] .q-card--dark,
[data-theme='#{$theme}'] .q-stepper--dark {
background: $color !important;
}
}
}
[data-theme='#{$theme}'] {
@each $name, $color in $colors {
.bg-#{$name} {
background: $color !important;
}
.text-#{$name} {
color: $color !important;
}
}
}
}
[data-theme='freedom'] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme='freedom'] .q-header {
background: #0a0a0a !important;
}
[data-theme='cyber'] .q-drawer--dark {
background: #0a0a0a !important;
}
[data-theme='cyber'] .q-header {
background: #0a0a0a !important;
}
[data-theme='salvador'] .q-drawer--dark {
background: #242424 !important;
}
[data-theme='salvador'] .q-header {
background: #0f47af !important;
}
@import 'themes';
@import 'borders';
@import 'background';
[v-cloak] {
display: none;
+49
View File
@@ -0,0 +1,49 @@
@each $theme, $colors in $themes {
@each $name, $color in $colors {
@if $name== 'primary' {
body[data-theme='#{$theme}'].neon-border {
.q-card,
.q-card.q-card--dark,
.q-date,
.q-date--dark {
border: 2px solid $color;
box-shadow: none;
}
}
}
}
}
body.hard-border {
.q-card,
.q-card.q-card--dark,
.q-date,
.q-date--dark {
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.12),
0 0 0 1px #ffffff47;
border: none;
}
}
body.retro-border {
.q-card,
.q-card.q-card--dark,
.q-date,
.q-date--dark {
border: none;
box-shadow:
0 1px 5px rgba(255, 255, 255, 0.2),
0 2px 2px rgba(255, 255, 255, 0.14),
0 3px 1px -2px rgba(255, 255, 255, 0.12);
}
}
body.no-border {
.q-card,
.q-card.q-card--dark,
.q-date,
.q-date--dark {
border: none;
box-shadow: none;
}
}
+74
View File
@@ -0,0 +1,74 @@
$themes: (
'classic': (
primary: #673ab7,
secondary: #9c27b0,
dark: #1f2234,
info: #333646,
marginal-bg: #1f2234,
marginal-text: #fff
),
'bitcoin': (
primary: #ea611d,
secondary: #e56f35,
dark: #2d293b,
info: #333646,
marginal-bg: #000000,
marginal-text: #fff
),
'freedom': (
primary: #e22156,
secondary: #b91a45,
dark: #462f36,
info: #47393d,
marginal-bg: #2d293b,
marginal-text: #fff
),
'cyber': (
primary: #7cb342,
secondary: #558b2f,
dark: #000,
info: #1f2915,
marginal-bg: #2d293b,
marginal-text: #fff
),
'mint': (
primary: #3ab77d,
secondary: #27b065,
dark: #1f342b,
info: #334642,
marginal-bg: #1f342b,
marginal-text: #fff
),
'autumn': (
primary: #b7763a,
secondary: #b07927,
dark: #34291f,
info: #463f33,
marginal-bg: #342a1f,
marginal-text: rgb(255, 255, 255)
),
'flamingo': (
primary: #ff00ff,
secondary: #fda3fd,
dark: #2f032f,
info: #bc23bc,
marginal-bg: #311231,
marginal-text: rgb(255, 255, 255)
),
'monochrome': (
primary: #494949,
secondary: #6b6b6b,
dark: #000,
info: rgb(39, 39, 39),
marginal-bg: #000,
marginal-text: rgb(255, 255, 255)
),
'salvador': (
primary: #1976d2,
secondary: #26a69a,
dark: #253647,
info: #343d47,
marginal-bg: #0d1620,
marginal-text: rgb(255, 255, 255)
)
);
+2
View File
@@ -38,6 +38,8 @@
"js/bolt11-decoder.js"
],
"components": [
"js/components/lnbits-qrcode.js",
"js/components/lnbits-qrcode-lnurl.js",
"js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js",
"js/components/payment-list.js",
File diff suppressed because one or more lines are too long
+11 -46
View File
@@ -1,5 +1,4 @@
import asyncio
import time
import traceback
import uuid
from collections.abc import Coroutine
@@ -11,7 +10,6 @@ from typing import (
from loguru import logger
from lnbits.core.crud import (
get_payments,
get_standalone_payment,
update_payment,
)
@@ -153,51 +151,18 @@ def wait_for_paid_invoices(
return wrapper
async def check_pending_payments():
"""
check_pending_payments is called during startup to check for pending payments with
the backend and also to delete expired invoices. Incoming payments will be
checked only once, outgoing pending payments will be checked regularly.
"""
sleep_time = 60 * 30 # 30 minutes
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"""
while settings.lnbits_running:
funding_source = get_funding_source()
if funding_source.__class__.__name__ == "VoidWallet":
logger.warning("Task: skipping pending check for VoidWallet")
await asyncio.sleep(sleep_time)
continue
start_time = time.time()
pending_payments = await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
pending=True,
exclude_uncheckable=True,
)
count = len(pending_payments)
if count > 0:
logger.info(f"Task: checking {count} pending payments of last 15 days...")
for i, payment in enumerate(pending_payments):
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
await asyncio.sleep(0.01) # to avoid complete blocking
logger.info(
f"Task: pending check finished for {count} payments"
f" (took {time.time() - start_time:0.3f} s)"
)
await asyncio.sleep(sleep_time)
async def wrapper() -> None:
while settings.lnbits_running:
await func()
await asyncio.sleep(interval_seconds)
return wrapper
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
-3
View File
@@ -423,9 +423,6 @@
Object.keys(WINDOW_SETTINGS).forEach(key => {
window[key] = WINDOW_SETTINGS[key]
})
if (LNBITS_THEME_OPTIONS && LNBITS_THEME_OPTIONS.length) {
window.allowedThemes = LNBITS_THEME_OPTIONS.map(str => str.trim())
}
</script>
{% include('components.vue') %} {% block vue_templates %}{% endblock %} {%
for url in INCLUDED_JS %}
+101 -4
View File
@@ -370,7 +370,12 @@
v-if="extras.length"
>
<template v-for="entry in extras">
<q-item v-if="!!entry.value" key="entry.key" class="text-grey-4">
<q-item
v-if="!!entry.value"
key="entry.key"
class="text-grey-4"
style="white-space: normal; word-break: break-all"
>
<q-item-section>
<q-item-label v-text="entry.key"></q-item-label>
<q-item-label caption v-text="entry.value"></q-item-label>
@@ -630,6 +635,25 @@
</div>
</template>
<template id="lnbits-qrcode-lnurl">
<div class="qrcode_lnurl__wrapper">
<q-tabs
v-model="tab"
dense
class="text-grey q-mb-md"
active-color="primary"
indicator-color="primary"
align="justify"
narrow-indicator
inline-label
>
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
</q-tabs>
<lnbits-qrcode :value="lnurl" class="rounded-borders"></lnbits-qrcode>
</div>
</template>
<template id="lnbits-lnurlpay-success-action">
<div>
<p
@@ -880,14 +904,37 @@
:props="props"
style="white-space: normal; word-break: break-all"
>
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
<q-icon
v-if="
props.row.isIn &&
props.row.isPending &&
props.row.extra.hold_invoice
"
name="pause_presentation"
color="grey"
class="cursor-pointer q-mr-sm"
@click="showHoldInvoiceDialog(props.row)"
>
<q-tooltip><span v-text="$t('hold_invoice')"></span></q-tooltip>
</q-icon>
<q-badge
v-if="props.row.tag"
color="yellow"
text-color="black"
class="q-mr-sm"
>
<a
v-text="'#' + props.row.tag"
class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge>
<span class="q-ml-sm" v-text="props.row.memo"></span>
<span v-text="props.row.memo"></span>
<span
class="text-grey-5 q-ml-sm ellipsis"
v-if="props.row.extra.internal_memo"
v-text="`(${props.row.extra.internal_memo})`"
></span>
<br />
<i>
@@ -931,7 +978,7 @@
</q-td>
<q-dialog v-model="props.expand" :props="props" position="top">
<q-card class="q-pa-sm q-pt-xl lnbits__dialog-card">
<q-card-section class="">
<q-card-section>
<q-list bordered separator>
<q-expansion-item
expand-separator
@@ -994,6 +1041,7 @@
></lnbits-payment-details>
</q-expansion-item>
</q-list>
<div
v-if="props.row.isIn && props.row.isPending && props.row.bolt11"
class="text-center q-my-lg"
@@ -1050,6 +1098,55 @@
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="hodlInvoice.show" position="top">
<q-card class="q-pa-sm q-pt-xl lnbits__dialog-card">
<q-card-section>
<q-item-label class="text-h6">
<span v-text="$t('hold_invoice')"></span>
</q-item-label>
<q-item-label class="text-subtitle2">
<span v-text="$t('hold_invoice_description')"></span>
</q-item-label>
</q-card-section>
<q-card-section>
<q-input
filled
:label="$t('preimage')"
:hint="$t('preimage_hint')"
v-model="hodlInvoice.preimage"
dense
autofocus
@keyup.enter="settleHoldInvoice(hodlInvoice.preimage)"
>
</q-input>
</q-card-section>
<q-card-section class="row q-gutter-x-sm">
<q-btn
@click="settleHoldInvoice(hodlInvoice.preimage)"
outline
v-close-popup
color="grey"
:label="$t('settle_invoice')"
>
</q-btn>
<q-btn
v-close-popup
outline
color="grey"
class="q-ml-sm"
@click="cancelHoldInvoice(hodlInvoice.payment.payment_hash)"
:label="$t('cancel_invoice')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
:label="$t('close')"
></q-btn>
</q-card-section>
</q-card>
</q-dialog>
</q-tr>
</template>
</q-table>
-3
View File
@@ -49,9 +49,6 @@
Object.keys(WINDOW_SETTINGS).forEach(key => {
window[key] = WINDOW_SETTINGS[key]
})
if (LNBITS_THEME_OPTIONS && LNBITS_THEME_OPTIONS.length) {
window.allowedThemes = LNBITS_THEME_OPTIONS.map(str => str.trim())
}
</script>
{% include('components.vue') %}{% block vue_templates %}{% endblock %} {%
for url in INCLUDED_JS %}
+2 -1
View File
@@ -66,7 +66,8 @@ class AESCipher:
self, salt: bytes, output_len: int = 32 + 16
) -> tuple[bytes, bytes]:
# extended from https://gist.github.com/gsakkis/4546068
assert len(salt) == 8, "Salt must be 8 bytes"
if len(salt) != 8:
raise ValueError("Salt must be 8 bytes")
data = self.key + salt
key = md5(data).digest()
final_key = key
+1 -1
View File
@@ -13,7 +13,7 @@ from lnbits.settings import settings
def log_server_info():
logger.info("Starting LNbits")
logger.info("LNbits Info")
logger.info(f"Version: {settings.version}")
logger.info(f"Baseurl: {settings.lnbits_baseurl}")
logger.info(f"Host: {settings.host}")
+8 -4
View File
@@ -175,14 +175,17 @@ def normalize_private_key(key: str) -> str:
def normalize_bech32_key(hrp: str, key: str) -> str:
if key.startswith(hrp):
_, decoded_data = bech32_decode(key)
assert decoded_data, f"Key is not valid {hrp}."
if not decoded_data:
raise ValueError(f"Key is not valid {hrp}.")
decoded_data_bits = convertbits(decoded_data, 5, 8, False)
assert decoded_data_bits, f"Key is not valid {hrp}."
if not decoded_data_bits:
raise ValueError(f"Key is not valid {hrp}.")
return bytes(decoded_data_bits).hex()
assert len(key) == 64, "Key has wrong length."
if len(key) != 64:
raise ValueError("Key has wrong length.")
try:
int(key, 16)
except Exception as exc:
@@ -203,7 +206,8 @@ def hex_to_npub(hex_pubkey: str) -> str:
normalize_public_key(hex_pubkey)
pubkey_bytes = bytes.fromhex(hex_pubkey)
bits = convertbits(pubkey_bytes, 8, 5, True)
assert bits
if not bits:
raise ValueError("Invalid Nostr public key.")
return bech32_encode("npub", bits)
+1
View File
@@ -0,0 +1 @@
MIIBYzCCARWgAwIBAgIHPjlTc1IbAzAFBgMrZXAwEDEOMAwGA1UEAxMFQnJlZXowHhcNMjUwNzAyMDkxODMzWhcNMzUwNjMwMDkxODMzWjAfMQ8wDQYDVQQKEwZsbmJpdHMxDDAKBgNVBAMTA2RuaTAqMAUGAytlcAMhANCD9cvfIDwcoiDKKYdT9BunHLS2/OuKzV8NS0SzqV13o38wfTAOBgNVHQ8BAf8EBAMCBaAwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU2jmj7l5rSw0yVb/vlWAYkK/YBwkwHwYDVR0jBBgwFoAU3qrWklbzjed0khb8TLYgsmsomGswHQYDVR0RBBYwFIESb2ZmaWNlQGRuaWxhYnMuY29tMAUGAytlcANBAMGS8jEfZbfNpv6mVrg328NXnjA/nG6TuGA0aAw0NyDz499aeu/0TURjF8FzmxzmdNOiffUZ6akPWCZUKFYuGgA=
+8 -5
View File
@@ -2,15 +2,16 @@ from __future__ import annotations
import importlib
from lnbits.nodes import set_node_class
from lnbits.settings import settings
from lnbits.wallets.base import Wallet
from lnbits.wallets.base import Feature, Wallet
from .alby import AlbyWallet
from .blink import BlinkWallet
from .boltz import BoltzWallet
from .breez import BreezSdkWallet
from .breez_liquid import BreezLiquidSdkWallet
from .cliche import ClicheWallet
from .clnrest import CLNRestWallet
from .corelightning import CoreLightningWallet
# The following import is intentional to keep backwards compatibility
@@ -33,13 +34,13 @@ from .void import VoidWallet
from .zbd import ZBDWallet
def set_funding_source(class_name: str | None = None):
def set_funding_source(class_name: str | None = None) -> None:
backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
funding_source_constructor = getattr(wallets_module, backend_wallet_class)
global funding_source
funding_source = funding_source_constructor()
if funding_source.__node_cls__:
set_node_class(funding_source.__node_cls__(funding_source))
settings.has_nodemanager = funding_source.has_feature(Feature.nodemanager)
settings.has_holdinvoice = funding_source.has_feature(Feature.holdinvoice)
def get_funding_source() -> Wallet:
@@ -57,7 +58,9 @@ __all__ = [
"AlbyWallet",
"BlinkWallet",
"BoltzWallet",
"BreezLiquidSdkWallet",
"BreezSdkWallet",
"CLNRestWallet",
"CLightningWallet",
"ClicheWallet",
"CoreLightningRestWallet",
+39 -1
View File
@@ -3,14 +3,24 @@ from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Coroutine
from enum import Enum
from typing import TYPE_CHECKING, NamedTuple
from loguru import logger
from lnbits.exceptions import InvoiceError
from lnbits.settings import settings
if TYPE_CHECKING:
from lnbits.nodes.base import Node
class Feature(Enum):
nodemanager = "nodemanager"
holdinvoice = "holdinvoice"
# bolt12 = "bolt12"
class StatusResponse(NamedTuple):
error_message: str | None
balance_msat: int
@@ -22,6 +32,7 @@ class InvoiceResponse(NamedTuple):
payment_request: str | None = None
error_message: str | None = None
preimage: str | None = None
fee_msat: int | None = None
@property
def success(self) -> bool:
@@ -97,6 +108,10 @@ class PaymentPendingStatus(PaymentStatus):
class Wallet(ABC):
__node_cls__: type[Node] | None = None
features: list[Feature] | None = None
def has_feature(self, feature: Feature) -> bool:
return self.features is not None and feature in self.features
def __init__(self) -> None:
self.pending_invoices: list[str] = []
@@ -138,8 +153,31 @@ class Wallet(ABC):
) -> Coroutine[None, None, PaymentStatus]:
pass
async def create_hold_invoice(
self,
amount: int,
payment_hash: str,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def settle_hold_invoice(self, preimage: str) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def cancel_hold_invoice(self, payment_hash: str) -> InvoiceResponse:
raise InvoiceError(
message="Hold invoices are not supported by this wallet.", status="failed"
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
while settings.lnbits_running:
for invoice in self.pending_invoices:
try:
status = await self.get_invoice_status(invoice)
+8 -8
View File
@@ -7,8 +7,7 @@ from typing import Optional
import httpx
from loguru import logger
from pydantic import BaseModel
from websockets.legacy.client import WebSocketClientProtocol, connect
from websockets.typing import Subprotocol
from websockets import Subprotocol, connect
from lnbits import bolt11
from lnbits.helpers import normalize_endpoint
@@ -48,7 +47,7 @@ class BlinkWallet(Wallet):
"payload": {"X-API-KEY": settings.blink_token},
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.auth)
self.ws: Optional[WebSocketClientProtocol] = None
self.ws = None
self._wallet_id = None
@property
@@ -252,7 +251,8 @@ class BlinkWallet(Wallet):
response = await self._graphql_query(data)
response_data = response.get("data")
assert response_data is not None
if response_data is None:
raise ValueError("No data found in response.")
txs_data = (
response_data.get("me", {})
.get("defaultAccount", {})
@@ -260,7 +260,8 @@ class BlinkWallet(Wallet):
.get("transactionsByPaymentHash", [])
)
tx_data = next((t for t in txs_data if t.get("direction") == "SEND"), None)
assert tx_data, "No SEND data found."
if not tx_data:
raise ValueError("No SEND data found.")
fee = tx_data.get("settlementFee")
preimage = tx_data.get("settlementVia", {}).get("preImage")
status = tx_data.get("status")
@@ -284,9 +285,8 @@ class BlinkWallet(Wallet):
await ws.send(json.dumps(self.ws_auth))
confirmation = await ws.recv()
ack = json.loads(confirmation)
assert (
ack.get("type") == "connection_ack"
), "Websocket connection not acknowledged."
if ack.get("type") != "connection_ack":
raise ValueError("Websocket connection not acknowledged.")
logger.info("Websocket connection acknowledged.")
subscription_req = {
+4 -3
View File
@@ -63,7 +63,7 @@ class BoltzWallet(Wallet):
channel = grpc.aio.insecure_channel(settings.boltz_client_endpoint)
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id: int = 0
self.wallet_id = 0
# Auto-create wallet if running in Docker mode
async def _init_boltz_wallet():
@@ -142,7 +142,8 @@ class BoltzWallet(Wallet):
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
invoice = decode(bolt11)
assert invoice.amount_msat, "amountless invoice"
if not invoice.amount_msat:
raise ValueError("amountless invoice")
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = service_fee + pair_info.fees.miner_fees * 1000
if estimate > fee_limit_msat:
@@ -236,7 +237,7 @@ class BoltzWallet(Wallet):
return PaymentFailedStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
while settings.lnbits_running:
try:
request = boltzrpc_pb2.GetSwapInfoRequest()
info: boltzrpc_pb2.GetSwapInfoResponse
+100 -74
View File
@@ -1,15 +1,9 @@
import base64
from importlib.util import find_spec
from lnbits.exceptions import UnsupportedError
try:
import breez_sdk # type: ignore
BREEZ_SDK_INSTALLED = True
except ImportError:
BREEZ_SDK_INSTALLED = False
if not BREEZ_SDK_INSTALLED:
if not find_spec("breez_sdk"):
class BreezSdkWallet: # pyright: ignore
def __init__(self):
@@ -24,9 +18,31 @@ else:
from pathlib import Path
from typing import Optional
from bolt11 import Bolt11Exception
from bolt11 import decode as bolt11_decode
from breez_sdk import (
BreezEvent,
ConnectRequest,
EnvironmentType,
EventListener,
GreenlightCredentials,
GreenlightNodeConfig,
NodeConfig,
PaymentDetails,
PaymentType,
ReceivePaymentRequest,
ReceivePaymentResponse,
ReportIssueRequest,
ReportPaymentFailureDetails,
SendPaymentRequest,
SendPaymentResponse,
connect,
default_config,
mnemonic_to_seed,
)
from breez_sdk import PaymentStatus as BreezPaymentStatus
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from lnbits.settings import settings
from .base import (
@@ -40,7 +56,15 @@ else:
Wallet,
)
breez_event_queue: asyncio.Queue = asyncio.Queue()
breez_incoming_queue: asyncio.Queue[PaymentDetails.LN] = asyncio.Queue()
class PaymentsListener(EventListener):
def on_event(self, e: BreezEvent) -> None:
logger.debug(f"received breez sdk event: {e}")
if isinstance(e, BreezEvent.PAYMENT_SUCCEED) and isinstance(
e.details, PaymentDetails.LN
):
breez_incoming_queue.put_nowait(e.details)
def load_bytes(source: str, extension: str) -> Optional[bytes]:
# first check if it can be read from a file
@@ -57,15 +81,11 @@ else:
# else convert from base64
try:
return base64.b64decode(source)
except Exception:
pass
except Exception as exc:
logger.debug(exc)
return None
def load_greenlight_credentials() -> (
Optional[
breez_sdk.GreenlightCredentials # pyright: ignore[reportUnboundVariable]
]
):
def load_greenlight_credentials() -> Optional[GreenlightCredentials]:
if (
settings.breez_greenlight_device_key
and settings.breez_greenlight_device_cert
@@ -78,19 +98,12 @@ else:
"cannot decode breez_greenlight_device_key "
"or breez_greenlight_device_cert"
)
return breez_sdk.GreenlightCredentials( # pyright: ignore[reportUnboundVariable]
return GreenlightCredentials(
developer_key=list(device_key_bytes),
developer_cert=list(device_cert_bytes),
)
return None
class SDKListener(
breez_sdk.EventListener # pyright: ignore[reportUnboundVariable]
):
def on_event(self, event):
logger.debug(event)
breez_event_queue.put_nowait(event)
class BreezSdkWallet(Wallet): # type: ignore[no-redef]
def __init__(self):
if not settings.breez_greenlight_seed:
@@ -118,15 +131,15 @@ else:
"missing breez_greenlight_device_key"
)
self.config = breez_sdk.default_config(
breez_sdk.EnvironmentType.PRODUCTION,
gl_config = GreenlightNodeConfig(
partner_credentials=load_greenlight_credentials(),
invite_code=settings.breez_greenlight_invite_code,
)
node_config = NodeConfig.GREENLIGHT(config=gl_config)
self.config = default_config(
EnvironmentType.PRODUCTION,
settings.breez_api_key,
breez_sdk.NodeConfig.GREENLIGHT(
config=breez_sdk.GreenlightNodeConfig(
partner_credentials=load_greenlight_credentials(),
invite_code=settings.breez_greenlight_invite_code,
)
),
node_config=node_config, # type: ignore[arg-type]
)
breez_sdk_working_dir = Path(settings.lnbits_data_folder, "breez-sdk")
@@ -134,9 +147,9 @@ else:
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
try:
seed = breez_sdk.mnemonic_to_seed(settings.breez_greenlight_seed)
connect_request = breez_sdk.ConnectRequest(self.config, seed)
self.sdk_services = breez_sdk.connect(connect_request, SDKListener())
seed = mnemonic_to_seed(settings.breez_greenlight_seed)
connect_request = ConnectRequest(config=self.config, seed=seed)
self.sdk_services = connect(connect_request, PaymentsListener())
except Exception as exc:
logger.warning(exc)
raise ValueError(f"cannot initialize BreezSdkWallet: {exc!s}") from exc
@@ -146,7 +159,7 @@ else:
async def status(self) -> StatusResponse:
try:
node_info: breez_sdk.NodeState = self.sdk_services.node_info()
node_info = self.sdk_services.node_info()
except Exception as exc:
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
@@ -168,14 +181,14 @@ else:
"'description_hash' unsupported by Greenlight, provide"
" 'unhashed_description'"
)
breez_invoice: breez_sdk.ReceivePaymentResponse = (
breez_invoice: ReceivePaymentResponse = (
self.sdk_services.receive_payment(
breez_sdk.ReceivePaymentRequest(
amount * 1000, # breez uses msat
(
ReceivePaymentRequest(
amount_msat=amount * 1000, # breez uses msat
description=(
unhashed_description.decode()
if unhashed_description
else memo
else memo or ""
),
preimage=kwargs.get("preimage"),
opening_fee_params=None,
@@ -198,36 +211,45 @@ else:
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
logger.debug(f"fee_limit_msat {fee_limit_msat} is ignored by Breez SDK")
try:
send_payment_request = breez_sdk.SendPaymentRequest(
invoice = bolt11_decode(bolt11)
except Bolt11Exception as exc:
logger.warning(exc)
return PaymentResponse(
ok=False, error_message=f"invalid bolt11 invoice: {exc}"
)
try:
send_payment_request = SendPaymentRequest(
bolt11=bolt11, use_trampoline=settings.breez_use_trampoline
)
send_payment_response: breez_sdk.SendPaymentResponse = (
send_payment_response: SendPaymentResponse = (
self.sdk_services.send_payment(send_payment_request)
)
payment: breez_sdk.Payment = send_payment_response.payment
payment = send_payment_response.payment
except Exception as exc:
logger.warning(exc)
try:
# try to report issue to Breez to improve LSP routing
self.sdk_services.report_issue(
breez_sdk.ReportIssueRequest.PAYMENT_FAILURE(
breez_sdk.ReportPaymentFailureDetails(invoice.payment_hash)
)
# report issue to Breez to improve LSP routing
payment_error = ReportIssueRequest.PAYMENT_FAILURE(
ReportPaymentFailureDetails(payment_hash=invoice.payment_hash)
)
self.sdk_services.report_issue(payment_error) # type: ignore[arg-type]
except Exception as ex:
logger.info(ex)
# assume that payment failed?
return PaymentResponse(ok=False, error_message=f"payment failed: {exc}")
return PaymentResponse(error_message=f"exception while payment {exc!s}")
if payment.status != breez_sdk.PaymentStatus.COMPLETE:
return PaymentResponse(ok=False, error_message="payment is pending")
if payment.status != BreezPaymentStatus.COMPLETE:
return PaymentResponse(ok=None, error_message="payment is pending")
# let's use the payment_hash as the checking_id
checking_id = invoice.payment_hash
if not isinstance(payment.details, PaymentDetails.LN):
return PaymentResponse(
error_message="Breez SDK returned a non-LN payment details object",
)
return PaymentResponse(
ok=True,
checking_id=checking_id,
@@ -237,18 +259,22 @@ else:
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
payment = self.sdk_services.payment_by_hash(checking_id)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.RECEIVED:
if payment.payment_type != PaymentType.RECEIVED:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.FAILED:
if not isinstance(payment.details, PaymentDetails.LN):
logger.warning(f"unexpected paymentdetails type: {payment.details}")
return PaymentPendingStatus()
if payment.status == BreezPaymentStatus.FAILED:
return PaymentFailedStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
return PaymentSuccessStatus()
if payment.status == BreezPaymentStatus.COMPLETE:
return PaymentSuccessStatus(
fee_msat=payment.fee_msat,
preimage=payment.details.data.payment_preimage,
)
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
@@ -256,20 +282,21 @@ else:
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
payment: breez_sdk.Payment = self.sdk_services.payment_by_hash(
checking_id
)
payment = self.sdk_services.payment_by_hash(checking_id)
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != breez_sdk.PaymentType.SENT:
logger.warning(f"unexpected payment type: {payment.status}")
if payment.payment_type != PaymentType.SENT:
logger.warning(f"unexpected payment type: {payment.payment_type}")
return PaymentPendingStatus()
if payment.status == breez_sdk.PaymentStatus.COMPLETE:
if not isinstance(payment.details, PaymentDetails.LN):
logger.warning(f"unexpected paymentdetails type: {payment.details}")
return PaymentPendingStatus()
if payment.status == BreezPaymentStatus.COMPLETE:
return PaymentSuccessStatus(
fee_msat=payment.fee_msat,
preimage=payment.details.data.payment_preimage,
)
if payment.status == breez_sdk.PaymentStatus.FAILED:
if payment.status == BreezPaymentStatus.FAILED:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
@@ -277,7 +304,6 @@ else:
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
event = await breez_event_queue.get()
if event.is_invoice_paid():
yield event.details.payment_hash
while settings.lnbits_running:
details = await breez_incoming_queue.get()
yield details.data.payment_hash
+311
View File
@@ -0,0 +1,311 @@
# Based on breez.py
from importlib.util import find_spec
if not find_spec("breez_sdk_liquid"):
class BreezLiquidSdkWallet: # pyright: ignore
def __init__(self):
raise RuntimeError(
"Breez Liquid SDK is not installed. "
"Ask admin to run `poetry add -E breez` to install it."
)
else:
import asyncio
from asyncio import Queue
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Optional
from bolt11 import decode as bolt11_decode
from breez_sdk_liquid import (
ConnectRequest,
EventListener,
GetInfoResponse,
GetPaymentRequest,
LiquidNetwork,
Payment,
PaymentDetails,
PaymentMethod,
PaymentState,
PaymentType,
PrepareReceiveRequest,
PrepareSendRequest,
ReceiveAmount,
ReceivePaymentRequest,
SdkEvent,
SendPaymentRequest,
connect,
default_config,
)
from loguru import logger
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
breez_incoming_queue: Queue[PaymentDetails.LIGHTNING] = Queue()
breez_outgoing_queue: dict[str, Queue[PaymentDetails.LIGHTNING]] = {}
class PaymentsListener(EventListener):
def on_event(self, e: SdkEvent) -> None:
logger.debug(f"received breez sdk event: {e}")
# TODO: when this issue is fixed:
# https://github.com/breez/breez-sdk-liquid/issues/961
# use SdkEvent.PAYMENT_WAITING_CONFIRMATION
if not isinstance(e, SdkEvent.PAYMENT_SUCCEEDED) or not isinstance(
e.details.details, PaymentDetails.LIGHTNING
):
return
payment = e.details
payment_details = e.details.details
if payment.payment_type is PaymentType.RECEIVE:
breez_incoming_queue.put_nowait(payment_details)
elif (
payment.payment_type is PaymentType.SEND
and payment_details.payment_hash in breez_outgoing_queue
):
breez_outgoing_queue[payment_details.payment_hash].put_nowait(
payment_details
)
class BreezLiquidSdkWallet(Wallet): # type: ignore[no-redef]
def __init__(self):
if not settings.breez_liquid_seed:
raise ValueError(
"cannot initialize BreezLiquidSdkWallet: missing breez_liquid_seed"
)
if not settings.breez_liquid_api_key:
with open(Path("lnbits/wallets", ".breez")) as f:
settings.breez_liquid_api_key = f.read().strip()
self.config = default_config(
LiquidNetwork.MAINNET,
breez_api_key=settings.breez_liquid_api_key,
)
breez_sdk_working_dir = Path(
settings.lnbits_data_folder, "breez-liquid-sdk"
)
breez_sdk_working_dir.mkdir(parents=True, exist_ok=True)
self.config.working_dir = breez_sdk_working_dir.absolute().as_posix()
try:
mnemonic = settings.breez_liquid_seed
connect_request = ConnectRequest(config=self.config, mnemonic=mnemonic)
self.sdk_services = connect(connect_request)
self.sdk_services.add_event_listener(PaymentsListener())
except Exception as exc:
logger.warning(exc)
raise ValueError(
f"cannot initialize BreezLiquidSdkWallet: {exc!s}"
) from exc
async def cleanup(self):
self.sdk_services.disconnect()
async def status(self) -> StatusResponse:
try:
info: GetInfoResponse = self.sdk_services.get_info()
except Exception as exc:
logger.warning(exc)
return StatusResponse(f"Failed to connect to breez, got: '{exc}...'", 0)
return StatusResponse(None, int(info.wallet_info.balance_sat * 1000))
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**_,
) -> InvoiceResponse:
try:
# issue with breez sdk, receive_amount is of type BITCOIN
# not ReceiveAmount after initialisation
receive_amount = ReceiveAmount.BITCOIN(amount)
req = self.sdk_services.prepare_receive_payment(
PrepareReceiveRequest(
payment_method=PaymentMethod.BOLT11_INVOICE,
amount=receive_amount, # type: ignore
)
)
receive_fees_sats = req.fees_sat
description = memo or (
unhashed_description.decode() if unhashed_description else ""
)
res = self.sdk_services.receive_payment(
ReceivePaymentRequest(
prepare_response=req,
description=description,
use_description_hash=description_hash is not None,
)
)
bolt11 = res.destination
invoice_data = bolt11_decode(bolt11)
payment_hash = invoice_data.payment_hash
return InvoiceResponse(
ok=True,
checking_id=payment_hash,
payment_request=bolt11,
fee_msat=receive_fees_sats * 1000,
)
except Exception as e:
logger.warning(e)
return InvoiceResponse(ok=False, error_message=str(e))
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> PaymentResponse:
invoice_data = bolt11_decode(bolt11)
try:
prepare_req = PrepareSendRequest(destination=bolt11)
req = self.sdk_services.prepare_send_payment(prepare_req)
fee_limit_sat = settings.breez_liquid_fee_offset_sat + int(
fee_limit_msat / 1000
)
if req.fees_sat and req.fees_sat > fee_limit_sat:
return PaymentResponse(
ok=False,
error_message=(
f"fee of {req.fees_sat} sat exceeds limit of "
f"{fee_limit_sat} sat"
),
)
send_response = self.sdk_services.send_payment(
SendPaymentRequest(prepare_response=req)
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message=f"Exception while payment: {exc}")
payment: Payment = send_response.payment
logger.debug(f"pay invoice res: {payment}")
checking_id = invoice_data.payment_hash
fees = req.fees_sat * 1000 if req.fees_sat and req.fees_sat > 0 else 0
if payment.status != PaymentState.COMPLETE:
return await self._wait_for_outgoing_payment(checking_id, fees, 10)
if not isinstance(payment.details, PaymentDetails.LIGHTNING):
return PaymentResponse(
error_message="lightning payment details are not available"
)
return PaymentResponse(
ok=True,
checking_id=checking_id,
fee_msat=payment.fees_sat * 1000,
preimage=payment.details.preimage,
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
req = GetPaymentRequest.PAYMENT_HASH(checking_id)
payment = self.sdk_services.get_payment(req=req) # type: ignore
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != PaymentType.RECEIVE:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == PaymentState.FAILED:
return PaymentFailedStatus()
if payment.status == PaymentState.COMPLETE and isinstance(
payment.details, PaymentDetails.LIGHTNING
):
return PaymentSuccessStatus(
paid=True,
fee_msat=int(payment.fees_sat * 1000),
preimage=payment.details.preimage,
)
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
req = GetPaymentRequest.PAYMENT_HASH(checking_id)
payment = self.sdk_services.get_payment(req=req) # type: ignore
if payment is None:
return PaymentPendingStatus()
if payment.payment_type != PaymentType.SEND:
logger.warning(f"unexpected payment type: {payment.status}")
return PaymentPendingStatus()
if payment.status == PaymentState.COMPLETE:
if not isinstance(payment.details, PaymentDetails.LIGHTNING):
logger.warning("payment details are not of type LIGHTNING")
return PaymentPendingStatus()
return PaymentSuccessStatus(
fee_msat=int(payment.fees_sat * 1000),
preimage=payment.details.preimage,
)
if payment.status == PaymentState.FAILED:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
details = await breez_incoming_queue.get()
logger.debug(f"breez invoice paid event: {details}")
if not details.invoice:
logger.warning(
"Paid invoices stream expected bolt11 invoice, got None"
)
continue
invoice_data = bolt11_decode(details.invoice)
yield invoice_data.payment_hash
async def _wait_for_outgoing_payment(
self, checking_id: str, fees: int, timeout: int
) -> PaymentResponse:
logger.debug(f"waiting for outgoing payment {checking_id} to complete")
try:
breez_outgoing_queue[checking_id] = Queue()
payment_details = await asyncio.wait_for(
breez_outgoing_queue[checking_id].get(), timeout
)
return PaymentResponse(
ok=True,
preimage=payment_details.preimage,
checking_id=checking_id,
fee_msat=fees,
)
except asyncio.TimeoutError:
logger.debug(
f"payment '{checking_id}' is still pending after {timeout} seconds"
)
return PaymentResponse(
checking_id=checking_id,
fee_msat=fees,
error_message="payment is pending",
)
finally:
breez_outgoing_queue.pop(checking_id, None)
+2 -1
View File
@@ -182,7 +182,8 @@ class ClicheWallet(Wallet):
try:
if data["result"]["status"]:
yield data["result"]["payment_hash"]
except Exception:
except Exception as exc:
logger.debug(exc)
continue
except Exception as exc:
logger.error(
+498
View File
@@ -0,0 +1,498 @@
import asyncio
import base64
import json
import os
import ssl
import uuid
from collections.abc import AsyncGenerator
from typing import Optional
from urllib.parse import urlparse
import httpx
from bolt11 import Bolt11Exception
from bolt11.decode import decode
from loguru import logger
from lnbits.exceptions import UnsupportedError
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash
from .base import (
InvoiceResponse,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class CLNRestWallet(Wallet):
def __init__(self):
if not settings.clnrest_url:
raise ValueError("Cannot initialize CLNRestWallet: missing CLNREST_URL")
if not settings.clnrest_readonly_rune:
raise ValueError(
"cannot initialize CLNRestWallet: " "missing clnrest_readonly_rune"
)
self.url = normalize_endpoint(settings.clnrest_url)
if not settings.clnrest_nodeid:
logger.info("missing CLNREST_NODEID, but this is only needed for v23.08")
self.base_headers = {
"accept": "application/json",
"User-Agent": settings.user_agent,
"Content-Type": "application/json",
}
if settings.clnrest_nodeid is not None:
self.base_headers["nodeid"] = settings.clnrest_nodeid
# Ensure the readonly rune is set
if settings.clnrest_readonly_rune is not None:
self.readonly_headers = {
**self.base_headers,
"rune": settings.clnrest_readonly_rune,
}
else:
logger.warning(
"Readonly rune 'CLNREST_READONLY_RUNE' is required but not set."
)
if settings.clnrest_invoice_rune is not None:
self.invoice_headers = {
**self.base_headers,
"rune": settings.clnrest_invoice_rune,
}
else:
logger.warning(
"Will be unable to create any invoices without "
"setting 'CLNREST_INVOICE_RUNE[:4]'"
)
if settings.clnrest_pay_rune is not None:
self.pay_headers = {**self.base_headers, "rune": settings.clnrest_pay_rune}
else:
logger.warning(
"Will be unable to call pay endpoint without setting 'CLNREST_PAY_RUNE'"
)
if settings.clnrest_renepay_rune is not None:
self.renepay_headers = {
**self.base_headers,
"rune": settings.clnrest_renepay_rune,
}
else:
logger.warning(
"Will be unable to call renepay endpoint without "
"setting 'CLNREST_RENEPAY_RUNE'"
)
# https://docs.corelightning.org/reference/lightning-pay
# -32602: Invalid bolt11: Prefix bc is not for regtest
# -1: Catchall nonspecific error.
## 201: Already paid
# 203: Permanent failure at destination.
# 205: Unable to find a route.
# 206: Route too expensive.
# 207: Invoice expired.
# 210: Payment timed out without a payment in progress.
# 401: Unauthorized. Probably a rune issue
self.pay_failure_error_codes = [-32602, 203, 205, 206, 207, 210, 401]
self.client = self._create_client()
self.last_pay_index = settings.clnrest_last_pay_index
self.statuses = {
"paid": True,
"complete": True,
"failed": False,
"pending": None,
}
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as exc:
logger.warning(f"Error closing wallet connection: {exc}")
async def status(self) -> StatusResponse:
try:
logger.debug("REQUEST to /v1/listfunds")
r = await self.client.post(
"/v1/listfunds", timeout=15, headers=self.readonly_headers
)
r.raise_for_status()
response_data = r.json()
if not response_data:
logger.warning("Received empty response data")
return StatusResponse("no data", 0)
channels = response_data.get("channels", [])
total_our_amount_msat = sum(
channel.get("our_amount_msat", 0) for channel in channels
)
return StatusResponse(None, total_our_amount_msat)
except json.JSONDecodeError as exc:
logger.warning(f"JSON decode error: {exc!s}")
return StatusResponse(f"Failed to decode JSON response from {self.url}", 0)
except httpx.ReadTimeout:
logger.warning(
"Timeout error: The server did not respond in time. This can happen if "
"the server is running HTTPS but the client is using HTTP."
)
return StatusResponse(
"Unable to connect to 'v1/listfunds' due to timeout", 0
)
except (httpx.ConnectError, httpx.RequestError) as exc:
logger.warning(f"Connection error: {exc}")
return StatusResponse("Unable to connect to 'v1/listfunds'", 0)
except httpx.HTTPStatusError as exc:
logger.warning(
f"HTTP error: {exc.response.status_code} {exc.response.reason_phrase} "
f"while accessing {exc.request.url}"
)
return StatusResponse(
f"Failed with HTTP {exc.response.status_code} on 'v1/listfunds'", 0
)
except Exception as exc:
logger.warning(exc)
return StatusResponse(f"Unable to connect to {self.url}.", 0)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
if not settings.clnrest_invoice_rune:
return InvoiceResponse(
ok=False, error_message="Unable to invoice without an invoice rune"
)
data: dict = {
"amount_msat": int(amount * 1000),
"description": memo,
"label": _generate_label(),
}
if description_hash and not unhashed_description:
raise UnsupportedError(
"'description_hash' unsupported by CoreLightningRest, "
"provide 'unhashed_description'"
)
if unhashed_description:
data["description"] = unhashed_description.decode()
if kwargs.get("expiry"):
data["expiry"] = kwargs["expiry"]
if kwargs.get("preimage"):
data["preimage"] = kwargs["preimage"]
else:
preimage, _ = random_secret_and_hash()
data["preimage"] = preimage
try:
r = await self.client.post(
"/v1/invoice",
json=data,
headers=self.invoice_headers,
)
r.raise_for_status()
response_data = r.json()
if "error" in response_data:
error_message = response_data["error"]
logger.debug(f"Error creating invoice: {error_message}")
return InvoiceResponse(ok=False, error_message=error_message)
if "payment_hash" not in response_data or "bolt11" not in response_data:
return InvoiceResponse(
ok=False, error_message="Server error: 'missing required fields'"
)
return InvoiceResponse(
ok=True,
checking_id=response_data["payment_hash"],
payment_request=response_data["bolt11"],
preimage=data["preimage"],
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return InvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
)
except Exception as exc:
logger.warning(f"Unable to connect to {self.url}: {exc}")
return InvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.url}."
)
async def pay_invoice(
self,
bolt11: str,
fee_limit_msat: int,
**_,
) -> PaymentResponse:
try:
invoice = decode(bolt11)
except Bolt11Exception as exc:
return PaymentResponse(ok=False, error_message=str(exc))
if not invoice.amount_msat or invoice.amount_msat <= 0:
return PaymentResponse(
ok=False, error_message="0 amount invoices are not allowed"
)
if not settings.clnrest_pay_rune and not settings.clnrest_renepay_rune:
return PaymentResponse(
ok=False,
error_message="Unable to pay invoice without a pay or renepay rune",
)
data = {
"label": _generate_label(),
"description": invoice.description,
"maxfee": fee_limit_msat,
}
if settings.clnrest_renepay_rune:
endpoint = "/v1/renepay"
headers = self.renepay_headers
data["invstring"] = bolt11
else:
endpoint = "/v1/pay"
headers = self.pay_headers
data["bolt11"] = bolt11
try:
r = await self.client.post(
endpoint,
json=data,
headers=headers,
timeout=None,
)
r.raise_for_status()
data = r.json()
if "payment_preimage" not in data:
error_message = data.get("error", "No payment preimage in response")
logger.warning(error_message)
return PaymentResponse(error_message=error_message)
return PaymentResponse(
ok=self.statuses.get(data["status"]),
checking_id=data["payment_hash"],
fee_msat=data["amount_sent_msat"] - data["amount_msat"],
preimage=data["payment_preimage"],
)
except httpx.HTTPStatusError as exc:
try:
data = exc.response.json()
error = data.get("error", {})
error_code = int(error.get("code", 0))
error_message = error.get("message", "Unknown error")
if error_code in self.pay_failure_error_codes:
return PaymentResponse(ok=False, error_message=error_message)
else:
return PaymentResponse(error_message=error_message)
except Exception:
error_message = f"Error parsing response from {self.url}: {exc!s}"
logger.warning(error_message)
return PaymentResponse(error_message=error_message)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
error_message = f"Unable to connect to {self.url}."
return PaymentResponse(error_message=error_message)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
data: dict = {"payment_hash": checking_id}
try:
r = await self.client.post(
"/v1/listinvoices",
json=data,
headers=self.readonly_headers,
)
r.raise_for_status()
data = r.json()
if r.is_error or "error" in data or data.get("invoices") is None:
logger.warning(f"error in cln response '{checking_id}'")
return PaymentPendingStatus()
status = self.statuses.get(data["invoices"][0]["status"])
fee_msat = data["invoices"][0].get("amount_received_msat", 0) - data[
"invoices"
][0].get("amount_msat", 0)
return PaymentStatus(
paid=status,
preimage=data["invoices"][0].get("preimage"),
fee_msat=fee_msat,
)
except Exception as exc:
logger.warning(f"Error getting invoice status: {exc}")
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
data: dict = {"payment_hash": checking_id}
r = await self.client.post(
"/v1/listpays",
json=data,
headers=self.readonly_headers,
)
r.raise_for_status()
data = r.json()
if r.is_error or "error" in data:
logger.warning(f"API response error: {data}")
return PaymentPendingStatus()
pays_list = data.get("pays", [])
if not pays_list:
logger.warning(f"No payments found for payment hash {checking_id}.")
return PaymentPendingStatus()
if len(pays_list) != 1:
logger.warning(
f"Expected one payment status, but found {len(pays_list)}"
)
return PaymentPendingStatus()
pay = pays_list[-1]
if pay["status"] == "complete":
fee_msat = pay["amount_sent_msat"] - pay["amount_msat"]
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"])
except Exception as exc:
logger.warning(f"Error getting payment status: {exc}")
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
try:
waitanyinvoice_timeout = None
request_timeout = httpx.Timeout(
connect=5.0, read=waitanyinvoice_timeout, write=60.0, pool=60.0
)
data: dict = {
"lastpay_index": self.last_pay_index,
"timeout": waitanyinvoice_timeout,
}
url = "/v1/waitanyinvoice"
async with self.client.stream(
"POST",
url,
json=data,
headers=self.readonly_headers,
timeout=request_timeout,
) as r:
async for line in r.aiter_lines():
inv = json.loads(line)
if "error" in inv and "message" in inv["error"]:
logger.warning("Error in paid_invoices_stream:", inv)
raise Exception(inv["error"]["message"])
try:
paid = inv["status"] == "paid"
self.last_pay_index = inv["pay_index"]
if not paid:
continue
except Exception as exc:
logger.warning(exc)
continue
logger.debug(f"paid invoice: {inv}")
if "payment_hash" in inv:
payment_hash_from_waitanyinvoice = inv["payment_hash"]
yield payment_hash_from_waitanyinvoice
except Exception as exc:
logger.debug(
f"lost connection to corelightning-rest invoices stream: '{exc}', "
"reconnecting..."
)
await asyncio.sleep(0.02)
def _create_client(self) -> httpx.AsyncClient:
"""Create an HTTP client with specified headers and SSL configuration."""
parsed_url = urlparse(self.url)
# Validate the URL scheme
if parsed_url.scheme == "http":
if parsed_url.hostname in ("localhost", "127.0.0.1", "::1"):
logger.warning("Not using SSL for connection to CLNRestWallet")
else:
raise ValueError(
"Insecure HTTP connections are only allowed for localhost or "
"equivalent IP addresses. Set CLNREST_URL to https:// for external "
"connections or use localhost."
)
return httpx.AsyncClient(base_url=self.url)
if parsed_url.scheme == "https":
logger.info(f"Using SSL to connect to {self.url}")
# Check for CA certificate
if not settings.clnrest_ca:
logger.warning(
"No CA certificate provided for CLNRestWallet. "
"This setup requires a CA certificate for server authentication "
"and trust. Set CLNREST_CA to the CA certificate file path or the "
"contents of the certificate."
)
raise ValueError("CA certificate is required for secure communication.")
logger.info(f"CA Certificate provided: {settings.clnrest_ca}")
# Create an SSL context and load the CA certificate
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
# Load CA certificate
if os.path.isfile(settings.clnrest_ca):
logger.info(f"Using CA certificate file: {settings.clnrest_ca}")
ssl_context.load_verify_locations(cafile=settings.clnrest_ca)
else:
logger.info("Using CA certificate from PEM string: ")
ca_content = settings.clnrest_ca.replace("\\n", "\n")
logger.info(ca_content)
ssl_context.load_verify_locations(cadata=ca_content)
# Optional: Disable hostname checking if necessary
# especially for ip addresses
ssl_context.check_hostname = False
# Create the HTTP client without a client certificate
client = httpx.AsyncClient(base_url=self.url, verify=ssl_context)
return client
else:
raise ValueError("CLNREST_URL must start with http:// or https://")
def _generate_label() -> str:
"""Generate a unique label for the invoice."""
random_uuid = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip(b"=").decode()
return f"LNbits_{random_uuid}"
+7 -2
View File
@@ -1,6 +1,6 @@
import asyncio
import random
from collections.abc import AsyncGenerator
from secrets import token_urlsafe
from typing import Any, Optional
from bolt11.decode import decode as bolt11_decode
@@ -14,6 +14,7 @@ from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash
from .base import (
Feature,
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
@@ -31,12 +32,16 @@ async def run_sync(func) -> Any:
class CoreLightningWallet(Wallet):
"""Core Lightning RPC implementation."""
__node_cls__ = CoreLightningNode
features = [Feature.nodemanager]
async def cleanup(self):
pass
def __init__(self):
rpc = settings.corelightning_rpc or settings.clightning_rpc
if not rpc:
raise ValueError(
@@ -92,7 +97,7 @@ class CoreLightningWallet(Wallet):
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
label = kwargs.get("label", f"lbl{random.random()}")
label = kwargs.get("label", f"lbl{token_urlsafe(16)}")
msat: int = int(amount * 1000)
try:
if description_hash and not unhashed_description:
+10 -8
View File
@@ -1,7 +1,7 @@
import asyncio
import json
import random
from collections.abc import AsyncGenerator
from secrets import token_urlsafe
from typing import Optional
import httpx
@@ -111,7 +111,7 @@ class CoreLightningRestWallet(Wallet):
unhashed_description: Optional[bytes] = None,
**kwargs,
) -> InvoiceResponse:
label = kwargs.get("label", f"lbl{random.random()}")
label = kwargs.get("label", f"lbl{token_urlsafe(16)}")
data: dict = {
"amount": amount * 1000,
"description": memo,
@@ -298,7 +298,8 @@ class CoreLightningRestWallet(Wallet):
self.last_pay_index = inv["pay_index"]
if not paid:
continue
except Exception:
except Exception as exc:
logger.debug(exc)
continue
logger.trace(f"paid invoice: {inv}")
@@ -313,11 +314,12 @@ class CoreLightningRestWallet(Wallet):
)
paid_invoice = r.json()
logger.trace(f"paid invoice: {paid_invoice}")
assert self.statuses[
paid_invoice["invoices"][0]["status"]
], "streamed invoice not paid"
assert "invoices" in paid_invoice, "no invoices in response"
assert len(paid_invoice["invoices"]), "no invoices in response"
if not self.statuses[paid_invoice["invoices"][0]["status"]]:
raise ValueError("streamed invoice not paid")
if "invoices" not in paid_invoice:
raise ValueError("no invoices in response")
if not len(paid_invoice["invoices"]):
raise ValueError("no invoices in response")
yield paid_invoice["invoices"][0]["payment_hash"]
except Exception as exc:
+4 -2
View File
@@ -9,7 +9,7 @@ from typing import Any, Optional
import httpx
from loguru import logger
from websockets.legacy.client import connect
from websockets import connect
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
@@ -245,7 +245,9 @@ class EclairWallet(Wallet):
try:
async with connect(
self.ws_url,
extra_headers=[("Authorization", self.headers["Authorization"])],
additional_headers=[
("Authorization", self.headers["Authorization"])
],
) as ws:
while settings.lnbits_running:
message = await ws.recv()
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Optional
import httpx
from loguru import logger
from websockets.legacy.client import connect
from websockets import connect
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: invoices.proto
# Protobuf Python Version: 5.28.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
5,
28,
1,
'',
'invoices.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0einvoices.proto\x12\x0binvoicesrpc\x1a\x0flightning.proto\"(\n\x10\x43\x61ncelInvoiceMsg\x12\x14\n\x0cpayment_hash\x18\x01 \x01(\x0c\"\x13\n\x11\x43\x61ncelInvoiceResp\"\xe4\x01\n\x15\x41\x64\x64HoldInvoiceRequest\x12\x0c\n\x04memo\x18\x01 \x01(\t\x12\x0c\n\x04hash\x18\x02 \x01(\x0c\x12\r\n\x05value\x18\x03 \x01(\x03\x12\x12\n\nvalue_msat\x18\n \x01(\x03\x12\x18\n\x10\x64\x65scription_hash\x18\x04 \x01(\x0c\x12\x0e\n\x06\x65xpiry\x18\x05 \x01(\x03\x12\x15\n\rfallback_addr\x18\x06 \x01(\t\x12\x13\n\x0b\x63ltv_expiry\x18\x07 \x01(\x04\x12%\n\x0broute_hints\x18\x08 \x03(\x0b\x32\x10.lnrpc.RouteHint\x12\x0f\n\x07private\x18\t \x01(\x08\"V\n\x12\x41\x64\x64HoldInvoiceResp\x12\x17\n\x0fpayment_request\x18\x01 \x01(\t\x12\x11\n\tadd_index\x18\x02 \x01(\x04\x12\x14\n\x0cpayment_addr\x18\x03 \x01(\x0c\"$\n\x10SettleInvoiceMsg\x12\x10\n\x08preimage\x18\x01 \x01(\x0c\"\x13\n\x11SettleInvoiceResp\"5\n\x1dSubscribeSingleInvoiceRequest\x12\x0e\n\x06r_hash\x18\x02 \x01(\x0cJ\x04\x08\x01\x10\x02\"\x99\x01\n\x10LookupInvoiceMsg\x12\x16\n\x0cpayment_hash\x18\x01 \x01(\x0cH\x00\x12\x16\n\x0cpayment_addr\x18\x02 \x01(\x0cH\x00\x12\x10\n\x06set_id\x18\x03 \x01(\x0cH\x00\x12\x34\n\x0flookup_modifier\x18\x04 \x01(\x0e\x32\x1b.invoicesrpc.LookupModifierB\r\n\x0binvoice_ref\".\n\nCircuitKey\x12\x0f\n\x07\x63han_id\x18\x01 \x01(\x04\x12\x0f\n\x07htlc_id\x18\x02 \x01(\x04\"\xdd\x02\n\x11HtlcModifyRequest\x12\x1f\n\x07invoice\x18\x01 \x01(\x0b\x32\x0e.lnrpc.Invoice\x12\x36\n\x15\x65xit_htlc_circuit_key\x18\x02 \x01(\x0b\x32\x17.invoicesrpc.CircuitKey\x12\x15\n\rexit_htlc_amt\x18\x03 \x01(\x04\x12\x18\n\x10\x65xit_htlc_expiry\x18\x04 \x01(\r\x12\x16\n\x0e\x63urrent_height\x18\x05 \x01(\r\x12\x64\n\x1d\x65xit_htlc_wire_custom_records\x18\x06 \x03(\x0b\x32=.invoicesrpc.HtlcModifyRequest.ExitHtlcWireCustomRecordsEntry\x1a@\n\x1e\x45xitHtlcWireCustomRecordsEntry\x12\x0b\n\x03key\x18\x01 \x01(\x04\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"z\n\x12HtlcModifyResponse\x12,\n\x0b\x63ircuit_key\x18\x01 \x01(\x0b\x32\x17.invoicesrpc.CircuitKey\x12\x15\n\x08\x61mt_paid\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x12\n\ncancel_set\x18\x03 \x01(\x08\x42\x0b\n\t_amt_paid*D\n\x0eLookupModifier\x12\x0b\n\x07\x44\x45\x46\x41ULT\x10\x00\x12\x11\n\rHTLC_SET_ONLY\x10\x01\x12\x12\n\x0eHTLC_SET_BLANK\x10\x02\x32\xf0\x03\n\x08Invoices\x12V\n\x16SubscribeSingleInvoice\x12*.invoicesrpc.SubscribeSingleInvoiceRequest\x1a\x0e.lnrpc.Invoice0\x01\x12N\n\rCancelInvoice\x12\x1d.invoicesrpc.CancelInvoiceMsg\x1a\x1e.invoicesrpc.CancelInvoiceResp\x12U\n\x0e\x41\x64\x64HoldInvoice\x12\".invoicesrpc.AddHoldInvoiceRequest\x1a\x1f.invoicesrpc.AddHoldInvoiceResp\x12N\n\rSettleInvoice\x12\x1d.invoicesrpc.SettleInvoiceMsg\x1a\x1e.invoicesrpc.SettleInvoiceResp\x12@\n\x0fLookupInvoiceV2\x12\x1d.invoicesrpc.LookupInvoiceMsg\x1a\x0e.lnrpc.Invoice\x12S\n\x0cHtlcModifier\x12\x1f.invoicesrpc.HtlcModifyResponse\x1a\x1e.invoicesrpc.HtlcModifyRequest(\x01\x30\x01\x42\x33Z1github.com/lightningnetwork/lnd/lnrpc/invoicesrpcb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'invoices_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'Z1github.com/lightningnetwork/lnd/lnrpc/invoicesrpc'
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._loaded_options = None
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._serialized_options = b'8\001'
_globals['_LOOKUPMODIFIER']._serialized_start=1224
_globals['_LOOKUPMODIFIER']._serialized_end=1292
_globals['_CANCELINVOICEMSG']._serialized_start=48
_globals['_CANCELINVOICEMSG']._serialized_end=88
_globals['_CANCELINVOICERESP']._serialized_start=90
_globals['_CANCELINVOICERESP']._serialized_end=109
_globals['_ADDHOLDINVOICEREQUEST']._serialized_start=112
_globals['_ADDHOLDINVOICEREQUEST']._serialized_end=340
_globals['_ADDHOLDINVOICERESP']._serialized_start=342
_globals['_ADDHOLDINVOICERESP']._serialized_end=428
_globals['_SETTLEINVOICEMSG']._serialized_start=430
_globals['_SETTLEINVOICEMSG']._serialized_end=466
_globals['_SETTLEINVOICERESP']._serialized_start=468
_globals['_SETTLEINVOICERESP']._serialized_end=487
_globals['_SUBSCRIBESINGLEINVOICEREQUEST']._serialized_start=489
_globals['_SUBSCRIBESINGLEINVOICEREQUEST']._serialized_end=542
_globals['_LOOKUPINVOICEMSG']._serialized_start=545
_globals['_LOOKUPINVOICEMSG']._serialized_end=698
_globals['_CIRCUITKEY']._serialized_start=700
_globals['_CIRCUITKEY']._serialized_end=746
_globals['_HTLCMODIFYREQUEST']._serialized_start=749
_globals['_HTLCMODIFYREQUEST']._serialized_end=1098
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._serialized_start=1034
_globals['_HTLCMODIFYREQUEST_EXITHTLCWIRECUSTOMRECORDSENTRY']._serialized_end=1098
_globals['_HTLCMODIFYRESPONSE']._serialized_start=1100
_globals['_HTLCMODIFYRESPONSE']._serialized_end=1222
_globals['_INVOICES']._serialized_start=1295
_globals['_INVOICES']._serialized_end=1791
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,392 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import lnbits.wallets.lnd_grpc_files.invoices_pb2 as invoices__pb2
import lnbits.wallets.lnd_grpc_files.lightning_pb2 as lightning__pb2
GRPC_GENERATED_VERSION = '1.68.1'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ f' but the generated code in invoices_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class InvoicesStub(object):
"""
Comments in this file will be directly parsed into the API
Documentation as descriptions of the associated method, message, or field.
These descriptions should go right above the definition of the object, and
can be in either block or // comment format.
An RPC method can be matched to an lncli command by placing a line in the
beginning of the description in exactly the following format:
lncli: `methodname`
Failure to specify the exact name of the command will cause documentation
generation to fail.
More information on how exactly the gRPC documentation is generated from
this proto file can be found here:
https://github.com/lightninglabs/lightning-api
Invoices is a service that can be used to create, accept, settle and cancel
invoices.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.SubscribeSingleInvoice = channel.unary_stream(
'/invoicesrpc.Invoices/SubscribeSingleInvoice',
request_serializer=invoices__pb2.SubscribeSingleInvoiceRequest.SerializeToString,
response_deserializer=lightning__pb2.Invoice.FromString,
_registered_method=True)
self.CancelInvoice = channel.unary_unary(
'/invoicesrpc.Invoices/CancelInvoice',
request_serializer=invoices__pb2.CancelInvoiceMsg.SerializeToString,
response_deserializer=invoices__pb2.CancelInvoiceResp.FromString,
_registered_method=True)
self.AddHoldInvoice = channel.unary_unary(
'/invoicesrpc.Invoices/AddHoldInvoice',
request_serializer=invoices__pb2.AddHoldInvoiceRequest.SerializeToString,
response_deserializer=invoices__pb2.AddHoldInvoiceResp.FromString,
_registered_method=True)
self.SettleInvoice = channel.unary_unary(
'/invoicesrpc.Invoices/SettleInvoice',
request_serializer=invoices__pb2.SettleInvoiceMsg.SerializeToString,
response_deserializer=invoices__pb2.SettleInvoiceResp.FromString,
_registered_method=True)
self.LookupInvoiceV2 = channel.unary_unary(
'/invoicesrpc.Invoices/LookupInvoiceV2',
request_serializer=invoices__pb2.LookupInvoiceMsg.SerializeToString,
response_deserializer=lightning__pb2.Invoice.FromString,
_registered_method=True)
self.HtlcModifier = channel.stream_stream(
'/invoicesrpc.Invoices/HtlcModifier',
request_serializer=invoices__pb2.HtlcModifyResponse.SerializeToString,
response_deserializer=invoices__pb2.HtlcModifyRequest.FromString,
_registered_method=True)
class InvoicesServicer(object):
"""
Comments in this file will be directly parsed into the API
Documentation as descriptions of the associated method, message, or field.
These descriptions should go right above the definition of the object, and
can be in either block or // comment format.
An RPC method can be matched to an lncli command by placing a line in the
beginning of the description in exactly the following format:
lncli: `methodname`
Failure to specify the exact name of the command will cause documentation
generation to fail.
More information on how exactly the gRPC documentation is generated from
this proto file can be found here:
https://github.com/lightninglabs/lightning-api
Invoices is a service that can be used to create, accept, settle and cancel
invoices.
"""
def SubscribeSingleInvoice(self, request, context):
"""
SubscribeSingleInvoice returns a uni-directional stream (server -> client)
to notify the client of state transitions of the specified invoice.
Initially the current invoice state is always sent out.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def CancelInvoice(self, request, context):
"""lncli: `cancelinvoice`
CancelInvoice cancels a currently open invoice. If the invoice is already
canceled, this call will succeed. If the invoice is already settled, it will
fail.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def AddHoldInvoice(self, request, context):
"""lncli: `addholdinvoice`
AddHoldInvoice creates a hold invoice. It ties the invoice to the hash
supplied in the request.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def SettleInvoice(self, request, context):
"""lncli: `settleinvoice`
SettleInvoice settles an accepted invoice. If the invoice is already
settled, this call will succeed.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def LookupInvoiceV2(self, request, context):
"""
LookupInvoiceV2 attempts to look up at invoice. An invoice can be referenced
using either its payment hash, payment address, or set ID.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def HtlcModifier(self, request_iterator, context):
"""
HtlcModifier is a bidirectional streaming RPC that allows a client to
intercept and modify the HTLCs that attempt to settle the given invoice. The
server will send HTLCs of invoices to the client and the client can modify
some aspects of the HTLC in order to pass the invoice acceptance tests.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_InvoicesServicer_to_server(servicer, server):
rpc_method_handlers = {
'SubscribeSingleInvoice': grpc.unary_stream_rpc_method_handler(
servicer.SubscribeSingleInvoice,
request_deserializer=invoices__pb2.SubscribeSingleInvoiceRequest.FromString,
response_serializer=lightning__pb2.Invoice.SerializeToString,
),
'CancelInvoice': grpc.unary_unary_rpc_method_handler(
servicer.CancelInvoice,
request_deserializer=invoices__pb2.CancelInvoiceMsg.FromString,
response_serializer=invoices__pb2.CancelInvoiceResp.SerializeToString,
),
'AddHoldInvoice': grpc.unary_unary_rpc_method_handler(
servicer.AddHoldInvoice,
request_deserializer=invoices__pb2.AddHoldInvoiceRequest.FromString,
response_serializer=invoices__pb2.AddHoldInvoiceResp.SerializeToString,
),
'SettleInvoice': grpc.unary_unary_rpc_method_handler(
servicer.SettleInvoice,
request_deserializer=invoices__pb2.SettleInvoiceMsg.FromString,
response_serializer=invoices__pb2.SettleInvoiceResp.SerializeToString,
),
'LookupInvoiceV2': grpc.unary_unary_rpc_method_handler(
servicer.LookupInvoiceV2,
request_deserializer=invoices__pb2.LookupInvoiceMsg.FromString,
response_serializer=lightning__pb2.Invoice.SerializeToString,
),
'HtlcModifier': grpc.stream_stream_rpc_method_handler(
servicer.HtlcModifier,
request_deserializer=invoices__pb2.HtlcModifyResponse.FromString,
response_serializer=invoices__pb2.HtlcModifyRequest.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'invoicesrpc.Invoices', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('invoicesrpc.Invoices', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class Invoices(object):
"""
Comments in this file will be directly parsed into the API
Documentation as descriptions of the associated method, message, or field.
These descriptions should go right above the definition of the object, and
can be in either block or // comment format.
An RPC method can be matched to an lncli command by placing a line in the
beginning of the description in exactly the following format:
lncli: `methodname`
Failure to specify the exact name of the command will cause documentation
generation to fail.
More information on how exactly the gRPC documentation is generated from
this proto file can be found here:
https://github.com/lightninglabs/lightning-api
Invoices is a service that can be used to create, accept, settle and cancel
invoices.
"""
@staticmethod
def SubscribeSingleInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/invoicesrpc.Invoices/SubscribeSingleInvoice',
invoices__pb2.SubscribeSingleInvoiceRequest.SerializeToString,
lightning__pb2.Invoice.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def CancelInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/CancelInvoice',
invoices__pb2.CancelInvoiceMsg.SerializeToString,
invoices__pb2.CancelInvoiceResp.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def AddHoldInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/AddHoldInvoice',
invoices__pb2.AddHoldInvoiceRequest.SerializeToString,
invoices__pb2.AddHoldInvoiceResp.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def SettleInvoice(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/SettleInvoice',
invoices__pb2.SettleInvoiceMsg.SerializeToString,
invoices__pb2.SettleInvoiceResp.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def LookupInvoiceV2(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/invoicesrpc.Invoices/LookupInvoiceV2',
invoices__pb2.LookupInvoiceMsg.SerializeToString,
lightning__pb2.Invoice.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def HtlcModifier(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/invoicesrpc.Invoices/HtlcModifier',
invoices__pb2.HtlcModifyResponse.SerializeToString,
invoices__pb2.HtlcModifyRequest.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More