Add lightning addresses for wallets in core (#4035)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
blackcoffeexbt 2026-07-31 07:35:18 +01:00 committed by GitHub
parent bae9e1bf80
commit e6a61fafbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1151 additions and 15 deletions

View file

@ -5,6 +5,7 @@ from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
from lnbits.db import Connection, Filters, Page
from lnbits.helpers import generate_ln_address
from lnbits.settings import settings
from lnbits.utils.cache import cache
@ -30,6 +31,8 @@ async def create_wallet(
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
)
if settings.ln_address_creation_allowed and wallet.is_lightning_wallet:
wallet.lightning_address = await generate_lightning_address_local_part(conn)
await (conn or db).insert("wallets", wallet)
return wallet
@ -123,11 +126,21 @@ async def get_standalone_wallet(
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone(
wallet = await (conn or db).fetchone(
query,
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
if not wallet:
return None
if deleted is True:
return wallet
if not wallet.lightning_address and settings.ln_address_creation_allowed:
wallet.lightning_address = await generate_lightning_address_local_part(conn)
await update_wallet(wallet, conn)
return wallet
async def get_wallet(
@ -220,6 +233,30 @@ async def get_wallets_count():
return row.get("count", 0)
async def generate_lightning_address_local_part(
conn: Connection | None = None,
) -> str:
for _ in range(100):
local_part = generate_ln_address()
if await get_wallet_id_by_ln_address(local_part, conn):
continue
return local_part
raise ValueError("Could not generate a unique wallet lightning address.")
async def get_wallet_id_by_ln_address(
local_part: str, conn: Connection | None = None
) -> str | None:
row: dict = await (conn or db).fetchone(
"""
SELECT id FROM wallets
WHERE lightning_address = :lightning_address
""",
{"lightning_address": local_part.lower()},
)
return row["id"] if row else None
async def get_wallet_for_key(
key: str,
conn: Connection | None = None,

View file

@ -890,3 +890,15 @@ async def m049_add_permissions_to_user_extensions(db: Connection):
Adds user-level extension permission grants.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
async def m050_add_lightning_address_to_wallets(db: Connection):
"""
Adds a LUD-16 lightning address local-part to wallets.
"""
await db.execute("ALTER TABLE wallets ADD COLUMN lightning_address TEXT")
logger.debug("Creating index idx_wallets_lightning_address...")
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallets_lightning_address
ON wallets (lightning_address);
""")

View file

@ -126,6 +126,7 @@ class Wallet(BaseWallet):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None
lightning_address: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
@ -150,6 +151,7 @@ class Wallet(BaseWallet):
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.lightning_address = shared_wallet.lightning_address
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
@ -240,10 +242,18 @@ class BaseWalletTypeInfo:
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__search_fields__ = ["id", "name", "currency", "lightning_address"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
__sort_fields__ = [
"id",
"name",
"currency",
"lightning_address",
"created_at",
"updated_at",
]
id: str | None
name: str | None
currency: str | None
lightning_address: str | None

View file

@ -0,0 +1,242 @@
import json
import re
from fastapi import Query, Request
from lnurl import (
CallbackUrl,
LightningInvoice,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
MilliSatoshi,
)
from pydantic import parse_obj_as
from lnbits.core.crud.wallets import (
get_wallet,
get_wallet_id_by_ln_address,
update_wallet,
)
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.models.wallets import Wallet
from lnbits.core.services.payments import (
create_invoice,
create_wallet_invoice,
pay_invoice,
)
from lnbits.db import Connection
from lnbits.exceptions import PaymentError
from lnbits.settings import settings
MAX_SENDABLE_MSAT = 2_100_000_000_000_000_000
COMMENT_ALLOWED = 799
LIGHTNING_ADDRESS_REGEX = re.compile(r"^[a-z0-9_.-]{1,210}$")
async def set_wallet_lightning_address(
*,
wallet: Wallet,
local_part: str,
allow_blacklisted: bool = False,
charge: bool = False,
conn: Connection | None = None,
) -> Wallet:
if not settings.ln_address_creation_allowed:
raise ValueError("Wallet Lightning Addresses are disabled.")
if not wallet.is_lightning_wallet or wallet.deleted:
raise ValueError("Lightning Address can only be set for active wallets.")
local_part = await _validate_local_part(
local_part, wallet.id, allow_blacklisted, conn=conn
)
if wallet.lightning_address == local_part:
return wallet
if charge:
await _charge_for_lightning_address(wallet)
wallet.lightning_address = local_part
return await update_wallet(wallet, conn=conn)
async def wallet_lightning_address_response(
username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
local_part, tag = _split_tagged_local_part(username)
wallet_id = await get_wallet_id_by_ln_address(local_part)
if not wallet_id:
return LnurlErrorResponse(reason="Lightning address not found.")
tagged_local_part = local_part
if tag:
tagged_local_part = f"{tagged_local_part}+{tag}"
callback = request.url_for(
"lnurl.api_wallet_lightning_address_callback",
username=tagged_local_part,
)
identifier = _lightning_address_for_request(request, tagged_local_part)
return LnurlPayResponse(
callback=parse_obj_as(CallbackUrl, str(callback)),
minSendable=MilliSatoshi(1000),
maxSendable=MilliSatoshi(MAX_SENDABLE_MSAT),
metadata=LnurlPayMetadata(json.dumps(_metadata(identifier, tag))),
commentAllowed=COMMENT_ALLOWED,
)
async def wallet_lightning_address_callback(
username: str,
request: Request,
amount: int = Query(...),
) -> LnurlErrorResponse | LnurlPayActionResponse:
local_part, tag = _split_tagged_local_part(username)
wallet_id = await get_wallet_id_by_ln_address(local_part)
if not wallet_id:
return LnurlErrorResponse(reason="Lightning address not found.")
if amount < 1000:
return LnurlErrorResponse(reason="Amount is smaller than minimum 1000.")
if amount > MAX_SENDABLE_MSAT:
return LnurlErrorResponse(
reason=f"Amount is greater than maximum {MAX_SENDABLE_MSAT}."
)
comment = request.query_params.get("comment")
if len(comment or "") > COMMENT_ALLOWED:
return LnurlErrorResponse(
reason=(
f"Got a comment with {len(comment or '')} characters, "
f"but can only accept {COMMENT_ALLOWED}"
)
)
tagged_local_part = local_part
if tag:
tagged_local_part = f"{tagged_local_part}+{tag}"
identifier = _lightning_address_for_request(request, tagged_local_part)
extra = {
"tag": "wallet_lightning_address",
"lnaddress": identifier,
}
if tag:
extra["lnaddress_tag"] = tag
if comment:
extra["comment"] = comment
metadata = LnurlPayMetadata(json.dumps(_metadata(identifier, tag)))
payment = await create_invoice(
wallet_id=wallet_id,
amount=int(amount / 1000),
memo=f"Payment to {identifier}",
unhashed_description=metadata.encode(),
extra=extra,
)
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11))
return LnurlPayActionResponse(pr=invoice, disposable=False)
def _lightning_address_for_request(request: Request, local_part: str) -> str:
return f"{local_part}@{request.url.netloc}"
async def _validate_local_part(
local_part: str,
wallet_id: str,
allow_blacklisted: bool = False,
conn: Connection | None = None,
) -> str:
local_part = local_part.strip().lower()
if not local_part:
raise ValueError("Lightning Address is required.")
if "+" in local_part:
raise ValueError("Lightning Address cannot include tags.")
if "@" in local_part:
raise ValueError("Enter only the Lightning Address name before @.")
if not LIGHTNING_ADDRESS_REGEX.match(local_part):
raise ValueError(
"Lightning Address can only contain lowercase letters, numbers, "
"dash, underscore, and dot."
)
if not allow_blacklisted and _uses_blacklisted_word(local_part):
raise ValueError("Lightning Address contains a reserved word.")
existing_wallet_id = await get_wallet_id_by_ln_address(local_part, conn=conn)
if existing_wallet_id and existing_wallet_id != wallet_id:
raise ValueError("Lightning Address is already taken.")
return local_part
def _split_tagged_local_part(local_part: str) -> tuple[str, str | None]:
username, separator, tag = local_part.partition("+")
if not separator or not tag:
return username.lower(), None
return username.lower(), tag
def _metadata(identifier: str, tag: str | None = None) -> list[list[str]]:
metadata = [
["text/plain", f"Payment to {identifier}"],
["text/identifier", identifier],
]
if tag:
metadata.append(["text/tag", tag])
return metadata
async def _charge_for_lightning_address(wallet: Wallet) -> None:
price_sats = settings.lnbits_wallet_lightning_address_price_sats
if not settings.lnbits_charge_wallet_lightning_addresses or price_sats <= 0:
return
if not settings.lnbits_service_fee_wallet:
raise ValueError("Lightning Address fee wallet is not configured.")
if settings.lnbits_service_fee_wallet == wallet.source_wallet_id:
raise ValueError("Lightning Address fee wallet cannot be the same wallet.")
fee_wallet = await get_wallet(settings.lnbits_service_fee_wallet)
if not fee_wallet:
raise ValueError("Lightning Address fee wallet is not configured.")
invoice = await create_wallet_invoice(
settings.lnbits_service_fee_wallet,
CreateInvoice(
out=False,
amount=price_sats,
memo="Lightning Address fee",
internal=True,
extra={
"tag": "wallet_lightning_address_fee",
"wallet": wallet.source_wallet_id,
},
),
)
try:
await pay_invoice(
wallet_id=wallet.source_wallet_id,
payment_request=invoice.bolt11,
description="Lightning Address fee",
tag="wallet_lightning_address_fee",
extra={
"tag": "wallet_lightning_address_fee",
"fee_wallet": settings.lnbits_service_fee_wallet,
},
)
except PaymentError as exc:
raise ValueError(exc.message) from exc
def _blacklist_words() -> set[str]:
return {
word.strip().lower()
for word in settings.lnbits_wallet_lightning_address_blacklist
if word.strip()
}
def _uses_blacklisted_word(local_part: str) -> bool:
words = _blacklist_words()
if not words:
return False
segments = [segment for segment in re.split(r"[._-]+", local_part) if segment]
return local_part in words or any(segment in words for segment in segments)

View file

@ -1,10 +1,13 @@
from http import HTTPStatus
from typing import Any
import httpx
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
)
from lnurl import (
LnurlAuthResponse,
@ -18,6 +21,7 @@ from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import LnurlResponseModel
from loguru import logger
from pydantic import ValidationError
from lnbits.core.models import Payment
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
@ -27,13 +31,50 @@ from lnbits.decorators import (
require_base_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.settings import RedirectPath, settings
from ..services import fetch_lnurl_pay_request, pay_invoice
from ..services.lightning_address import (
wallet_lightning_address_callback,
wallet_lightning_address_response,
)
lnurl_router = APIRouter(tags=["LNURL"])
@lnurl_router.get(
"/.well-known/lnurlp/{username}",
name="lnurl.api_wallet_lightning_address_response",
)
async def api_wallet_lightning_address_response(
username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
if settings.lnbits_ln_address_mode in ["extension_first", "extension_only"]:
req_headers = request["headers"] if "headers" in request else []
redirect = settings.find_extension_redirect(request.url.path, req_headers)
if redirect:
resp = await _check_extension_well_known(redirect, request)
if resp and resp.ok:
return resp
if settings.lnbits_ln_address_mode == "extension_only":
return LnurlErrorResponse(
reason="Lightning addresses are not supported on this instance."
)
return await wallet_lightning_address_response(username, request)
@lnurl_router.get(
"/api/v1/lnurl/wallet/{username}/cb",
name="lnurl.api_wallet_lightning_address_callback",
)
async def api_wallet_lightning_address_callback(
username: str, request: Request, amount: int = Query(...)
) -> LnurlErrorResponse | Any:
return await wallet_lightning_address_callback(username, request, amount)
async def _handle(lnurl: str) -> LnurlResponseModel:
try:
if "@" in lnurl: # lower case lightning addresses
@ -137,3 +178,32 @@ async def api_payments_pay_lnurl(
)
return payment
async def _check_extension_well_known(
redirect: RedirectPath, request: Request
) -> LnurlPayResponse | LnurlErrorResponse | None:
target_path = redirect.new_path_from(request.url.path)
transport = httpx.ASGITransport(app=request.app)
try:
async with httpx.AsyncClient(
transport=transport,
base_url=str(request.base_url),
) as client:
response = await client.get(
target_path,
headers={"accept": "application/json"},
)
response.raise_for_status()
response_data = response.json()
try:
return LnurlPayResponse.parse_obj(response_data)
except ValidationError:
return LnurlErrorResponse.parse_obj(response_data)
except Exception as exc:
logger.warning(
f"Failed to fetch LNURL response Extension redirect {target_path}: {exc}"
)
return None

View file

@ -41,6 +41,7 @@ from lnbits.core.services import (
update_user_extensions,
update_wallet_balance,
)
from lnbits.core.services.lightning_address import set_wallet_lightning_address
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import (
@ -280,6 +281,34 @@ async def api_users_create_user_wallet(
return wallet
@users_router.put(
"/user/{user_id}/wallet/{wallet}/lightning-address",
name="Set wallet Lightning Address",
)
async def api_users_set_wallet_lightning_address(
user_id: str,
wallet: str,
lightning_address: str = Body(..., embed=True),
) -> Wallet:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id != wal.user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Wallet does not belong to user.",
)
return await set_wallet_lightning_address(
wallet=wal,
local_part=lightning_address,
allow_blacklisted=True,
charge=False,
)
@users_router.put(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)

View file

@ -22,6 +22,7 @@ from lnbits.core.models.wallets import (
WalletSharePermission,
WalletType,
)
from lnbits.core.services.lightning_address import set_wallet_lightning_address
from lnbits.core.services.wallets import (
create_lightning_shared_wallet,
delete_wallet_share,
@ -38,6 +39,7 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.settings import settings
from ..crud import (
delete_wallet,
@ -164,6 +166,7 @@ async def api_update_wallet(
color: str | None = Body(None),
currency: str | None = Body(None),
pinned: bool | None = Body(None),
lightning_address: str | None = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
@ -175,6 +178,20 @@ async def api_update_wallet(
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
if lightning_address and lightning_address != wallet.lightning_address:
if not settings.lnbits_allow_custom_wallet_lightning_addresses:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Users cannot specify Lightning Addresses.",
)
# too much logic here
wallet = await set_wallet_lightning_address(
wallet=wallet,
local_part=lightning_address,
charge=True,
)
return wallet
await update_wallet(wallet)
return wallet

View file

@ -13,6 +13,7 @@ from fastapi.routing import APIRoute
from loguru import logger
from packaging import version
from pydantic.schema import field_schema
from random_username.generate import generate_username # type: ignore[import-untyped]
from starlette.templating import Jinja2Templates
from lnbits.settings import settings
@ -22,6 +23,10 @@ from lnbits.utils.exchange_rates import currencies
from .db import FilterModel
def generate_ln_address() -> str:
return generate_username(1)[0].lower()
def get_db_vendor_name():
db_url = settings.lnbits_database_url
return (

View file

@ -104,7 +104,7 @@ class ExtensionsRedirectMiddleware:
req_headers = scope["headers"] if "headers" in scope else []
redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect:
if redirect and not redirect.is_duplicate_well_known():
scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send)

View file

@ -11,7 +11,7 @@ from enum import Enum
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from typing import Any, Literal
from uuid import uuid4
from loguru import logger
@ -45,6 +45,16 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True)
lnbits_ln_address_mode: Literal[
"core_first", "extension_first", "extension_only"
] = Field(default="extension_first")
lnbits_allow_custom_wallet_lightning_addresses: bool = Field(default=False)
lnbits_charge_wallet_lightning_addresses: bool = Field(default=False)
lnbits_wallet_lightning_address_price_sats: int = Field(default=1000, ge=0)
lnbits_wallet_lightning_address_blacklist: list[str] = Field(
default=["admin", "info", "support", "help", "security"]
)
lnbits_require_user_activation: bool = Field(default=False)
lnbits_user_activation_by_email: bool = Field(default=False)
@ -58,6 +68,10 @@ class UsersSettings(LNbitsSettings):
def new_accounts_allowed(self) -> bool:
return self.lnbits_allow_new_accounts and len(self.lnbits_allowed_users) == 0
@property
def ln_address_creation_allowed(self) -> bool:
return self.lnbits_ln_address_mode != "extension_only"
class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[])
@ -126,6 +140,9 @@ class RedirectPath(BaseModel):
redirect_to_path: str
header_filters: dict = {}
def is_duplicate_well_known(self) -> bool:
return self.from_path in ["/.well-known/lnurlp"]
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
@ -1062,6 +1079,7 @@ class EditableSettings(
@validator(
"lnbits_admin_users",
"lnbits_allowed_users",
"lnbits_wallet_lightning_address_blacklist",
"lnbits_theme_options",
"lnbits_admin_extensions",
"lnbits_extensions_manifests",
@ -1362,6 +1380,18 @@ class PublicSettings(BaseModel):
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
enable_wallet_lightning_addresses: bool = Field(
alias="enableWalletLightningAddresses"
)
allow_custom_wallet_lightning_addresses: bool = Field(
alias="allowCustomWalletLightningAddresses"
)
charge_wallet_lightning_addresses: bool = Field(
alias="chargeWalletLightningAddresses"
)
wallet_lightning_address_price_sats: int = Field(
alias="walletLightningAddressPriceSats"
)
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
lnbits_user_activation_by_invitation_code: bool = Field(
@ -1427,6 +1457,16 @@ class PublicSettings(BaseModel):
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
enableWalletLightningAddresses=settings.ln_address_creation_allowed,
allowCustomWalletLightningAddresses=(
settings.lnbits_allow_custom_wallet_lightning_addresses
),
chargeWalletLightningAddresses=(
settings.lnbits_charge_wallet_lightning_addresses
),
walletLightningAddressPriceSats=(
settings.lnbits_wallet_lightning_address_price_sats
),
userActivationByEmail=settings.lnbits_user_activation_by_email,
userActivationByPayment=settings.lnbits_user_activation_by_payment,
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -44,7 +44,8 @@ window.localisation.en = {
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup',
name_your_wallet: 'Name your {name} wallet',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
paste_invoice_label:
'Paste an invoice, payment request, Lightning Address or LNURL*',
lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
export_to_phone: 'Export to Phone with QR Code',
@ -913,5 +914,30 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated',
color: 'Color',
sort: 'Sort',
sort_by: 'Sort by'
sort_by: 'Sort by',
lightning_address: 'Lightning Address',
lightning_addresses: 'Lightning Addresses',
lightning_address_price: 'Lightning Address price',
enable_lightning_address: 'Enable Lightning Addresses',
ln_address_mode: 'Lightning Address Resolution Mode',
ln_address_core_first: 'Resolve from LNbits Core first',
ln_address_extension_first: 'Resolve from Pay Links extension first',
ln_address_extension_only: 'Resolve from Pay Links extension only',
ln_address_mode_hint:
'Choose how LNbits should resolve Lightning Addresses. Using both LNbits Core and the Pay Links extension will have a small impact on performance.',
enable_lightning_address_for_all_wallets:
'Enable Lightning Addresses for all LNbits wallets',
allow_users_specify_lightning_addresses:
'Allow users to specify Lightning Addresses',
allow_wallet_owners_set_custom_lightning_addresses:
'Allow wallet owners to set custom Lightning Addresses',
charge_for_lightning_addresses: 'Charge for Lightning Addresses',
charge_users_set_change_lightning_address:
'Charge users when they set or change a Lightning Address.',
service_fee_wallet_id_must_be_set:
'Service Fee Wallet ID must be set in the Service Fees section below for this to work.',
lightning_address_blacklist: 'Lightning Address blacklist',
lightning_address_blacklist_instructions:
'Newline separated reserved words. Users cannot choose a Lightning Address that matches any of these words.',
set_lightning_address: 'Set Lightning Address'
}

View file

@ -47,6 +47,7 @@ window.LNbits = {
adminkey: data.adminkey,
inkey: data.inkey,
currency: data.currency,
lightningAddress: data.lightning_address,
extra: data.extra,
canReceivePayments: true,
canSendPayments: true
@ -59,6 +60,9 @@ window.LNbits = {
newWallet.canSendPayments = perms.includes('send-payments')
}
newWallet.url = `/wallet?&wal=${data.id}`
newWallet.lightningAddressFull = newWallet.lightningAddress
? `${newWallet.lightningAddress}@${window.location.host}`
: null
newWallet.storedPaylinks = data.stored_paylinks.links
return newWallet
}

View file

@ -1,4 +1,18 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server'
template: '#lnbits-admin-server',
computed: {
lightningAddressBlacklistText: {
get() {
const value = this.formData.lnbits_wallet_lightning_address_blacklist
return Array.isArray(value) ? value.join('\n') : value || ''
},
set(value) {
this.formData.lnbits_wallet_lightning_address_blacklist = value
.split(/[\n,]/)
.map(word => word.trim().toLowerCase())
.filter(word => word.length)
}
}
}
})

View file

@ -1,12 +1,46 @@
window.app.component('lnbits-wallet-extra', {
template: '#lnbits-wallet-extra',
props: ['chartConfig'],
data() {
return {
lightningAddressInput: ''
}
},
computed: {
exportUrl() {
return `${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`
},
canEditLightningAddress() {
return (
this.g.settings.enableWalletLightningAddresses &&
this.g.settings.allowCustomWalletLightningAddresses &&
this.g.wallet.walletType === 'lightning'
)
},
lightningAddressSuffix() {
return `@${window.location.host}`
},
lightningAddressChanged() {
return (
this.lightningAddressInput !== (this.g.wallet.lightningAddress || '')
)
},
lightningAddressFeeHint() {
if (!this.g.settings.chargeWalletLightningAddresses) return ''
return `Fee: ${this.g.settings.walletLightningAddressPriceSats} sats`
}
},
watch: {
'g.wallet.id': 'resetLightningAddressInput',
'g.wallet.lightningAddress': 'resetLightningAddressInput'
},
methods: {
resetLightningAddressInput() {
this.lightningAddressInput = this.g.wallet.lightningAddress || ''
},
saveLightningAddress() {
this.updateWallet({lightning_address: this.lightningAddressInput})
},
handleSendLnurl(lnurl) {
this.$emit('send-lnurl', lnurl)
},
@ -80,6 +114,7 @@ window.app.component('lnbits-wallet-extra', {
}
},
created() {
this.resetLightningAddressInput()
if (this.g.wallet.currency !== '' && this.g.isSatsDenomination) {
this.g.fiatTracking = true
this.updateFiatBalance()

View file

@ -29,6 +29,11 @@ window.PageUsers = {
data: {},
show: false
},
lightningAddressDialog: {
wallet: null,
lightningAddress: '',
show: false
},
walletTable: {
columns: [
{
@ -173,6 +178,11 @@ window.PageUsers = {
created() {
this.fetchUsers()
},
computed: {
lightningAddressSuffix() {
return `@${window.location.host}`
}
},
methods: {
formatSat(value) {
@ -348,6 +358,35 @@ window.PageUsers = {
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
this.utils.copyText(url)
},
showLightningAddressDialog(wallet) {
this.lightningAddressDialog.wallet = wallet
this.lightningAddressDialog.lightningAddress =
wallet.lightning_address || ''
this.lightningAddressDialog.show = true
},
saveLightningAddress() {
const wallet = this.lightningAddressDialog.wallet
if (!wallet) return
LNbits.api
.request(
'PUT',
`/users/api/v1/user/${wallet.user}/wallet/${wallet.id}/lightning-address`,
null,
{
lightning_address: this.lightningAddressDialog.lightningAddress
}
)
.then(response => {
Object.assign(wallet, response.data)
this.lightningAddressDialog.show = false
Quasar.Notify.create({
type: 'positive',
message: this.$t('lightning_address_updated'),
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
fetchUsers(props) {
this.relaxFilterForFields(['username', 'email'])
const params = LNbits.utils.prepareFilterQuery(this.usersTable, props)

View file

@ -631,14 +631,19 @@ window.PageWallet = {
LNbits.api
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
.then(response => {
this.g.wallet = {...this.g.wallet, ...response.data}
const walletData = {...response.data}
if (walletData.lightning_address) {
walletData.lightningAddress = walletData.lightning_address
walletData.lightningAddressFull = `${walletData.lightning_address}@${window.location.host}`
}
this.g.wallet = {...this.g.wallet, ...walletData}
const walletIndex = this.g.user.wallets.findIndex(
wallet => wallet.id === response.data.id
)
if (walletIndex !== -1) {
this.g.user.wallets[walletIndex] = {
...this.g.user.wallets[walletIndex],
...response.data
...walletData
}
}
Quasar.Notify.create({

View file

@ -69,6 +69,127 @@
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm" v-text="$t('lightning_addresses')"></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 q-mt-sm">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('ln_address_mode')"></q-item-label>
<q-item-label
caption
v-text="$t('ln_address_mode_hint')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-select
filled
emit-value
map-options
v-model="formData.lnbits_ln_address_mode"
:label="$t('ln_address_mode')"
:options="[
{label: $t('ln_address_core_first'), value: 'core_first'},
{
label: $t('ln_address_extension_first'),
value: 'extension_first'
},
{
label: $t('ln_address_extension_only'),
value: 'extension_only'
}
]"
></q-select>
</q-item-section>
</q-item>
</div>
<div
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
class="col-12 col-md-6 q-mt-sm"
>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('allow_users_specify_lightning_addresses')"
></q-item-label>
<q-item-label
caption
v-text="
$t('allow_wallet_owners_set_custom_lightning_addresses')
"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="
formData.lnbits_allow_custom_wallet_lightning_addresses
"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
class="col-12 col-md-6 q-mt-sm"
>
<div>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('charge_for_lightning_addresses')"
></q-item-label>
<q-item-label
caption
v-text="$t('charge_users_set_change_lightning_address')"
></q-item-label>
<q-item-label
caption
v-text="$t('service_fee_wallet_id_must_be_set')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_charge_wallet_lightning_addresses"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<q-input
v-if="formData.lnbits_charge_wallet_lightning_addresses"
class="q-mt-sm"
filled
dense
type="number"
min="0"
v-model.number="formData.lnbits_wallet_lightning_address_price_sats"
:label="$t('lightning_address_price')"
suffix="sats"
></q-input>
</div>
<div
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
class="col-12 col-md-6 q-mt-sm"
>
<q-input
filled
dense
type="textarea"
autogrow
v-model="lightningAddressBlacklistText"
:label="$t('lightning_address_blacklist')"
:hint="$t('lightning_address_blacklist_instructions')"
></q-input>
</div>
</div>
<q-separator class="q-mb-lg q-mt-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('wallet_limiter')"></span>
</h6>

View file

@ -157,6 +157,60 @@
</div>
</div>
</q-card-section>
<q-card-section
v-if="
g.settings.enableWalletLightningAddresses &&
(g.wallet.lightningAddressFull || canEditLightningAddress)
"
>
<div class="row">
<div :class="canEditLightningAddress ? 'col-8' : 'col-10'">
<q-input
v-if="canEditLightningAddress"
filled
dense
v-model.trim="lightningAddressInput"
:label="$t('lightning_address')"
:suffix="lightningAddressSuffix"
:hint="lightningAddressFeeHint"
>
</q-input>
<q-input
v-else
filled
readonly
dense
:model-value="g.wallet.lightningAddressFull"
:label="$t('lightning_address')"
>
</q-input>
</div>
<div v-if="canEditLightningAddress" class="col-2 q-pl-sm">
<q-btn
dense
color="primary"
class="q-mt-xs full-width"
:disable="
!lightningAddressInput || !lightningAddressChanged
"
label="Save"
@click="saveLightningAddress"
></q-btn>
</div>
<div class="col-2">
<q-btn
flat
round
icon="content_copy"
class="float-right q-mt-xs"
:disable="!g.wallet.lightningAddressFull"
@click="utils.copyText(g.wallet.lightningAddressFull)"
>
<q-tooltip v-text="$t('copy')"></q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-6">

View file

@ -109,6 +109,18 @@
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="g.user.super_user && !props.row.deleted"
icon="alternate_email"
size="sm"
color="accent"
class="q-ml-xs"
@click="showLightningAddressDialog(props.row)"
>
<q-tooltip v-text="$t('set_lightning_address')"></q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
@ -201,6 +213,39 @@
</template>
</q-table>
</q-card>
<q-dialog v-model="lightningAddressDialog.show">
<q-card class="q-pa-md lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="$t('lightning_address')"></div>
<div
v-if="lightningAddressDialog.wallet"
class="text-caption q-mt-xs"
>
<span v-text="lightningAddressDialog.wallet.name"></span>
<span> · </span>
<span v-text="lightningAddressDialog.wallet.id"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
filled
dense
v-model.trim="lightningAddressDialog.lightningAddress"
:label="$t('lightning_address')"
:suffix="lightningAddressSuffix"
></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
color="primary"
:label="$t('save')"
:disable="!lightningAddressDialog.lightningAddress"
@click="saveLightningAddress"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">

View file

@ -596,6 +596,34 @@
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
>
</lnbits-qrcode>
<div
v-if="
!receive.fiatPaymentReq &&
g.settings.enableWalletLightningAddresses &&
g.wallet.lightningAddressFull
"
class="q-mt-md"
>
<q-input
filled
readonly
dense
:model-value="g.wallet.lightningAddressFull"
:label="$t('lightning_address')"
>
<template v-slot:append>
<q-btn
flat
round
dense
icon="content_copy"
@click="utils.copyText(g.wallet.lightningAddressFull)"
>
<q-tooltip v-text="$t('copy')"></q-tooltip>
</q-btn>
</template>
</q-input>
</div>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>

14
poetry.lock generated
View file

@ -3954,6 +3954,18 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "random-username"
version = "1.0.2"
description = "Randomly generate compelling usernames."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "random-username-1.0.2.tar.gz", hash = "sha256:5fdc0604b5d1bdfe4acf4cd7491a9de1caf41bbdd890f646b434e09ae2a1b7ce"},
{file = "random_username-1.0.2-py3-none-any.whl", hash = "sha256:2536feb63fecde7e01ede4a541aadb6f0b58794a7ab327ca5369d2a4b7664c06"},
]
[[package]]
name = "referencing"
version = "0.36.2"
@ -5184,4 +5196,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "b167368cde275d9b12ea2c352a9ca2558da1aa9476ada0795cfd4f45d2ca72bc"
content-hash = "e41fd327115a1614f2e7b5dae7d7077e30f1cb723e1880a88132ec38df2b4b1a"

View file

@ -54,6 +54,7 @@ dependencies = [
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
"wasmtime>=45.0.0",
"random-username~=1.0.2",
]
[project.scripts]

View file

@ -1,3 +1,6 @@
import json
import re
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
@ -16,9 +19,11 @@ from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.lightning_address import wallet_lightning_address_callback
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
@ -38,6 +43,90 @@ TEST_BOLT11 = (
)
@pytest.mark.anyio
async def test_wallet_lightning_address_lookup_and_callback(
client, to_user, settings, mocker
):
settings.lnbits_ln_address_mode = "core_first"
wallet = await create_wallet(user_id=to_user.id, wallet_name="ln address")
assert wallet.lightning_address
response = await client.get(f"/.well-known/lnurlp/{wallet.lightning_address}")
assert response.status_code == 200
data = response.json()
metadata = data["metadata"]
assert data["minSendable"] == 1000
assert data["maxSendable"] == 2_100_000_000_000_000_000
assert data["commentAllowed"] == 799
assert f"{wallet.lightning_address}@" in metadata
assert "text/identifier" in metadata
tagged_response = await client.get(
f"/.well-known/lnurlp/{wallet.lightning_address}+market"
)
assert tagged_response.status_code == 200
tagged_data = tagged_response.json()
tagged_metadata = json.loads(tagged_data["metadata"])
assert ["text/tag", "market"] in tagged_metadata
assert any(
entry[0] == "text/identifier"
and entry[1].startswith(f"{wallet.lightning_address}+market@")
for entry in tagged_metadata
)
create_invoice_mock = mocker.patch(
"lnbits.core.services.lightning_address.create_invoice",
mocker.AsyncMock(return_value=SimpleNamespace(bolt11=TEST_BOLT11)),
)
callback = tagged_data["callback"].split("testserver")[-1]
callback_response = await client.get(f"{callback}?amount=21000&comment=hello")
assert callback_response.status_code == 200
assert callback_response.json()["pr"] == TEST_BOLT11
create_invoice_mock.assert_awaited_once()
kwargs = create_invoice_mock.await_args.kwargs
assert kwargs["wallet_id"] == wallet.id
assert kwargs["amount"] == 21
assert kwargs["extra"]["tag"] == "wallet_lightning_address"
assert kwargs["extra"]["comment"] == "hello"
assert kwargs["extra"]["lnaddress"].startswith(f"{wallet.lightning_address}+")
assert kwargs["extra"]["lnaddress_tag"] == "market"
@pytest.mark.anyio
async def test_wallet_lightning_address_generation_settings(to_user, settings):
settings.lnbits_ln_address_mode = "core_first"
wallet = await create_wallet(user_id=to_user.id)
assert wallet.lightning_address
assert re.fullmatch(r"[a-z]+[a-z]+[0-9]", wallet.lightning_address)
settings.lnbits_ln_address_mode = "extension_only"
disabled_wallet = await create_wallet(user_id=to_user.id)
assert disabled_wallet.lightning_address is None
settings.lnbits_ln_address_mode = "core_first"
backfilled = await get_wallet(disabled_wallet.id)
assert backfilled
assert backfilled.lightning_address
@pytest.mark.anyio
async def test_wallet_lightning_address_callback_validates_comment(
to_user, settings, mocker
):
settings.lnbits_ln_address_mode = "core_first"
wallet = await create_wallet(user_id=to_user.id)
assert wallet.lightning_address
request = mocker.Mock()
request.url.netloc = "example.com"
request.query_params.get.return_value = "x" * 800
result = await wallet_lightning_address_callback(
wallet.lightning_address, request, amount=1000
)
assert isinstance(result, LnurlErrorResponse)
assert "can only accept 799" in result.reason
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()

View file

@ -83,6 +83,45 @@ async def test_user_api_get_wallets_and_delete_all_wallets(
assert active_wallets == []
@pytest.mark.anyio
async def test_user_api_superuser_sets_wallet_lightning_address(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
settings.lnbits_ln_address_mode = "core_first"
settings.lnbits_wallet_lightning_address_blacklist = ["admin"]
settings.lnbits_charge_wallet_lightning_addresses = True
settings.lnbits_wallet_lightning_address_price_sats = 1_000
settings.lnbits_service_fee_wallet = None
unauthorized = await http_client.put(
f"/users/api/v1/user/{user.id}/wallet/{wallet.id}/lightning-address",
json={"lightning_address": "admin"},
)
assert unauthorized.status_code == 401
response = await http_client.put(
f"/users/api/v1/user/{user.id}/wallet/{wallet.id}/lightning-address",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"lightning_address": "admin"},
)
assert response.status_code == 200
assert response.json()["lightning_address"] == "admin"
updated_wallet = await get_wallet(wallet.id)
assert updated_wallet
assert updated_wallet.lightning_address == "admin"
assert updated_wallet.balance == 0
@pytest.mark.anyio
async def test_user_api_create_wallet_validates_currency():
user = await create_user_account(

View file

@ -5,7 +5,9 @@ from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models.users import Account
from lnbits.core.services import update_wallet_balance
from lnbits.core.services.users import create_user_account
from lnbits.settings import settings
@pytest.mark.anyio
@ -166,6 +168,127 @@ async def test_wallet_api_paginated_update_reset_and_store_paylinks(
assert updated.json()["extra"]["pinned"] is True
@pytest.mark.anyio
async def test_wallet_api_custom_lightning_address_owner_rules(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
headers = _admin_headers(wallet.adminkey)
settings.lnbits_ln_address_mode = "core_first"
settings.lnbits_allow_custom_wallet_lightning_addresses = False
disabled = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "custom.name"},
)
assert disabled.status_code == 403
settings.lnbits_allow_custom_wallet_lightning_addresses = True
settings.lnbits_wallet_lightning_address_blacklist = ["admin"]
blacklisted = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "admin"},
)
assert blacklisted.status_code == 400
invalid = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "custom+tag"},
)
assert invalid.status_code == 400
existing_wallet = await create_wallet(
user_id=user.id,
wallet_name="existing lightning address",
)
existing = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(existing_wallet.adminkey),
json={"lightning_address": "pay.link"},
)
assert existing.status_code == 200
conflict = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "pay.link"},
)
assert conflict.status_code == 400
updated = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "custom.name"},
)
assert updated.status_code == 200
assert updated.json()["lightning_address"] == "custom.name"
@pytest.mark.anyio
async def test_wallet_api_custom_lightning_address_charges_fee(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
fee_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"fees_{uuid4().hex[:8]}",
email=f"fees_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
fee_wallet = fee_user.wallets[0]
await update_wallet_balance(wallet=wallet, amount=2_000)
settings.lnbits_ln_address_mode = "core_first"
settings.lnbits_allow_custom_wallet_lightning_addresses = True
settings.lnbits_charge_wallet_lightning_addresses = True
settings.lnbits_wallet_lightning_address_price_sats = 1_000
settings.lnbits_service_fee_wallet = fee_wallet.id
updated = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(wallet.adminkey),
json={"lightning_address": "paid.name"},
)
assert updated.status_code == 200
assert updated.json()["lightning_address"] == "paid.name"
charged_wallet = await get_wallet(wallet.id)
credited_wallet = await get_wallet(fee_wallet.id)
assert charged_wallet
assert credited_wallet
assert charged_wallet.balance == 1_000
assert credited_wallet.balance == 1_000
settings.lnbits_service_fee_wallet = None
missing_fee_wallet = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(wallet.adminkey),
json={"lightning_address": "paid.other"},
)
assert missing_fee_wallet.status_code == 400
assert missing_fee_wallet.json()["detail"] == (
"Lightning Address fee wallet is not configured."
)
@pytest.mark.anyio
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
user = await create_user_account(

View file

@ -1,5 +1,5 @@
from pathlib import Path
from typing import Any
from typing import Any, Literal
import pytest
from pytest_mock.plugin import MockerFixture
@ -14,6 +14,7 @@ from lnbits.settings import (
RedirectPath,
SecuritySettings,
Settings,
UsersSettings,
list_parse_fallback,
set_cli_settings,
)
@ -40,6 +41,33 @@ nostrrelay_redirect_path: dict[str, Any] = {
}
@pytest.mark.parametrize(
("mode", "creation_allowed"),
[
("core_first", True),
("extension_first", True),
("extension_only", False),
],
)
def test_ln_address_mode(
mode: Literal["core_first", "extension_first", "extension_only"],
creation_allowed: bool,
):
users_settings = UsersSettings(lnbits_ln_address_mode=mode)
assert users_settings.lnbits_ln_address_mode == mode
assert users_settings.ln_address_creation_allowed is creation_allowed
def test_ln_address_mode_defaults_to_extension_first():
assert UsersSettings().lnbits_ln_address_mode == "extension_first"
def test_ln_address_mode_rejects_invalid_value():
with pytest.raises(ValueError):
UsersSettings.parse_obj({"lnbits_ln_address_mode": "invalid"})
@pytest.fixture()
def lnurlp():
return RedirectPath(ext_id="lnurlp", **lnurlp_redirect_path)

11
uv.lock generated
View file

@ -1324,6 +1324,7 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "pywebpush" },
{ name = "random-username" },
{ name = "shortuuid" },
{ name = "slowapi" },
{ name = "sqlalchemy" },
@ -1414,6 +1415,7 @@ requires-dist = [
{ name = "python-dotenv", specifier = "~=1.2.1" },
{ name = "python-multipart", specifier = "~=0.0.22" },
{ name = "pywebpush", specifier = "~=2.2.0" },
{ name = "random-username", specifier = "~=1.0.2" },
{ name = "shortuuid", specifier = "~=1.0.13" },
{ name = "slowapi", specifier = "~=0.1.9" },
{ name = "sqlalchemy", specifier = "~=1.4.54" },
@ -2392,6 +2394,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
]
[[package]]
name = "random-username"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/84/7004e0038707827e7ebe91a09e0f46483e36134de3b20bc0d5cf864a4caa/random-username-1.0.2.tar.gz", hash = "sha256:5fdc0604b5d1bdfe4acf4cd7491a9de1caf41bbdd890f646b434e09ae2a1b7ce", size = 3805, upload-time = "2019-01-11T22:57:45.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/d8/0f96b9bdea0683884b0ff2d8663b2580e2bad4418848540d1a12e1fd1d7b/random_username-1.0.2-py3-none-any.whl", hash = "sha256:2536feb63fecde7e01ede4a541aadb6f0b58794a7ab327ca5369d2a4b7664c06", size = 6673, upload-time = "2019-01-11T22:57:43.656Z" },
]
[[package]]
name = "referencing"
version = "0.36.2"