mirror of
https://github.com/lnbits/lnbits.git
synced 2025-07-28 13:42:42 +02:00
support pay-to-identifier.
This commit is contained in:
@@ -346,7 +346,10 @@ new Vue({
|
|||||||
.split('&')[0]
|
.split('&')[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) {
|
if (
|
||||||
|
this.parse.data.request.toLowerCase().startsWith('lnurl1') ||
|
||||||
|
this.parse.data.request.match(/[\w.+-~_]+@[\w.+-~_]/)
|
||||||
|
) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'GET',
|
'GET',
|
||||||
|
@@ -458,7 +458,7 @@
|
|||||||
{% raw %}
|
{% raw %}
|
||||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is requesting {{
|
||||||
parse.lnurlpay.maxSendable | msatoshiFormat }} sat
|
parse.lnurlpay.maxSendable | msatoshiFormat }} sat
|
||||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||||
<br />
|
<br />
|
||||||
|
@@ -1,14 +1,14 @@
|
|||||||
import trio
|
import trio
|
||||||
import json
|
import json
|
||||||
import lnurl # type: ignore
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import hashlib
|
||||||
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
|
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
|
||||||
from quart import g, current_app, jsonify, make_response, url_for
|
from quart import g, current_app, jsonify, make_response, url_for
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
from typing import Dict, Union
|
from typing import Dict, Union
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11, lnurl
|
||||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||||
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
|
from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis
|
||||||
|
|
||||||
@@ -231,9 +231,12 @@ async def api_payments_pay_lnurl():
|
|||||||
timeout=40,
|
timeout=40,
|
||||||
)
|
)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
|
raise httpx.ConnectError
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
|
return (
|
||||||
|
jsonify({"message": f"Failed to connect to {domain}."}),
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
params = json.loads(r.text)
|
params = json.loads(r.text)
|
||||||
if params.get("status") == "ERROR":
|
if params.get("status") == "ERROR":
|
||||||
@@ -367,24 +370,35 @@ async def api_payments_sse():
|
|||||||
@api_check_wallet_key("invoice")
|
@api_check_wallet_key("invoice")
|
||||||
async def api_lnurlscan(code: str):
|
async def api_lnurlscan(code: str):
|
||||||
try:
|
try:
|
||||||
url = lnurl.Lnurl(code)
|
url = lnurl.decode(code)
|
||||||
except ValueError:
|
domain = urlparse(url).netloc
|
||||||
return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
except:
|
||||||
|
# parse internet identifier (user@domain.com)
|
||||||
domain = urlparse(url.url).netloc
|
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:
|
||||||
|
return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
# params is what will be returned to the client
|
# params is what will be returned to the client
|
||||||
params: Dict = {"domain": domain}
|
params: Dict = {"domain": domain}
|
||||||
|
|
||||||
if url.is_login:
|
if "tag=login" in url:
|
||||||
params.update(kind="auth")
|
params.update(kind="auth")
|
||||||
params.update(callback=url.url) # with k1 already in it
|
params.update(callback=url) # with k1 already in it
|
||||||
|
|
||||||
lnurlauth_key = g.wallet.lnurlauth_key(domain)
|
lnurlauth_key = g.wallet.lnurlauth_key(domain)
|
||||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||||
else:
|
else:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.get(url.url, timeout=40)
|
r = await client.get(url, timeout=5)
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
return (
|
return (
|
||||||
jsonify({"domain": domain, "message": "failed to get parameters"}),
|
jsonify({"domain": domain, "message": "failed to get parameters"}),
|
||||||
@@ -392,9 +406,8 @@ async def api_lnurlscan(code: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
jdata = json.loads(r.text)
|
data = json.loads(r.text)
|
||||||
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
|
except json.decoder.JSONDecodeError:
|
||||||
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
|
|
||||||
return (
|
return (
|
||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
@@ -405,44 +418,68 @@ async def api_lnurlscan(code: str):
|
|||||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
if type(data) is lnurl.LnurlChannelResponse:
|
try:
|
||||||
|
tag = data["tag"]
|
||||||
|
if tag == "channelRequest":
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{"domain": domain, "kind": "channel", "message": "unsupported"}
|
||||||
|
),
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
params.update(**data)
|
||||||
|
|
||||||
|
if 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))
|
||||||
|
|
||||||
|
if tag == "payRequest":
|
||||||
|
params.update(kind="pay")
|
||||||
|
params.update(fixed=data["minSendable"] == data["maxSendable"])
|
||||||
|
|
||||||
|
params.update(
|
||||||
|
description_hash=hashlib.sha256(
|
||||||
|
data["metadata"].encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
)
|
||||||
|
metadata = json.loads(data["metadata"])
|
||||||
|
for [k, v] in metadata:
|
||||||
|
if k == "text/plain":
|
||||||
|
params.update(description=v)
|
||||||
|
if k == "image/jpeg;base64" or k == "image/png;base64":
|
||||||
|
data_uri = "data:" + k + "," + v
|
||||||
|
params.update(image=data_uri)
|
||||||
|
if k == "text/email" or k == "text/identifier":
|
||||||
|
params.update(targetUser=v)
|
||||||
|
|
||||||
|
params.update(commentAllowed=data.get("commentAllowed", 0))
|
||||||
|
except KeyError as exc:
|
||||||
return (
|
return (
|
||||||
jsonify(
|
jsonify(
|
||||||
{"domain": domain, "kind": "channel", "message": "unsupported"}
|
{
|
||||||
|
"domain": domain,
|
||||||
|
"message": f"lnurl JSON response invalid: {exc}",
|
||||||
|
}
|
||||||
),
|
),
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
params.update(**data.dict())
|
|
||||||
|
|
||||||
if type(data) is lnurl.LnurlWithdrawResponse:
|
|
||||||
params.update(kind="withdraw")
|
|
||||||
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
|
|
||||||
|
|
||||||
# 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 jdata:
|
|
||||||
params.update(balanceCheck=jdata["balanceCheck"])
|
|
||||||
|
|
||||||
# format callback url and send to client
|
|
||||||
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
|
|
||||||
params.update(callback=urlunparse(parsed_callback))
|
|
||||||
|
|
||||||
if type(data) is lnurl.LnurlPayResponse:
|
|
||||||
params.update(kind="pay")
|
|
||||||
params.update(fixed=data.min_sendable == data.max_sendable)
|
|
||||||
params.update(description_hash=data.metadata.h)
|
|
||||||
params.update(description=data.metadata.text)
|
|
||||||
if data.metadata.images:
|
|
||||||
image = min(data.metadata.images, key=lambda image: len(image[1]))
|
|
||||||
data_uri = "data:" + image[0] + "," + image[1]
|
|
||||||
params.update(image=data_uri)
|
|
||||||
params.update(commentAllowed=jdata.get("commentAllowed", 0))
|
|
||||||
|
|
||||||
return jsonify(params)
|
return jsonify(params)
|
||||||
|
|
||||||
|
|
||||||
|
7
lnbits/lnurl.py
Normal file
7
lnbits/lnurl.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from bech32 import bech32_decode, convertbits
|
||||||
|
|
||||||
|
|
||||||
|
def decode(lnurl: str) -> str:
|
||||||
|
hrp, data = bech32_decode(lnurl)
|
||||||
|
bech32_data = convertbits(data, 5, 8, False)
|
||||||
|
return bytes(bech32_data).decode("utf-8")
|
Reference in New Issue
Block a user