Scan for silent payments in /hdwallet via BIP-352 indexer

This commit is contained in:
Alex Gleason
2026-05-21 19:25:24 -05:00
parent 774305f799
commit 059f75dbc5
14 changed files with 1629 additions and 10 deletions
+1
View File
@@ -153,6 +153,7 @@ const hardcodedConfig: AppConfig = {
'https://blockstream.info/api',
],
blockbookBaseUrl: 'https://btc.trezor.io',
bip352IndexerUrl: '',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Scan for silent payments</DialogTitle>
<DialogDescription>
Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-scan-from" className="text-xs">
From block
</Label>
<Input
id="sp-scan-from"
type="number"
inputMode="numeric"
min={0}
value={from}
onChange={(e) => {
setTouched(true);
setFrom(e.target.value);
}}
disabled={sp.isScanning}
aria-invalid={!fromValid}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-scan-to" className="text-xs">
To block
</Label>
<Input
id="sp-scan-to"
type="number"
inputMode="numeric"
min={0}
placeholder="tip"
value={to}
onChange={(e) => {
setTouched(true);
setTo(e.target.value);
}}
disabled={sp.isScanning}
aria-invalid={!toValid}
/>
</div>
</div>
{sp.tipHeight !== undefined && (
<p className="text-xs text-muted-foreground">
Indexer tip: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
{sp.storage && (
<>
{' · '}
Last fully scanned:{' '}
<span className="font-mono">
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'}
</span>
</>
)}
</p>
)}
{sp.isScanning && sp.scanProgress && (
<div className="space-y-2">
<Progress value={progressPercent} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '}
{sp.scanProgress.toHeight.toLocaleString()}
</span>
<span>
{sp.scanProgress.matchesFound} match
{sp.scanProgress.matchesFound === 1 ? '' : 'es'}
</span>
</div>
</div>
)}
{!sp.isScanning && sp.scanError && (
<div className="flex items-start gap-2 text-xs text-destructive">
<AlertCircle className="size-4 shrink-0 mt-0.5" />
<p>{sp.scanError.message}</p>
</div>
)}
{!sp.isScanning && !sp.scanError && sp.scanProgress && (
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<CheckCircle2 className="size-4 shrink-0 mt-0.5 text-green-500" />
<p>
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.'}
</p>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
{sp.isScanning ? (
<Button variant="outline" onClick={() => sp.cancelScan()}>
Cancel
</Button>
) : (
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={handleScan} disabled={!inputsValid}>
Start scan
</Button>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+28
View File
@@ -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.
+64 -4
View File
@@ -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<HdTransaction[] | undefined>(() => {
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<string, { amount: number; height: number }>();
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<DerivedAddress | undefined>(() => {
@@ -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,
+431
View File
@@ -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<void>;
/** Scan the most recent `DEFAULT_RECENT_SCAN_BLOCKS` blocks (or fewer if newer). */
scanRecent: () => Promise<void>;
/** 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<SilentPaymentKeys | undefined>(() => {
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<number>({
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<SPStorageDocument>({
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<SPStorageDocument | null>(null);
const [optimisticVersion, setOptimisticVersion] = useState(0);
void optimisticVersion; // touched so React knows to re-render on bump
const storage = useMemo<SPStorageDocument | undefined>(() => {
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<UseHdWalletSpResult['scanProgress']>();
const [scanError, setScanError] = useState<Error | undefined>();
const [isScanning, setIsScanning] = useState(false);
const scanAbortRef = useRef<AbortController | null>(null);
// Debounce timer for republishing storage during a long scan.
const republishTimerRef = useRef<ReturnType<typeof setTimeout> | 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<UseHdWalletSpResult['scanRange']>(
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<UseHdWalletSpResult['scanRecent']>(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,
};
}
+76
View File
@@ -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
// ---------------------------------------------------------------------------
+9
View File
@@ -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) => {
+182
View File
@@ -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;
}
+206
View File
@@ -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 <xonly>`).
* 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<Uint8Array[]> {
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<ParsedUtxo[]> {
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<number> {
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<ScanTweakEntry[]> {
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,
}));
}
+184
View File
@@ -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<number>(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<ScanTweakEntry>,
bscan: Uint8Array,
Bspend: Uint8Array,
opts: ScanBatchOptions = {},
): Promise<SPMatchedUtxo[]> {
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<void>((resolve) => setTimeout(resolve, 0));
}
}
if (lastReportedHeight >= 0) {
opts.onProgress?.(lastReportedHeight);
}
return matches;
}
+186
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<SPStoredUtxo>,
fresh: ReadonlyArray<SPStoredUtxo>,
): SPStoredUtxo[] {
const key = (u: SPStoredUtxo) => `${u.txid}:${u.vout}`;
const map = new Map<string, SPStoredUtxo>();
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 };
+8
View File
@@ -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(),
+51 -6
View File
@@ -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}
/>
<HDSilentPaymentScanDialog open={spScanOpen} onOpenChange={setSpScanOpen} />
{/* Receive Dialog */}
<Dialog open={receiveOpen} onOpenChange={setReceiveOpen}>
<DialogContent className="sm:max-w-sm">
@@ -312,11 +319,37 @@ export function HDWalletPage() {
)}
</button>
<p className="text-xs text-orange-500 dark:text-orange-400 text-center max-w-xs">
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.
</p>
{sp.unavailableReason === 'no-indexer' ? (
<p className="text-xs text-orange-500 dark:text-orange-400 text-center max-w-xs">
Scanning disabled no BIP-352 indexer configured. Set{' '}
<span className="font-mono">bip352IndexerUrl</span> in your app config to
detect incoming silent payments.
</p>
) : sp.enabled ? (
<div className="flex flex-col items-center gap-2 w-full">
{sp.balance > 0 && (
<p className="text-xs text-muted-foreground text-center">
Silent payment balance:{' '}
<span className="text-foreground font-medium">
{btcPrice
? satsToUSD(sp.balance, btcPrice)
: `${formatBTC(sp.balance)} BTC`}
</span>
</p>
)}
<Button
variant="outline"
size="sm"
onClick={() => setSpScanOpen(true)}
className="rounded-full"
>
<Radar className="size-3.5 mr-1.5" />
{sp.storage?.scanHeight && sp.storage.scanHeight > 0
? 'Scan for new payments'
: 'Scan for payments'}
</Button>
</div>
) : null}
</div>
)}
</TabsContent>
@@ -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 (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
@@ -396,7 +430,18 @@ function TxRow({ tx, btcPrice }: { tx: HdTransaction; btcPrice?: number }) {
: <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-sm font-medium flex items-center gap-1.5">
{isReceive ? 'Received' : 'Sent'}
{isSilent && (
<span
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
title="Detected via BIP-352 silent payment scan"
>
<Radar className="size-2.5" />
silent
</span>
)}
</p>
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
</div>
</div>
+1
View File
@@ -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: '',