actually paying and withdrawing with lnurl.

This commit is contained in:
fiatjaf 2020-10-12 18:15:27 -03:00
parent 3cd15c40fc
commit bc2207ba27
5 changed files with 207 additions and 35 deletions

View File

@ -51,7 +51,12 @@ def create_invoice(
def pay_invoice( def pay_invoice(
*, wallet_id: str, payment_request: str, max_sat: Optional[int] = None, extra: Optional[Dict] = None *,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
) -> str: ) -> str:
temp_id = f"temp_{urlsafe_short_hash()}" temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}" internal_id = f"internal_{urlsafe_short_hash()}"
@ -79,7 +84,7 @@ def pay_invoice(
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat, amount=-invoice.amount_msat,
memo=invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
) )

View File

@ -256,16 +256,36 @@ new Vue({
.createInvoice( .createInvoice(
this.g.wallet, this.g.wallet,
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo this.receive.data.memo,
this.receive.lnurl && this.receive.lnurl.callback
) )
.then(response => { .then(response => {
this.receive.status = 'success' this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request this.receive.paymentReq = response.data.payment_request
if (this.receive.lnurl) { if (response.data.lnurl_response !== null) {
// send invoice to lnurl callback if (response.data.lnurl_response === false) {
console.log('sending', this.receive.lnurl) response.data.lnurl_response = `Unable to connect`
LNbits.api.sendInvoiceToLnurlWithdraw(this.receive.paymentReq) }
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'negative',
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,
type: 'positive',
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
} }
this.receive.paymentChecker = setInterval(() => { this.receive.paymentChecker = setInterval(() => {
@ -274,6 +294,7 @@ new Vue({
.then(response => { .then(response => {
if (response.data.paid) { if (response.data.paid) {
this.fetchPayments() this.fetchPayments()
this.fetchBalance()
this.receive.show = false this.receive.show = false
clearInterval(this.receive.paymentChecker) clearInterval(this.receive.paymentChecker)
} }
@ -314,12 +335,11 @@ new Vue({
let data = response.data let data = response.data
if (data.status === 'ERROR') { if (data.status === 'ERROR') {
Quasar.plugins.Notify.create({ this.$q.notify({
timeout: 5000, timeout: 5000,
type: 'warning', type: 'warning',
message: data.reason, message: `${data.domain} lnurl call failed.`,
caption: `${data.domain} returned an error to the lnurl call.`, caption: data.reason
icon: null
}) })
return return
} }
@ -331,13 +351,16 @@ new Vue({
this.parse.show = false this.parse.show = false
this.receive.show = true this.receive.show = true
this.receive.status = 'pending' this.receive.status = 'pending'
this.receive.data.amount = data.maxWithdrawable this.paymentReq = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription this.receive.data.memo = data.defaultDescription
this.receive.minMax = [data.minWithdrawable, data.maxWithdrawable] this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = { this.receive.lnurl = {
domain: data.domain, domain: data.domain,
callback: data.callback, callback: data.callback,
k1: data.k1,
fixed: data.fixed fixed: data.fixed
} }
} }
@ -353,8 +376,7 @@ new Vue({
timeout: 3000, timeout: 3000,
type: 'warning', type: 'warning',
message: error + '.', message: error + '.',
caption: '400 BAD REQUEST', caption: '400 BAD REQUEST'
icon: null
}) })
this.parse.show = false this.parse.show = false
return return
@ -390,8 +412,7 @@ new Vue({
payInvoice: function () { payInvoice: function () {
let dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Processing payment...', message: 'Processing payment...'
icon: null
}) })
LNbits.api LNbits.api
@ -406,6 +427,7 @@ new Vue({
clearInterval(this.parse.paymentChecker) clearInterval(this.parse.paymentChecker)
dismissPaymentMsg() dismissPaymentMsg()
this.fetchPayments() this.fetchPayments()
this.fetchBalance()
} }
}) })
}, 2000) }, 2000)
@ -418,22 +440,55 @@ new Vue({
payLnurl: function () { payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Processing payment...', message: 'Processing payment...'
icon: null
}) })
LNbits.api LNbits.api
.payInvoice(this.g.wallet, this.parse.data.bolt11) .payLnurl(
this.g.wallet,
this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120)
)
.then(response => { .then(response => {
this.parse.show = false
this.parse.paymentChecker = setInterval(() => { this.parse.paymentChecker = setInterval(() => {
LNbits.api LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash) .getPayment(this.g.wallet, response.data.payment_hash)
.then(res => { .then(res => {
if (res.data.paid) { if (res.data.paid) {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
dismissPaymentMsg() dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
this.fetchPayments() this.fetchPayments()
this.fetchBalance()
// show lnurlpay success action
if (response.data.success_action) {
switch (response.data.success_action.tag) {
case 'url':
this.$q.notify({
message: `<a target="_blank" style="color:inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
html: true,
type: 'info',
timeout: 0,
closeBtn: true
})
break
case 'message':
this.$q.notify({
message: response.data.success_action.message,
type: 'info',
timeout: 0,
closeBtn: true
})
break
case 'aes':
break
}
}
} }
}) })
}, 2000) }, 2000)
@ -475,8 +530,7 @@ new Vue({
checkPendingPayments: function () { checkPendingPayments: function () {
var dismissMsg = this.$q.notify({ var dismissMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Checking pending transactions...', message: 'Checking pending transactions...'
icon: null
}) })
this.fetchPayments(true).then(() => { this.fetchPayments(true).then(() => {

View File

@ -130,7 +130,7 @@
<q-td auto-width key="sat" :props="props"> <q-td auto-width key="sat" :props="props">
{{ props.row.fsat }} {{ props.row.fsat }}
</q-td> </q-td>
<q-td auto-width key="sat" :props="props"> <q-td auto-width key="fee" :props="props">
{{ props.row.fee }} {{ props.row.fee }}
</q-td> </q-td>
</q-tr> </q-tr>
@ -266,8 +266,8 @@
v-model.number="receive.data.amount" v-model.number="receive.data.amount"
type="number" type="number"
label="Amount (sat) *" label="Amount (sat) *"
min="receive.minMax[0]" :min="receive.minMax[0]"
max="receive.minMax[1]" :max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed" :readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
<q-input <q-input
@ -347,7 +347,8 @@
{% 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">
{{ parse.lnurlpay.maxSendable | msatoshiFormat }} <b>{{ parse.lnurlpay.domain }}</b> is requesting
{{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat
</p> </p>
<p v-else class="q-my-none text-h6 text-center"> <p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.domain }}</b> is requesting <br /> <b>{{ parse.lnurlpay.domain }}</b> is requesting <br />

View File

@ -3,11 +3,11 @@ import json
import lnurl import lnurl
import httpx import httpx
import traceback import traceback
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, request, make_response from quart import g, jsonify, request, make_response
from http import HTTPStatus from http import HTTPStatus
from binascii import unhexlify from binascii import unhexlify
from urllib.parse import urlparse from typing import Dict, Union
from typing import Dict
from lnbits import bolt11 from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
@ -51,6 +51,7 @@ async def api_payments():
"amount": {"type": "integer", "min": 1, "required": True}, "amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"}, "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"}, "description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
"lnurl_callback": {"type": "string", "empty": False, "required": False},
} }
) )
async def api_payments_create_invoice(): async def api_payments_create_invoice():
@ -70,6 +71,23 @@ async def api_payments_create_invoice():
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if "lnurl_callback" in g.data:
print(g.data["lnurl_callback"])
try:
r = httpx.get(g.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.RequestError:
lnurl_response = False
return ( return (
jsonify( jsonify(
{ {
@ -77,6 +95,7 @@ async def api_payments_create_invoice():
"payment_request": payment_request, "payment_request": payment_request,
# maintain backwards compatibility with API clients: # maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash, "checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
} }
), ),
HTTPStatus.CREATED, HTTPStatus.CREATED,
@ -117,6 +136,74 @@ async def api_payments_create():
return await api_payments_create_invoice() return await api_payments_create_invoice()
@core_app.route("/api/v1/payments/lnurl", methods=["POST"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"description_hash": {"type": "string", "empty": False, "required": True},
"callback": {"type": "string", "empty": False, "required": True},
"amount": {"type": "number", "empty": False, "required": True},
"description": {"type": "string", "empty": True, "required": False},
}
)
async def api_payments_pay_lnurl():
try:
r = httpx.get(g.data["callback"], params={"amount": g.data["amount"]}, timeout=20)
if r.is_error:
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
except httpx.RequestError:
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
params = json.loads(r.text)
if params.get("status") == "ERROR":
domain = urlparse(g.data["callback"]).netloc
return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != g.data["amount"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}."
}
),
HTTPStatus.BAD_REQUEST,
)
if invoice.description_hash != g.data["description_hash"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}."
}
),
HTTPStatus.BAD_REQUEST,
)
try:
payment_hash = pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra={"success_action": params.get("successAction")},
)
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
return (
jsonify(
{
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
)
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"]) @core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
async def api_payment(payment_hash): async def api_payment(payment_hash):
@ -216,10 +303,20 @@ async def api_lnurlscan(code: str):
params: Dict = data.dict() params: Dict = data.dict()
if type(data) is lnurl.LnurlWithdrawResponse: if type(data) is lnurl.LnurlWithdrawResponse:
params.update(kind="withdraw", fixed=data.min_withdrawable == data.max_withdrawable) params.update(kind="withdraw")
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
# callback with k1 already in it
url: ParseResult = urlparse(data.callback)
qs: Dict = parse_qs(url.query)
qs["k1"] = data.k1
url = url._replace(query=urlencode(qs, doseq=True))
params.update(callback=urlunparse(url))
if type(data) is lnurl.LnurlPayResponse: if type(data) is lnurl.LnurlPayResponse:
params.update(kind="pay", fixed=data.min_sendable == data.max_sendable) 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) params.update(description=data.metadata.text)
if data.metadata.images: if data.metadata.images:
image = min(data.metadata.images, key=lambda image: len(image[1])) image = min(data.metadata.images, key=lambda image: len(image[1]))

View File

@ -16,11 +16,12 @@ var LNbits = {
data: data data: data
}) })
}, },
createInvoice: function (wallet, amount, memo) { createInvoice: function (wallet, amount, memo, lnurlCallback = null) {
return this.request('post', '/api/v1/payments', wallet.inkey, { return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false, out: false,
amount: amount, amount: amount,
memo: memo memo: memo,
lnurl_callback: lnurlCallback
}) })
}, },
payInvoice: function (wallet, bolt11) { payInvoice: function (wallet, bolt11) {
@ -29,6 +30,20 @@ var LNbits = {
bolt11: bolt11 bolt11: bolt11
}) })
}, },
payLnurl: function (
wallet,
callback,
description_hash,
amount,
description = ''
) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback,
description_hash,
amount,
description
})
},
getWallet: function (wallet) { getWallet: function (wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey) return this.request('get', '/api/v1/wallet', wallet.inkey)
}, },