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:
parent
89a75ab641
commit
581f98b3a3
5 changed files with 371 additions and 2 deletions
|
|
@ -484,3 +484,7 @@ class SimpleStatus(BaseModel):
|
||||||
class DbVersion(BaseModel):
|
class DbVersion(BaseModel):
|
||||||
db: str
|
db: str
|
||||||
version: int
|
version: int
|
||||||
|
|
||||||
|
|
||||||
|
class PayLnurlWData(BaseModel):
|
||||||
|
lnurl_w: str
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-dialog v-model="receive.show" position="top">
|
<q-dialog
|
||||||
|
v-model="receive.show"
|
||||||
|
position="top"
|
||||||
|
@hide="onReceiveDialogHide"
|
||||||
|
>
|
||||||
<q-card
|
<q-card
|
||||||
v-if="!receive.paymentReq"
|
v-if="!receive.paymentReq"
|
||||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||||
|
|
@ -371,6 +375,23 @@
|
||||||
></lnbits-qrcode>
|
></lnbits-qrcode>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
outline
|
outline
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from lnbits.core.models import (
|
||||||
CreateLnurl,
|
CreateLnurl,
|
||||||
DecodePayment,
|
DecodePayment,
|
||||||
KeyType,
|
KeyType,
|
||||||
|
PayLnurlWData,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentFilters,
|
PaymentFilters,
|
||||||
PaymentHistoryPoint,
|
PaymentHistoryPoint,
|
||||||
|
|
@ -406,3 +407,59 @@ async def api_payments_decode(data: DecodePayment) -> JSONResponse:
|
||||||
{"message": f"Failed to decode: {exc!s}"},
|
{"message": f"Failed to decode: {exc!s}"},
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
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}"})
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ window.app = Vue.createApp({
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
paymentReq: null,
|
paymentReq: null,
|
||||||
paymentHash: null,
|
paymentHash: null,
|
||||||
|
amountMsat: null,
|
||||||
minMax: [0, 2100000000000000],
|
minMax: [0, 2100000000000000],
|
||||||
lnurl: null,
|
lnurl: null,
|
||||||
units: ['sat'],
|
units: ['sat'],
|
||||||
|
|
@ -56,7 +57,9 @@ window.app = Vue.createApp({
|
||||||
currency: null
|
currency: null
|
||||||
},
|
},
|
||||||
inkeyHidden: true,
|
inkeyHidden: true,
|
||||||
adminkeyHidden: true
|
adminkeyHidden: true,
|
||||||
|
hasNfc: false,
|
||||||
|
nfcReaderAbortController: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -78,6 +81,19 @@ window.app = Vue.createApp({
|
||||||
canPay: function () {
|
canPay: function () {
|
||||||
if (!this.parse.invoice) return false
|
if (!this.parse.invoice) return false
|
||||||
return this.parse.invoice.sat <= this.balance
|
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: {
|
methods: {
|
||||||
|
|
@ -105,6 +121,11 @@ window.app = Vue.createApp({
|
||||||
this.receive.lnurl = null
|
this.receive.lnurl = null
|
||||||
this.focusInput('setAmount')
|
this.focusInput('setAmount')
|
||||||
},
|
},
|
||||||
|
onReceiveDialogHide: function () {
|
||||||
|
if (this.hasNfc) {
|
||||||
|
this.nfcReaderAbortController.abort()
|
||||||
|
}
|
||||||
|
},
|
||||||
showParseDialog: function () {
|
showParseDialog: function () {
|
||||||
this.parse.show = true
|
this.parse.show = true
|
||||||
this.parse.invoice = null
|
this.parse.invoice = null
|
||||||
|
|
@ -146,8 +167,11 @@ window.app = Vue.createApp({
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.receive.status = 'success'
|
this.receive.status = 'success'
|
||||||
this.receive.paymentReq = response.data.bolt11
|
this.receive.paymentReq = response.data.bolt11
|
||||||
|
this.receive.amountMsat = response.data.amount
|
||||||
this.receive.paymentHash = response.data.payment_hash
|
this.receive.paymentHash = response.data.payment_hash
|
||||||
|
|
||||||
|
this.readNfcTag()
|
||||||
|
|
||||||
// TODO: lnurl_callback and lnurl_response
|
// TODO: lnurl_callback and lnurl_response
|
||||||
// WITHDRAW
|
// WITHDRAW
|
||||||
if (response.data.lnurl_response !== null) {
|
if (response.data.lnurl_response !== null) {
|
||||||
|
|
@ -547,6 +571,102 @@ window.app = Vue.createApp({
|
||||||
navigator.clipboard.readText().then(text => {
|
navigator.clipboard.readText().then(text => {
|
||||||
this.parse.data.request = text.trim()
|
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 () {
|
created: function () {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from http import HTTPStatus
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pytest_mock.plugin import MockerFixture
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.core.models import CreateInvoice, Payment
|
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_currency"] == "EUR"
|
||||||
assert extra["wallet_fiat_amount"] != payment["amount"]
|
assert extra["wallet_fiat_amount"] != payment["amount"]
|
||||||
assert extra["wallet_fiat_rate"]
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue