From 581f98b3a37b1f1ce7b50cad22db9f530585c2ea Mon Sep 17 00:00:00 2001 From: Osvaldo Rosales Date: Tue, 29 Oct 2024 17:50:48 -0500 Subject: [PATCH] Add NFC Payment Support and Display Receive Amount in Receive Dialog (#2747) * feat: add readNfcTag to core wallet * feat: added payments/ endpoint to pay invoice with lnurlw from nfc tag * feat: add notifications to nfc read and payment process * feat: display sat and fiat amount on receive invoice * feat: add notifications for non-lnurl nfc tags * removed unnecesary payment updates * fix: case when lnurlw was already used. lnurl_req status error * fix: lnurl response status error * fix: abort nfc reading on receive dialog hid * feat: dismiss tap suggestion when nfc tag read successfully * update: NFC supported chip * remove console.log * add: function return type * test: happy path for api_payment_pay_with_nfc * feat: follow LUD-17, no support for lightning: url schema * explicit lnurl withdraw for payment * test: add parametrized tests for all cases of api_payment_pay_with_nfc endpoint * fix: payment.amount in response comes already in milisats --- lnbits/core/models.py | 4 + lnbits/core/templates/core/wallet.html | 23 +++- lnbits/core/views/payment_api.py | 57 +++++++++ lnbits/static/js/wallet.js | 122 +++++++++++++++++- tests/api/test_api.py | 167 +++++++++++++++++++++++++ 5 files changed, 371 insertions(+), 2 deletions(-) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 79465c4e4..3d97e04dd 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -484,3 +484,7 @@ class SimpleStatus(BaseModel): class DbVersion(BaseModel): db: str version: int + + +class PayLnurlWData(BaseModel): + lnurl_w: str diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 282064dcf..d1c73797d 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -281,7 +281,11 @@ - + +
+

+ +

+
+ +
+ + + NFC supported + + NFC not supported +
JSONResponse: {"message": f"Failed to decode: {exc!s}"}, status_code=HTTPStatus.BAD_REQUEST, ) + + +@payment_router.post("/{payment_request}/pay-with-nfc", status_code=HTTPStatus.OK) +async def api_payment_pay_with_nfc( + payment_request: str, + lnurl_data: PayLnurlWData, +) -> JSONResponse: + + lnurl = lnurl_data.lnurl_w.lower() + + # Follow LUD-17 -> https://github.com/lnurl/luds/blob/luds/17.md + url = lnurl.replace("lnurlw://", "https://") + + headers = {"User-Agent": settings.user_agent} + async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: + try: + lnurl_req = await client.get(url, timeout=10) + if lnurl_req.is_error: + return JSONResponse( + {"success": False, "detail": "Error loading LNURL request"} + ) + + lnurl_res = lnurl_req.json() + + if lnurl_res.get("status") == "ERROR": + return JSONResponse({"success": False, "detail": lnurl_res["reason"]}) + + if lnurl_res.get("tag") != "withdrawRequest": + return JSONResponse( + {"success": False, "detail": "Invalid LNURL-withdraw"} + ) + + callback_url = lnurl_res["callback"] + k1 = lnurl_res["k1"] + + callback_req = await client.get( + callback_url, + params={"k1": k1, "pr": payment_request}, + timeout=10, + ) + if callback_req.is_error: + return JSONResponse( + {"success": False, "detail": "Error loading callback request"} + ) + + callback_res = callback_req.json() + + if callback_res.get("status") == "ERROR": + return JSONResponse( + {"success": False, "detail": callback_res["reason"]} + ) + else: + return JSONResponse({"success": True, "detail": callback_res}) + + except Exception as e: + return JSONResponse({"success": False, "detail": f"Unexpected error: {e}"}) diff --git a/lnbits/static/js/wallet.js b/lnbits/static/js/wallet.js index 0a58bf206..d4e737d0d 100644 --- a/lnbits/static/js/wallet.js +++ b/lnbits/static/js/wallet.js @@ -14,6 +14,7 @@ window.app = Vue.createApp({ status: 'pending', paymentReq: null, paymentHash: null, + amountMsat: null, minMax: [0, 2100000000000000], lnurl: null, units: ['sat'], @@ -56,7 +57,9 @@ window.app = Vue.createApp({ currency: null }, inkeyHidden: true, - adminkeyHidden: true + adminkeyHidden: true, + hasNfc: false, + nfcReaderAbortController: null } }, computed: { @@ -78,6 +81,19 @@ window.app = Vue.createApp({ canPay: function () { if (!this.parse.invoice) return false return this.parse.invoice.sat <= this.balance + }, + formattedAmount: function () { + if (this.receive.unit != 'sat') { + return LNbits.utils.formatCurrency( + Number(this.receive.data.amount).toFixed(2), + this.receive.unit + ) + } else { + return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat' + } + }, + formattedSatAmount: function () { + return LNbits.utils.formatMsat(this.receive.amountMsat) + ' sat' } }, methods: { @@ -105,6 +121,11 @@ window.app = Vue.createApp({ this.receive.lnurl = null this.focusInput('setAmount') }, + onReceiveDialogHide: function () { + if (this.hasNfc) { + this.nfcReaderAbortController.abort() + } + }, showParseDialog: function () { this.parse.show = true this.parse.invoice = null @@ -146,8 +167,11 @@ window.app = Vue.createApp({ .then(response => { this.receive.status = 'success' this.receive.paymentReq = response.data.bolt11 + this.receive.amountMsat = response.data.amount this.receive.paymentHash = response.data.payment_hash + this.readNfcTag() + // TODO: lnurl_callback and lnurl_response // WITHDRAW if (response.data.lnurl_response !== null) { @@ -547,6 +571,102 @@ window.app = Vue.createApp({ navigator.clipboard.readText().then(text => { this.parse.data.request = text.trim() }) + }, + readNfcTag: function () { + try { + if (typeof NDEFReader == 'undefined') { + console.debug('NFC not supported on this device or browser.') + return + } + + const ndef = new NDEFReader() + + this.nfcReaderAbortController = new AbortController() + this.nfcReaderAbortController.signal.onabort = event => { + console.debug('All NFC Read operations have been aborted.') + } + + this.hasNfc = true + let dismissNfcTapMsg = Quasar.Notify.create({ + message: 'Tap your NFC tag to pay this invoice with LNURLw.' + }) + + return ndef + .scan({signal: this.nfcReaderAbortController.signal}) + .then(() => { + ndef.onreadingerror = () => { + Quasar.Notify.create({ + type: 'negative', + message: 'There was an error reading this NFC tag.' + }) + } + + ndef.onreading = ({message}) => { + //Decode NDEF data from tag + const textDecoder = new TextDecoder('utf-8') + + const record = message.records.find(el => { + const payload = textDecoder.decode(el.data) + return payload.toUpperCase().indexOf('LNURLW') !== -1 + }) + + if (record) { + dismissNfcTapMsg() + Quasar.Notify.create({ + type: 'positive', + message: 'NFC tag read successfully.' + }) + const lnurl = textDecoder.decode(record.data) + this.payInvoiceWithNfc(lnurl) + } else { + Quasar.Notify.create({ + type: 'warning', + message: 'NFC tag does not have LNURLw record.' + }) + } + } + }) + } catch (error) { + Quasar.Notify.create({ + type: 'negative', + message: error + ? error.toString() + : 'An unexpected error has occurred.' + }) + } + }, + payInvoiceWithNfc: function (lnurl) { + let dismissPaymentMsg = Quasar.Notify.create({ + timeout: 0, + spinner: true, + message: this.$t('processing_payment') + }) + + LNbits.api + .request( + 'POST', + `/api/v1/payments/${this.receive.paymentReq}/pay-with-nfc`, + this.g.wallet.adminkey, + {lnurl_w: lnurl} + ) + .then(response => { + dismissPaymentMsg() + if (response.data.success) { + Quasar.Notify.create({ + type: 'positive', + message: 'Payment successful' + }) + } else { + Quasar.Notify.create({ + type: 'negative', + message: response.data.detail || 'Payment failed' + }) + } + }) + .catch(err => { + dismissPaymentMsg() + LNbits.utils.notifyApiError(err) + }) } }, created: function () { diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 385eccb85..88045a0ff 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -1,6 +1,9 @@ import hashlib +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock import pytest +from pytest_mock.plugin import MockerFixture from lnbits import bolt11 from lnbits.core.models import CreateInvoice, Payment @@ -517,3 +520,167 @@ async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings): assert extra["wallet_fiat_currency"] == "EUR" assert extra["wallet_fiat_amount"] != payment["amount"] assert extra["wallet_fiat_rate"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "lnurl_response_data, callback_response_data, expected_response", + [ + # Happy path + ( + { + "tag": "withdrawRequest", + "callback": "https://example.com/callback", + "k1": "randomk1value", + }, + { + "status": "OK", + }, + { + "success": True, + "detail": {"status": "OK"}, + }, + ), + # Error loading LNURL request + ( + "error_loading_lnurl", + None, + { + "success": False, + "detail": "Error loading LNURL request", + }, + ), + # LNURL response with error status + ( + { + "status": "ERROR", + "reason": "LNURL request failed", + }, + None, + { + "success": False, + "detail": "LNURL request failed", + }, + ), + # Invalid LNURL-withdraw + ( + { + "tag": "payRequest", + "callback": "https://example.com/callback", + "k1": "randomk1value", + }, + None, + { + "success": False, + "detail": "Invalid LNURL-withdraw", + }, + ), + # Error loading callback request + ( + { + "tag": "withdrawRequest", + "callback": "https://example.com/callback", + "k1": "randomk1value", + }, + "error_loading_callback", + { + "success": False, + "detail": "Error loading callback request", + }, + ), + # Callback response with error status + ( + { + "tag": "withdrawRequest", + "callback": "https://example.com/callback", + "k1": "randomk1value", + }, + { + "status": "ERROR", + "reason": "Callback failed", + }, + { + "success": False, + "detail": "Callback failed", + }, + ), + # Unexpected exception during LNURL response JSON parsing + ( + "exception_in_lnurl_response_json", + None, + { + "success": False, + "detail": "Unexpected error: Simulated exception", + }, + ), + ], +) +async def test_api_payment_pay_with_nfc( + client, + mocker: MockerFixture, + lnurl_response_data, + callback_response_data, + expected_response, +): + payment_request = "lnbc1..." + lnurl = "lnurlw://example.com/lnurl" + lnurl_data = {"lnurl_w": lnurl} + + # Create a mock for httpx.AsyncClient + mock_async_client = AsyncMock() + mock_async_client.__aenter__.return_value = mock_async_client + + # Mock the get method + async def mock_get(url, *args, **kwargs): + if url == "https://example.com/lnurl": + if lnurl_response_data == "error_loading_lnurl": + response = Mock() + response.is_error = True + return response + elif lnurl_response_data == "exception_in_lnurl_response_json": + response = Mock() + response.is_error = False + response.json.side_effect = Exception("Simulated exception") + return response + elif isinstance(lnurl_response_data, dict): + response = Mock() + response.is_error = False + response.json.return_value = lnurl_response_data + return response + else: + # Handle unexpected data + response = Mock() + response.is_error = True + return response + elif url == "https://example.com/callback": + if callback_response_data == "error_loading_callback": + response = Mock() + response.is_error = True + return response + elif isinstance(callback_response_data, dict): + response = Mock() + response.is_error = False + response.json.return_value = callback_response_data + return response + else: + # Handle cases where callback is not called + response = Mock() + response.is_error = True + return response + else: + response = Mock() + response.is_error = True + return response + + mock_async_client.get.side_effect = mock_get + + # Mock httpx.AsyncClient to return our mock_async_client + mocker.patch("httpx.AsyncClient", return_value=mock_async_client) + + response = await client.post( + f"/api/v1/payments/{payment_request}/pay-with-nfc", + json=lnurl_data, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == expected_response