From 8968d41ca98f206dcc45857b7865e489e4f72b08 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 21 Jul 2026 13:14:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(wallet):=20QR=20scan=20modal=20=E2=80=94?= =?UTF-8?q?=20camera=20+=20animated=20QR,=20wired=20into=20Home/Send/Recei?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New WalletScanModal scans QR codes with the device camera (BarcodeDetector with jsQR fallback) including animated/multi-frame QRs via qrloop, then routes what it saw: BOLT11 invoices -> lightning pay (amount locked from the invoice when present), bitcoin:/BIP21 URIs -> on-chain send, Cashu tokens -> redeem, Fedimint invites -> join. Entry points: camera button on the wallet card (far right), and a Scan button centered between the Close/Send and Close/Receive buttons of both modals. Co-Authored-By: Claude Fable 5 --- neode-ui/package-lock.json | 99 ++- neode-ui/package.json | 3 + .../src/components/ReceiveBitcoinModal.vue | 8 +- neode-ui/src/components/SendBitcoinModal.vue | 8 +- neode-ui/src/components/WalletScanModal.vue | 683 ++++++++++++++++++ .../src/composables/useAnimatedQRDecoder.ts | 60 ++ neode-ui/src/types/qrloop.d.ts | 9 + neode-ui/src/views/Home.vue | 9 +- neode-ui/src/views/home/HomeWalletCard.vue | 19 +- 9 files changed, 882 insertions(+), 16 deletions(-) create mode 100644 neode-ui/src/components/WalletScanModal.vue create mode 100644 neode-ui/src/composables/useAnimatedQRDecoder.ts create mode 100644 neode-ui/src/types/qrloop.d.ts diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 0811baba..4a838d91 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -10,13 +10,16 @@ "dependencies": { "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", + "buffer": "^6.0.3", "d3": "^7.9.0", "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fuse.js": "^7.1.0", "leaflet": "^1.9.4", "pinia": "^3.0.4", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", + "qrloop": "^1.4.1", "vue": "^3.5.24", "vue-i18n": "^11.3.0", "vue-router": "^4.6.3" @@ -3896,6 +3899,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/qrcode": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", @@ -4727,7 +4736,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4801,6 +4809,31 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -4901,10 +4934,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -4922,7 +4954,7 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-from": { @@ -5099,6 +5131,15 @@ "node": ">=8" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -5499,6 +5540,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -7408,7 +7458,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -7564,6 +7613,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -8358,6 +8413,17 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -9193,6 +9259,15 @@ "node": ">=6" } }, + "node_modules/qr-scanner": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz", + "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==", + "license": "MIT", + "dependencies": { + "@types/offscreencanvas": "^2019.6.4" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -9317,6 +9392,16 @@ "node": ">=6" } }, + "node_modules/qrloop": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/qrloop/-/qrloop-1.4.1.tgz", + "integrity": "sha512-LXkwCl1Qd8imTHb+KqjMn+cHmuncyFT81AXoooWJvbG3+g9q61l9udSRPgY4cgl+5goQHuAK4teEdUF6UErYXw==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "md5": "^2.3.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", diff --git a/neode-ui/package.json b/neode-ui/package.json index 18090e6f..22fe5eb2 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -26,13 +26,16 @@ "dependencies": { "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", + "buffer": "^6.0.3", "d3": "^7.9.0", "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fuse.js": "^7.1.0", "leaflet": "^1.9.4", "pinia": "^3.0.4", + "qr-scanner": "^1.4.2", "qrcode": "^1.5.4", + "qrloop": "^1.4.1", "vue": "^3.5.24", "vue-i18n": "^11.3.0", "vue-router": "^4.6.3" diff --git a/neode-ui/src/components/ReceiveBitcoinModal.vue b/neode-ui/src/components/ReceiveBitcoinModal.vue index dabafda8..56abd8e0 100644 --- a/neode-ui/src/components/ReceiveBitcoinModal.vue +++ b/neode-ui/src/components/ReceiveBitcoinModal.vue @@ -72,6 +72,12 @@
+ @@ -95,7 +101,7 @@ const props = defineProps<{ /** Generate an on-chain address immediately when the modal opens */ autoGenerate?: boolean }>() -const emit = defineEmits<{ close: []; received: [] }>() +const emit = defineEmits<{ close: []; received: []; scan: [] }>() watch(() => props.show, (open) => { if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) { diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 8730187d..aff6d099 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -69,6 +69,12 @@
+ @@ -85,7 +91,7 @@ import BaseModal from '@/components/BaseModal.vue' const { t } = useI18n() const props = defineProps<{ show: boolean }>() -const emit = defineEmits<{ close: []; sent: [] }>() +const emit = defineEmits<{ close: []; sent: []; scan: [] }>() const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto') const amount = ref(0) diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue new file mode 100644 index 00000000..7eb3e860 --- /dev/null +++ b/neode-ui/src/components/WalletScanModal.vue @@ -0,0 +1,683 @@ + + + + + diff --git a/neode-ui/src/composables/useAnimatedQRDecoder.ts b/neode-ui/src/composables/useAnimatedQRDecoder.ts new file mode 100644 index 00000000..328dcbaf --- /dev/null +++ b/neode-ui/src/composables/useAnimatedQRDecoder.ts @@ -0,0 +1,60 @@ +import { ref } from 'vue' +import { + parseFramesReducer, + areFramesComplete, + framesToData, + totalNumberOfFrames, + currentNumberOfFrames, +} from 'qrloop' + +/** + * Collects animated-QR frames (qrloop format, as used by k484 for large + * Fedimint tokens) and reassembles them into the original token string. + */ +export function useAnimatedQRDecoder() { + const framesState = ref | null>(null) + const isComplete = ref(false) + const decodedData = ref(null) + const uniqueFrames = ref>(new Set()) + + /** Feed one scanned frame; returns true once the full payload is decoded. */ + function addFrame(frame: string): boolean { + if (isComplete.value) return true + if (uniqueFrames.value.has(frame)) return false + uniqueFrames.value.add(frame) + + try { + framesState.value = parseFramesReducer(framesState.value, frame) + if (areFramesComplete(framesState.value)) { + const dataBuffer = framesToData(framesState.value) + // Tokens travel as URL-safe base64 + decodedData.value = dataBuffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + isComplete.value = true + return true + } + return false + } catch { + // A frame that qrloop rejects may just be a different QR format + return false + } + } + + function progressText(): string { + if (!framesState.value) return '' + const total = totalNumberOfFrames(framesState.value) + const current = currentNumberOfFrames(framesState.value) + return `${current}/${total} frames` + } + + function reset() { + framesState.value = null + uniqueFrames.value.clear() + isComplete.value = false + decodedData.value = null + } + + return { isComplete, decodedData, addFrame, reset, progressText } +} diff --git a/neode-ui/src/types/qrloop.d.ts b/neode-ui/src/types/qrloop.d.ts new file mode 100644 index 00000000..b6962862 --- /dev/null +++ b/neode-ui/src/types/qrloop.d.ts @@ -0,0 +1,9 @@ +declare module 'qrloop' { + export type FramesState = unknown + export function parseFramesReducer(state: FramesState | null, frame: string): FramesState + export function areFramesComplete(state: FramesState): boolean + export function framesToData(state: FramesState): { toString(encoding: string): string } + export function totalNumberOfFrames(state: FramesState): number + export function currentNumberOfFrames(state: FramesState): number + export function progressOfFrames(state: FramesState): number +} diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index d7855cdd..b3fd097b 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -136,6 +136,7 @@ :wallet-ark="walletArk" :wallet-transactions="walletTransactions" :is-dev="isDev" + @show-scan="showScanModal = true" @show-send="showSendModal = true" @show-receive="showReceiveModal = true" @show-transactions="showTransactionsModal = true" @@ -278,8 +279,9 @@
- - + + +
@@ -289,6 +291,7 @@ import { computed, ref, watch, onBeforeUnmount, onMounted } from 'vue' import { RouterLink, useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' +import WalletScanModal from '@/components/WalletScanModal.vue' import SendBitcoinModal from '@/components/SendBitcoinModal.vue' import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue' import TransactionsModal from '@/components/TransactionsModal.vue' @@ -517,7 +520,7 @@ onMounted(async () => { }) // Wallet modals -const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false) +const showScanModal = ref(false); const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false) async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } } diff --git a/neode-ui/src/views/home/HomeWalletCard.vue b/neode-ui/src/views/home/HomeWalletCard.vue index 2c8d174b..69eb1db3 100644 --- a/neode-ui/src/views/home/HomeWalletCard.vue +++ b/neode-ui/src/views/home/HomeWalletCard.vue @@ -145,19 +145,29 @@ {{ (walletArk ?? 0).toLocaleString() }} sats -
- - - +
@@ -197,6 +207,7 @@ const props = defineProps<{ }>() defineEmits<{ + showScan: [] showSend: [] showReceive: [] showTransactions: []