mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
Add lightning addresses for wallets in core (#4035)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
parent
bae9e1bf80
commit
e6a61fafbb
29 changed files with 1151 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
242
lnbits/core/services/lightning_address.py
Normal file
242
lnbits/core/services/lightning_address.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
14
poetry.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ dependencies = [
|
|||
"urllib3>=2.7.0",
|
||||
"pyinstrument>=5.1.2",
|
||||
"wasmtime>=45.0.0",
|
||||
"random-username~=1.0.2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
11
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue