refactor: lnurl pay/withdraw/auth use lnurl lib more

- refactor lnurl endpoints into own router `lnurl_api.py`
- add a new withdraw dialog to frontend (create invoice dialog was used
before)
- utilize lnurl library

remove

revert status

remove
This commit is contained in:
dni ⚡ 2024-04-23 22:41:49 +02:00
parent 7298c4664b
commit 554a5c12a7
No known key found for this signature in database
GPG Key ID: D1F416F29AD26E87
11 changed files with 332 additions and 582 deletions

View File

@ -8,6 +8,7 @@ from .views.extension_api import extension_router
# this compat is needed for usermanager extension
from .views.generic import generic_router
from .views.lnurl_api import lnurl_router
from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router
from .views.public_api import public_router
@ -38,3 +39,4 @@ def init_core_routers(app: FastAPI):
app.include_router(tinyurl_router)
app.include_router(webpush_router)
app.include_router(users_router)
app.include_router(lnurl_router)

View File

@ -1,6 +1,4 @@
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
@ -8,14 +6,12 @@ from enum import Enum
from sqlite3 import Row
from typing import Callable, Dict, List, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from lnurl import LnurlAuthResponse, LnurlPayResponse, LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel
from lnbits.db import Connection, FilterModel, FromRowModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentPendingStatus, PaymentStatus
@ -40,28 +36,6 @@ class Wallet(BaseWallet):
def balance(self) -> int:
return self.balance_msat // 1000
@property
def withdrawable_balance(self) -> int:
from .services import fee_reserve
return self.balance_msat - fee_reserve(self.balance_msat)
@property
def lnurlwithdraw_full(self) -> str:
url = url_for("/withdraw", external=True, usr=self.user, wal=self.id)
try:
return lnurl_encode(url)
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
)
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_standalone_payment
@ -397,15 +371,23 @@ class DecodePayment(BaseModel):
data: str
class CreateLnurl(BaseModel):
description_hash: str
callback: str
class CreateLnurlAuth(BaseModel):
auth_response: LnurlAuthResponse
class CreateLnurlPay(BaseModel):
pay_response: LnurlPayResponse
amount: int
comment: Optional[str] = None
description: Optional[str] = None
unit: Optional[str] = None
class CreateLnurlWithdraw(BaseModel):
withdraw_response: LnurlWithdrawResponse
amount: int
memo: Optional[str] = None
class CreateInvoice(BaseModel):
unit: str = "sat"
internal: bool = False
@ -418,7 +400,6 @@ class CreateInvoice(BaseModel):
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
class CreateTopup(BaseModel):
@ -426,10 +407,6 @@ class CreateTopup(BaseModel):
amount: int
class CreateLnurlAuth(BaseModel):
callback: str
class CreateWallet(BaseModel):
name: Optional[str] = None

View File

@ -1,17 +1,13 @@
import asyncio
import datetime
import json
import time
from io import BytesIO
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypedDict
from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from fastapi import WebSocket
from loguru import logger
from passlib.context import CryptContext
from py_vapid import Vapid
@ -20,13 +16,8 @@ from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
check_user_extension_access,
require_admin_key,
)
from lnbits.helpers import url_for
from lnbits.lnurl import LnurlErrorResponse
from lnbits.lnurl import decode as decode_lnurl
from lnbits.settings import (
EditableSettings,
SuperSettings,
@ -475,140 +466,6 @@ async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
)
async def redeem_lnurl_withdraw(
wallet_id: str,
lnurl_request: str,
memo: Optional[str] = None,
extra: Optional[Dict] = None,
wait_seconds: int = 0,
conn: Optional[Connection] = None,
) -> None:
if not lnurl_request:
return None
res = {}
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
lnurl = decode_lnurl(lnurl_request)
r = await client.get(str(lnurl))
res = r.json()
try:
_, payment_request = await create_invoice(
wallet_id=wallet_id,
amount=int(res["maxWithdrawable"] / 1000),
memo=memo or res["defaultDescription"] or "",
extra=extra,
conn=conn,
)
except Exception:
logger.warning(
f"failed to create invoice on redeem_lnurl_withdraw "
f"from {lnurl}. params: {res}"
)
return None
if wait_seconds:
await asyncio.sleep(wait_seconds)
params = {"k1": res["k1"], "pr": payment_request}
try:
params["balanceNotify"] = url_for(
f"/withdraw/notify/{urlparse(lnurl_request).netloc}",
external=True,
wal=wallet_id,
)
except Exception:
pass
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
await client.get(res["callback"], params=params)
except Exception:
pass
async def perform_lnurlauth(
callback: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)
def int_to_bytes_suitable_der(x: int) -> bytes:
"""for strict DER we need to encode the integer with some quirks"""
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
if len(b) == 0:
# ensure there's at least one byte when the int is zero
return bytes([0])
if b[0] & 0x80 != 0:
# ensure it doesn't start with a 0x80 and so it isn't
# interpreted as a negative number
return bytes([0]) + b
return b
def encode_strict_der(r: int, s: int, order: int):
# if s > order/2 verification will fail sometimes
# so we must fix it here see:
# https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147
if s > order // 2:
s = order - s
# now we do the strict DER encoding copied from
# https://github.com/KiriKiri/bip66 (without any checks)
r_temp = int_to_bytes_suitable_der(r)
s_temp = int_to_bytes_suitable_der(s)
r_len = len(r_temp)
s_len = len(s_temp)
sign_len = 6 + r_len + s_len
signature = BytesIO()
signature.write(0x30.to_bytes(1, "big", signed=False))
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(r_len.to_bytes(1, "big", signed=False))
signature.write(r_temp)
signature.write(0x02.to_bytes(1, "big", signed=False))
signature.write(s_len.to_bytes(1, "big", signed=False))
signature.write(s_temp)
return signature.getvalue()
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
assert key.verifying_key, "LNURLauth verifying_key does not exist"
r = await client.get(
callback,
params={
"k1": k1.hex(),
"key": key.verifying_key.to_string("compressed").hex(),
"sig": sig.hex(),
},
)
try:
resp = json.loads(r.text)
if resp["status"] == "OK":
return None
return LnurlErrorResponse(reason=resp["reason"])
except (KeyError, json.decoder.JSONDecodeError):
return LnurlErrorResponse(
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text
)
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus:

View File

@ -126,26 +126,6 @@
<q-separator></q-separator>
<q-list>
{% if wallet.lnurlwithdraw_full %}
<q-expansion-item
group="extras"
icon="crop_free"
:label="$t('drain_funds')"
>
<q-card>
<q-card-section class="text-center">
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<lnbits-qrcode
:value="lightning:{{wallet.lnurlwithdraw_full}}"
></lnbits-qrcode>
</a>
<p v-text="$t('drain_funds_desc')"></p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
{% endif %}
<q-expansion-item
group="extras"
icon="settings_cell"
@ -276,16 +256,12 @@
{% endif %}
</div>
</div>
<q-dialog v-model="receive.show" position="top">
<q-card
v-if="!receive.paymentReq"
v-if="receive.paymentReq === null"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b v-text="receive.lnurl.domain"></b> is requesting an invoice:
</p>
{% if LNBITS_DENOMINATION != 'sats' %}
<q-input
filled
@ -297,7 +273,6 @@
reverse-fill-mask
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% else %}
<q-select
@ -320,7 +295,6 @@
:step="receive.unit != 'sat' ? '0.01' : '1'"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input>
{% endif %}
@ -337,11 +311,7 @@
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit"
>
<span
v-if="receive.lnurl"
v-text="$t('withdraw_from') + receive.lnurl.domain"
></span>
<span v-else v-text="$t('create_invoice')"></span>
<span v-text="$t('create_invoice')"></span>
</q-btn>
<q-btn
v-close-popup
@ -358,8 +328,9 @@
></q-spinner>
</q-form>
</q-card>
<q-card
v-else-if="receive.paymentReq && receive.lnurl == null"
v-else-if="receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg">
@ -448,7 +419,7 @@
<div v-else-if="parse.lnurlauth">
<q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6">
Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
Authenticate with <b v-text="parse.domain"></b>?
</p>
<q-separator class="q-my-sm"></q-separator>
<p>
@ -458,9 +429,7 @@
data will be shared with
<span v-text="parse.lnurlauth.domain"></span>.
</p>
<p>
Your public key for <b v-text="parse.lnurlauth.domain"></b> is:
</p>
<p>Your public key for <b v-text="parse.domain"></b> is:</p>
<p class="q-mx-xl">
<code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
</p>
@ -481,10 +450,56 @@
</div>
</q-form>
</div>
<div v-else-if="parse.lnurlwithdraw">
<q-form @submit="withdrawLnurl" class="q-gutter-md">
<p class="q-my-none text-h6 text-center">
<b v-text="parse.domain"></b> is requesting <br />
between
<b
v-text="msatoshiFormat(parse.lnurlwithdraw.minWithdrawable)"
></b>
and
<b
v-text="msatoshiFormat(parse.lnurlwithdraw.maxWithdrawable)"
></b>
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p
class="col text-justify text-italic"
v-text="parse.lnurlwithdraw.defaultDescription"
></p>
</div>
<div class="row">
<div class="col">
<q-input
ref="setAmount"
v-model.number="parse.data.amount"
:min="parse.lnurlwithdraw.minWithdrawable / 1000"
:max="parse.lnurlwithdraw.maxWithdrawable / 1000"
:label="$t('amount') + ' *'"
filled
dense
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Withdraw</q-btn>
<q-btn
:label="$t('cancel')"
v-close-popup
flat
color="grey"
class="q-ml-auto"
></q-btn>
</div>
</q-form>
</div>
<div v-else-if="parse.lnurlpay">
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b v-text="parse.lnurlpay.domain"></b> is requesting
<b v-text="parse.domain"></b> is requesting
<span
v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"
></span>
@ -497,9 +512,7 @@
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b
v-text="parse.lnurlpay.targetUser || parse.lnurlpay.domain"
></b>
<b v-text="parse.lnurlpay.targetUser || parse.domain"></b>
is requesting <br />
between
<b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
@ -516,7 +529,7 @@
<div class="row">
<p
class="col text-justify text-italic"
v-text="parse.lnurlpay.description"
v-text="getLnurlDescription(parse.lnurlpay)"
></p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
@ -536,17 +549,17 @@
<br />
<q-input
ref="setAmount"
filled
dense
v-model.number="parse.data.amount"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay && parse.lnurlpay.fixed"
:label="$t('amount') + ' (' + parse.data.unit + ') *'"
:mask="parse.data.unit != 'sat' ? '#.##' : '#'"
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
fill-mask="0"
reverse-fill-mask
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay && parse.lnurlpay.fixed"
filled
dense
></q-input>
</div>
<div

View File

@ -1,11 +1,7 @@
import hashlib
import json
from http import HTTPStatus
from io import BytesIO
from typing import Dict, List
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
from typing import List
import httpx
import pyqrcode
from fastapi import (
APIRouter,
@ -17,18 +13,13 @@ from starlette.responses import StreamingResponse
from lnbits.core.models import (
BaseWallet,
ConversionData,
CreateLnurlAuth,
CreateWallet,
User,
Wallet,
)
from lnbits.decorators import (
WalletTypeInfo,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import (
allowed_currencies,
@ -36,7 +27,7 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
from ..services import create_user_account, perform_lnurlauth
from ..services import create_user_account
# backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions
@ -70,136 +61,6 @@ async def api_create_account(data: CreateWallet) -> Wallet:
return account.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
async def api_lnurlscan(
code: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
url = str(lnurl_decode(code))
domain = urlparse(url).netloc
except Exception as exc:
# parse internet identifier (user@domain.com)
name_domain = code.split("@")
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
name, domain = name_domain
url = (
("http://" if domain.endswith(".onion") else "https://")
+ domain
+ "/.well-known/lnurlp/"
+ name
)
# will proceed with these values
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
) from exc
# params is what will be returned to the client
params: Dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
params.update(callback=url) # with k1 already in it
lnurlauth_key = wallet.wallet.lnurlauth_key(domain)
assert lnurlauth_key.verifying_key
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
r = await client.get(url, timeout=5)
r.raise_for_status()
if r.is_error:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={"domain": domain, "message": "failed to get parameters"},
)
try:
data = json.loads(r.text)
except json.decoder.JSONDecodeError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"got invalid response '{r.text[:200]}'",
},
) from exc
try:
tag: str = data.get("tag")
params.update(**data)
if tag == "channelRequest":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"domain": domain,
"kind": "channel",
"message": "unsupported",
},
)
elif tag == "withdrawRequest":
params.update(kind="withdraw")
params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
if "balanceCheck" in data:
params.update(balanceCheck=data["balanceCheck"])
# format callback url and send to client
parsed_callback = parsed_callback._replace(
query=urlencode(qs, doseq=True)
)
params.update(callback=urlunparse(parsed_callback))
elif tag == "payRequest":
params.update(kind="pay")
params.update(fixed=data["minSendable"] == data["maxSendable"])
params.update(
description_hash=hashlib.sha256(
data["metadata"].encode()
).hexdigest()
)
metadata = json.loads(data["metadata"])
for [k, v] in metadata:
if k == "text/plain":
params.update(description=v)
if k in ("image/jpeg;base64", "image/png;base64"):
data_uri = f"data:{k},{v}"
params.update(image=data_uri)
if k in ("text/email", "text/identifier"):
params.update(targetUser=v)
params.update(commentAllowed=data.get("commentAllowed", 0))
except KeyError as exc:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail={
"domain": domain,
"message": f"lnurl JSON response invalid: {exc}",
},
) from exc
return params
@api_router.post("/api/v1/lnurlauth")
async def api_perform_lnurlauth(
data: CreateLnurlAuth, wallet: WalletTypeInfo = Depends(require_admin_key)
):
err = await perform_lnurlauth(data.callback, wallet=wallet)
if err:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
)
return ""
@api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]:
return allowed_currencies()

View File

@ -0,0 +1,156 @@
import json
from http import HTTPStatus
from fastapi import (
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from lnurl import (
LnurlPayActionResponse,
LnurlSuccessResponse,
execute_login,
execute_pay_request,
)
from lnurl import handle as lnurl_handle
from lnurl.core import execute_withdraw
from lnurl.exceptions import InvalidLnurl
from lnbits.core.models import CreateLnurlAuth, CreateLnurlPay, CreateLnurlWithdraw
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
lnurl_router = APIRouter(tags=["LNURL"])
@lnurl_router.get("/api/v1/lnurlscan/{code}")
@lnurl_router.get("/lnurl/api/v1/scan/{code}")
async def api_lnurl_scan(code: str) -> dict:
try:
handle = await lnurl_handle(code, user_agent=settings.user_agent)
return handle.dict()
except InvalidLnurl as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid LNURL",
) from exc
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Error processing LNURL",
) from exc
@lnurl_router.post("/lnurl/api/v1/auth")
async def api_lnurl_auth(
data: CreateLnurlAuth, key_type: WalletTypeInfo = Depends(require_admin_key)
):
try:
res = await execute_login(data.auth_response, key_type.wallet.adminkey)
assert isinstance(
res, LnurlSuccessResponse
), "unexpected response from execute_login"
return res
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Failed to auth, try new LNURL",
) from exc
@lnurl_router.post("/lnurl/api/v1/withdraw")
async def api_lnurl_withdraw(
data: CreateLnurlWithdraw, key_type: WalletTypeInfo = Depends(require_admin_key)
):
try:
payment_hash, payment_request = await create_invoice(
wallet_id=key_type.wallet.id,
amount=data.amount / 1000,
memo=data.memo or "",
extra={"tag": "lnurl-withdraw"},
)
res = await execute_withdraw(data.withdraw_response, payment_request)
assert isinstance(
res, LnurlSuccessResponse
), "unexpected response from execute_withdraw"
return {"payment_hash": payment_hash}
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to withdraw: {exc}",
) from exc
@lnurl_router.post("/api/v1/payments/lnurl")
@lnurl_router.post("/lnurl/api/v1/pay")
async def api_lnurl_pay(
data: CreateLnurlPay, key_type: WalletTypeInfo = Depends(require_admin_key)
):
amount_msat = data.amount
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision, why?
amount_msat = int(amount_msat // 1000) * 1000
description = None
metadata = json.loads(data.pay_response.metadata)
for x in metadata:
if x[0] == "text/plain":
description = x[1]
if not description:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="description required on LNURL pay_response.",
)
# pay
# params.update(
# description_hash=hashlib.sha256(
# data["metadata"].encode()
# ).hexdigest()
# )
# metadata = json.loads(data["metadata"])
# for [k, v] in metadata:
# if k == "text/plain":
# params.update(description=v)
# if k in ("image/jpeg;base64", "image/png;base64"):
# data_uri = f"data:{k},{v}"
# params.update(image=data_uri)
# if k in ("text/email", "text/identifier"):
# params.update(targetUser=v)
try:
res = await execute_pay_request(data.pay_response, str(amount_msat))
assert isinstance(
res, LnurlPayActionResponse
), "unexpected response from execute_pay_request"
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to fetch invoice: {exc}",
) from exc
extra: dict = {}
if res.success_action:
extra["success_action"] = res.success_action.json()
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
payment_hash = await pay_invoice(
wallet_id=key_type.wallet.id,
payment_request=res.pr,
description=description,
extra=extra,
)
return {
"success_action": res.success_action,
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}

View File

@ -1,12 +1,8 @@
import asyncio
import json
import uuid
from http import HTTPStatus
from math import ceil
from typing import List, Optional, Union
from urllib.parse import urlparse
from typing import List, Optional
import httpx
from fastapi import (
APIRouter,
Body,
@ -17,6 +13,7 @@ from fastapi import (
Request,
)
from fastapi.responses import JSONResponse
from lnurl import decode as lnurl_decode
from loguru import logger
from sse_starlette.sse import EventSourceResponse
@ -24,7 +21,6 @@ from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
DecodePayment,
KeyType,
Payment,
@ -37,13 +33,10 @@ from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
parse_filters,
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.lnurl import decode as lnurl_decode
from lnbits.settings import settings
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from ..crud import (
DateTrunc,
@ -168,36 +161,9 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
assert payment_db is not None, "payment not found"
checking_id = payment_db.checking_id
invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
params={
"pr": payment_request,
},
timeout=10,
)
if r.is_error:
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
else:
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
lnurl_response = False
return {
"payment_hash": invoice.payment_hash,
"payment_hash": payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
@ -268,89 +234,10 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
)
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
):
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
r = await client.get(
data.callback,
params={"amount": amount_msat, "comment": data.comment},
timeout=40,
)
if r.is_error:
raise httpx.ConnectError("LNURL callback connection error")
r.raise_for_status()
except (httpx.ConnectError, httpx.RequestError) as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to connect to {domain}.",
) from exc
params = json.loads(r.text)
if params.get("status") == "ERROR":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} said: '{params.get('reason', '')}'",
)
if not params.get("pr"):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"{domain} did not return a payment request.",
)
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != amount_msat:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
(
f"{domain} returned an invalid invoice. Expected"
f" {amount_msat} msat, got {invoice.amount_msat}."
),
),
)
extra = {}
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
"""
Subscribe to new invoices for a wallet. Can be wrapped in EventSourceResponse.
Listenes invoming payments for a wallet and yields jsons with payment details.
Listenes incoming payments for a wallet and yields jsons with payment details.
"""
this_wallet_id = wallet.id

View File

@ -22,18 +22,11 @@ window.LNbits = {
data: data
})
},
createInvoice: async function (
wallet,
amount,
memo,
unit = 'sat',
lnurlCallback = null
) {
createInvoice: async function (wallet, amount, memo, unit = 'sat') {
return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false,
amount: amount,
memo: memo,
lnurl_callback: lnurlCallback,
unit: unit
})
},
@ -43,27 +36,24 @@ window.LNbits = {
bolt11: bolt11
})
},
payLnurl: function (
wallet,
callback,
description_hash,
amount,
description = '',
comment = '',
unit = ''
) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback,
description_hash,
payLnurl: function (wallet, pay_response, amount, comment = '', unit = '') {
return this.request('post', '/lnurl/api/v1/pay', wallet.adminkey, {
pay_response,
amount,
comment,
description,
unit
})
},
authLnurl: function (wallet, callback) {
return this.request('post', '/api/v1/lnurlauth', wallet.adminkey, {
callback
withdrawLnurl: function (wallet, withdraw_response, amount, memo) {
return this.request('post', '/lnurl/api/v1/withdraw', wallet.adminkey, {
withdraw_response,
amount,
memo
})
},
authLnurl: function (wallet, auth_response) {
return this.request('post', '/lnurl/api/v1/auth', wallet.adminkey, {
auth_response
})
},
createAccount: function (name) {

View File

@ -30,6 +30,7 @@ new Vue({
invoice: null,
lnurlpay: null,
lnurlauth: null,
lnurlwithdraw: null,
data: {
request: '',
amount: 0,
@ -130,6 +131,12 @@ new Vue({
this.receive.paymentHash = null
}
},
getLnurlDescription: function (lnurl) {
const description = JSON.parse(lnurl.metadata).filter(
item => item[0] === 'text/plain'
)
return description[0][1]
},
createInvoice: function () {
this.receive.status = 'loading'
if (LNBITS_DENOMINATION != 'sats') {
@ -147,30 +154,6 @@ new Vue({
this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request
this.receive.paymentHash = response.data.payment_hash
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
})
.then(() => {
this.updatePayments = !this.updatePayments
@ -232,11 +215,7 @@ new Vue({
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.wallet.adminkey
)
.request('GET', '/lnurl/api/v1/scan/' + this.parse.data.request, null)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
@ -253,28 +232,21 @@ new Vue({
return
}
if (data.kind === 'pay') {
const url = new URL(data.callback)
this.parse.domain = url.host
if (data.tag === 'payRequest') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'auth') {
} else if (data.tag === 'login') {
this.parse.lnurlauth = Object.freeze(data)
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.paymentHash = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
} else if (data.tag === 'withdrawRequest') {
this.parse.lnurlwithdraw = Object.freeze(data)
this.parse.data.amount = data.maxWithdrawable / 1000
this.parse.data.memo = data.defaultDescription
this.parse.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
return
@ -364,6 +336,42 @@ new Vue({
this.parse.show = false
})
},
withdrawLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing withdraw...'
})
LNbits.api
.withdrawLnurl(
this.g.wallet,
this.parse.lnurlwithdraw,
this.parse.data.amount * 1000,
this.parse.data.memo
)
.then(response => {
this.parse.show = false
this.parse.lnurlwithdraw = null
clearInterval(this.parse.paymentChecker)
setTimeout(() => {
clearInterval(this.parse.paymentChecker)
}, 40000)
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
this.updatePayments = !this.updatePayments
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({
timeout: 0,
@ -373,10 +381,8 @@ new Vue({
LNbits.api
.payLnurl(
this.g.wallet,
this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.lnurlpay,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment,
this.parse.data.unit
)
@ -453,7 +459,7 @@ new Vue({
})
LNbits.api
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
.authLnurl(this.g.wallet, this.parse.lnurlauth)
.then(_ => {
dismissAuthMsg()
this.$q.notify({

43
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "anyio"
@ -767,13 +767,13 @@ wmi = ["wmi (>=1.5.1)"]
[[package]]
name = "ecdsa"
version = "0.18.0"
version = "0.19.0"
description = "ECDSA cryptographic signature library (pure python)"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.6"
files = [
{file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"},
{file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"},
{file = "ecdsa-0.19.0-py2.py3-none-any.whl", hash = "sha256:2cea9b88407fdac7bbeca0833b189e4c9c53f2ef1e1eaa29f6224dbc809b707a"},
{file = "ecdsa-0.19.0.tar.gz", hash = "sha256:60eaad1199659900dd0af521ed462b793bbdf867432b3948e87416ae4caf6bf8"},
]
[package.dependencies]
@ -987,39 +987,40 @@ cryptography = ">=2.5"
[[package]]
name = "httpcore"
version = "0.18.0"
version = "1.0.5"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-0.18.0-py3-none-any.whl", hash = "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced"},
{file = "httpcore-0.18.0.tar.gz", hash = "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9"},
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
]
[package.dependencies]
anyio = ">=3.0,<5.0"
certifi = "*"
h11 = ">=0.13,<0.15"
sniffio = "==1.*"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.25.0"
version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.25.0-py3-none-any.whl", hash = "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100"},
{file = "httpx-0.25.0.tar.gz", hash = "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875"},
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = ">=0.18.0,<0.19.0"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
@ -1253,19 +1254,21 @@ rediscluster = ["redis (>=4.2.0,!=4.5.2,!=4.5.3)"]
[[package]]
name = "lnurl"
version = "0.4.2"
version = "0.5.1"
description = "LNURL implementation for Python."
optional = false
python-versions = ">=3.9,<4.0"
python-versions = "<4.0,>=3.9"
files = [
{file = "lnurl-0.4.2-py3-none-any.whl", hash = "sha256:93f79ae7e0b0c66fed5b29ac1520e85e3e2c8648561a4b42974f0b7bffd34d84"},
{file = "lnurl-0.4.2.tar.gz", hash = "sha256:c5e708b255d5333a0c08ceffe90ae4be6d2d09eb51dc8c35d19d8aa4cb21842a"},
{file = "lnurl-0.5.1-py3-none-any.whl", hash = "sha256:41a03eac08c32b9ee2c6d83b9f1e88bcc5b393b36d82a41d73a02180fa04f249"},
{file = "lnurl-0.5.1.tar.gz", hash = "sha256:a099899e622b23e6197c3f2e3ba38499658f1e6e9e2c456d55caa57373ca925b"},
]
[package.dependencies]
bech32 = ">=1.2.0,<2.0.0"
bolt11 = ">=2.0.5,<3.0.0"
ecdsa = ">=0.19.0,<0.20.0"
httpx = ">=0.27.0,<0.28.0"
pydantic = ">=1,<2"
requests = ">=2.31.0,<3.0.0"
[[package]]
name = "loguru"
@ -3054,4 +3057,4 @@ liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10 | ^3.9"
content-hash = "33f9d6ee851ae77b6e02cc8964d1a6ea233ba3ff4cfaeeb082c327654c9cd7e0"
content-hash = "4e316d3fcc22d41ada4de66e57049011312664f6b71853b0be441e289a402595"

View File

@ -15,11 +15,10 @@ packages = [
python = "^3.10 | ^3.9"
bech32 = "1.2.0"
click = "8.1.7"
ecdsa = "0.18.0"
fastapi = "0.109.2"
httpx = "0.25.0"
httpx = "0.27.0"
jinja2 = "3.1.4"
lnurl = "0.4.2"
lnurl = "0.5.1"
psycopg2-binary = "2.9.7"
pydantic = "1.10.17"
pyqrcode = "1.2.1"
@ -132,7 +131,6 @@ module = [
"lnurl.*",
"bolt11.*",
"bitstring.*",
"ecdsa.*",
"psycopg2.*",
"pyngrok.*",
"pyln.client.*",