From 043d4fa6ae500bb620800e0e8e348fcba68b07c6 Mon Sep 17 00:00:00 2001 From: Nicolas Burtey Date: Mon, 12 Feb 2024 15:51:31 -0600 Subject: [PATCH] feat: POC for NFC for Pay --- apps/pay/app/page.tsx | 4 +- .../ParsePOSPayment/Receive-Invoice.tsx | 102 ++++++++++++- apps/pay/components/ParsePOSPayment/nfc.tsx | 143 ++++++++++++++++++ apps/pay/package.json | 1 + pnpm-lock.yaml | 54 ++++++- 5 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 apps/pay/components/ParsePOSPayment/nfc.tsx diff --git a/apps/pay/app/page.tsx b/apps/pay/app/page.tsx index c93726f14fb..7b8ce03ebd1 100644 --- a/apps/pay/app/page.tsx +++ b/apps/pay/app/page.tsx @@ -12,6 +12,7 @@ import { useRouter } from "next/navigation" import CurrencyDropdown from "../components/Currency/currency-dropdown" import { getClientSideGqlConfig } from "../config/config" +import NFCComponent from "../components/ParsePOSPayment/nfc" const GET_NODE_STATS = gql` query nodeIds { @@ -46,6 +47,7 @@ function Home() { return ( +
@@ -66,7 +68,7 @@ function Home() { > {error ? "Unavailable" - : loading + : loading ? "Loading..." : data.globals.nodesIds[0]}

diff --git a/apps/pay/components/ParsePOSPayment/Receive-Invoice.tsx b/apps/pay/components/ParsePOSPayment/Receive-Invoice.tsx index a1aad521461..1fce4432618 100644 --- a/apps/pay/components/ParsePOSPayment/Receive-Invoice.tsx +++ b/apps/pay/components/ParsePOSPayment/Receive-Invoice.tsx @@ -1,8 +1,10 @@ /* eslint-disable react-hooks/exhaustive-deps */ // TODO: remove eslint-disable, the logic likely needs to be reworked import copy from "copy-to-clipboard" +import { getParams } from "js-lnurl" + import { useParams, useSearchParams } from "next/navigation" -import React, { useCallback } from "react" +import React, { useCallback, useState } from "react" import Image from "react-bootstrap/Image" import OverlayTrigger from "react-bootstrap/OverlayTrigger" import Tooltip from "react-bootstrap/Tooltip" @@ -22,6 +24,7 @@ import { extractSearchParams, safeAmount } from "../../utils/utils" import LoadingComponent from "../Loading" import styles from "./parse-payment.module.css" +import NFCComponent from "./nfc" interface Props { recipientWalletCurrency?: string @@ -214,6 +217,99 @@ function ReceiveInvoice({ recipientWalletCurrency, walletId, state, dispatch }: } } + const [nfcMessage, setNfcMessage] = useState("") + + const decodeNDEFRecord = (record) => { + // Ensure that the record's data is an instance of ArrayBuffer + if (record.data instanceof ArrayBuffer) { + const decoder = new TextDecoder(record.encoding || "utf-8") + return decoder.decode(record.data) + } else { + // If it's not an ArrayBuffer, it might be a DataView or another typed array. + // In that case, we can create a new Uint8Array from the buffer of the DataView. + const decoder = new TextDecoder(record.encoding || "utf-8") + return decoder.decode(new Uint8Array(record.data.buffer)) + } + } + + const handleNFCScan = () => { + if ("NDEFReader" in window) { + const ndef = new NDEFReader() + ndef + .scan() + .then(() => { + console.log("NFC scan started successfully.") + + ndef.onreading = (event) => { + console.log("NFC tag read.") + const record = event.message.records[0] + const text = decodeNDEFRecord(record) + + if (text.toLowerCase().includes("lnurl")) { + setNfcMessage(text) + // Handle your "lnurl" logic here... + } + } + + ndef.onreadingerror = () => { + console.log("Cannot read data from the NFC tag. Try another one?") + } + }) + .catch((error) => { + console.log(`Error! Scan failed to start: ${error}.`) + }) + } else { + console.log("NFC is not supported") + } + } + + React.useEffect(() => { + console.log("nfcMessage", nfcMessage) + + setNfcMessage( + "lnurlw://boltcard.tiankii.app/v1/lnurl/b1pizbxx0ikdivim5tpt9csy9kezxi?p=960C0DDCE939D1295C301D6B1A65BE78&c=CDFC90874BCE5AF2", + ) + }, []) + + React.useEffect(() => { + ;(async () => { + if (nfcMessage) { + const lnurlParams = await getParams(nfcMessage) + + console.log("lnurlParams", lnurlParams) + + if (!("tag" in lnurlParams && lnurlParams.tag === "withdrawRequest")) { + console.error("not a lnurl withdraw tag") + return + } + + if (!invoice?.paymentRequest) { + console.error("no invoice to redeem") + return + } + + const { callback, k1 } = lnurlParams + + const urlObject = new URL(callback) + const searchParams = urlObject.searchParams + searchParams.set("k1", k1) + searchParams.set("pr", invoice?.paymentRequest) + + const url = urlObject.toString() + + const result = await fetch(url) + if (result.ok) { + const lnurlResponse = await result.json() + if (lnurlResponse?.status?.toLowerCase() !== "ok") { + console.error(lnurlResponse, "error with redeeming") + } + } else { + console.error(result.text(), "error with submitting withdrawalRequest") + } + } + })() + }, [nfcMessage, invoice]) + const copyInvoice = () => { if (!invoice?.paymentRequest) { return @@ -242,6 +338,8 @@ function ReceiveInvoice({ recipientWalletCurrency, walletId, state, dispatch }: return } + console.log("invoice", invoice) + return (
{recipientWalletCurrency === "USD" && ( @@ -254,6 +352,8 @@ function ReceiveInvoice({ recipientWalletCurrency, walletId, state, dispatch }:
)}
+ + {data ? ( <>
{ + // Ensure that the record's data is an instance of ArrayBuffer + if (record.data instanceof ArrayBuffer) { + const decoder = new TextDecoder(record.encoding || "utf-8") + return decoder.decode(record.data) + } else { + // If it's not an ArrayBuffer, it might be a DataView or another typed array. + // In that case, we can create a new Uint8Array from the buffer of the DataView. + const decoder = new TextDecoder(record.encoding || "utf-8") + return decoder.decode(new Uint8Array(record.data.buffer)) + } + } + + const handleNFCScan = () => { + if (!("NDEFReader" in window)) { + console.error("NFC is not supported") + return + } + + console.log("NFC is supported, start reading") + + const ndef = new NDEFReader() + ndef + .scan() + .then(() => { + console.log("NFC scan started successfully.") + + ndef.onreading = (event) => { + console.log("NFC tag read.") + const record = event.message.records[0] + const text = decodeNDEFRecord(record) + + if (text.toLowerCase().includes("lnurl")) { + setNfcMessage(text) + // Handle your "lnurl" logic here... + } + } + + ndef.onreadingerror = () => { + console.log("Cannot read data from the NFC tag. Try another one?") + } + }) + .catch((error) => { + console.log(`Error! Scan failed to start: ${error}.`) + }) + } + + useEffect(() => { + ;(async () => { + if (!("permissions" in navigator)) { + console.error("Permissions API not supported") + return + } + + const result = await navigator.permissions.query({ name: "nfc" }) + + console.log("result permission query", result) + + if (result.state === "granted") { + setHasNFCPermission(true) + } else { + setHasNFCPermission(false) + } + + result.onchange = () => { + if (result.state === "granted") { + setHasNFCPermission(true) + } else { + setHasNFCPermission(false) + } + } + })() + }, [setHasNFCPermission]) + + React.useEffect(() => { + console.log("hasNFCPermission", hasNFCPermission) + + if (hasNFCPermission) { + handleNFCScan() + } + }, [hasNFCPermission]) + + React.useEffect(() => { + ;(async () => { + if (!nfcMessage) { + console.error("no nfc message") + return + } + + if (!paymentRequest) { + console.error("no invoice to redeem") + return + } + + const lnurlParams = await getParams(nfcMessage) + + if (!("tag" in lnurlParams && lnurlParams.tag === "withdrawRequest")) { + console.error("not a lnurl withdraw tag") + return + } + + const { callback, k1 } = lnurlParams + + const urlObject = new URL(callback) + const searchParams = urlObject.searchParams + searchParams.set("k1", k1) + searchParams.set("pr", paymentRequest) + + const url = urlObject.toString() + + const result = await fetch(url) + if (result.ok) { + const lnurlResponse = await result.json() + if (lnurlResponse?.status?.toLowerCase() !== "ok") { + console.error(lnurlResponse, "error with redeeming") + } + } else { + console.error(result.text(), "error with submitting withdrawalRequest") + } + })() + }, [nfcMessage, paymentRequest]) + + return ( +
+ {!hasNFCPermission && } + {nfcMessage &&
LNURL: {nfcMessage}
} +
+ ) +} + +export default NFCComponent diff --git a/apps/pay/package.json b/apps/pay/package.json index b3db2a80131..28a327aa2cd 100644 --- a/apps/pay/package.json +++ b/apps/pay/package.json @@ -25,6 +25,7 @@ "graphql-ws": "^5.14.0", "html2canvas": "^1.4.1", "ioredis": "^5.3.1", + "js-lnurl": "^0.6.0", "lnurl-pay": "^1.0.1", "lodash.debounce": "^4.0.8", "next": "^14.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fcf29ba616..f6dd89da91e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -607,6 +607,9 @@ importers: ioredis: specifier: ^5.3.1 version: 5.3.2 + js-lnurl: + specifier: ^0.6.0 + version: 0.6.0 lnurl-pay: specifier: ^1.0.1 version: 1.0.1 @@ -13773,6 +13776,10 @@ packages: engines: {node: '>=10.13.0'} dev: false + /@types/aes-js@3.1.4: + resolution: {integrity: sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA==} + dev: false + /@types/aria-query@5.0.4: resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} dev: true @@ -13810,6 +13817,10 @@ packages: '@babel/types': 7.23.6 dev: true + /@types/base64-js@1.3.2: + resolution: {integrity: sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg==} + dev: false + /@types/basic-auth@1.1.7: resolution: {integrity: sha512-bFR3Ld3Fty5ayg45sqr3RI4e/GTXyp2W8jzMmw3WOC8RuQ19TrpsZE4y3jcw9iGSZj5f9mH6e+2biPeFUDovww==} dependencies: @@ -17797,7 +17808,6 @@ packages: node-fetch: 2.7.0 transitivePeerDependencies: - encoding - dev: true /cross-inspect@1.0.0: resolution: {integrity: sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ==} @@ -18114,7 +18124,6 @@ packages: /decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} - dev: true /decompress-response@3.3.0: resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==} @@ -20754,6 +20763,11 @@ packages: dependencies: to-regex-range: 5.0.1 + /filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + dev: false + /finalhandler@1.1.2: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} @@ -23913,6 +23927,22 @@ packages: nopt: 6.0.0 dev: true + /js-lnurl@0.6.0: + resolution: {integrity: sha512-U4hnInqlHVM9DyYMnOLk0IqlD293A7GVen8JBNWbXrq7C1IigQpTfoal+Fgz/eTZOsYtIEFEOyW9mKgFD/Oc0w==} + dependencies: + '@types/aes-js': 3.1.4 + '@types/base64-js': 1.3.2 + aes-js: 3.1.2 + base64-js: 1.5.1 + bech32: 1.1.4 + buffer: 5.7.1 + cross-fetch: 3.1.8 + query-string: 6.14.1 + safe-buffer: 5.2.1 + transitivePeerDependencies: + - encoding + dev: false + /js-md5@0.7.3: resolution: {integrity: sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==} dev: false @@ -27384,6 +27414,16 @@ packages: strict-uri-encode: 1.1.0 dev: true + /query-string@6.14.1: + resolution: {integrity: sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==} + engines: {node: '>=6'} + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + dev: false + /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -28852,6 +28892,11 @@ packages: - utf-8-validate dev: true + /split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + dev: false + /split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -29017,6 +29062,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + dev: false + /string-env-interpolation@1.0.1: resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==} dev: true