From bc2207ba278ef6597b9361965e74847b69def1a7 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 12 Oct 2020 18:15:27 -0300 Subject: [PATCH] actually paying and withdrawing with lnurl. --- lnbits/core/services.py | 9 ++- lnbits/core/static/js/wallet.js | 100 +++++++++++++++++------ lnbits/core/templates/core/wallet.html | 9 ++- lnbits/core/views/api.py | 105 ++++++++++++++++++++++++- lnbits/static/js/base.js | 19 ++++- 5 files changed, 207 insertions(+), 35 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 8bdb73ac..d4d4d8c0 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -51,7 +51,12 @@ def create_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: temp_id = f"temp_{urlsafe_short_hash()}" internal_id = f"internal_{urlsafe_short_hash()}" @@ -79,7 +84,7 @@ def pay_invoice( payment_request=payment_request, payment_hash=invoice.payment_hash, amount=-invoice.amount_msat, - memo=invoice.description or "", + memo=description or invoice.description or "", extra=extra, ) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index b39a25da..72f54d49 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -256,16 +256,36 @@ new Vue({ .createInvoice( this.g.wallet, this.receive.data.amount, - this.receive.data.memo + this.receive.data.memo, + this.receive.lnurl && this.receive.lnurl.callback ) .then(response => { this.receive.status = 'success' this.receive.paymentReq = response.data.payment_request - if (this.receive.lnurl) { - // send invoice to lnurl callback - console.log('sending', this.receive.lnurl) - LNbits.api.sendInvoiceToLnurlWithdraw(this.receive.paymentReq) + 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: '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(() => { @@ -274,6 +294,7 @@ new Vue({ .then(response => { if (response.data.paid) { this.fetchPayments() + this.fetchBalance() this.receive.show = false clearInterval(this.receive.paymentChecker) } @@ -314,12 +335,11 @@ new Vue({ let data = response.data if (data.status === 'ERROR') { - Quasar.plugins.Notify.create({ + this.$q.notify({ timeout: 5000, type: 'warning', - message: data.reason, - caption: `${data.domain} returned an error to the lnurl call.`, - icon: null + message: `${data.domain} lnurl call failed.`, + caption: data.reason }) return } @@ -331,13 +351,16 @@ new Vue({ this.parse.show = false this.receive.show = true 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.minMax = [data.minWithdrawable, data.maxWithdrawable] + this.receive.minMax = [ + data.minWithdrawable / 1000, + data.maxWithdrawable / 1000 + ] this.receive.lnurl = { domain: data.domain, callback: data.callback, - k1: data.k1, fixed: data.fixed } } @@ -353,8 +376,7 @@ new Vue({ timeout: 3000, type: 'warning', message: error + '.', - caption: '400 BAD REQUEST', - icon: null + caption: '400 BAD REQUEST' }) this.parse.show = false return @@ -390,8 +412,7 @@ new Vue({ payInvoice: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, - message: 'Processing payment...', - icon: null + message: 'Processing payment...' }) LNbits.api @@ -406,6 +427,7 @@ new Vue({ clearInterval(this.parse.paymentChecker) dismissPaymentMsg() this.fetchPayments() + this.fetchBalance() } }) }, 2000) @@ -418,22 +440,55 @@ new Vue({ payLnurl: function () { let dismissPaymentMsg = this.$q.notify({ timeout: 0, - message: 'Processing payment...', - icon: null + message: 'Processing payment...' }) 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 => { + this.parse.show = false + this.parse.paymentChecker = setInterval(() => { LNbits.api .getPayment(this.g.wallet, response.data.payment_hash) .then(res => { if (res.data.paid) { - this.parse.show = false - clearInterval(this.parse.paymentChecker) dismissPaymentMsg() + clearInterval(this.parse.paymentChecker) 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: `${response.data.success_action.url}`, + 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) @@ -475,8 +530,7 @@ new Vue({ checkPendingPayments: function () { var dismissMsg = this.$q.notify({ timeout: 0, - message: 'Checking pending transactions...', - icon: null + message: 'Checking pending transactions...' }) this.fetchPayments(true).then(() => { diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 405f970b..4edf134d 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -130,7 +130,7 @@ {{ props.row.fsat }} - + {{ props.row.fee }} @@ -266,8 +266,8 @@ v-model.number="receive.data.amount" type="number" label="Amount (sat) *" - min="receive.minMax[0]" - max="receive.minMax[1]" + :min="receive.minMax[0]" + :max="receive.minMax[1]" :readonly="receive.lnurl && receive.lnurl.fixed" >

- {{ parse.lnurlpay.maxSendable | msatoshiFormat }} + {{ parse.lnurlpay.domain }} is requesting + {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat

{{ parse.lnurlpay.domain }} is requesting
diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 5be279e1..7ff1e88e 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -3,11 +3,11 @@ import json import lnurl import httpx import traceback +from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult from quart import g, jsonify, request, make_response from http import HTTPStatus from binascii import unhexlify -from urllib.parse import urlparse -from typing import Dict +from typing import Dict, Union from lnbits import bolt11 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}, "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"}, "description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"}, + "lnurl_callback": {"type": "string", "empty": False, "required": False}, } ) 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 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 ( jsonify( { @@ -77,6 +95,7 @@ async def api_payments_create_invoice(): "payment_request": payment_request, # maintain backwards compatibility with API clients: "checking_id": invoice.payment_hash, + "lnurl_response": lnurl_response, } ), HTTPStatus.CREATED, @@ -117,6 +136,74 @@ async def api_payments_create(): 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/", methods=["GET"]) @api_check_wallet_key("invoice") async def api_payment(payment_hash): @@ -216,10 +303,20 @@ async def api_lnurlscan(code: str): params: Dict = data.dict() 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: - 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) if data.metadata.images: image = min(data.metadata.images, key=lambda image: len(image[1])) diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js index abaec60c..9f1bb283 100644 --- a/lnbits/static/js/base.js +++ b/lnbits/static/js/base.js @@ -16,11 +16,12 @@ var LNbits = { data: data }) }, - createInvoice: function (wallet, amount, memo) { + createInvoice: function (wallet, amount, memo, lnurlCallback = null) { return this.request('post', '/api/v1/payments', wallet.inkey, { out: false, amount: amount, - memo: memo + memo: memo, + lnurl_callback: lnurlCallback }) }, payInvoice: function (wallet, bolt11) { @@ -29,6 +30,20 @@ var LNbits = { 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) { return this.request('get', '/api/v1/wallet', wallet.inkey) },