From 85b7a6200fe62af93bac4fee3037aeec0bb754c1 Mon Sep 17 00:00:00 2001 From: Hallab Date: Sun, 26 Jul 2026 04:32:47 +0100 Subject: [PATCH] feat(stellar): add send QR scanner --- package.json | 1 + pnpm-lock.yaml | 3 + src/components/QRCodeModal.tsx | 46 ++++++-- src/components/StellarReceive.tsx | 10 +- src/components/StellarSend.tsx | 188 ++++++++++++++++++++++++------ src/utils/qr.test.ts | 53 +++++++++ src/utils/qr.ts | 92 +++++++++++++++ 7 files changed, 349 insertions(+), 44 deletions(-) create mode 100644 src/utils/qr.test.ts create mode 100644 src/utils/qr.ts diff --git a/package.json b/package.json index d164f46..3dbfba5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@walletconnect/core": "^2.23.10", "@walletconnect/sign-client": "^2.23.10", "@wraith-protocol/sdk": "^1.4.5", + "@zxing/browser": "0.0.7", "bs58": "^6.0.0", "buffer": "^6.0.3", "i18next": "^24.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebb8d33..d8a9bdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@wraith-protocol/sdk': specifier: ^1.4.5 version: 1.4.5(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(@stellar/stellar-sdk@13.3.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) + '@zxing/browser': + specifier: 0.0.7 + version: 0.0.7(@zxing/library@0.18.6) bs58: specifier: ^6.0.0 version: 6.0.0 diff --git a/src/components/QRCodeModal.tsx b/src/components/QRCodeModal.tsx index 1b47924..a4fd934 100644 --- a/src/components/QRCodeModal.tsx +++ b/src/components/QRCodeModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { CopyButton } from '@/components/CopyButton'; @@ -6,10 +6,19 @@ interface QRCodeModalProps { value: string; onClose: () => void; title?: string; + variants?: Array<{ label: string; value: string }>; } -export function QRCodeModal({ value, onClose, title = 'Stealth Meta-Address' }: QRCodeModalProps) { +export function QRCodeModal({ + value, + onClose, + title = 'Stealth Meta-Address', + variants, +}: QRCodeModalProps) { const closeButtonRef = useRef(null); + const [selectedVariant, setSelectedVariant] = useState(0); + const qrVariants = variants?.length ? variants : [{ label: 'Meta-address', value }]; + const activeVariant = qrVariants[Math.min(selectedVariant, qrVariants.length - 1)]; // Close on Escape key press, and focus close button on mount useEffect(() => { @@ -34,7 +43,7 @@ export function QRCodeModal({ value, onClose, title = 'Stealth Meta-Address' }: try { await navigator.share({ title: title, - text: value, + text: activeVariant.value, }); } catch (err) { console.error('Error sharing:', err); @@ -42,7 +51,7 @@ export function QRCodeModal({ value, onClose, title = 'Stealth Meta-Address' }: } else { // Fallback: Copy to clipboard try { - await navigator.clipboard.writeText(value); + await navigator.clipboard.writeText(activeVariant.value); } catch (err) { console.error('Failed to copy address:', err); } @@ -88,15 +97,38 @@ export function QRCodeModal({ value, onClose, title = 'Stealth Meta-Address' }:
+ {qrVariants.length > 1 && ( +
+ {qrVariants.map((variant, index) => ( + + ))} +
+ )} +
- +
- {value} + {activeVariant.value} - +
diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index ea0b24b..65101ad 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -39,6 +39,7 @@ import { KeyVault } from '@/vault'; import { STELLAR_ASSETS, getAssetByKey, parseAssetBalances } from '@/lib/stellar/assets'; import { useStellarNotifications } from '@/hooks/useStellarNotifications'; import { StellarBatchWithdrawModal } from '@/components/StellarBatchWithdrawModal'; +import { createStellarQrUri } from '@/utils/qr'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; @@ -1449,7 +1450,14 @@ export function StellarReceive() { } /> {showQRModal && stellarMetaAddress && ( - setShowQRModal(false)} /> + setShowQRModal(false)} + /> )} (null); + const qrImageInputRef = useRef(null); // Focus close button on mount and handle Escape key to close useEffect(() => { @@ -137,38 +142,61 @@ export function StellarSend() { } }, [isScanningQR]); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _handleScanResult = useCallback((result: any, _error: any) => { - if (result) { - const text = result.text?.trim(); - if (!text) return; + const applyQrPayload = useCallback((text: string) => { + try { + const payload = parseStellarQrPayload(text); + setRecipient(payload.metaAddress); + if (payload.amount) setAmount(payload.amount); + if (payload.memo) setMemo(payload.memo); + setTouched((previous) => ({ ...previous, recipient: true })); + setScannerError(''); + setIsScanningQR(false); + } catch (scanError) { + setScannerError( + scanError instanceof Error ? scanError.message : 'This QR code could not be read.', + ); + } + }, []); - // 1. Check if it's a payment link - try { - const url = new URL(text); - const toParam = url.searchParams.get('to'); - if (toParam && toParam.startsWith('st:xlm:')) { - setRecipient(toParam); - const amtParam = url.searchParams.get('amount'); - if (amtParam) setAmount(amtParam); - const memoParam = url.searchParams.get('memo'); - if (memoParam) setMemo(memoParam); - setIsScanningQR(false); - return; - } - } catch { - // Not a URL, continue checking if it's a raw meta-address + const handleScanResult = useCallback( + (result: any, scanError: any) => { + const text = result?.getText?.() ?? result?.text; + if (text) { + applyQrPayload(text); + return; } - // 2. Check if it's a raw meta-address - if (text.startsWith('st:xlm:')) { - setRecipient(text); - setIsScanningQR(false); - } else { - setScannerError('Invalid QR code. Must be a stealth meta-address or payment link.'); + if (isCameraUnavailableError(scanError)) { + setCameraUnavailable(true); + setScannerError('Camera access is unavailable. Choose a saved QR image to continue.'); } + }, + [applyQrPayload], + ); + + const openQrScanner = () => { + setScannerError(''); + setCameraUnavailable(false); + setIsScanningQR(true); + }; + + const handleQrImage = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setIsDecodingQrImage(true); + setScannerError(''); + try { + applyQrPayload(await decodeQrImage(file)); + } catch (scanError) { + setScannerError( + scanError instanceof Error ? scanError.message : 'This QR image could not be read.', + ); + } finally { + setIsDecodingQrImage(false); + event.target.value = ''; } - }, []); + }; const [sourceBalance, setSourceBalance] = useState(null); const [isBalanceLoading, setIsBalanceLoading] = useState(false); @@ -659,20 +687,30 @@ export function StellarSend() { -
+
setRecipient(e.target.value)} placeholder={t('stellar.recipientPlaceholder')} - className="h-12 w-full border border-outline-variant bg-surface px-4 pr-20 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" + className="h-12 w-full border border-outline-variant bg-surface px-4 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" /> - +
+ + +
@@ -840,6 +878,84 @@ export function StellarSend() { )}
)} + + {isScanningQR && ( +
+
+
+

+ Scan recipient QR +

+ +
+ + {!cameraUnavailable && ( +
+ +
+ )} + + {cameraUnavailable && ( +
+

+ Camera permission was denied or the camera is unavailable. +

+

+ Select a screenshot or saved QR image instead. +

+
+ )} + + {scannerError && !cameraUnavailable && ( +

+ {scannerError} +

+ )} + + + +

+ QR images are decoded locally in your browser and are never uploaded. +

+
+
+ )} ); } diff --git a/src/utils/qr.test.ts b/src/utils/qr.test.ts new file mode 100644 index 0000000..bc4183e --- /dev/null +++ b/src/utils/qr.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { createStellarQrUri, isCameraUnavailableError, parseStellarQrPayload } from '@/utils/qr'; + +const META_ADDRESS = + 'st:xlm:5a1922b5614eed2ef72ebad40abc5d014f7c27b6e1de5dc36976e9eec4cbe29e6b912a495f9f14513d54a00a7887f986d394a30a77239475caf211e8094b6cdb'; + +describe('Stellar QR payloads', () => { + it('round-trips a receive meta-address through a stellar URI', () => { + const uri = createStellarQrUri(META_ADDRESS); + + expect(uri).toMatch(/^stellar:pay\?/); + expect(parseStellarQrPayload(uri)).toEqual({ metaAddress: META_ADDRESS }); + }); + + it('accepts a raw meta-address', () => { + expect(parseStellarQrPayload(META_ADDRESS)).toEqual({ metaAddress: META_ADDRESS }); + }); + + it('reads optional payment fields and common recipient parameter names', () => { + const uri = `stellar:pay?destination=${encodeURIComponent(META_ADDRESS)}&amount=12.5&memo=Invoice`; + + expect(parseStellarQrPayload(uri)).toEqual({ + metaAddress: META_ADDRESS, + amount: '12.5', + memo: 'Invoice', + }); + }); + + it('preserves support for browser payment links', () => { + const link = `https://example.test/pay?to=${encodeURIComponent(META_ADDRESS)}&amount=4`; + + expect(parseStellarQrPayload(link)).toEqual({ + metaAddress: META_ADDRESS, + amount: '4', + }); + }); + + it('rejects unrelated QR content', () => { + expect(() => parseStellarQrPayload('https://example.test/no-recipient')).toThrow( + /meta-address/i, + ); + expect(() => parseStellarQrPayload('not a payment')).toThrow(/supported stellar/i); + }); + + it('recognizes denied camera permission without treating scan misses as denial', () => { + const denied = new DOMException('Permission denied', 'NotAllowedError'); + const scanMiss = new Error('No QR code found'); + scanMiss.name = 'NotFoundException'; + + expect(isCameraUnavailableError(denied)).toBe(true); + expect(isCameraUnavailableError(scanMiss)).toBe(false); + }); +}); diff --git a/src/utils/qr.ts b/src/utils/qr.ts new file mode 100644 index 0000000..28fbb41 --- /dev/null +++ b/src/utils/qr.ts @@ -0,0 +1,92 @@ +export interface StellarQrPayload { + metaAddress: string; + amount?: string; + memo?: string; +} + +const META_ADDRESS_PREFIX = 'st:xlm:'; +const SUPPORTED_PROTOCOLS = new Set(['stellar:', 'web+stellar:', 'http:', 'https:']); +const CAMERA_UNAVAILABLE_ERRORS = new Set([ + 'AbortError', + 'NotAllowedError', + 'NotFoundError', + 'NotReadableError', + 'OverconstrainedError', + 'SecurityError', +]); + +function assertMetaAddress(value: string | null): string { + const metaAddress = value?.trim() ?? ''; + if (!metaAddress.startsWith(META_ADDRESS_PREFIX)) { + throw new Error('QR code does not contain a Stellar stealth meta-address'); + } + return metaAddress; +} + +export function createStellarQrUri(metaAddress: string): string { + const params = new URLSearchParams({ to: assertMetaAddress(metaAddress) }); + return `stellar:pay?${params.toString()}`; +} + +export function parseStellarQrPayload(value: string): StellarQrPayload { + const payload = value.trim(); + if (payload.startsWith(META_ADDRESS_PREFIX)) { + return { metaAddress: payload }; + } + + let url: URL; + try { + url = new URL(payload); + } catch { + throw new Error('QR code is not a supported Stellar payment payload'); + } + + if (!SUPPORTED_PROTOCOLS.has(url.protocol)) { + throw new Error('QR code uses an unsupported protocol'); + } + + if ( + (url.protocol === 'stellar:' || url.protocol === 'web+stellar:') && + url.pathname.replace(/^\/+/, '') !== 'pay' + ) { + throw new Error('QR code is not a Stellar payment URI'); + } + + const metaAddress = assertMetaAddress( + url.searchParams.get('to') ?? + url.searchParams.get('recipient') ?? + url.searchParams.get('destination'), + ); + const amount = url.searchParams.get('amount')?.trim(); + const memo = url.searchParams.get('memo')?.trim(); + + return { + metaAddress, + ...(amount ? { amount } : {}), + ...(memo ? { memo } : {}), + }; +} + +export function isCameraUnavailableError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const name = 'name' in error && typeof error.name === 'string' ? error.name : ''; + const message = 'message' in error && typeof error.message === 'string' ? error.message : ''; + return CAMERA_UNAVAILABLE_ERRORS.has(name) || message.includes('MediaDevices API has no support'); +} + +export async function decodeQrImage(file: File): Promise { + if (!file.type.startsWith('image/')) { + throw new Error('Choose an image containing a QR code'); + } + + const objectUrl = URL.createObjectURL(file); + try { + const { BrowserQRCodeReader } = await import('@zxing/browser'); + const result = await new BrowserQRCodeReader().decodeFromImageUrl(objectUrl); + return result.getText(); + } catch { + throw new Error('No readable QR code was found in that image'); + } finally { + URL.revokeObjectURL(objectUrl); + } +}