import { useState, useCallback, useRef } from 'react'; import { isValidClinkOffer } from '@/lib/clink'; // Html5Qrcode imported lazily inside startScanning to avoid canvas crash at module load type Html5QrcodeType = import('html5-qrcode').Html5Qrcode; export type QRScanResult = | { type: 'lightning_invoice'; value: string } | { type: 'lightning_address'; value: string } | { type: 'cashu_token'; value: string } | { type: 'cashu_request'; value: string } | { type: 'bitcoin_address'; value: string } | { type: 'npub'; value: string } | { type: 'clink_offer'; value: string } | { type: 'clink_debit'; value: string } | { type: 'unknown'; value: string }; const LIGHTNING_INVOICE_RE = /^lnbc[a-z0-9]+$/i; const BITCOIN_ADDRESS_RE = /^(bc1|3|1)[a-zA-Z0-9]{25,62}$/; /** * NUT-18 Cashu payment request. Two encodings: * - bech32 `creq1…` (what a BIP21 `?creq=` carries, e.g. from a Numo terminal) * - legacy raw `creqA…` */ const CASHU_REQUEST_RE = /^creq1[0-9a-z]+$/i; const CASHU_REQUEST_RAW_RE = /^creqA[0-9A-Za-z+/=_-]+$/; /** True for a NUT-18 Cashu payment request (`creq…`). */ export function isCashuRequest(value: string): boolean { return CASHU_REQUEST_RE.test(value) || CASHU_REQUEST_RAW_RE.test(value); } /** * Read a BIP21 query param WITHOUT `URLSearchParams`' `+`→space coercion (a raw * `creqA…` request can contain base64 `+`). Returns null when absent. */ function bip21Param(query: string, key: string): string | null { for (const pair of query.split('&')) { const eq = pair.indexOf('='); if (eq === -1) continue; if (pair.slice(0, eq).toLowerCase() !== key) continue; const raw = pair.slice(eq + 1); try { return decodeURIComponent(raw); } catch { return raw; } } return null; } /** * BIP21 `bitcoin:` URI — the shape a Numo POS emits, e.g. * `bitcoin:?creq=…&lightning=lnbc…`. Prefer the Lightning invoice (payable by * every wallet), then a Cashu request, then a bare on-chain address. */ function classifyBitcoinUri(uri: string): QRScanResult { const rest = uri.slice('bitcoin:'.length); const qIndex = rest.indexOf('?'); const address = (qIndex === -1 ? rest : rest.slice(0, qIndex)).trim(); const query = qIndex === -1 ? '' : rest.slice(qIndex + 1); const lightning = bip21Param(query, 'lightning'); if (lightning && LIGHTNING_INVOICE_RE.test(lightning)) { return { type: 'lightning_invoice', value: lightning }; } const creq = bip21Param(query, 'creq'); if (creq && isCashuRequest(creq)) { return { type: 'cashu_request', value: creq }; } if (BITCOIN_ADDRESS_RE.test(address)) { return { type: 'bitcoin_address', value: address }; } return { type: 'unknown', value: uri }; } /** * Classify scanned QR code content */ export function classifyQRCode(decodedText: string): QRScanResult { const trimmed = decodedText.trim(); // BIP21 bitcoin: URI — may carry a Lightning invoice and/or a Cashu request. if (/^bitcoin:/i.test(trimmed)) { return classifyBitcoinUri(trimmed); } // Lightning invoice: lnbc... or lightning:lnbc... const lightningInvoiceMatch = trimmed.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i); if (lightningInvoiceMatch) { return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] }; } // Cashu payment request (NUT-18): creq1... (bech32) or creqA... (raw) if (isCashuRequest(trimmed)) { return { type: 'cashu_request', value: trimmed }; } // Lightning address: name@domain.tld or lightning:name@domain.tld const lightningAddressMatch = trimmed.match(/^(?:lightning:)?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/); if (lightningAddressMatch) { return { type: 'lightning_address', value: lightningAddressMatch[1] }; } // Cashu token: cashu... if (/^cashu[A-Za-z0-9+/=_-]+$/i.test(trimmed)) { return { type: 'cashu_token', value: trimmed }; } // Bitcoin address: bc1... or legacy (1..., 3...) if (BITCOIN_ADDRESS_RE.test(trimmed)) { return { type: 'bitcoin_address', value: trimmed }; } // Nostr npub: npub1... or nostr:npub1... const npubMatch = trimmed.match(/^(?:nostr:)?(npub1[a-z0-9]{58})$/); if (npubMatch) { return { type: 'npub', value: npubMatch[1] }; } // CLINK Offer code: noffer1… or nostr:noffer1… (self-custodial static payment code). // Validated with a real bech32+TLV decode (checksum included), not a regex. const nofferMatch = trimmed.match(/^(?:nostr:)?(noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i); if (nofferMatch && isValidClinkOffer(nofferMatch[1])) { return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() }; } // CLINK Debit authorization: ndebit1... or nostr:ndebit1... const ndebitMatch = trimmed.match(/^(?:nostr:)?(ndebit1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i); if (ndebitMatch) { return { type: 'clink_debit', value: ndebitMatch[1].toLowerCase() }; } return { type: 'unknown', value: trimmed }; } export function useQRCodeScanner() { const [isScanning, setIsScanning] = useState(false); const [error, setError] = useState(null); const scannerRef = useRef(null); const isStartingRef = useRef(false); const shouldCancelRef = useRef(false); const startScanning = useCallback(async ( onResult: (result: QRScanResult) => void ): Promise => { try { isStartingRef.current = true; shouldCancelRef.current = false; setError(null); // Lazy load to avoid canvas crash at module load time const { Html5Qrcode } = await import('html5-qrcode'); // Find available cameras const devices = await Html5Qrcode.getCameras(); if (devices.length === 0) { throw new Error('No cameras found'); } if (shouldCancelRef.current) { isStartingRef.current = false; return; } // Use rear camera by default, or first available const cameraId = devices.length > 1 ? devices.find(d => d.label.toLowerCase().includes('back') || d.label.toLowerCase().includes('rear') )?.id || devices[0].id : devices[0].id; // Initialize scanner const scanner = new Html5Qrcode('qr-reader'); scannerRef.current = scanner; // Start scanning await scanner.start( cameraId, { fps: 10, aspectRatio: 1, qrbox: 250 }, (decodedText) => { const result = classifyQRCode(decodedText); onResult(result); }, (errorMessage) => { // Ignore "QR code not found" errors if (!errorMessage.includes('qr')) { console.debug('QR scan error:', errorMessage); } } ); isStartingRef.current = false; setIsScanning(true); } catch (err) { isStartingRef.current = false; let errorMessage = 'Failed to start camera'; if (err instanceof Error) { errorMessage = err.message; } if (errorMessage.includes('NotAllowedError') || errorMessage.includes('Permission denied')) { errorMessage = 'Camera permission denied. Please allow camera access.'; } else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No device')) { errorMessage = 'No camera found. Please connect a camera.'; } setError(errorMessage); setIsScanning(false); } }, []); const stopScanning = useCallback(async (): Promise => { try { if (isStartingRef.current) { shouldCancelRef.current = true; let attempts = 0; while (isStartingRef.current && attempts < 50) { await new Promise(resolve => setTimeout(resolve, 100)); attempts++; } } if (scannerRef.current) { const scanner = scannerRef.current; const state = scanner.getState(); if (state === 2) { // SCANNING await scanner.stop(); } else if (state === 1) { // STARTING await new Promise(resolve => setTimeout(resolve, 200)); if (scanner.getState() === 2) { await scanner.stop(); } } await scanner.clear(); scannerRef.current = null; } shouldCancelRef.current = false; isStartingRef.current = false; setIsScanning(false); setError(null); } catch (err) { console.error('Error stopping scanner:', err); scannerRef.current = null; setIsScanning(false); } }, []); return { isScanning, error, startScanning, stopScanning, classifyQRCode, }; }