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
This commit is contained in:
Osvaldo Rosales 2024-10-29 17:50:48 -05:00 committed by GitHub
parent 89a75ab641
commit 581f98b3a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 371 additions and 2 deletions

View file

@ -484,3 +484,7 @@ class SimpleStatus(BaseModel):
class DbVersion(BaseModel):
db: str
version: int
class PayLnurlWData(BaseModel):
lnurl_w: str

View file

@ -281,7 +281,11 @@
</div>
</div>
<q-dialog v-model="receive.show" position="top">
<q-dialog
v-model="receive.show"
position="top"
@hide="onReceiveDialogHide"
>
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
@ -371,6 +375,23 @@
></lnbits-qrcode>
</a>
</div>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>
</h3>
<h5 v-if="receive.unit != 'sat'" class="q-mt-none q-mb-sm">
<span v-text="formattedSatAmount"></span>
</h5>
<q-chip v-if="hasNfc" outline square color="positive">
<q-avatar
icon="nfc"
color="positive"
text-color="white"
></q-avatar>
NFC supported
</q-chip>
<span v-else class="text-caption text-grey">NFC not supported</span>
</div>
<div class="row q-mt-lg">
<q-btn
outline

View file

@ -25,6 +25,7 @@ from lnbits.core.models import (
CreateLnurl,
DecodePayment,
KeyType,
PayLnurlWData,
Payment,
PaymentFilters,
PaymentHistoryPoint,
@ -406,3 +407,59 @@ async def api_payments_decode(data: DecodePayment) -> 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}"})

View file

@ -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 () {

View file

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