mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-25 07:07:48 +01:00
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):
|
||||
db: str
|
||||
version: int
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl_w: str
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"})
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue