feat: lud11 disposable and storeable payRequests. (#3317)

This commit is contained in:
dni ⚡
2025-08-19 11:33:57 +02:00
committed by GitHub
parent 1488f580ff
commit ae6cf47244
12 changed files with 289 additions and 46 deletions

View File

@@ -33,7 +33,7 @@ async def create_wallet(
async def update_wallet(
wallet: Wallet,
conn: Optional[Connection] = None,
) -> Optional[Wallet]:
) -> Wallet:
wallet.updated_at = datetime.now(timezone.utc)
await (conn or db).update("wallets", wallet)
return wallet

View File

@@ -735,3 +735,11 @@ async def m032_add_external_id_to_accounts(db: Connection):
async def m033_update_payment_table(db: Connection):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_provider TEXT")
async def m034_add_stored_paylinks_to_wallet(db: Connection):
await db.execute(
"""
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
"""
)

View File

@@ -1,11 +1,13 @@
from time import time
from typing import Optional
from lnurl import LnAddress, Lnurl, LnurlPayResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse
res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None
amount: int
comment: Optional[str] = None
unit: Optional[str] = None
@@ -18,3 +20,13 @@ class CreateLnurlWithdraw(BaseModel):
class LnurlScan(BaseModel):
lnurl: Lnurl | LnAddress
class StoredPayLink(BaseModel):
lnurl: str
label: str
last_used: int = Field(default_factory=lambda: int(time()))
class StoredPayLinks(BaseModel):
links: list[StoredPayLink] = []

View File

@@ -1,15 +1,13 @@
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from ecdsa import SECP256k1, SigningKey
from lnurl import encode as lnurl_encode
from pydantic import BaseModel, Field
from lnbits.core.models.lnurl import StoredPayLinks
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.settings import settings
@@ -41,6 +39,7 @@ class Wallet(BaseModel):
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
@property
def balance(self) -> int:
@@ -58,14 +57,6 @@ class Wallet(BaseModel):
except Exception:
return ""
def lnurlauth_key(self, domain: str) -> SigningKey:
hashing_key = hashlib.sha256(self.id.encode()).digest()
linking_key = hmac.digest(hashing_key, domain.encode(), "sha256")
return SigningKey.from_string(
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
class CreateWallet(BaseModel):
name: str | None = None

View File

@@ -1,4 +1,8 @@
from time import time
from lnurl import (
LnAddress,
Lnurl,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayResponse,
@@ -6,8 +10,11 @@ from lnurl import (
execute_pay_request,
handle,
)
from loguru import logger
from lnbits.core.models import CreateLnurlPayment
from lnbits.core.crud import update_wallet
from lnbits.core.models import CreateLnurlPayment, Wallet
from lnbits.core.models.lnurl import StoredPayLink
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
@@ -34,12 +41,26 @@ async def get_pr_from_lnurl(
return res2.pr
async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionResponse:
async def fetch_lnurl_pay_request(
data: CreateLnurlPayment, wallet: Wallet | None = None
) -> tuple[LnurlPayResponse, LnurlPayActionResponse]:
"""
Pay an LNURL payment request.
optional `wallet` is used to store the pay link in the wallet's stored links.
raises `LnurlResponseException` if pay request fails
"""
if not data.res and data.lnurl:
res = await handle(data.lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise LnurlResponseException(res.reason)
if not isinstance(res, LnurlPayResponse):
raise LnurlResponseException(
"Invalid LNURL response. Expected LnurlPayResponse."
)
data.res = res
if not data.res:
raise LnurlResponseException("No LNURL pay request provided.")
if data.unit and data.unit != "sat":
# shift to float with 2 decimal places
@@ -49,10 +70,69 @@ async def fetch_lnurl_pay_request(data: CreateLnurlPayment) -> LnurlPayActionRes
else:
amount_msat = data.amount
return await execute_pay_request(
res2 = await execute_pay_request(
data.res,
msat=amount_msat,
comment=data.comment,
user_agent=settings.user_agent,
timeout=10,
)
if wallet:
await store_paylink(data.res, res2, wallet, data.lnurl)
return data.res, res2
async def store_paylink(
res: LnurlPayResponse,
res2: LnurlPayActionResponse,
wallet: Wallet,
lnurl: LnAddress | Lnurl | None = None,
) -> None:
if res2.disposable is not False:
return # do not store disposable LNURL pay links
logger.debug(f"storing lnurl pay link for wallet {wallet.id}. ")
stored_paylink = None
# If we have only a LnurlPayResponse, we can use its lnaddress
# because the lnurl is not available.
if not lnurl:
for _data in res.metadata.list():
if _data[0] == "text/identifier":
stored_paylink = StoredPayLink(
lnurl=LnAddress(_data[1]), label=res.metadata.text
)
if not stored_paylink:
logger.warning(
"No lnaddress found in metadata for LNURL pay link. "
"Skipping storage."
)
return # skip if lnaddress not found in metadata
else:
if isinstance(lnurl, Lnurl):
_lnurl = str(lnurl.lud17 or lnurl.bech32)
else:
_lnurl = str(lnurl)
stored_paylink = StoredPayLink(lnurl=_lnurl, label=res.metadata.text)
# update last_used if its already stored
for pl in wallet.stored_paylinks.links:
if pl.lnurl == stored_paylink.lnurl:
pl.last_used = int(time())
await update_wallet(wallet)
logger.debug(
"Updated last used time for LNURL "
f"pay link {stored_paylink.lnurl} in wallet {wallet.id}."
)
return
# if not already stored, append it
if not any(stored_paylink.lnurl == pl.lnurl for pl in wallet.stored_paylinks.links):
wallet.stored_paylinks.links.append(stored_paylink)
await update_wallet(wallet)
logger.debug(
f"Stored LNURL pay link {stored_paylink.lnurl} for wallet {wallet.id}."
)

View File

@@ -289,9 +289,87 @@
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
v-if="'{{ LNBITS_DENOMINATION }}' == 'sats'"
group="extras"
icon="qr_code"
v-if="stored_paylinks.length > 0"
:label="$t('stored_paylinks')"
>
<q-card>
<q-card-section>
<div class="row flex" v-for="paylink in stored_paylinks">
<q-btn
dense
flat
color="primary"
icon="send"
size="xs"
@click="sendToPaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`send to: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<q-btn
dense
flat
color="secondary"
icon="content_copy"
size="xs"
@click="copyText(paylink.lnurl)"
>
<q-tooltip>
<span v-text="`copy: ${paylink.lnurl}`"></span>
</q-tooltip>
</q-btn>
<span
v-text="paylink.label"
class="q-mr-xs q-ml-xs"
></span>
<q-btn dense flat color="primary" icon="edit" size="xs">
<q-popup-edit
@update:model-value="editPaylink()"
v-model="paylink.label"
v-slot="scope"
>
<q-input
dark
color="white"
v-model="scope.value"
dense
autofocus
counter
@keyup.enter="scope.set"
>
<template v-slot:append>
<q-icon name="edit" />
</template>
</q-input>
</q-popup-edit>
<q-tooltip>
<span v-text="$t('edit')"></span>
</q-tooltip>
</q-btn>
<span style="flex-grow: 1"></span>
<q-btn
dense
flat
color="red"
icon="delete"
size="xs"
@click="deletePaylink(paylink.lnurl)"
>
<q-tooltip>
<span v-text="$t('delete')"></span>
</q-tooltip>
</q-btn>
<span v-text="dateFromNow(paylink.last_used)"></span>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="phone_android"
:label="$t('access_wallet_on_mobile')"

View File

@@ -37,6 +37,18 @@ from ..services import fetch_lnurl_pay_request, pay_invoice
lnurl_router = APIRouter(tags=["LNURL"])
async def _handle(lnurl: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(lnurl, user_agent=settings.user_agent, timeout=5)
if isinstance(res, LnurlErrorResponse):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=res.reason)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
return res
@lnurl_router.get(
"/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_invoice_key)],
@@ -47,13 +59,7 @@ lnurl_router = APIRouter(tags=["LNURL"])
| LnurlErrorResponse,
)
async def api_lnurlscan(code: str) -> LnurlResponseModel:
try:
res = await lnurl_handle(code, user_agent=settings.user_agent, timeout=5)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
res = await _handle(code)
if isinstance(res, (LnurlPayResponse, LnurlWithdrawResponse, LnurlAuthResponse)):
check_callback_url(res.callback)
return res
@@ -68,13 +74,7 @@ async def api_lnurlscan(code: str) -> LnurlResponseModel:
| LnurlErrorResponse,
)
async def api_lnurlscan_post(scan: LnurlScan) -> LnurlResponseModel:
try:
res = await lnurl_handle(scan.lnurl, user_agent=settings.user_agent, timeout=5)
except LnurlResponseException as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
return res
return await _handle(scan.lnurl)
@lnurl_router.post("/api/v1/lnurlauth")
@@ -101,16 +101,29 @@ async def api_perform_lnurlauth(
async def api_payments_pay_lnurl(
data: CreateLnurlPayment, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> Payment:
"""
Pay an LNURL payment request.
Either provice `res` (LnurlPayResponse) or `lnurl` (str) in the `data` object.
"""
if not data.res and not data.lnurl:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Missing LNURL or LnurlPayResponse data.",
)
try:
res = await fetch_lnurl_pay_request(data=data)
res, res2 = await fetch_lnurl_pay_request(data=data, wallet=wallet.wallet)
except LnurlResponseException as exc:
logger.warning(exc)
msg = f"Failed to connect to {data.res.callback}."
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg) from exc
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc
extra: dict[str, Any] = {}
if res.success_action:
extra["success_action"] = res.success_action.json()
if res2.disposable is False:
extra["stored"] = True
if res2.success_action:
extra["success_action"] = res2.success_action.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
@@ -119,8 +132,8 @@ async def api_payments_pay_lnurl(
payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=str(res.pr),
description=data.res.metadata.text,
payment_request=str(res2.pr),
description=res.metadata.text,
extra=extra,
)

View File

@@ -10,11 +10,11 @@ from fastapi import (
)
from lnbits.core.crud.wallets import get_wallets_paginated
from lnbits.core.models import CreateWallet, KeyType, User, Wallet
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.wallets import WalletsFilters
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
parse_filters,
require_admin_key,
@@ -93,6 +93,21 @@ async def api_reset_wallet_keys(
return wallet
@wallet_router.put("/stored_paylinks/{wallet_id}")
async def api_put_stored_paylinks(
wallet_id: str,
data: StoredPayLinks,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> list[StoredPayLink]:
if key_info.wallet.id != wallet_id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="You cannot modify this wallet"
)
key_info.wallet.stored_paylinks.links = data.links
wallet = await update_wallet(key_info.wallet)
return wallet.stored_paylinks.links
@wallet_router.patch("")
async def api_update_wallet(
name: Optional[str] = Body(None),

File diff suppressed because one or more lines are too long

View File

@@ -49,6 +49,7 @@ window.localisation.en = {
export_to_phone_desc:
'This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.',
access_wallet_on_mobile: 'Mobile Access',
stored_paylinks: 'Stored LNURL pay links',
wallet: 'Wallet: ',
wallet_name: 'Wallet name',
wallets: 'Wallets',

View File

@@ -5,6 +5,7 @@ window.WalletPageLogic = {
origin: window.location.origin,
baseUrl: `${window.location.protocol}//${window.location.host}/`,
websocketUrl: `${'http:' ? 'ws://' : 'wss://'}${window.location.host}/api/v1/ws`,
stored_paylinks: [],
parse: {
show: false,
invoice: null,
@@ -177,6 +178,10 @@ window.WalletPageLogic = {
}
},
methods: {
dateFromNow(unix) {
const date = new Date(unix * 1000)
return moment.utc(date).fromNow()
},
formatFiatAmount(amount, currency) {
this.update.currency = currency
this.formattedFiatAmount = LNbits.utils.formatCurrency(
@@ -536,6 +541,7 @@ window.WalletPageLogic = {
LNbits.api
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
res: this.parse.lnurlpay,
lnurl: this.parse.data.request,
unit: this.parse.data.unit,
amount: this.parse.data.amount * 1000,
comment: this.parse.data.comment,
@@ -1098,9 +1104,51 @@ window.WalletPageLogic = {
saveChartsPreferences() {
this.$q.localStorage.set('lnbits.wallets.chartConfig', this.chartConfig)
this.refreshCharts()
},
updatePaylinks() {
LNbits.api
.request(
'PUT',
`/api/v1/wallet/stored_paylinks/${this.g.wallet.id}`,
this.g.wallet.adminkey,
{
links: this.stored_paylinks
}
)
.then(() => {
Quasar.Notify.create({
message: 'Paylinks updated.',
type: 'positive',
timeout: 3500
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
sendToPaylink(lnurl) {
this.parse.data.request = lnurl
this.parse.show = true
this.lnurlScan()
},
editPaylink() {
this.$nextTick(() => {
this.updatePaylinks()
})
},
deletePaylink(lnurl) {
const links = []
this.stored_paylinks.forEach(link => {
if (link.lnurl !== lnurl) {
links.push(link)
}
})
this.stored_paylinks = links
this.updatePaylinks()
}
},
created() {
this.stored_paylinks = wallet.stored_paylinks.links
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has('lightning') || urlParams.has('lnurl')) {
this.parse.data.request =

View File

@@ -792,9 +792,6 @@ async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
"/api/v1/payments/lnurl", json=lnurl_data, headers=adminkey_headers_from
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Failed to connect to https://xxxxxxx.lnbits.com."
)
# Test with invalid callback URL
lnurl_data["res"]["callback"] = "invalid-url.lnbits.com"