diff --git a/src/App.tsx b/src/App.tsx index d720474a..9868c2f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -153,6 +153,7 @@ const hardcodedConfig: AppConfig = { 'https://blockstream.info/api', ], blockbookBaseUrl: 'https://btc.trezor.io', + bip352IndexerUrl: '', sidebarWidgets: [ { id: 'trends' }, { id: 'hot-posts' }, diff --git a/src/components/HDSilentPaymentScanDialog.tsx b/src/components/HDSilentPaymentScanDialog.tsx new file mode 100644 index 00000000..7e0ffa27 --- /dev/null +++ b/src/components/HDSilentPaymentScanDialog.tsx @@ -0,0 +1,202 @@ +import { useEffect, useState } from 'react'; +import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { useHdWalletSp } from '@/hooks/useHdWalletSp'; + +// --------------------------------------------------------------------------- +// HD wallet — silent-payment "Scan history" dialog +// --------------------------------------------------------------------------- +// +// Walks the user through running a BIP-352 chain scan over a configurable +// block range. Defaults to "from last scanned height → tip", which is the +// common forward-catch-up case; advanced users can edit the bounds for a +// targeted backfill. +// --------------------------------------------------------------------------- + +export interface HDSilentPaymentScanDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) { + const sp = useHdWalletSp(); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [touched, setTouched] = useState(false); + + // Seed defaults whenever the dialog opens or upstream data changes. + useEffect(() => { + if (!open) { + setTouched(false); + return; + } + if (touched) return; + const tip = sp.tipHeight; + const lastScanned = sp.storage?.scanHeight ?? 0; + const defaultFrom = lastScanned > 0 ? lastScanned + 1 : tip ? Math.max(0, tip - 144) : 0; + setFrom(String(defaultFrom)); + setTo(tip ? String(tip) : ''); + }, [open, sp.tipHeight, sp.storage?.scanHeight, touched]); + + const fromNum = Number(from); + const toNum = Number(to); + const fromValid = Number.isInteger(fromNum) && fromNum >= 0; + const toValid = to === '' || (Number.isInteger(toNum) && toNum >= fromNum); + const inputsValid = fromValid && toValid; + + const handleScan = async () => { + if (!inputsValid) return; + await sp.scanRange({ + fromHeight: fromNum, + toHeight: to === '' ? undefined : toNum, + }); + }; + + const progressPercent = sp.scanProgress + ? Math.min( + 100, + Math.round( + ((sp.scanProgress.currentHeight - sp.scanProgress.fromHeight + 1) / + Math.max(1, sp.scanProgress.toHeight - sp.scanProgress.fromHeight + 1)) * + 100, + ), + ) + : 0; + + return ( + + + + Scan for silent payments + + Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments. + + + +
+
+
+ + { + setTouched(true); + setFrom(e.target.value); + }} + disabled={sp.isScanning} + aria-invalid={!fromValid} + /> +
+
+ + { + setTouched(true); + setTo(e.target.value); + }} + disabled={sp.isScanning} + aria-invalid={!toValid} + /> +
+
+ + {sp.tipHeight !== undefined && ( +

+ Indexer tip: {sp.tipHeight.toLocaleString()} + {sp.storage && ( + <> + {' · '} + Last fully scanned:{' '} + + {sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'} + + + )} +

+ )} + + {sp.isScanning && sp.scanProgress && ( +
+ +
+ + + Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '} + {sp.scanProgress.toHeight.toLocaleString()} + + + {sp.scanProgress.matchesFound} match + {sp.scanProgress.matchesFound === 1 ? '' : 'es'} + +
+
+ )} + + {!sp.isScanning && sp.scanError && ( +
+ +

{sp.scanError.message}

+
+ )} + + {!sp.isScanning && !sp.scanError && sp.scanProgress && ( +
+ +

+ Scanned blocks {sp.scanProgress.fromHeight.toLocaleString()} →{' '} + {sp.scanProgress.currentHeight.toLocaleString()}.{' '} + {sp.scanProgress.matchesFound > 0 + ? `Found ${sp.scanProgress.matchesFound} new ${ + sp.scanProgress.matchesFound === 1 ? 'output' : 'outputs' + }.` + : 'No new payments.'} +

+
+ )} + +
+ {sp.isScanning ? ( + + ) : ( + <> + + + + )} +
+
+
+
+ ); +} diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index ca500db9..b5b3ee22 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -312,6 +312,34 @@ export interface AppConfig { * Suite itself uses. */ blockbookBaseUrl: string; + /** + * Base URL of a BIP-352 tweak-data indexer (BlindBit Oracle v2-compatible), + * used by the HD wallet at `/hdwallet` to detect incoming silent payments. + * + * The wallet derives the scan private key `bscan` locally from the user's + * nsec and finishes the BIP-352 ECDH step itself; only public per-tx tweak + * data and Taproot outputs come over the wire. `bscan` MUST NEVER leave the + * device. + * + * Endpoints consumed (all public, no auth): + * - `GET /info` → tip height + * - `GET /tweaks/:height` → 33-byte compressed tweaks + * - `GET /utxos/:height` → P2TR outputs in the block + * + * No version segment, no trailing slash. Default is an empty string, which + * disables silent-payment scanning entirely — the wallet still displays the + * static `sp1q…` receive address, but never resolves balances or history. + * Set this to a self-hosted or trusted BlindBit endpoint to enable scanning. + * + * **Privacy note**: the indexer never sees `bscan`, but it does observe the + * sequence of block heights you ask about. If you scan a contiguous range + * (history backfill) that signal is uninformative; for live ongoing scans + * the indexer can correlate your IP with the wallet's last-known tip. + * Self-hosting is the strongest mitigation. + * + * Default: `""` (disabled). + */ + bip352IndexerUrl: string; /** * Display preference for monetary amounts (zap totals, balances, send forms). * - "usd" (default): convert sats to USD using the live BTC price. diff --git a/src/hooks/useHdWallet.ts b/src/hooks/useHdWallet.ts index 51327e87..3652e715 100644 --- a/src/hooks/useHdWallet.ts +++ b/src/hooks/useHdWallet.ts @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAppContext } from '@/hooks/useAppContext'; import { useSecureLocalStorage } from '@/hooks/useSecureLocalStorage'; import { useHdWalletAccess, type HdWalletAvailability } from '@/hooks/useHdWalletAccess'; +import { useHdWalletSp } from '@/hooks/useHdWalletSp'; import { deriveReceiveAddress, deriveSilentPaymentAddress, @@ -16,6 +17,7 @@ import { type HdTransaction, scanAccount, } from '@/lib/hdwallet/scan'; +import type { SPStorageDocument } from '@/lib/hdwallet/sp/storage'; // --------------------------------------------------------------------------- // Persisted UI cursor (per user) @@ -71,6 +73,13 @@ export interface UseHdWalletResult { totalBalance: number; /** Pending (mempool) balance in sats. */ pendingBalance: number; + /** + * Confirmed balance of silent-payment UTXOs only, in sats. Already included + * in `totalBalance` — this field is exposed for the UI breakdown. + */ + silentPaymentBalance: number; + /** The persisted SP UTXO document, if loaded. */ + silentPaymentStorage?: SPStorageDocument; /** Initial scan in progress. */ isLoading: boolean; /** Scan currently fetching (initial or background refresh). */ @@ -108,6 +117,7 @@ export function useHdWallet(): UseHdWalletResult { const { blockbookBaseUrl } = config; const availability = useHdWalletAccess(); const queryClient = useQueryClient(); + const sp = useHdWalletSp(); const pubkey = availability.status === 'available' ? availability.pubkey : ''; const account = availability.status === 'available' ? availability.account : undefined; @@ -140,10 +150,58 @@ export function useHdWallet(): UseHdWalletResult { }); // ── Transaction history (derived; zero extra fetches) ──────── + // + // Combines BIP-86 transactions (scanned from Blockbook) with silent-payment + // receives (discovered by the BIP-352 scanner). SP UTXOs don't carry a + // wall-clock timestamp — BlindBit only exposes block heights — so we + // synthesise an approximate timestamp from height using a fixed anchor + // (block 800,000 ≈ 2023-07-23T00:00:00Z, average 10-minute spacing). This + // is good enough for sort-order and the relative-time UI ("2d ago"); a more + // accurate solution would require an extra Blockbook block-header call + // per SP tx, which isn't worth the round-trip cost today. const transactions = useMemo(() => { - if (!scan) return undefined; - return buildHdTransactions(scan); - }, [scan]); + if (!scan && !sp.storage) return undefined; + + const bip86 = scan ? buildHdTransactions(scan) : []; + + // Group SP UTXOs by txid and sum to keep the row shape consistent with + // the rest of the wallet (one row per tx, not per output). + const spByTxid = new Map(); + for (const u of sp.storage?.utxos ?? []) { + const existing = spByTxid.get(u.txid); + if (existing) { + existing.amount += u.value; + // Same tx → same block; keep first. + } else { + spByTxid.set(u.txid, { amount: u.value, height: u.height }); + } + } + + const HEIGHT_ANCHOR = 800_000; + const TIMESTAMP_ANCHOR = 1_690_070_400; // 2023-07-23T00:00:00Z (block 800,000) + const SECONDS_PER_BLOCK = 600; + + const spRows: HdTransaction[] = Array.from(spByTxid.entries()).map(([txid, info]) => ({ + txid, + amount: info.amount, + type: 'receive', + // SP UTXOs come from confirmed P2TR outputs in mined blocks — mempool + // SP detection isn't supported by BlindBit (you need a confirmed block + // to derive `input_hash`), so any UTXO we've persisted is confirmed. + confirmed: true, + timestamp: TIMESTAMP_ANCHOR + (info.height - HEIGHT_ANCHOR) * SECONDS_PER_BLOCK, + source: 'silent-payment', + })); + + const merged = [...bip86, ...spRows]; + merged.sort((a, b) => { + if (!a.timestamp && !b.timestamp) return 0; + if (!a.timestamp) return -1; + if (!b.timestamp) return 1; + return b.timestamp - a.timestamp; + }); + return merged; + }, [scan, sp.storage]); // ── Current receive address ────────────────────────────────── const currentReceiveAddress = useMemo(() => { @@ -181,8 +239,10 @@ export function useHdWallet(): UseHdWalletResult { silentPaymentAddress, scan, transactions, - totalBalance: scan?.totalBalance ?? 0, + totalBalance: (scan?.totalBalance ?? 0) + sp.balance, pendingBalance: scan?.pendingBalance ?? 0, + silentPaymentBalance: sp.balance, + silentPaymentStorage: sp.storage, isLoading: scanLoading, isFetching: scanFetching, error: scanError, diff --git a/src/hooks/useHdWalletSp.ts b/src/hooks/useHdWalletSp.ts new file mode 100644 index 00000000..e2956627 --- /dev/null +++ b/src/hooks/useHdWalletSp.ts @@ -0,0 +1,431 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useHdWalletAccess } from '@/hooks/useHdWalletAccess'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { + deriveSilentPaymentKeys, + type SilentPaymentKeys, +} from '@/lib/hdwallet/derivation'; +import { fetchBlockEntries, fetchTipHeight } from '@/lib/hdwallet/sp/indexer'; +import { scanBatch, type SPMatchedUtxo } from '@/lib/hdwallet/sp/scanner'; +import { + EMPTY_SP_STORAGE, + matchedUtxoToStored, + mergeUtxos, + parseSPStorage, + serializeSPStorage, + type SPStorageDocument, + spStorageBalance, + spStorageDTag, + SP_STORAGE_VERSION, +} from '@/lib/hdwallet/sp/storage'; + +// --------------------------------------------------------------------------- +// HD wallet — silent-payments orchestrator +// --------------------------------------------------------------------------- +// +// Pulls everything below together so the HD wallet UI can: +// +// 1. Read the persisted SP UTXO state (NIP-78 / kind 30078, NIP-44 encrypted). +// 2. Run a chain scan against a BlindBit Oracle v2 indexer in user-driven +// ranges (`scanRange({ fromHeight, toHeight? })`). +// 3. Persist freshly discovered UTXOs back to the encrypted NIP-78 event +// as they're found. +// +// Spending and sending are deliberately not in scope — see +// `src/lib/hdwallet/sp/crypto.ts` for the rationale. +// --------------------------------------------------------------------------- + +/** Default scan window when the user clicks "Scan recent" with no explicit bounds. */ +const DEFAULT_RECENT_SCAN_BLOCKS = 144; // ~24 hours of mainnet blocks. + +export interface UseHdWalletSpResult { + /** Whether the feature is usable. False when not logged in with nsec, or no indexer configured. */ + enabled: boolean; + /** Concrete reason `enabled` is false, when applicable. */ + unavailableReason?: 'logged-out' | 'unsupported-signer' | 'no-indexer'; + + /** The wallet's SP key material. `undefined` until the hook is enabled. */ + keys?: SilentPaymentKeys; + + /** The decrypted persisted UTXO document. `undefined` while loading. */ + storage?: SPStorageDocument; + /** Sum of all stored SP UTXO values, in satoshis. */ + balance: number; + /** True until the first storage load resolves. */ + isLoading: boolean; + + /** Active scan progress, if any. */ + scanProgress?: { + fromHeight: number; + toHeight: number; + currentHeight: number; + matchesFound: number; + }; + /** True while `scanRange` (or a derived helper) is running. */ + isScanning: boolean; + /** Error from the most recent scan, if it failed. Cleared on next scan start. */ + scanError?: Error; + + /** Tip height as reported by the indexer (cached, lightly refreshed). */ + tipHeight?: number; + + /** Scan a contiguous block range. `toHeight` defaults to current tip. */ + scanRange: (args: { fromHeight: number; toHeight?: number }) => Promise; + /** Scan the most recent `DEFAULT_RECENT_SCAN_BLOCKS` blocks (or fewer if newer). */ + scanRecent: () => Promise; + /** Abort an in-flight scan. */ + cancelScan: () => void; +} + +const EMPTY_RESULT: UseHdWalletSpResult = { + enabled: false, + balance: 0, + isLoading: false, + isScanning: false, + scanRange: async () => {}, + scanRecent: async () => {}, + cancelScan: () => {}, +}; + +export function useHdWalletSp(): UseHdWalletSpResult { + const { config } = useAppContext(); + const { user } = useCurrentUser(); + const { nostr } = useNostr(); + const access = useHdWalletAccess(); + const queryClient = useQueryClient(); + + const indexerUrl = (config.bip352IndexerUrl ?? '').trim(); + const pubkey = access.status === 'available' ? access.pubkey : ''; + const nsecBytes = access.status === 'available' ? access.nsecBytes : undefined; + + // ── SP key derivation (memoised) ───────────────────────────── + const keys = useMemo(() => { + if (!nsecBytes) return undefined; + return deriveSilentPaymentKeys(nsecBytes); + }, [nsecBytes]); + + // ── Availability gating ────────────────────────────────────── + // Compute the early-return shape *before* hooks branch so React's + // hook-order rule stays happy. + const unavailableReason: UseHdWalletSpResult['unavailableReason'] = + access.status === 'logged-out' + ? 'logged-out' + : access.status === 'unsupported' + ? 'unsupported-signer' + : indexerUrl === '' + ? 'no-indexer' + : undefined; + const enabled = unavailableReason === undefined; + + // ── Stable d-tag for the persisted UTXO event ──────────────── + const dTag = spStorageDTag(config.appId); + + // ── Tip-height query (cheap, refreshed every 60s when enabled) ── + const { data: tipHeight } = useQuery({ + queryKey: ['hdwallet-sp-tip', indexerUrl], + queryFn: ({ signal }) => fetchTipHeight(indexerUrl, signal), + enabled, + refetchInterval: 60_000, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); + + // ── Persisted storage event ────────────────────────────────── + // + // Two-stage query like `useEncryptedSettings`: stage 1 fetches the raw + // event from relays, stage 2 NIP-44-decrypts it. We key the parse stage + // on the event id so a stale parse doesn't survive an event update. + const storageEventQuery = useQuery({ + queryKey: ['hdwallet-sp-event', pubkey, dTag], + queryFn: async () => { + if (!user) return null; + const events = await nostr.query([ + { + kinds: [30078], + authors: [user.pubkey], + '#d': [dTag], + limit: 1, + }, + ]); + if (events.length === 0) return null; + // Pick the most recent if multiple relays returned different versions. + return events.reduce((latest, current) => + current.created_at > latest.created_at ? current : latest, + ); + }, + enabled: enabled && !!user, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: true, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + const storageDocQuery = useQuery({ + queryKey: ['hdwallet-sp-doc', storageEventQuery.data?.id ?? '(empty)'], + queryFn: async () => { + const event = storageEventQuery.data; + if (!event) return { ...EMPTY_SP_STORAGE }; + if (!user?.signer.nip44) return { ...EMPTY_SP_STORAGE }; + if (!event.content) return { ...EMPTY_SP_STORAGE }; + try { + const plaintext = await user.signer.nip44.decrypt(user.pubkey, event.content); + return parseSPStorage(plaintext); + } catch (err) { + console.warn('Failed to decrypt SP storage event; treating as empty:', err); + return { ...EMPTY_SP_STORAGE }; + } + }, + enabled: enabled && !!user, + staleTime: 0, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + // ── Optimistic in-memory copy ──────────────────────────────── + // + // The relay round-trip on each scan-progress tick would be unacceptable, so + // we maintain an in-memory document that the scanner updates synchronously + // and the relay republish coalesces every few seconds. The `?? loaded` + // pattern below means we drop the optimistic copy as soon as a newer + // event lands. + const optimisticRef = useRef(null); + const [optimisticVersion, setOptimisticVersion] = useState(0); + void optimisticVersion; // touched so React knows to re-render on bump + + const storage = useMemo(() => { + if (!enabled) return undefined; + if (!storageDocQuery.data) return undefined; + // Prefer the optimistic copy when it's at least as fresh as relays. + const loaded = storageDocQuery.data; + const opt = optimisticRef.current; + if (!opt) return loaded; + if (opt.scanHeight >= loaded.scanHeight && opt.utxos.length >= loaded.utxos.length) { + return opt; + } + return loaded; + }, [enabled, storageDocQuery.data]); + + // ── Mutation: persist a new document to relays ─────────────── + const publishStorage = useMutation({ + mutationFn: async (next: SPStorageDocument) => { + if (!user) throw new Error('not logged in'); + if (!user.signer.nip44) throw new Error('signer does not support NIP-44'); + // Always read-modify-write off the freshest event so a concurrent device + // doesn't lose its progress. + const prev = await fetchFreshEvent(nostr, { + kinds: [30078], + authors: [user.pubkey], + '#d': [dTag], + }); + let merged: SPStorageDocument = next; + if (prev?.content) { + try { + const decrypted = await user.signer.nip44.decrypt(user.pubkey, prev.content); + const remote = parseSPStorage(decrypted); + merged = { + version: SP_STORAGE_VERSION, + scanHeight: Math.max(remote.scanHeight, next.scanHeight), + utxos: mergeUtxos(remote.utxos, next.utxos), + }; + } catch { + // Treat undecryptable remote as empty rather than blocking the write. + } + } + const ciphertext = await user.signer.nip44.encrypt(user.pubkey, serializeSPStorage(merged)); + const unsigned = { + kind: 30078, + content: ciphertext, + tags: [ + ['d', dTag], + ['title', `${config.appName} HD Wallet — Silent Payment UTXOs`], + ['client', config.appName, ...(config.client ? [config.client] : [])], + ['alt', 'Encrypted silent-payment UTXO set for the HD wallet'], + ], + created_at: Math.floor(Date.now() / 1000), + }; + const signed = await user.signer.signEvent(unsigned); + // Best-effort publish — the local optimistic copy is still authoritative. + nostr.event(signed, { signal: AbortSignal.timeout(5000) }).catch((e) => { + console.warn('Failed to publish SP storage event:', e); + }); + return { merged, signed }; + }, + onSuccess: ({ merged, signed }) => { + // Update query caches in-place to avoid an immediate refetch round-trip. + queryClient.setQueryData(['hdwallet-sp-event', pubkey, dTag], signed); + queryClient.setQueryData(['hdwallet-sp-doc', signed.id], merged); + }, + }); + + // ── Scan state ─────────────────────────────────────────────── + const [scanProgress, setScanProgress] = useState(); + const [scanError, setScanError] = useState(); + const [isScanning, setIsScanning] = useState(false); + const scanAbortRef = useRef(null); + // Debounce timer for republishing storage during a long scan. + const republishTimerRef = useRef | null>(null); + + const cancelScan = useCallback(() => { + scanAbortRef.current?.abort(); + }, []); + + const flushRepublish = useCallback(() => { + if (republishTimerRef.current) { + clearTimeout(republishTimerRef.current); + republishTimerRef.current = null; + } + const doc = optimisticRef.current; + if (!doc) return; + publishStorage.mutate(doc); + }, [publishStorage]); + + const scheduleRepublish = useCallback(() => { + if (republishTimerRef.current) clearTimeout(republishTimerRef.current); + republishTimerRef.current = setTimeout(() => { + republishTimerRef.current = null; + const doc = optimisticRef.current; + if (doc) publishStorage.mutate(doc); + }, 5000); + }, [publishStorage]); + + // ── The core scan loop ─────────────────────────────────────── + const scanRange = useCallback( + async ({ fromHeight, toHeight }) => { + if (!enabled || !keys) return; + if (!storage) return; // Wait for the first load — caller can retry. + if (!Number.isInteger(fromHeight) || fromHeight < 0) { + throw new Error(`Invalid fromHeight: ${fromHeight}`); + } + + // Resolve the upper bound — default to current tip. + const resolvedTo = toHeight ?? tipHeight ?? (await fetchTipHeight(indexerUrl)); + if (!Number.isInteger(resolvedTo) || resolvedTo < fromHeight) { + throw new Error(`Invalid toHeight: ${resolvedTo}`); + } + + // Abort any prior in-flight scan. + scanAbortRef.current?.abort(); + const controller = new AbortController(); + scanAbortRef.current = controller; + + setScanError(undefined); + setIsScanning(true); + setScanProgress({ + fromHeight, + toHeight: resolvedTo, + currentHeight: fromHeight, + matchesFound: 0, + }); + + // Seed the optimistic doc from the current snapshot so we don't lose + // existing UTXOs while scanning a sparse range. + optimisticRef.current = { + version: SP_STORAGE_VERSION, + scanHeight: storage.scanHeight, + utxos: storage.utxos.slice(), + }; + + let matchesFound = 0; + let highestContiguousScanned = fromHeight - 1; + + try { + for (let h = fromHeight; h <= resolvedTo; h++) { + if (controller.signal.aborted) break; + + const entries = await fetchBlockEntries(indexerUrl, h, controller.signal); + let blockMatches: SPMatchedUtxo[] = []; + if (entries.length > 0) { + blockMatches = await scanBatch(entries, keys.bscan, keys.Bspend, { + signal: controller.signal, + }); + } + + // Merge matches into the optimistic doc. + if (blockMatches.length > 0) { + const opt = optimisticRef.current!; + const fresh = blockMatches.map(matchedUtxoToStored); + optimisticRef.current = { + version: SP_STORAGE_VERSION, + scanHeight: opt.scanHeight, + utxos: mergeUtxos(opt.utxos, fresh), + }; + matchesFound += blockMatches.length; + } + + // Forward the scan cursor as long as we advance contiguously from + // the start of this range. + if (h === highestContiguousScanned + 1) { + highestContiguousScanned = h; + const opt = optimisticRef.current!; + optimisticRef.current = { + ...opt, + scanHeight: Math.max(opt.scanHeight, highestContiguousScanned), + }; + } + + setScanProgress({ + fromHeight, + toHeight: resolvedTo, + currentHeight: h, + matchesFound, + }); + // Bump the optimistic-version state so `storage` recomputes. + setOptimisticVersion((v) => v + 1); + + // Coalesce relay republishes so a 10k-block scan doesn't fire 10k + // events at the user's signer. + scheduleRepublish(); + } + } catch (err) { + if (controller.signal.aborted) { + // Caller asked to cancel — not an error to surface. + } else { + setScanError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + setIsScanning(false); + // Final flush — make sure the last scan progress reaches relays. + flushRepublish(); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + } + } + }, + [enabled, keys, storage, tipHeight, indexerUrl, scheduleRepublish, flushRepublish], + ); + + const scanRecent = useCallback(async () => { + if (!enabled) return; + const tip = tipHeight ?? (await fetchTipHeight(indexerUrl)); + const from = Math.max(0, tip - DEFAULT_RECENT_SCAN_BLOCKS + 1); + await scanRange({ fromHeight: from, toHeight: tip }); + }, [enabled, indexerUrl, tipHeight, scanRange]); + + const balance = useMemo(() => (storage ? spStorageBalance(storage) : 0), [storage]); + + // ── Assemble the public shape ─────────────────────────────── + if (!enabled) { + return { ...EMPTY_RESULT, unavailableReason, keys }; + } + + return { + enabled, + keys, + storage, + balance, + isLoading: storageEventQuery.isLoading || storageDocQuery.isLoading, + scanProgress, + isScanning, + scanError, + tipHeight, + scanRange, + scanRecent, + cancelScan, + }; +} diff --git a/src/lib/hdwallet/derivation.ts b/src/lib/hdwallet/derivation.ts index 2c1535f3..c7158365 100644 --- a/src/lib/hdwallet/derivation.ts +++ b/src/lib/hdwallet/derivation.ts @@ -324,6 +324,37 @@ export interface SilentPaymentAddress { spendPubkeyHex: string; } +/** + * Receive-side silent-payment key material, including the *private* scan key. + * + * Per BIP-352 / NIP-SP §5, scanning the chain for incoming silent payments + * requires `bscan` (the 32-byte scan private scalar) to complete the ECDH + * step locally: `shared = bscan · tweak`. Because `bscan` is derived under + * a hardened branch, disclosing it does not reveal the master nsec or the + * spend private key — it grants only "see incoming payments" capability. + * BIP-352 explicitly contemplates handing `bscan` to a remote scan helper + * for that reason (we don't do that here; `bscan` stays on the device). + * + * `bspend` is **deliberately omitted** from this struct. The HD wallet is + * receive-only with respect to silent payments; we never sign SP inputs, so + * the spend private key never needs to be materialised. If spend support is + * added later, a dedicated helper should derive `bspend` at signing time. + */ +export interface SilentPaymentKeys { + /** 33-byte compressed scan pubkey. */ + Bscan: Uint8Array; + /** 33-byte compressed spend pubkey. */ + Bspend: Uint8Array; + /** + * 32-byte scan private scalar. MUST stay on the device — disclosure grants + * watch-only privacy break (every incoming SP payment becomes observable). + * Does NOT enable spending. + */ + bscan: Uint8Array; + /** Bech32m-encoded `sp1q…` address built from `(Bscan, Bspend)`. */ + address: string; +} + /** * Convert an 8-bit byte array to 5-bit words for bech32m encoding. * @@ -375,6 +406,51 @@ export function deriveSilentPaymentAddress(nsecBytes: Uint8Array): SilentPayment }; } +/** + * Same derivation as {@link deriveSilentPaymentAddress}, but additionally + * returns the *scan private key* `bscan`. Required by the BIP-352 receiver-side + * scanner (`src/lib/hdwallet/sp/`) which must compute `shared = bscan · tweak` + * locally to detect incoming silent payments. + * + * `bspend` is **not** returned — see {@link SilentPaymentKeys} for the + * receive-only rationale. Disclosure of `bscan` alone never lets an attacker + * spend; it only lets them see the wallet's incoming SP payments. + */ +export function deriveSilentPaymentKeys(nsecBytes: Uint8Array): SilentPaymentKeys { + const root = nsecToBip32Root(nsecBytes); + + const spendNode = root.derive(SP_SPEND_PATH); + const scanNode = root.derive(SP_SCAN_PATH); + + const Bspend = spendNode.publicKey; + const Bscan = scanNode.publicKey; + const bscan = scanNode.privateKey; + if (!Bspend || !Bscan || !bscan) { + throw new Error('Failed to derive silent payment keys'); + } + if (Bspend.length !== 33 || Bscan.length !== 33) { + throw new Error('Expected compressed (33-byte) silent payment pubkeys'); + } + if (bscan.length !== 32) { + throw new Error('Expected 32-byte silent payment scan private key'); + } + + // Payload: scan_pubkey || spend_pubkey (per BIP-352). + const payload = new Uint8Array(66); + payload.set(Bscan, 0); + payload.set(Bspend, 33); + const words = [SILENT_PAYMENT_VERSION, ...bech32m.toWords(payload)]; + const address = bech32m.encode(SILENT_PAYMENT_HRP, words, BECH32M_MAX_LENGTH); + + // Defensive copies — @scure/bip32 holds internal references. + return { + Bscan: new Uint8Array(Bscan), + Bspend: new Uint8Array(Bspend), + bscan: new Uint8Array(bscan), + address, + }; +} + // --------------------------------------------------------------------------- // Network constant export // --------------------------------------------------------------------------- diff --git a/src/lib/hdwallet/scan.ts b/src/lib/hdwallet/scan.ts index e502ddda..41614a57 100644 --- a/src/lib/hdwallet/scan.ts +++ b/src/lib/hdwallet/scan.ts @@ -83,6 +83,14 @@ export interface HdTransaction { type: 'receive' | 'send'; confirmed: boolean; timestamp?: number; + /** + * Which scan path discovered this transaction. Used by the UI to render a + * "silent payment" indicator on SP receives. `'bip86'` covers both receive + * and change addresses on the BIP-86 chains; `'silent-payment'` covers + * UTXOs detected by the BIP-352 scanner. Optional for back-compat with + * persisted UI state that pre-dates the field. + */ + source?: 'bip86' | 'silent-payment'; } // --------------------------------------------------------------------------- @@ -410,6 +418,7 @@ export function buildHdTransactions(result: AccountScanResult): HdTransaction[] type: m.netSats >= 0 ? 'receive' : 'send', confirmed: m.confirmed, timestamp: m.timestamp, + source: 'bip86', })); out.sort((a, b) => { diff --git a/src/lib/hdwallet/sp/crypto.ts b/src/lib/hdwallet/sp/crypto.ts new file mode 100644 index 00000000..ea483f59 --- /dev/null +++ b/src/lib/hdwallet/sp/crypto.ts @@ -0,0 +1,182 @@ +import { sha256 } from '@noble/hashes/sha256'; +import * as ecc from '@bitcoinerlab/secp256k1'; + +// --------------------------------------------------------------------------- +// BIP-352 silent-payments cryptographic primitives — receive-only subset +// --------------------------------------------------------------------------- +// +// This module is the math kernel that lets the HD wallet detect incoming +// silent payments. It is intentionally smaller than a full BIP-352 +// implementation: we only need the receiver-side primitives (per-output +// `Pₖ` derivation, public-data re-derivation) because the HD wallet +// scans-and-displays SP receives but cannot spend or send them. +// +// Specifically NOT included (and not needed for receive-only): +// +// - Sender-side `computeSPRecipientOutput` (we never construct SP outputs) +// - Receiver-side `deriveSPSpendScalar` (we never sign SP inputs) +// - BIP-352 label support (we never produce change because we never spend, +// and we don't currently hand out labeled receive addresses) +// - Address encode/decode (the bare receive address is produced by +// `derivation.deriveSilentPaymentAddress`, which the rest of the wallet +// already uses) +// +// Reference: this is the cryptographic subset of Ditto's `silent-payments.ts` +// that is needed by the receiver-side scanner — see also NIP-SP §5. +// +// All scalars are 32-byte big-endian, reduced mod the secp256k1 group order. +// All points are 33-byte compressed SEC1 except where x-only is explicit. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Byte / scalar helpers (no external dep beyond noble) +// --------------------------------------------------------------------------- + +function concat(...arrs: Uint8Array[]): Uint8Array { + let total = 0; + for (const a of arrs) total += a.length; + const out = new Uint8Array(total); + let off = 0; + for (const a of arrs) { + out.set(a, off); + off += a.length; + } + return out; +} + +/** + * Encode an ASCII-only string as bytes. All BIP-352 tags are ASCII + * ("BIP0352/Inputs", "BIP0352/SharedSecret", …) so this is exact. Avoiding + * `TextEncoder` keeps the output a vanilla `Uint8Array` realm, which sidesteps + * jsdom instanceof flakes in `@noble/hashes`. + */ +function asciiBytes(s: string): Uint8Array { + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; +} + +/** + * BIP-340 tagged hash: `SHA256(SHA256(tag) ‖ SHA256(tag) ‖ msg)`. + * + * `bitcoin.crypto.taggedHash` cannot be used here because it is locked to a + * fixed enum of bitcoin-protocol tags ("TapTweak", "BIP0340/challenge", …) + * and refuses arbitrary tag strings like "BIP0352/SharedSecret". + */ +export function taggedHash(tag: string, msg: Uint8Array): Uint8Array { + const tagHash = sha256(asciiBytes(tag)); + return sha256(concat(tagHash, tagHash, msg)); +} + +/** + * Big-endian 4-byte serialisation per BIP-352 `ser32`. Used to feed the + * per-output counter `k` into the `BIP0352/SharedSecret` tagged hash. + */ +function ser32BE(n: number): Uint8Array { + if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) { + throw new Error(`ser32 out of range: ${n}`); + } + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, n, false); + return out; +} + +/** Constant-time-ish byte compare. (Returns true on equal length + content.) */ +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let acc = 0; + for (let i = 0; i < a.length; i++) acc |= a[i] ^ b[i]; + return acc === 0; +} + +// --------------------------------------------------------------------------- +// Receiver-side per-output derivation (BIP-352, NIP-SP §5) +// --------------------------------------------------------------------------- + +/** + * Given a per-transaction shared-secret point and a recipient's `Bspend`, + * compute the on-chain Taproot output key `Pₖ` at index `k`: + * + * ``` + * tₖ = H_tagged("BIP0352/SharedSecret", serP(shared) ‖ ser32_BE(k)) + * Pₖ = Bspend + tₖ · G + * ``` + * + * The receiver computes `shared = bscan · tweak` (where `tweak = serP(input_hash · A)` + * comes from the BIP-352 tweak indexer) and walks `k = 0, 1, …` until no + * output matches. + * + * Returns the 33-byte full point (for the parity-aware label-match path that + * would be needed if we ever supported labels), the 32-byte x-only output key + * (what the scanner compares against on-chain outputs), and the 32-byte + * `tₖ` scalar (persisted into the kind-30078 UTXO storage so a future + * spending path could derive `dₖ = bspend + tₖ` without re-running the scan). + */ +export function derivePkAtIndex( + shared: Uint8Array, + Bspend: Uint8Array, + k: number, +): { xonlyPk: Uint8Array; fullPk: Uint8Array; tweak: Uint8Array } { + if (shared.length !== 33) throw new Error('shared must be a 33-byte compressed point'); + if (Bspend.length !== 33) throw new Error('Bspend must be a 33-byte compressed point'); + + const tk = taggedHash('BIP0352/SharedSecret', concat(shared, ser32BE(k))); + + const tG = ecc.pointFromScalar(tk, true); + if (!tG) throw new Error('Failed to compute tₖ · G'); + const Pk = ecc.pointAdd(Bspend, tG, true); + if (!Pk) throw new Error('Failed to compute Pₖ'); + + const full = new Uint8Array(Pk); + return { + xonlyPk: full.slice(1, 33), + fullPk: full, + tweak: tk, + }; +} + +/** + * Compute the on-chain x-only Taproot output key from a persisted `tₖ` + * tweak — pure public-data derivation that doesn't need `bscan`: + * + * ``` + * Pₖ = Bspend + tₖ · G + * ``` + * + * Useful for re-deriving the spendable output key for a previously-discovered + * SP UTXO without re-running ECDH against an indexer. The HD wallet doesn't + * currently spend SP UTXOs so this helper is exported mainly for future-proofing + * and for tests that verify the round-trip discover → store → re-derive cycle. + */ +export function derivePkFromStoredTweak( + Bspend: Uint8Array, + tweak: Uint8Array, +): Uint8Array { + if (Bspend.length !== 33) throw new Error('Bspend must be 33-byte compressed'); + if (tweak.length !== 32) throw new Error('tweak must be 32 bytes'); + + const tG = ecc.pointFromScalar(tweak, true); + if (!tG) throw new Error('Failed to compute tₖ · G'); + const Pk = ecc.pointAdd(Bspend, tG, true); + if (!Pk) throw new Error('Failed to compute Pₖ'); + return new Uint8Array(Pk.slice(1, 33)); +} + +// --------------------------------------------------------------------------- +// Hex helpers +// --------------------------------------------------------------------------- + +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('Invalid hex length'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; +} + +export function bytesToHex(b: Uint8Array): string { + let s = ''; + for (const x of b) s += x.toString(16).padStart(2, '0'); + return s; +} diff --git a/src/lib/hdwallet/sp/indexer.ts b/src/lib/hdwallet/sp/indexer.ts new file mode 100644 index 00000000..4efb0ded --- /dev/null +++ b/src/lib/hdwallet/sp/indexer.ts @@ -0,0 +1,206 @@ +import { hexToBytes } from './crypto'; +import type { ScanTweakEntry } from './scanner'; + +// --------------------------------------------------------------------------- +// BIP-352 tweak-data indexer client — BlindBit Oracle v2 backend +// --------------------------------------------------------------------------- +// +// The HD wallet's silent-payment scan path (BIP-352 §"Scanning", NIP-SP §5.1) +// expects an external indexer that exposes only PUBLIC per-tx tweak data, so +// the wallet completes the ECDH locally with `bscan` (which MUST NOT leave +// the device). +// +// Endpoints consumed: +// +// GET /info → { network, height, ... } +// GET /tweaks/:blockheight → string[] (33-byte compressed tweaks, hex) +// GET /utxos/:blockheight → Array<{ txid, vout, value, scriptpubkey, spent, ... }> +// +// Why we need BOTH /tweaks and /utxos per block: +// BlindBit's /tweaks payload is intentionally minimal — just the list of +// public tweaks for the block, no txid↔tweak mapping and no outputs. We pair +// each tweak with the block's full SP-eligible UTXO set (`/utxos/:height`) +// and let the BIP-352 math match `Pₖ` against the right output; an unrelated +// UTXO has only a ~2⁻²⁵⁶ chance of accidentally matching a derived `Pₖ`. +// +// `spent: true` rows in /utxos are filtered out so a fresh scan doesn't +// briefly add already-spent UTXOs to the wallet's persisted SP set. +// +// The base URL is configurable via `AppConfig.bip352IndexerUrl`. When unset +// the wallet's scan UI is hidden and no calls are made. +// --------------------------------------------------------------------------- + +interface BlindBitInfoResponse { + network?: unknown; + height?: unknown; +} + +interface BlindBitUtxoRow { + txid?: unknown; + vout?: unknown; + value?: unknown; + scriptpubkey?: unknown; + spent?: unknown; +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +function isHexString(v: unknown, lenChars: number): v is string { + return typeof v === 'string' && v.length === lenChars && /^[0-9a-fA-F]+$/.test(v); +} + +function isInt(v: unknown): v is number { + return typeof v === 'number' && Number.isInteger(v) && v >= 0; +} + +/** + * Extract the 32-byte x-only key from a P2TR scriptPubKey (`OP_1 OP_PUSH32 `). + * Returns null for any other script — BlindBit only indexes Taproot outputs, + * so a non-P2TR row is treated as malformed and skipped. + */ +function xonlyFromP2trScriptPubKey(spk: string): Uint8Array | null { + if (spk.length !== 68) return null; + if (!spk.toLowerCase().startsWith('5120')) return null; + try { + return hexToBytes(spk.slice(4)); + } catch { + return null; + } +} + +interface ParsedUtxo { + txid: string; + vout: number; + xonlyPk: Uint8Array; + value: number; +} + +function parseUtxoRow(raw: BlindBitUtxoRow): ParsedUtxo | null { + if (!isHexString(raw.txid, 64)) return null; + if (!isInt(raw.vout)) return null; + if (!isInt(raw.value)) return null; + if (typeof raw.scriptpubkey !== 'string') return null; + const xonly = xonlyFromP2trScriptPubKey(raw.scriptpubkey); + if (!xonly) return null; + return { + txid: (raw.txid as string).toLowerCase(), + vout: raw.vout, + xonlyPk: xonly, + value: raw.value, + }; +} + +function trimBase(url: string): string { + return url.replace(/\/+$/, ''); +} + +function requireBase(url: string): string { + const root = trimBase(url); + if (!root) throw new Error('BIP-352 indexer base URL must not be empty'); + return root; +} + +// --------------------------------------------------------------------------- +// Per-block fetchers +// --------------------------------------------------------------------------- + +async function fetchTweaksForBlock( + root: string, + height: number, + signal?: AbortSignal, +): Promise { + const r = await fetch(`${root}/tweaks/${height}`, { signal }); + if (!r.ok) throw new Error(`BIP-352 /tweaks/${height} returned ${r.status}`); + const data = await r.json(); + if (!Array.isArray(data)) { + throw new Error(`BIP-352 /tweaks/${height} response is not an array`); + } + const out: Uint8Array[] = []; + for (const v of data) { + if (!isHexString(v, 66)) continue; + try { + out.push(hexToBytes((v as string).toLowerCase())); + } catch { + // Skip malformed entries — one bad tweak shouldn't sink the block. + } + } + return out; +} + +async function fetchUtxosForBlock( + root: string, + height: number, + signal?: AbortSignal, +): Promise { + const r = await fetch(`${root}/utxos/${height}`, { signal }); + if (!r.ok) throw new Error(`BIP-352 /utxos/${height} returned ${r.status}`); + const data = await r.json(); + if (!Array.isArray(data)) { + throw new Error(`BIP-352 /utxos/${height} response is not an array`); + } + const out: ParsedUtxo[] = []; + for (const raw of data as BlindBitUtxoRow[]) { + // Filter out already-spent rows so we don't add them to the wallet's + // active UTXO set just to have a future reconcile pass prune them. + if (raw && raw.spent === true) continue; + const parsed = parseUtxoRow(raw); + if (parsed) out.push(parsed); + } + return out; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolve the indexer's current tip height via `GET /info`. Cheap call — + * a single HTTP round-trip and a small JSON response. + */ +export async function fetchTipHeight(baseUrl: string, signal?: AbortSignal): Promise { + const root = requireBase(baseUrl); + const r = await fetch(`${root}/info`, { signal }); + if (!r.ok) throw new Error(`BIP-352 /info returned ${r.status}`); + const data = (await r.json()) as BlindBitInfoResponse; + const h = data.height; + if (!isInt(h)) throw new Error('BIP-352 /info response missing valid `height`'); + return h; +} + +/** + * Fetch the `ScanTweakEntry[]` for ONE block: one entry per tweak, each + * sharing the block's full SP-eligible UTXO set as candidate outputs. + * + * The scanner matches each tweak's derived `Pₖ` against the pooled outputs + * and records the matched UTXO's own txid (per-output) — so attribution is + * correct even though the `/tweaks/:height` endpoint loses the tweak ↔ txid + * mapping. + * + * Optimisations: + * - When `/tweaks/:height` is empty we skip the `/utxos/:height` call. + * Empty blocks are common in mainnet history; this halves the request + * count on average. + * - All entries share one `outputs` array (read-only) so we don't duplicate + * per-output bytes across N tweaks in the same block. + */ +export async function fetchBlockEntries( + baseUrl: string, + height: number, + signal?: AbortSignal, +): Promise { + const root = requireBase(baseUrl); + const tweaks = await fetchTweaksForBlock(root, height, signal); + if (tweaks.length === 0) return []; + if (signal?.aborted) return []; + const utxos = await fetchUtxosForBlock(root, height, signal); + if (utxos.length === 0) return []; + + const sharedOutputs: ScanTweakEntry['outputs'] = utxos; + return tweaks.map((tweak) => ({ + height, + tweak, + outputs: sharedOutputs, + })); +} diff --git a/src/lib/hdwallet/sp/scanner.ts b/src/lib/hdwallet/sp/scanner.ts new file mode 100644 index 00000000..8bc8a9c0 --- /dev/null +++ b/src/lib/hdwallet/sp/scanner.ts @@ -0,0 +1,184 @@ +import * as ecc from '@bitcoinerlab/secp256k1'; + +import { bytesEqual, derivePkAtIndex } from './crypto'; + +// --------------------------------------------------------------------------- +// BIP-352 receiver-side per-transaction scanner +// --------------------------------------------------------------------------- +// +// Consumes per-transaction "tweak data" from an external indexer (see +// `indexer.ts`) and checks each tweak against the wallet's `bscan` / `Bspend`. +// The tweak is the public per-tx point `serP(input_hash · A)` — the indexer +// pre-computes everything that depends only on the transaction's eligible +// inputs, so the wallet completes the ECDH locally with `bscan`. `bscan` MUST +// NEVER leave the device. +// +// This module is pure math: no fetching, no React, no signers, no labels. +// Inputs in, matched UTXOs out. The orchestrator (`useHdWalletSp`) is +// responsible for fetching tweak data, persisting matches, and yielding to +// the UI. +// +// Label support is deliberately omitted: the receive-only wallet never +// produces labeled change (because it never spends), and we don't hand out +// labeled receive addresses. BIP-352's "every receiving wallet should scan +// for the change label" rule only applies to wallets that may have spent +// their own SP UTXOs — when spend support arrives we'll need to add label +// derivation back in. +// --------------------------------------------------------------------------- + +/** + * One unit of scanner work: a public tweak plus the set of candidate Taproot + * outputs it may have produced. + * + * Each output carries its own txid because BlindBit's `GET /tweaks/:height` + * endpoint does not return a tweak ↔ txid mapping — the wallet pairs every + * tweak in a block against the block's full SP-eligible UTXO set and lets + * the BIP-352 math pick the right output. + */ +export interface ScanTweakEntry { + /** Block height the tweak (and its candidate outputs) belongs to. */ + height: number; + /** Per-tx public tweak: 33-byte compressed `input_hash · A`. */ + tweak: Uint8Array; + /** + * Candidate Taproot outputs. Each output carries its txid so the matched + * UTXO can be attributed correctly even when the indexer pools outputs + * from multiple txs against the same tweak. + */ + outputs: ReadonlyArray<{ txid: string; vout: number; xonlyPk: Uint8Array; value: number }>; +} + +/** A UTXO the scanner determined belongs to us. */ +export interface SPMatchedUtxo { + txid: string; + vout: number; + /** Output value in satoshis. */ + value: number; + /** Block height at which the UTXO was mined. */ + height: number; + /** Per-output BIP-352 tweak `tₖ` (32 bytes). Needed at spend time. */ + tweak: Uint8Array; + /** Output index within the transaction's SP output set (k = 0, 1, …). */ + k: number; +} + +/** + * Check one tweak entry's outputs against the user's SP keys and return every + * matching UTXO. Per BIP-352 the receiver iterates `k = 0, 1, …` until no + * output matches the current `k`; we track which outputs have already been + * claimed so each output matches at most one `k`. + * + * Never throws on malformed inputs at the wire level — the orchestrator + * upstream is responsible for that. The only inputs validated here are the + * key/tweak lengths (anything else is a programmer error). + */ +export function scanTransaction( + entry: ScanTweakEntry, + bscan: Uint8Array, + Bspend: Uint8Array, +): SPMatchedUtxo[] { + if (bscan.length !== 32) throw new Error('bscan must be 32 bytes'); + if (Bspend.length !== 33) throw new Error('Bspend must be 33-byte compressed'); + if (entry.tweak.length !== 33) throw new Error('entry.tweak must be 33-byte compressed'); + + // shared = bscan · tweak == bscan · (input_hash · A) == input_hash · a · Bscan + // — the same shared secret the sender computed. + const shared = ecc.pointMultiply(entry.tweak, bscan, true); + if (!shared) return []; + + if (entry.outputs.length === 0) return []; + + const remaining = new Set(entry.outputs.map((_, i) => i)); + const matches: SPMatchedUtxo[] = []; + const sharedBytes = new Uint8Array(shared); + + let k = 0; + while (remaining.size > 0) { + const { xonlyPk, tweak: tk } = derivePkAtIndex(sharedBytes, Bspend, k); + + let matchedIdx: number | null = null; + for (const i of remaining) { + if (bytesEqual(entry.outputs[i].xonlyPk, xonlyPk)) { + matchedIdx = i; + break; + } + } + if (matchedIdx === null) break; + + const o = entry.outputs[matchedIdx]; + matches.push({ + txid: o.txid, + vout: o.vout, + value: o.value, + height: entry.height, + tweak: tk, + k, + }); + remaining.delete(matchedIdx); + k += 1; + } + + return matches; +} + +// --------------------------------------------------------------------------- +// Batch orchestration +// --------------------------------------------------------------------------- + +export interface ScanBatchOptions { + /** Yield to the event loop every N processed entries. Default: 64. */ + yieldEvery?: number; + /** Called after each yield with the highest height fully processed. */ + onProgress?: (height: number) => void; + /** Abort signal — when triggered, the scanner returns whatever it has so far. */ + signal?: AbortSignal; +} + +/** + * Walk a batch of tweak entries, scanning each against the user's SP keys. + * Yields to the event loop periodically so a long scan doesn't freeze the UI. + * + * Entries SHOULD be sorted by (height, position) so `onProgress` reports + * monotonic heights. Malformed entries are skipped silently — one bad tweak + * shouldn't sink an otherwise-good scan window. + */ +export async function scanBatch( + entries: ReadonlyArray, + bscan: Uint8Array, + Bspend: Uint8Array, + opts: ScanBatchOptions = {}, +): Promise { + const yieldEvery = opts.yieldEvery ?? 64; + const matches: SPMatchedUtxo[] = []; + let lastReportedHeight = -1; + let processedSinceYield = 0; + + for (const entry of entries) { + if (opts.signal?.aborted) break; + + try { + const hit = scanTransaction(entry, bscan, Bspend); + if (hit.length > 0) matches.push(...hit); + } catch { + // Malformed entry — skip rather than abort the whole batch. + } + + if (entry.height > lastReportedHeight) { + lastReportedHeight = entry.height; + } + + processedSinceYield += 1; + if (processedSinceYield >= yieldEvery) { + processedSinceYield = 0; + opts.onProgress?.(lastReportedHeight); + // Yield to the macrotask queue so React renders + user input can run. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + if (lastReportedHeight >= 0) { + opts.onProgress?.(lastReportedHeight); + } + + return matches; +} diff --git a/src/lib/hdwallet/sp/storage.ts b/src/lib/hdwallet/sp/storage.ts new file mode 100644 index 00000000..a22690f3 --- /dev/null +++ b/src/lib/hdwallet/sp/storage.ts @@ -0,0 +1,186 @@ +import { bytesToHex, hexToBytes } from './crypto'; +import type { SPMatchedUtxo } from './scanner'; + +// --------------------------------------------------------------------------- +// Persisted silent-payment UTXO state — Agora NIP-78 codec +// --------------------------------------------------------------------------- +// +// The HD wallet's discovered SP UTXOs are persisted as an addressable +// **NIP-78** event (kind 30078) whose `content` is a NIP-44-encrypted JSON +// document. NIP-78 is "Application-specific data" — the natural home for +// per-user, per-app state that should sync across devices via the user's +// relays. +// +// Why NIP-78 (and not the legacy `useSecureLocalStorage` cursor we use for +// the receive-address pointer): +// +// - SP scanning is *expensive* — a deep history scan may pull thousands of +// blocks from a BIP-352 indexer. Storing the result on relays means a +// fresh install / new device gets balance + history immediately without +// re-scanning. The local cursor is a UX preference; this is wallet state. +// - The encrypted payload contains per-output BIP-352 tweaks (`tₖ`), +// which a hostile relay operator could in principle correlate with +// on-chain outputs if they leaked. NIP-44 encryption to the user's own +// pubkey closes that window. +// +// We deliberately do NOT use the Ditto NIP-SP kinds (10352 declaration, 10353 +// encrypted UTXO set) here: +// +// - Kind 10352 publishes the wallet's `(Bscan, Bspend)` so others can send +// SP payments to the user's npub. Agora's `sp1q…` address is already +// displayed in the receive UI; publishing 10352 is a separate sender-side +// concern we don't take on for receive-only. +// - Kind 10353 is the Ditto-specific encrypted UTXO state and shares the +// same purpose as this event, but the schemas differ (Ditto stores tweaks +// and labels in inner *tags*; we store them as JSON for simpler versioning +// and forward-compatibility, at the cost of slightly larger ciphertext). +// +// Event shape: +// +// kind: 30078 +// d-tag: `${appId}/hdwallet/sp-utxos` // e.g. "agora/hdwallet/sp-utxos" +// tags: [['d', ...], ['title', ...], ['client', ...]] +// content: NIP-44( JSON.stringify(SPStorageDocument) ) +// --------------------------------------------------------------------------- + +/** Current document schema version. Bump on breaking changes. */ +export const SP_STORAGE_VERSION = 1; + +/** Stable d-tag suffix appended to `appId` to form the full NIP-78 d-tag. */ +export const SP_STORAGE_D_TAG_SUFFIX = 'hdwallet/sp-utxos'; + +/** Build the full d-tag for the given appId, e.g. `"agora/hdwallet/sp-utxos"`. */ +export function spStorageDTag(appId: string): string { + return `${appId}/${SP_STORAGE_D_TAG_SUFFIX}`; +} + +/** One persisted silent-payment UTXO entry. */ +export interface SPStoredUtxo { + /** Lowercase 64-char hex transaction id. */ + txid: string; + /** Output index. */ + vout: number; + /** Value in satoshis. */ + value: number; + /** Block height the UTXO was mined at. */ + height: number; + /** 32-byte BIP-352 tweak `tₖ`, lowercase hex. Needed at spend time. */ + tweak: string; + /** Per-tx output index within the SP output set (`k = 0, 1, …`). */ + k: number; +} + +/** The full persisted document, after NIP-44 decrypt + JSON parse. */ +export interface SPStorageDocument { + /** Schema version. Always `SP_STORAGE_VERSION` for newly-written docs. */ + version: number; + /** + * The highest *fully-scanned* block height. Forward scans should resume at + * `scanHeight + 1`. `0` means "never scanned". + */ + scanHeight: number; + /** All discovered SP UTXOs the wallet has not pruned as spent. */ + utxos: SPStoredUtxo[]; +} + +/** Empty document used as the starting state. */ +export const EMPTY_SP_STORAGE: SPStorageDocument = { + version: SP_STORAGE_VERSION, + scanHeight: 0, + utxos: [], +}; + +// --------------------------------------------------------------------------- +// Codec +// --------------------------------------------------------------------------- + +/** + * Parse a decrypted JSON string into an `SPStorageDocument`. Returns the + * empty document on any error rather than throwing, so a corrupted relay + * payload doesn't break the wallet — a fresh scan recovers state. + */ +export function parseSPStorage(plaintext: string): SPStorageDocument { + let raw: unknown; + try { + raw = JSON.parse(plaintext); + } catch { + return { ...EMPTY_SP_STORAGE }; + } + if (!raw || typeof raw !== 'object') return { ...EMPTY_SP_STORAGE }; + const obj = raw as Record; + const scanHeight = typeof obj.scanHeight === 'number' && Number.isInteger(obj.scanHeight) && obj.scanHeight >= 0 + ? obj.scanHeight + : 0; + const utxosRaw = Array.isArray(obj.utxos) ? obj.utxos : []; + const utxos: SPStoredUtxo[] = []; + for (const u of utxosRaw) { + if (!u || typeof u !== 'object') continue; + const row = u as Record; + if (typeof row.txid !== 'string' || !/^[0-9a-f]{64}$/.test(row.txid)) continue; + if (typeof row.vout !== 'number' || !Number.isInteger(row.vout) || row.vout < 0) continue; + if (typeof row.value !== 'number' || !Number.isInteger(row.value) || row.value < 0) continue; + if (typeof row.height !== 'number' || !Number.isInteger(row.height) || row.height < 0) continue; + if (typeof row.tweak !== 'string' || !/^[0-9a-f]{64}$/.test(row.tweak)) continue; + if (typeof row.k !== 'number' || !Number.isInteger(row.k) || row.k < 0) continue; + utxos.push({ + txid: row.txid, + vout: row.vout, + value: row.value, + height: row.height, + tweak: row.tweak, + k: row.k, + }); + } + return { version: SP_STORAGE_VERSION, scanHeight, utxos }; +} + +/** Serialise a document for encryption — pretty-printed for slightly better diff-ability. */ +export function serializeSPStorage(doc: SPStorageDocument): string { + return JSON.stringify({ + version: SP_STORAGE_VERSION, + scanHeight: doc.scanHeight, + utxos: doc.utxos, + }); +} + +// --------------------------------------------------------------------------- +// UTXO helpers (pure-data ops; no I/O) +// --------------------------------------------------------------------------- + +/** Convert a freshly-discovered match into the persisted hex form. */ +export function matchedUtxoToStored(m: SPMatchedUtxo): SPStoredUtxo { + return { + txid: m.txid, + vout: m.vout, + value: m.value, + height: m.height, + tweak: bytesToHex(m.tweak), + k: m.k, + }; +} + +/** + * Merge a batch of newly-discovered UTXOs into the persisted set, de-duplicated + * by `(txid, vout)`. New entries overwrite existing ones with the same key — + * useful if a re-scan corrects a previously-mis-recorded height/value. + */ +export function mergeUtxos( + existing: ReadonlyArray, + fresh: ReadonlyArray, +): SPStoredUtxo[] { + const key = (u: SPStoredUtxo) => `${u.txid}:${u.vout}`; + const map = new Map(); + for (const u of existing) map.set(key(u), u); + for (const u of fresh) map.set(key(u), u); + return Array.from(map.values()); +} + +/** Total satoshi balance across all stored UTXOs. */ +export function spStorageBalance(doc: SPStorageDocument): number { + let total = 0; + for (const u of doc.utxos) total += u.value; + return total; +} + +// Re-export hex helpers for callers that want to read tweak bytes back. +export { hexToBytes, bytesToHex }; diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index a9dbd9c3..e5dee74b 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -266,6 +266,14 @@ export const AppConfigSchema = z.object({ z.array(z.string().url()).min(1), ]), blockbookBaseUrl: z.string().url(), + /** + * BIP-352 tweak-data indexer URL. Empty string disables silent-payment + * scanning. When set, must be a valid http(s) URL with no trailing slash. + */ + bip352IndexerUrl: z.union([ + z.literal(''), + z.string().url(), + ]).optional().default(''), currencyDisplay: z.enum(['usd', 'sats']).optional(), sidebarWidgets: z.array(z.object({ id: z.string(), diff --git a/src/pages/HDWalletPage.tsx b/src/pages/HDWalletPage.tsx index 46bf8145..35bb6d90 100644 --- a/src/pages/HDWalletPage.tsx +++ b/src/pages/HDWalletPage.tsx @@ -12,6 +12,7 @@ import { ShieldOff, ArrowRight, KeyRound, + Radar, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -27,8 +28,10 @@ import { } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { HDSendBitcoinDialog } from '@/components/HDSendBitcoinDialog'; +import { HDSilentPaymentScanDialog } from '@/components/HDSilentPaymentScanDialog'; import { useAppContext } from '@/hooks/useAppContext'; import { useHdWallet } from '@/hooks/useHdWallet'; +import { useHdWalletSp } from '@/hooks/useHdWalletSp'; import { useHdBtcPrice } from '@/hooks/useHdBtcPrice'; import { satsToUSD, formatBTC } from '@/lib/bitcoin'; import type { HdTransaction } from '@/lib/hdwallet/scan'; @@ -48,6 +51,7 @@ export function HDWalletPage() { refetch, nextReceiveAddress, } = useHdWallet(); + const sp = useHdWalletSp(); const { data: btcPrice } = useHdBtcPrice(); const [copiedAddress, setCopiedAddress] = useState(false); @@ -55,6 +59,7 @@ export function HDWalletPage() { const [txOpen, setTxOpen] = useState(false); const [sendOpen, setSendOpen] = useState(false); const [receiveOpen, setReceiveOpen] = useState(false); + const [spScanOpen, setSpScanOpen] = useState(false); useSeoMeta({ title: `HD Wallet | ${config.appName}`, @@ -230,6 +235,8 @@ export function HDWalletPage() { btcPrice={btcPrice} /> + + {/* Receive Dialog */} @@ -312,11 +319,37 @@ export function HDWalletPage() { )} -

- Receive-only. This wallet doesn't yet scan for incoming - silent payments — funds sent here won't show up in your - balance until silent payment support is wired in. -

+ {sp.unavailableReason === 'no-indexer' ? ( +

+ Scanning disabled — no BIP-352 indexer configured. Set{' '} + bip352IndexerUrl in your app config to + detect incoming silent payments. +

+ ) : sp.enabled ? ( +
+ {sp.balance > 0 && ( +

+ Silent payment balance:{' '} + + {btcPrice + ? satsToUSD(sp.balance, btcPrice) + : `${formatBTC(sp.balance)} BTC`} + +

+ )} + +
+ ) : null} )} @@ -380,6 +413,7 @@ function formatTxDate(timestamp?: number): string { function TxRow({ tx, btcPrice }: { tx: HdTransaction; btcPrice?: number }) { const isReceive = tx.type === 'receive'; + const isSilent = tx.source === 'silent-payment'; return ( }
-

{isReceive ? 'Received' : 'Sent'}

+

+ {isReceive ? 'Received' : 'Sent'} + {isSilent && ( + + + silent + + )} +

{formatTxDate(tx.timestamp)}

diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index bd137976..3db18ab2 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -119,6 +119,7 @@ export function TestApp({ children }: TestAppProps) { sandboxDomain: 'iframe.diy', esploraApis: ['https://mempool.space/api'], blockbookBaseUrl: 'https://btc.trezor.io', + bip352IndexerUrl: '', sidebarWidgets: [], aiBaseURL: 'https://ai.shakespeare.diy/v1', aiApiKey: '',