diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx
index 5ba27637..0aa0784b 100644
--- a/src/components/HDSendBitcoinDialog.tsx
+++ b/src/components/HDSendBitcoinDialog.tsx
@@ -26,6 +26,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { BitcoinAmountPicker } from '@/components/BitcoinAmountPicker';
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
import { BitcoinRecipientInput } from '@/components/BitcoinRecipientInput';
+import { QrScannerDialog } from '@/components/QrScannerDialog';
import { HelpTip } from '@/components/HelpTip';
import { cn } from '@/lib/utils';
@@ -42,6 +43,7 @@ import {
import {
isLargeAmount,
nostrPubkeyToBitcoinAddress,
+ parseBitcoinUri,
satsToUSD,
} from '@/lib/bitcoin';
import {
@@ -228,11 +230,39 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [success, setSuccess] = useState(null);
+ const [scannerOpen, setScannerOpen] = useState(false);
const feeSpeedUserChanged = useRef(false);
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
+ /**
+ * Interpret a freshly-scanned QR code and stuff it into the recipient
+ * input. A `bitcoin:bc1q…?sp=sp1q…` BIP-21 URI means "send via silent
+ * payment if you can; otherwise fall back to the on-chain address" — we
+ * prefer the `sp` parameter when it parses, and the swap toggle under the
+ * input lets the user fall back to the on-chain address if they want to.
+ * Anything else (bare address, `sp1…`, npub, nprofile) is dropped in
+ * verbatim and `resolveRecipient` does the rest.
+ */
+ const handleScan = useCallback((scanned: string) => {
+ setScannerOpen(false);
+ setError('');
+ const trimmed = scanned.trim();
+ const bip21 = parseBitcoinUri(trimmed);
+ if (bip21) {
+ if (bip21.sp && resolveRecipient(bip21.sp)) {
+ setRecipientInput(bip21.sp);
+ return;
+ }
+ if (bip21.address) {
+ setRecipientInput(bip21.address);
+ return;
+ }
+ }
+ setRecipientInput(trimmed);
+ }, []);
+
// ── Fee rates ────────────────────────────────────────────────
const { data: feeRates } = useQuery({
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
@@ -533,6 +563,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice, initialRecipien
// ── Render ───────────────────────────────────────────────────
return (
+ <>
+ setScannerOpen(false)}
+ onScan={handleScan}
+ title={t('walletSend.recipient.scan')}
+ />
+ >
);
}
diff --git a/src/components/QrScannerDialog.tsx b/src/components/QrScannerDialog.tsx
new file mode 100644
index 00000000..6aebdb47
--- /dev/null
+++ b/src/components/QrScannerDialog.tsx
@@ -0,0 +1,311 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Camera, Loader2, X, ZapOff, Zap } from 'lucide-react';
+import QrScanner from 'qr-scanner';
+
+import { Button } from '@/components/ui/button';
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { cn } from '@/lib/utils';
+
+interface QrScannerDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ /** Called with the decoded QR text the first time a code is read. */
+ onScan: (value: string) => void;
+ /** Override the dialog title (defaults to the localized "Scan QR code"). */
+ title?: string;
+}
+
+/** How long to wait after `start()` resolves before declaring the camera dead. */
+const VIDEO_READY_TIMEOUT_MS = 6000;
+
+/**
+ * Camera-based QR scanner dialog. Works in browsers, Capacitor's WKWebView
+ * (iOS), and Android's WebView, all via `getUserMedia` + the `qr-scanner`
+ * library (ZXing / BarcodeDetector under the hood).
+ *
+ * The dialog owns the camera lifecycle: it spins up the scanner when opened
+ * and tears it down on close, so callers only need to manage `isOpen` and
+ * react to `onScan`.
+ *
+ * Failure modes we explicitly surface (instead of a silent black screen):
+ * - Insecure context (HTTP) — getUserMedia is unavailable.
+ * - Camera permission denied.
+ * - No camera on the device.
+ * - `facingMode: 'environment'` not satisfiable (some laptops, some
+ * locked-down WebViews). We retry with the front camera.
+ * - `start()` resolves but the video never emits `loadedmetadata` within
+ * `VIDEO_READY_TIMEOUT_MS` — usually means the worker engine failed to
+ * initialize or another app is holding the camera.
+ */
+export function QrScannerDialog({ isOpen, onClose, onScan, title }: QrScannerDialogProps) {
+ const { t } = useTranslation();
+ // Callback ref so we know the moment the element is attached. Radix
+ // Dialog mounts content lazily inside a Portal, so a plain `useRef` is
+ // still null on the first effect tick after `isOpen` flips to true.
+ // A state-backed ref re-runs the effect once the