support pay-to-identifier.

This commit is contained in:
fiatjaf
2021-07-30 21:01:19 -03:00
parent 2bcc01d640
commit a019d29b9b
4 changed files with 97 additions and 50 deletions

View File

@@ -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',

View File

@@ -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 />

View File

@@ -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
View 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")