Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Arc
2026-06-03 20:49:03 +01:00
24 changed files with 143 additions and 76 deletions
+7 -1
View File
@@ -36,6 +36,7 @@ from lnbits.decorators import (
check_account_exists,
check_admin,
check_user_exists,
optional_user_id,
)
from lnbits.helpers import (
create_access_token,
@@ -320,7 +321,10 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider")
async def login_with_sso_provider(
request: Request, provider: str, user_id: str | None = None
request: Request,
provider: str,
user_id: str | None,
auth_user_id: str | None = Depends(optional_user_id),
):
provider_sso = _new_sso(provider)
if not provider_sso:
@@ -328,6 +332,8 @@ async def login_with_sso_provider(
HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.",
)
if user_id and user_id != auth_user_id:
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso:
-17
View File
@@ -384,23 +384,6 @@ async def handle_revolut_event(event: dict):
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _handle_revolut_subscription_initiated(event: dict):
subscription_id = event.get("subscription_id")
if not subscription_id:
subscription_id = event.get("id")
if not subscription_id:
logger.warning("Revolut subscription event missing subscription_id.")
return
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
subscription = await fiat_provider.get_subscription(subscription_id)
await _handle_revolut_subscription(subscription, fiat_provider)
async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
+3 -3
View File
@@ -131,15 +131,15 @@ class FiatSubscriptionResponse(BaseModel):
class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True
paid = True # type: ignore[reportIncompatibleVariableOverride]
class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False
paid = False # type: ignore[reportIncompatibleVariableOverride]
class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None
paid = None # type: ignore[reportIncompatibleVariableOverride]
class FiatProvider(ABC):
+3 -3
View File
@@ -1047,7 +1047,7 @@ class EditableSettings(
class UpdateSettings(EditableSettings):
class Config:
class Config(EditableSettings.Config):
extra = Extra.forbid
@@ -1198,11 +1198,11 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config:
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
json_loads = list_parse_fallback
json_loads = list_parse_fallback # type: ignore[assignment]
def is_user_allowed(self, user_id: str) -> bool:
return (
File diff suppressed because one or more lines are too long
@@ -360,18 +360,37 @@ window.app.component('lnbits-payment-list', {
paymentTableRowKey(row) {
return row.payment_hash + row.amount
},
exportCSV(detailed = false) {
async exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons
// and would cause duplication in the list
const pagination = this.paymentsTable.pagination
const query = {
sortby: pagination.sortBy ?? 'time',
direction: pagination.descending ? 'desc' : 'asc'
}
const params = new URLSearchParams(query)
LNbits.api.getPayments(this.wallet, params).then(response => {
let payments = response.data.data.map(this.mapPayment)
const maxPages = 100
const limit = 1000
let payments = []
this.paymentsCSV.loading = true
try {
for (let page = 0; page < maxPages; page++) {
const query = {
sortby: pagination.sortBy ?? 'time',
direction: pagination.descending ? 'desc' : 'asc',
limit,
offset: page * limit
}
const params = new URLSearchParams(query)
const response = await LNbits.api.getPayments(this.wallet, params)
const pagePayments = response.data.data || []
payments = payments.concat(pagePayments.map(this.mapPayment))
if (
pagePayments.length < limit ||
payments.length >= response.data.total
) {
break
}
}
let columns = this.paymentsCSV.columns
if (detailed) {
@@ -400,7 +419,11 @@ window.app.component('lnbits-payment-list', {
payments,
this.wallet.name + '-payments'
)
})
} catch (err) {
LNbits.utils.notifyApiError(err)
} finally {
this.paymentsCSV.loading = false
}
},
addFilterTag() {
if (!this.exportTagName) return
@@ -8,6 +8,10 @@ window.app.component('lnbits-qrcode-lnurl', {
prefix: {
type: String,
default: 'lnurlp'
},
href: {
type: String,
default: ''
}
},
data() {
@@ -21,7 +25,10 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl = `lightning:${bech32.toUpperCase()}`
this.lnurl =
this.href && this.href.trim() !== ''
? `${this.href}?lightning=${bech32.toUpperCase()}`
: `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://')
+3 -3
View File
@@ -94,15 +94,15 @@ class PaymentStatus(NamedTuple):
class PaymentSuccessStatus(PaymentStatus):
paid = True
paid = True # type: ignore[reportIncompatibleVariableOverride]
class PaymentFailedStatus(PaymentStatus):
paid = False
paid = False # type: ignore[reportIncompatibleVariableOverride]
class PaymentPendingStatus(PaymentStatus):
paid = None
paid = None # type: ignore[reportIncompatibleVariableOverride]
class Wallet(ABC):
+5 -7
View File
@@ -8,7 +8,7 @@ from loguru import logger
from pydantic import BaseModel
from websockets import Subprotocol, connect
from lnbits import bolt11
from lnbits import bolt11 as bolt11_lib
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
@@ -164,15 +164,13 @@ class BlinkWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def pay_invoice(
self, bolt11_invoice: str, fee_limit_msat: int
) -> PaymentResponse:
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
# https://dev.blink.sv/api/btc-ln-send
# Future: add check fee estimate is < fee_limit_msat before paying invoice
payment_variables = {
"input": {
"paymentRequest": bolt11_invoice,
"paymentRequest": bolt11,
"walletId": self.wallet_id,
"memo": "Payment memo",
}
@@ -190,7 +188,7 @@ class BlinkWallet(Wallet):
error_message = errors[0].get("message")
return PaymentResponse(ok=False, error_message=error_message)
checking_id = bolt11.decode(bolt11_invoice).payment_hash
checking_id = bolt11_lib.decode(bolt11).payment_hash
payment_status = await self.get_payment_status(checking_id)
fee_msat = payment_status.fee_msat
@@ -199,7 +197,7 @@ class BlinkWallet(Wallet):
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11_invoice}")
logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
error_message=f"Unable to connect to {self.endpoint}."
+4 -2
View File
@@ -19,7 +19,7 @@ else:
from bolt11 import Bolt11Exception
from bolt11 import decode as bolt11_decode
from breez_sdk import (
from breez_sdk import ( # type: ignore[reportMissingImports]
BreezEvent,
ConnectRequest,
EnvironmentType,
@@ -39,7 +39,9 @@ else:
default_config,
mnemonic_to_seed,
)
from breez_sdk import PaymentStatus as BreezPaymentStatus
from breez_sdk import (
PaymentStatus as BreezPaymentStatus, # type: ignore[reportMissingImports]
)
from loguru import logger
from lnbits.settings import settings
+1 -1
View File
@@ -18,7 +18,7 @@ else:
from pathlib import Path
from bolt11 import decode as bolt11_decode
from breez_sdk_liquid import (
from breez_sdk_liquid import ( # type: ignore[reportMissingImports]
ConnectRequest,
EventListener,
GetInfoResponse,
+2 -2
View File
@@ -103,7 +103,7 @@ class FakeWallet(Wallet):
preimage=preimage.hex(),
)
async def pay_invoice(self, bolt11: str, _: int) -> PaymentResponse:
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
invoice = decode(bolt11)
except Bolt11Exception as exc:
@@ -130,7 +130,7 @@ class FakeWallet(Wallet):
return PaymentPendingStatus()
return PaymentFailedStatus()
async def get_payment_status(self, _: str) -> PaymentStatus:
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+1 -1
View File
@@ -118,7 +118,7 @@ class LndWallet(Wallet):
cert = open(cert_path, "rb").read()
creds = grpc.ssl_channel_credentials(cert)
auth_creds = grpc.metadata_call_credentials(self.metadata_callback)
auth_creds = grpc.metadata_call_credentials(self.metadata_callback) # type: ignore[reportArgumentType]
composite_creds = grpc.composite_channel_credentials(creds, auth_creds)
channel = grpc.aio.secure_channel(
f"{self.endpoint}:{self.port}", composite_creds
+8 -5
View File
@@ -24,7 +24,7 @@
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
"pyright": "1.1.289",
"pyright": "1.1.409",
"sass": "^1.99.0",
"terser": "^5.47.1"
}
@@ -1808,9 +1808,9 @@
}
},
"node_modules/pyright": {
"version": "1.1.289",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.289.tgz",
"integrity": "sha512-fG3STxnwAt3i7bxbXUPJdYNFrcOWHLwCSEOySH2foUqtYdzWLcxDez0Kgl1X8LMQx0arMJ6HRkKghxfRD1/z6g==",
"version": "1.1.409",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.409.tgz",
"integrity": "sha512-13VFQyw4mJzshZxcxiYbNjo1hG/WHSRDj70Y3lbJEHqCkI2dvBAUTti8VV6Ezsr5gT93pFvC0e/jAQS4JdHarA==",
"dev": true,
"license": "MIT",
"bin": {
@@ -1818,7 +1818,10 @@
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=12.0.0"
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/qrcode.vue": {
+1 -1
View File
@@ -16,7 +16,7 @@
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
"pyright": "1.1.289",
"pyright": "1.1.409",
"sass": "^1.99.0",
"terser": "^5.47.1"
},
+35 -1
View File
@@ -72,9 +72,43 @@ async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker)
login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
response = await http_client.get(
unauthenticated = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}
)
assert unauthenticated.status_code == 403
assert unauthenticated.json()["detail"] == "User ID mismatch."
other_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
other_login = await http_client.post(
"/api/v1/auth/usr", json={"usr": other_user.id}
)
http_client.cookies.clear()
assert other_login.status_code == 200
other_headers = {
"Authorization": f"Bearer {other_login.json()['access_token']}",
}
wrong_user = await http_client.get(
f"/api/v1/auth/{provider}",
params={"user_id": user.id},
headers=other_headers,
)
assert wrong_user.status_code == 403
assert wrong_user.json()["detail"] == "User ID mismatch."
login = await http_client.post("/api/v1/auth/usr", json={"usr": user.id})
http_client.cookies.clear()
assert login.status_code == 200
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
response = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}, headers=headers
)
assert response.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login"
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
+2 -1
View File
@@ -1,3 +1,4 @@
from typing import cast
from uuid import uuid4
import pytest
@@ -106,7 +107,7 @@ async def test_lnurl_api_auth_and_pay_flow(mocker):
await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11),
pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)),
disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
)
+1 -1
View File
@@ -161,7 +161,7 @@ async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
)
reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(reserve.body)["fee_reserve"] >= 0
assert json.loads(bytes(reserve.body))["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
+6 -3
View File
@@ -1,3 +1,4 @@
from typing import cast
from uuid import uuid4
import pytest
@@ -86,7 +87,9 @@ async def test_get_pr_from_lnurl_success_and_error(mocker: MockerFixture):
mocker.patch(
"lnbits.core.services.lnurl.execute_pay_request",
mocker.AsyncMock(
return_value=LnurlPayActionResponse(pr=LightningInvoice(TEST_BOLT11))
return_value=LnurlPayActionResponse(
pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11))
)
),
)
@@ -106,7 +109,7 @@ async def test_fetch_lnurl_pay_request_converts_currency_and_stores_paylink(
):
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False
pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)), disposable=False
)
mocker.patch(
"lnbits.core.services.lnurl.fiat_amount_as_satoshis",
@@ -143,7 +146,7 @@ async def test_store_paylink_appends_and_updates_existing():
wallet = await _create_wallet()
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False
pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)), disposable=False
)
await store_paylink(
+2 -1
View File
@@ -1,3 +1,4 @@
from typing import Any
from uuid import uuid4
import pytest
@@ -66,7 +67,7 @@ async def test_create_user_account_no_check_rejects_duplicate_identity_fields(
existing = _account(**existing_data)
await create_account(existing)
resolved = {
resolved: dict[str, Any] = {
key: (value(existing) if callable(value) else value)
for key, value in new_data.items()
}
+6 -4
View File
@@ -1,3 +1,5 @@
from typing import Any
import pytest
from pytest_mock.plugin import MockerFixture
@@ -14,22 +16,22 @@ from lnbits.settings import (
set_cli_settings,
)
lnurlp_redirect_path = {
lnurlp_redirect_path: dict[str, Any] = {
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
lnurlp_redirect_path_with_headers = {
lnurlp_redirect_path_with_headers: dict[str, Any] = {
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
"header_filters": {"accept": "application/nostr+json"},
}
lnaddress_redirect_path = {
lnaddress_redirect_path: dict[str, Any] = {
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
nostrrelay_redirect_path = {
nostrrelay_redirect_path: dict[str, Any] = {
"from_path": "/",
"redirect_to_path": "/api/v1/relay-info",
"header_filters": {"accept": "application/nostr+json"},
+2 -1
View File
@@ -1,4 +1,5 @@
import os
from typing import cast
import pytest
from loguru import logger
@@ -29,7 +30,7 @@ logger.info(f"settings.blink_api_endpoint: {settings.blink_api_endpoint}")
logger.info(f"settings.blink_token: {settings.blink_token}")
set_funding_source()
funding_source = get_funding_source()
funding_source = cast(BlinkWallet, get_funding_source())
assert isinstance(funding_source, BlinkWallet)
+7 -4
View File
@@ -1,4 +1,5 @@
import importlib
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
@@ -80,7 +81,9 @@ def _check_calls(expected_calls):
for func_call in func_calls:
req = func_call["request_data"]
args = req["args"] if "args" in req else {}
kwargs = _eval_dict(req["kwargs"]) if "kwargs" in req else {}
kwargs: dict[str, Any] = (
_eval_dict(req["kwargs"]) or {} if "kwargs" in req else {}
)
if "klass" in req:
*rest, cls = req["klass"].split(".")
@@ -166,7 +169,7 @@ def _mock_field(field):
return response
def _eval_dict(data: dict | None) -> dict | None:
def _eval_dict(data: dict | None) -> dict[str, Any] | None:
fn_prefix = "__eval__:"
if not data:
return data
@@ -215,9 +218,9 @@ def _data_mock(data: dict) -> Mock:
def _raise(error: dict | None):
if not error:
return Exception()
data = error["data"] if "data" in error else None
data: dict[str, Any] = error["data"] if "data" in error else {}
if "module" not in error or "class" not in error:
return Exception(data)
return Exception(data or None)
error_module = importlib.import_module(error["module"])
error_class = getattr(error_module, error["class"])
Generated
+3 -3
View File
@@ -1075,11 +1075,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.14"
version = "3.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]