Scan for silent payments in /hdwallet via BIP-352 indexer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user