Compare commits

...
Author SHA1 Message Date
dni ⚡andGitHub 83699289fc chore: update to v1.5.5-rc2 (#3998) 2026-06-04 13:46:29 +02:00
Vlad StanandGitHub f04e88d8bf fix: CSV export limit (#3996) 2026-06-03 14:41:56 +03:00
Vlad StanandGitHub 564edfc447 fix: do not hit sso provider on user missmatch (#3995) 2026-06-03 14:29:54 +03:00
dni ⚡andGitHub b98515df14 chore: update pyright to latest and fix new issues (#3978) 2026-06-03 11:46:53 +02:00
Tiago VasconcelosandGitHub 1e0fc84586 Fix: Allow the LNURL spec fallback scheme for LNURLw (#3961) 2026-06-03 10:25:11 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a1d94834ae chore(deps): bump idna from 3.14 to 3.15 (#3981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 10:21:47 +02:00
5de4239f3c fix: revolut subscriptions (#3994)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-06-03 11:21:44 +03:00
c404666d7f fix: theming was slowing frontend (#3992)
Co-authored-by: alan <alan@lnbits.com>
2026-06-02 10:19:27 +02:00
30 changed files with 196 additions and 123 deletions
+7 -1
View File
@@ -36,6 +36,7 @@ from lnbits.decorators import (
check_account_exists, check_account_exists,
check_admin, check_admin,
check_user_exists, check_user_exists,
optional_user_id,
) )
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
@@ -320,7 +321,10 @@ async def api_delete_user_api_token(
@auth_router.get("/{provider}", description="SSO Provider") @auth_router.get("/{provider}", description="SSO Provider")
async def login_with_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) provider_sso = _new_sso(provider)
if not provider_sso: if not provider_sso:
@@ -328,6 +332,8 @@ async def login_with_sso_provider(
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
f"Login by '{provider}' not allowed.", 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" provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
with provider_sso: with provider_sso:
+29 -33
View File
@@ -357,16 +357,20 @@ async def handle_revolut_event(event: dict):
return return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if not payment: if payment:
await check_fiat_status(payment)
return
if event_type == "ORDER_COMPLETED":
logger.warning(f"No payment found for Revolut order: '{order_id}'.") logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id) await _handle_revolut_subscription_order_paid(order_id)
return return
await check_fiat_status(payment) logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
return return
if event_type == "SUBSCRIPTION_INITIATED": if event_type == "SUBSCRIPTION_INITIATED":
await _handle_revolut_subscription_initiated(event) logger.info("Revolut subscription initiated event received.")
return return
if event_type in [ if event_type in [
@@ -380,23 +384,6 @@ async def handle_revolut_event(event: dict):
logger.warning(f"Unhandled Revolut event type: '{event_type}'.") 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: async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut") fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet): if not isinstance(fiat_provider, RevolutWallet):
@@ -406,7 +393,10 @@ async def _get_revolut_provider() -> RevolutWallet | None:
async def _handle_revolut_subscription( async def _handle_revolut_subscription(
subscription: dict, fiat_provider: RevolutWallet subscription: dict,
fiat_provider: RevolutWallet,
order_id: str | None = None,
order: dict | None = None,
): ):
subscription_id = subscription.get("id") subscription_id = subscription.get("id")
if not subscription_id: if not subscription_id:
@@ -420,16 +410,17 @@ async def _handle_revolut_subscription(
logger.warning("Revolut subscription event missing LNbits metadata.") logger.warning("Revolut subscription event missing LNbits metadata.")
return return
cycle_id = subscription.get("current_cycle_id")
if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id: if not order_id:
logger.warning("Revolut subscription cycle missing order_id.") cycle_id = subscription.get("current_cycle_id")
return if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id:
logger.warning("Revolut subscription cycle missing order_id.")
return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}") existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment: if existing_payment:
@@ -439,7 +430,8 @@ async def _handle_revolut_subscription(
await check_fiat_status(existing_payment) await check_fiat_status(existing_payment)
return return
order = await fiat_provider.get_order(order_id) if not order:
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount") amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper() currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency: if amount_minor is None or not currency:
@@ -475,7 +467,9 @@ async def _handle_revolut_subscription_order_paid(order_id: str):
return return
order = await fiat_provider.get_order(order_id) order = await fiat_provider.get_order(order_id)
if order.get("type") != "payment" or order.get("state") != "completed": order_type = (order.get("type") or "").lower()
order_state = (order.get("state") or "").upper()
if order_type != "payment" or order_state != "COMPLETED":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.") logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return return
@@ -490,7 +484,9 @@ async def _handle_revolut_subscription_order_paid(order_id: str):
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.") logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return return
await _handle_revolut_subscription_initiated(subscription) await _handle_revolut_subscription(
subscription, fiat_provider, order_id=order_id, order=order
)
async def _create_revolut_subscription_payment( async def _create_revolut_subscription_payment(
+3 -3
View File
@@ -131,15 +131,15 @@ class FiatSubscriptionResponse(BaseModel):
class FiatPaymentSuccessStatus(FiatPaymentStatus): class FiatPaymentSuccessStatus(FiatPaymentStatus):
paid = True paid = True # type: ignore[reportIncompatibleVariableOverride]
class FiatPaymentFailedStatus(FiatPaymentStatus): class FiatPaymentFailedStatus(FiatPaymentStatus):
paid = False paid = False # type: ignore[reportIncompatibleVariableOverride]
class FiatPaymentPendingStatus(FiatPaymentStatus): class FiatPaymentPendingStatus(FiatPaymentStatus):
paid = None paid = None # type: ignore[reportIncompatibleVariableOverride]
class FiatProvider(ABC): class FiatProvider(ABC):
+3 -3
View File
@@ -1047,7 +1047,7 @@ class EditableSettings(
class UpdateSettings(EditableSettings): class UpdateSettings(EditableSettings):
class Config: class Config(EditableSettings.Config):
extra = Extra.forbid extra = Extra.forbid
@@ -1198,11 +1198,11 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
class Config: class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"
case_sensitive = False 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: def is_user_allowed(self, user_id: str) -> bool:
return ( return (
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
+7 -4
View File
@@ -212,12 +212,15 @@ body.bg-image .q-page-container {
backdrop-filter: none; /* Ensure the page content is not affected */ backdrop-filter: none; /* Ensure the page content is not affected */
} }
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
}
body.body--dark .q-header, body.body--dark .q-header,
body.body--dark .q-drawer { body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3); --q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8); backdrop-filter: brightness(0.8);
} }
body.rounded-ui .q-card, body.rounded-ui .q-card,
@@ -388,11 +391,11 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
} }
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18)); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
} }
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45)); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
} }
body.no-burger-background .q-drawer { body.no-burger-background .q-drawer {
@@ -360,18 +360,37 @@ window.app.component('lnbits-payment-list', {
paymentTableRowKey(row) { paymentTableRowKey(row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
exportCSV(detailed = false) { async exportCSV(detailed = false) {
// status is important for export but it is not in paymentsTable // status is important for export but it is not in paymentsTable
// because it is manually added with payment detail link and icons // because it is manually added with payment detail link and icons
// and would cause duplication in the list // and would cause duplication in the list
const pagination = this.paymentsTable.pagination const pagination = this.paymentsTable.pagination
const query = { const maxPages = 100
sortby: pagination.sortBy ?? 'time', const limit = 1000
direction: pagination.descending ? 'desc' : 'asc' let payments = []
}
const params = new URLSearchParams(query) this.paymentsCSV.loading = true
LNbits.api.getPayments(this.wallet, params).then(response => { try {
let payments = response.data.data.map(this.mapPayment) 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 let columns = this.paymentsCSV.columns
if (detailed) { if (detailed) {
@@ -400,7 +419,11 @@ window.app.component('lnbits-payment-list', {
payments, payments,
this.wallet.name + '-payments' this.wallet.name + '-payments'
) )
}) } catch (err) {
LNbits.utils.notifyApiError(err)
} finally {
this.paymentsCSV.loading = false
}
}, },
addFilterTag() { addFilterTag() {
if (!this.exportTagName) return if (!this.exportTagName) return
@@ -8,6 +8,10 @@ window.app.component('lnbits-qrcode-lnurl', {
prefix: { prefix: {
type: String, type: String,
default: 'lnurlp' default: 'lnurlp'
},
href: {
type: String,
default: ''
} }
}, },
data() { data() {
@@ -21,7 +25,10 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') { if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url) const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes) 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') { } else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) { if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://') this.lnurl = this.url.replace('http://', this.prefix + '://')
+6 -2
View File
@@ -58,11 +58,15 @@ body.bg-image {
} }
// transparent background for specific elements // transparent background for specific elements
body.body--dark { body.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark), .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
}
.q-header, .q-header,
.q-drawer { .q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)}; --q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8); backdrop-filter: brightness(0.8);
} }
} }
+2 -2
View File
@@ -61,13 +61,13 @@ body.rounded-ui {
body.card-shadow { body.card-shadow {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18)); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
} }
} }
body.card-shadow.body--dark { body.card-shadow.body--dark {
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) { .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45)); box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
} }
} }
+3 -3
View File
@@ -94,15 +94,15 @@ class PaymentStatus(NamedTuple):
class PaymentSuccessStatus(PaymentStatus): class PaymentSuccessStatus(PaymentStatus):
paid = True paid = True # type: ignore[reportIncompatibleVariableOverride]
class PaymentFailedStatus(PaymentStatus): class PaymentFailedStatus(PaymentStatus):
paid = False paid = False # type: ignore[reportIncompatibleVariableOverride]
class PaymentPendingStatus(PaymentStatus): class PaymentPendingStatus(PaymentStatus):
paid = None paid = None # type: ignore[reportIncompatibleVariableOverride]
class Wallet(ABC): class Wallet(ABC):
+5 -7
View File
@@ -8,7 +8,7 @@ from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from websockets import Subprotocol, connect from websockets import Subprotocol, connect
from lnbits import bolt11 from lnbits import bolt11 as bolt11_lib
from lnbits.helpers import normalize_endpoint from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings from lnbits.settings import settings
@@ -164,15 +164,13 @@ class BlinkWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}." ok=False, error_message=f"Unable to connect to {self.endpoint}."
) )
async def pay_invoice( async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
self, bolt11_invoice: str, fee_limit_msat: int
) -> PaymentResponse:
# https://dev.blink.sv/api/btc-ln-send # https://dev.blink.sv/api/btc-ln-send
# Future: add check fee estimate is < fee_limit_msat before paying invoice # Future: add check fee estimate is < fee_limit_msat before paying invoice
payment_variables = { payment_variables = {
"input": { "input": {
"paymentRequest": bolt11_invoice, "paymentRequest": bolt11,
"walletId": self.wallet_id, "walletId": self.wallet_id,
"memo": "Payment memo", "memo": "Payment memo",
} }
@@ -190,7 +188,7 @@ class BlinkWallet(Wallet):
error_message = errors[0].get("message") error_message = errors[0].get("message")
return PaymentResponse(ok=False, error_message=error_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) payment_status = await self.get_payment_status(checking_id)
fee_msat = payment_status.fee_msat 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 ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
) )
except Exception as exc: except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11_invoice}") logger.info(f"Failed to pay invoice {bolt11}")
logger.warning(exc) logger.warning(exc)
return PaymentResponse( return PaymentResponse(
error_message=f"Unable to connect to {self.endpoint}." 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 Bolt11Exception
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from breez_sdk import ( from breez_sdk import ( # type: ignore[reportMissingImports]
BreezEvent, BreezEvent,
ConnectRequest, ConnectRequest,
EnvironmentType, EnvironmentType,
@@ -39,7 +39,9 @@ else:
default_config, default_config,
mnemonic_to_seed, 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 loguru import logger
from lnbits.settings import settings from lnbits.settings import settings
+1 -1
View File
@@ -18,7 +18,7 @@ else:
from pathlib import Path from pathlib import Path
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from breez_sdk_liquid import ( from breez_sdk_liquid import ( # type: ignore[reportMissingImports]
ConnectRequest, ConnectRequest,
EventListener, EventListener,
GetInfoResponse, GetInfoResponse,
+2 -2
View File
@@ -103,7 +103,7 @@ class FakeWallet(Wallet):
preimage=preimage.hex(), 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: try:
invoice = decode(bolt11) invoice = decode(bolt11)
except Bolt11Exception as exc: except Bolt11Exception as exc:
@@ -130,7 +130,7 @@ class FakeWallet(Wallet):
return PaymentPendingStatus() return PaymentPendingStatus()
return PaymentFailedStatus() return PaymentFailedStatus()
async def get_payment_status(self, _: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
return PaymentPendingStatus() return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: 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() cert = open(cert_path, "rb").read()
creds = grpc.ssl_channel_credentials(cert) 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) composite_creds = grpc.composite_channel_credentials(creds, auth_creds)
channel = grpc.aio.secure_channel( channel = grpc.aio.secure_channel(
f"{self.endpoint}:{self.port}", composite_creds f"{self.endpoint}:{self.port}", composite_creds
+8 -5
View File
@@ -24,7 +24,7 @@
"clean-css-cli": "^5.6.3", "clean-css-cli": "^5.6.3",
"concat": "^1.0.3", "concat": "^1.0.3",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"pyright": "1.1.289", "pyright": "1.1.409",
"sass": "^1.99.0", "sass": "^1.99.0",
"terser": "^5.47.1" "terser": "^5.47.1"
} }
@@ -1808,9 +1808,9 @@
} }
}, },
"node_modules/pyright": { "node_modules/pyright": {
"version": "1.1.289", "version": "1.1.409",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.289.tgz", "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.409.tgz",
"integrity": "sha512-fG3STxnwAt3i7bxbXUPJdYNFrcOWHLwCSEOySH2foUqtYdzWLcxDez0Kgl1X8LMQx0arMJ6HRkKghxfRD1/z6g==", "integrity": "sha512-13VFQyw4mJzshZxcxiYbNjo1hG/WHSRDj70Y3lbJEHqCkI2dvBAUTti8VV6Ezsr5gT93pFvC0e/jAQS4JdHarA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -1818,7 +1818,10 @@
"pyright-langserver": "langserver.index.js" "pyright-langserver": "langserver.index.js"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
} }
}, },
"node_modules/qrcode.vue": { "node_modules/qrcode.vue": {
+1 -1
View File
@@ -16,7 +16,7 @@
"clean-css-cli": "^5.6.3", "clean-css-cli": "^5.6.3",
"concat": "^1.0.3", "concat": "^1.0.3",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"pyright": "1.1.289", "pyright": "1.1.409",
"sass": "^1.99.0", "sass": "^1.99.0",
"terser": "^5.47.1" "terser": "^5.47.1"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.5.5-rc1" version = "1.5.5-rc2"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
+35 -1
View File
@@ -72,9 +72,43 @@ async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker)
login_sso = _FakeSSO() login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso) 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} 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.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login" assert response.headers["location"] == "https://example.com/sso/login"
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token" assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
+6 -20
View File
@@ -194,7 +194,7 @@ async def test_callback_api_handles_revolut_subscription_event(
settings.revolut_api_secret_key = "revolut-secret" settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20" settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet() revolut_provider = RevolutWallet()
mocker.patch.object( get_subscription_mock = mocker.patch.object(
revolut_provider, revolut_provider,
"get_subscription", "get_subscription",
return_value={ return_value={
@@ -253,23 +253,10 @@ async def test_callback_api_handles_revolut_subscription_event(
} }
) )
assert create_wallet_invoice_mock.await_count == 1 get_subscription_mock.assert_not_awaited()
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args create_wallet_invoice_mock.assert_not_awaited()
assert called_wallet_id == "wallet_1" update_payment_mock.assert_not_awaited()
assert invoice.amount == 9.25 fiat_status_mock.assert_not_awaited()
assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.internal is True
assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1"
assert payment.fiat_provider == "revolut"
assert payment.fee == -2
assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1"
assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1"
update_payment_mock.assert_awaited_once_with(
payment, "fiat_revolut_order_ORDER_SUB_1"
)
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio @pytest.mark.anyio
@@ -353,10 +340,9 @@ async def test_callback_api_handles_revolut_subscription_order_event(
assert get_payment_mock.await_count == 2 assert get_payment_mock.await_count == 2
get_payment_mock.assert_any_await("fiat_revolut_order_ORDER_SUB_1") get_payment_mock.assert_any_await("fiat_revolut_order_ORDER_SUB_1")
assert get_order_mock.await_count == 2 assert get_order_mock.await_count == 1
assert [call.args for call in get_subscription_mock.await_args_list] == [ assert [call.args for call in get_subscription_mock.await_args_list] == [
("SUBSCRIPTION_1",), ("SUBSCRIPTION_1",),
("SUBSCRIPTION_1",),
] ]
assert create_wallet_invoice_mock.await_count == 1 assert create_wallet_invoice_mock.await_count == 1
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args
+2 -1
View File
@@ -1,3 +1,4 @@
from typing import cast
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -106,7 +107,7 @@ async def test_lnurl_api_auth_and_pay_flow(mocker):
await api_perform_lnurlauth(auth_response, wallet_info) await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse( action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)),
disposable=False, disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}), 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") wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
) )
reserve = await api_payments_fee_reserve(invoice.bolt11) 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."): with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE) await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
+6 -3
View File
@@ -1,3 +1,4 @@
from typing import cast
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -86,7 +87,9 @@ async def test_get_pr_from_lnurl_success_and_error(mocker: MockerFixture):
mocker.patch( mocker.patch(
"lnbits.core.services.lnurl.execute_pay_request", "lnbits.core.services.lnurl.execute_pay_request",
mocker.AsyncMock( 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") pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse( action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)), disposable=False
) )
mocker.patch( mocker.patch(
"lnbits.core.services.lnurl.fiat_amount_as_satoshis", "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() wallet = await _create_wallet()
pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test") pay_response = make_lnurl_pay_response(min_sendable_msat=1, text="Test")
action_response = LnurlPayActionResponse( action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11), disposable=False pr=cast(LightningInvoice, LightningInvoice(TEST_BOLT11)), disposable=False
) )
await store_paylink( await store_paylink(
+2 -1
View File
@@ -1,3 +1,4 @@
from typing import Any
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
@@ -66,7 +67,7 @@ async def test_create_user_account_no_check_rejects_duplicate_identity_fields(
existing = _account(**existing_data) existing = _account(**existing_data)
await create_account(existing) await create_account(existing)
resolved = { resolved: dict[str, Any] = {
key: (value(existing) if callable(value) else value) key: (value(existing) if callable(value) else value)
for key, value in new_data.items() for key, value in new_data.items()
} }
+6 -4
View File
@@ -1,3 +1,5 @@
from typing import Any
import pytest import pytest
from pytest_mock.plugin import MockerFixture from pytest_mock.plugin import MockerFixture
@@ -14,22 +16,22 @@ from lnbits.settings import (
set_cli_settings, set_cli_settings,
) )
lnurlp_redirect_path = { lnurlp_redirect_path: dict[str, Any] = {
"from_path": "/.well-known/lnurlp", "from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known", "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", "from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known", "redirect_to_path": "/api/v1/well-known",
"header_filters": {"accept": "application/nostr+json"}, "header_filters": {"accept": "application/nostr+json"},
} }
lnaddress_redirect_path = { lnaddress_redirect_path: dict[str, Any] = {
"from_path": "/.well-known/lnurlp", "from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known", "redirect_to_path": "/api/v1/well-known",
} }
nostrrelay_redirect_path = { nostrrelay_redirect_path: dict[str, Any] = {
"from_path": "/", "from_path": "/",
"redirect_to_path": "/api/v1/relay-info", "redirect_to_path": "/api/v1/relay-info",
"header_filters": {"accept": "application/nostr+json"}, "header_filters": {"accept": "application/nostr+json"},
+2 -1
View File
@@ -1,4 +1,5 @@
import os import os
from typing import cast
import pytest import pytest
from loguru import logger 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}") logger.info(f"settings.blink_token: {settings.blink_token}")
set_funding_source() set_funding_source()
funding_source = get_funding_source() funding_source = cast(BlinkWallet, get_funding_source())
assert isinstance(funding_source, BlinkWallet) assert isinstance(funding_source, BlinkWallet)
+7 -4
View File
@@ -1,4 +1,5 @@
import importlib import importlib
from typing import Any
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
@@ -80,7 +81,9 @@ def _check_calls(expected_calls):
for func_call in func_calls: for func_call in func_calls:
req = func_call["request_data"] req = func_call["request_data"]
args = req["args"] if "args" in req else {} 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: if "klass" in req:
*rest, cls = req["klass"].split(".") *rest, cls = req["klass"].split(".")
@@ -166,7 +169,7 @@ def _mock_field(field):
return response return response
def _eval_dict(data: dict | None) -> dict | None: def _eval_dict(data: dict | None) -> dict[str, Any] | None:
fn_prefix = "__eval__:" fn_prefix = "__eval__:"
if not data: if not data:
return data return data
@@ -215,9 +218,9 @@ def _data_mock(data: dict) -> Mock:
def _raise(error: dict | None): def _raise(error: dict | None):
if not error: if not error:
return Exception() 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: 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_module = importlib.import_module(error["module"])
error_class = getattr(error_module, error["class"]) error_class = getattr(error_module, error["class"])
Generated
+4 -4
View File
@@ -1075,11 +1075,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.14" version = "3.15"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -1275,7 +1275,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits" name = "lnbits"
version = "1.5.5rc1" version = "1.5.5rc2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },