Add HD Bitcoin wallet at /hdwallet
A production-grade BIP86 Taproot HD wallet, separate from the single-address
wallet at /wallet. The seed is derived deterministically from the user's nsec
via HKDF-SHA-256 with an app-specific info string, so there is no new secret
for the user to back up \u2014 if they have their nsec they have the wallet.
Architecture:
- src/lib/hdwallet/derivation.ts \u2014 HKDF seed, BIP86 (m/86'/0'/0'),
receive (0) and change (1) chains, per-leaf P2TR addresses, TapTweaked
signing keys.
- src/lib/hdwallet/scan.ts \u2014 Standard gap-limit (20) scan across both
chains via Esplora. Aggregated UTXO set, balance, and tx history
(merged by txid so send-with-change shows as one row).
- src/lib/hdwallet/transaction.ts \u2014 Largest-first coin selection
(confirmed first), multi-input P2TR PSBT build with per-input
tapInternalKey from re-derived child keys, fresh change addresses on
the internal chain (no address reuse).
- useHdWalletAccess \u2014 Gates on login type === 'nsec'. Extension and
bunker logins keep the key isolated, so the page shows an explanatory
card with a link back to /wallet.
- useHdWallet \u2014 Scan + tx history queries (60 s refresh), persisted
receive-cursor in secure storage (Keychain on native, localStorage on
web), auto-advance when chain catches up so old addresses are never
re-shown.
- HDWalletPage \u2014 Mirrors /wallet's clean UX: big USD balance, send
button, QR + truncated address, 'New address' rotator, collapsible tx
history.
- HDSendBitcoinDialog \u2014 Mirrors SendBitcoinDialog (USD presets, fee
speed picker, two-tap arm for large amounts, raw-address privacy
disclaimer, success screen) but uses the HD UTXO set across many
addresses and signs with per-input HD-derived keys.
This commit is contained in:
Generated
+46
-34
@@ -96,6 +96,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
@@ -6208,55 +6209,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
|
||||
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
|
||||
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
@@ -11828,6 +11814,32 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
|
||||
@@ -87,6 +87,7 @@ const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ de
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const HDWalletPage = lazy(() => import("./pages/HDWalletPage").then(m => ({ default: m.HDWalletPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
@@ -287,6 +288,7 @@ export function AppRouter() {
|
||||
/>
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
|
||||
<Route path="/hdwallet" element={<HDWalletPage />} />
|
||||
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import {
|
||||
broadcastTransaction,
|
||||
estimateFee,
|
||||
getFeeRates,
|
||||
isLargeAmount,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
satsToUSD,
|
||||
validateBitcoinAddress,
|
||||
type FeeRates,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
buildHdUnsignedPsbt,
|
||||
finalizeHdPsbt,
|
||||
type HdSpendableUtxo,
|
||||
signHdPsbt,
|
||||
} from '@/lib/hdwallet/transaction';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
|
||||
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
|
||||
|
||||
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
|
||||
fastest: '~10 min',
|
||||
halfHour: '~30 min',
|
||||
hour: '~1 hour',
|
||||
economy: '~1 day',
|
||||
};
|
||||
|
||||
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
|
||||
|
||||
function getRateForSpeed(rates: FeeRates, speed: FeeSpeed): number {
|
||||
switch (speed) {
|
||||
case 'fastest': return rates.fastestFee;
|
||||
case 'halfHour': return rates.halfHourFee;
|
||||
case 'hour': return rates.hourFee;
|
||||
case 'economy': return rates.economyFee;
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFeeSpeeds(rates: FeeRates | undefined): FeeSpeed[] {
|
||||
if (!rates) return FEE_SPEED_ORDER;
|
||||
const seen = new Set<number>();
|
||||
const result: FeeSpeed[] = [];
|
||||
for (const speed of FEE_SPEED_ORDER) {
|
||||
const rate = getRateForSpeed(rates, speed);
|
||||
if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recipient resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResolvedRecipient {
|
||||
/** Final P2TR/P2WPKH/etc. address used as the PSBT output. */
|
||||
address: string;
|
||||
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
|
||||
pubkey?: string;
|
||||
/** Raw text the user typed (for re-display). */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the recipient input as one of:
|
||||
* - bare Bitcoin address (mainnet, any standard type)
|
||||
* - npub1… → P2TR derived from the Nostr pubkey (matches /wallet's mapping)
|
||||
* - nprofile1… → P2TR derived from the encoded pubkey
|
||||
*
|
||||
* Returns `null` for unparseable input. The caller should treat `null` as
|
||||
* "input still in progress" rather than "error" until the user submits.
|
||||
*/
|
||||
function resolveRecipient(input: string): ResolvedRecipient | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Try bare Bitcoin address first — common case.
|
||||
if (validateBitcoinAddress(trimmed)) {
|
||||
return { address: trimmed, raw: trimmed };
|
||||
}
|
||||
|
||||
// Try NIP-19 npub / nprofile.
|
||||
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === 'npub') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data);
|
||||
if (address) return { address, pubkey: decoded.data, raw: trimmed };
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
|
||||
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HDSendBitcoinDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** BTC/USD price — passed in to avoid duplicate fetches. */
|
||||
btcPrice?: number;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
txid: string;
|
||||
amountSats: number;
|
||||
fee: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Send Bitcoin" dialog for the HD wallet at `/hdwallet`.
|
||||
*
|
||||
* Mirrors the UX of `SendBitcoinDialog` for visual consistency — large
|
||||
* editable USD amount, preset chips, fee speed picker, two-tap arming for
|
||||
* large amounts, privacy disclaimer for raw addresses — but uses the HD
|
||||
* wallet's UTXO set across many addresses, signs with per-input HD-derived
|
||||
* keys, and emits change to a fresh internal address.
|
||||
*/
|
||||
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
|
||||
const availability = useHdWalletAccess();
|
||||
const { scan, refetch: refetchWallet } = useHdWallet();
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isReady = availability.status === 'available';
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────
|
||||
const [recipientInput, setRecipientInput] = useState('');
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
|
||||
const [error, setError] = useState('');
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
const [success, setSuccess] = useState<SendResult | null>(null);
|
||||
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
const feeSpeedUserChanged = useRef(false);
|
||||
|
||||
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
|
||||
|
||||
// ── Fee rates ────────────────────────────────────────────────
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
|
||||
queryFn: () => getFeeRates(esploraBaseUrl),
|
||||
enabled: isOpen && isReady,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const currentFeeRate = useMemo(() => {
|
||||
if (!feeRates) return undefined;
|
||||
return getRateForSpeed(feeRates, feeSpeed);
|
||||
}, [feeRates, feeSpeed]);
|
||||
|
||||
// ── Owned UTXO set ───────────────────────────────────────────
|
||||
const ownedUtxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
|
||||
const totalBalance = useMemo(() => ownedUtxos.reduce((s, u) => s + u.value, 0), [ownedUtxos]);
|
||||
|
||||
// ── USD → sats ───────────────────────────────────────────────
|
||||
const amountSats = useMemo(() => {
|
||||
if (!btcPrice) return 0;
|
||||
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
||||
return Math.round((usd / btcPrice) * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
// ── Fee estimate (uses a conservative input count) ───────────
|
||||
//
|
||||
// We don't yet know coin selection will use _all_ UTXOs, but using the
|
||||
// full count is the safe over-estimate (real fee will be ≤ this).
|
||||
const estimatedFeeSats = useMemo(() => {
|
||||
if (!ownedUtxos.length || !currentFeeRate || !amountSats) return 0;
|
||||
const fee2 = estimateFee(ownedUtxos.length, 2, currentFeeRate);
|
||||
const change = totalBalance - amountSats - fee2;
|
||||
const numOutputs = change > 546 ? 2 : 1;
|
||||
return estimateFee(ownedUtxos.length, numOutputs, currentFeeRate);
|
||||
}, [ownedUtxos.length, currentFeeRate, amountSats, totalBalance]);
|
||||
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
const insufficient = totalBalance > 0 && totalSats > totalBalance;
|
||||
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
|
||||
|
||||
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
|
||||
// user has manually overridden.
|
||||
useEffect(() => {
|
||||
if (feeSpeedUserChanged.current) return;
|
||||
if (!ownedUtxos.length || !feeRates || amountSats <= 0) return;
|
||||
|
||||
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
|
||||
const threshold = amountSats * 0.4;
|
||||
|
||||
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
|
||||
for (const speed of uniqueSpeeds) {
|
||||
const rate = getRateForSpeed(feeRates, speed);
|
||||
const fee2 = estimateFee(ownedUtxos.length, 2, rate);
|
||||
const change = totalBalance - amountSats - fee2;
|
||||
const outputs = change > 546 ? 2 : 1;
|
||||
const fee = estimateFee(ownedUtxos.length, outputs, rate);
|
||||
if (fee <= threshold) { target = speed; break; }
|
||||
}
|
||||
setFeeSpeed((prev) => (prev === target ? prev : target));
|
||||
}, [amountSats, feeRates, ownedUtxos.length, totalBalance]);
|
||||
|
||||
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
setFeeSpeed(speed);
|
||||
setFeePopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
// ── Two-tap arm + raw-address disclaimer ─────────────────────
|
||||
const isLarge = isLargeAmount(totalSats, btcPrice);
|
||||
const isRawAddress = !!recipient && !recipient.pubkey;
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
setAcknowledgedPublic(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
|
||||
const requiresArm = isLarge || isRawAddress;
|
||||
|
||||
// ── Amount focus management ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (editingAmount) {
|
||||
amountInputRef.current?.focus();
|
||||
amountInputRef.current?.select();
|
||||
}
|
||||
}, [editingAmount]);
|
||||
|
||||
const commitAmountEdit = useCallback(() => {
|
||||
setEditingAmount(false);
|
||||
if (typeof usdAmount === 'string' && usdAmount.trim() === '') setUsdAmount(0);
|
||||
}, [usdAmount]);
|
||||
|
||||
// ── Send mutation ────────────────────────────────────────────
|
||||
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting'>('idle');
|
||||
|
||||
const sendMutation = useMutation<SendResult, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (availability.status !== 'available') {
|
||||
throw new Error('HD wallet is not available for this login type.');
|
||||
}
|
||||
if (!recipient) throw new Error('Enter a Bitcoin address or npub.');
|
||||
if (!ownedUtxos.length) throw new Error('No spendable Bitcoin in this wallet.');
|
||||
if (!feeRates) throw new Error('Fee rates not loaded.');
|
||||
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
|
||||
if (amountSats <= 0) throw new Error('Enter an amount.');
|
||||
if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
|
||||
|
||||
const rate = getRateForSpeed(feeRates, feeSpeed);
|
||||
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
|
||||
|
||||
setProgress('building');
|
||||
const built = buildHdUnsignedPsbt(
|
||||
availability.account,
|
||||
ownedUtxos,
|
||||
recipient.address,
|
||||
amountSats,
|
||||
rate,
|
||||
nextChangeIndex,
|
||||
);
|
||||
|
||||
setProgress('signing');
|
||||
const signedHex = signHdPsbt(built.psbtHex, built.inputDerivations, availability.account);
|
||||
const txHex = finalizeHdPsbt(signedHex);
|
||||
|
||||
setProgress('broadcasting');
|
||||
const txid = await broadcastTransaction(txHex, esploraBaseUrl);
|
||||
|
||||
return { txid, amountSats, fee: built.fee };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
notificationSuccess();
|
||||
setSuccess(result);
|
||||
// Invalidate HD wallet caches and the legacy single-address ones too
|
||||
// (some screens still read them).
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-txs'] });
|
||||
void refetchWallet();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
|
||||
},
|
||||
onSettled: () => setProgress('idle'),
|
||||
});
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
setError('');
|
||||
if (availability.status !== 'available') {
|
||||
setError('HD wallet is not available for this login type.'); return;
|
||||
}
|
||||
if (!recipient) { setError('Enter a Bitcoin address or npub.'); return; }
|
||||
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
|
||||
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
||||
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
||||
if (!ownedUtxos.length) { setError("You don't have any Bitcoin yet."); return; }
|
||||
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
|
||||
if (isRawAddress && !acknowledgedPublic) {
|
||||
setError('Acknowledge the privacy warning before sending.'); return;
|
||||
}
|
||||
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
|
||||
sendMutation.mutate();
|
||||
}, [
|
||||
availability,
|
||||
recipient,
|
||||
btcPrice,
|
||||
amountSats,
|
||||
ownedUtxos.length,
|
||||
insufficient,
|
||||
isRawAddress,
|
||||
acknowledgedPublic,
|
||||
requiresArm,
|
||||
confirmArmed,
|
||||
sendMutation,
|
||||
]);
|
||||
|
||||
// ── Reset on close ───────────────────────────────────────────
|
||||
const handleClose = useCallback(() => {
|
||||
if (sendMutation.isPending) return;
|
||||
onClose();
|
||||
// defer to allow exit animation
|
||||
setTimeout(() => {
|
||||
setRecipientInput('');
|
||||
setUsdAmount(5);
|
||||
setError('');
|
||||
setConfirmArmed(false);
|
||||
setAcknowledgedPublic(false);
|
||||
setSuccess(null);
|
||||
feeSpeedUserChanged.current = false;
|
||||
}, 200);
|
||||
}, [onClose, sendMutation.isPending]);
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────
|
||||
const sendButtonLabel = (() => {
|
||||
if (sendMutation.isPending) {
|
||||
switch (progress) {
|
||||
case 'building': return 'Building transaction…';
|
||||
case 'signing': return 'Signing…';
|
||||
case 'broadcasting': return 'Broadcasting…';
|
||||
default: return 'Sending…';
|
||||
}
|
||||
}
|
||||
if (confirmArmed) return 'Tap again to confirm';
|
||||
return 'Send Bitcoin';
|
||||
})();
|
||||
|
||||
const sendDisabled =
|
||||
sendMutation.isPending ||
|
||||
!recipient ||
|
||||
!btcPrice ||
|
||||
amountSats <= 0 ||
|
||||
insufficient ||
|
||||
!ownedUtxos.length ||
|
||||
(isRawAddress && !acknowledgedPublic);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden">
|
||||
<DialogTitle className="sr-only">Send Bitcoin</DialogTitle>
|
||||
|
||||
{success ? (
|
||||
<SuccessScreen
|
||||
txid={success.txid}
|
||||
amountSats={success.amountSats}
|
||||
btcPrice={btcPrice}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-5 px-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Send Bitcoin</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="flex flex-col items-center py-2">
|
||||
{editingAmount ? (
|
||||
<div className="flex items-center text-4xl font-bold tracking-tight">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input
|
||||
ref={amountInputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={usdAmount}
|
||||
onChange={(e) => setUsdAmount(e.target.value)}
|
||||
onBlur={commitAmountEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitAmountEdit(); }}
|
||||
className="bg-transparent border-none focus-visible:ring-0 text-4xl font-bold tracking-tight w-32 text-center px-0 h-auto"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmount(true)}
|
||||
className="text-4xl font-bold tracking-tight hover:text-primary transition-colors cursor-text"
|
||||
>
|
||||
${typeof usdAmount === 'number' ? usdAmount : (parseFloat(usdAmount) || 0)}
|
||||
</button>
|
||||
)}
|
||||
{amountSats > 0 && btcPrice && (
|
||||
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
|
||||
≈ {amountSats.toLocaleString()} sats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* USD presets */}
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{USD_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => { setUsdAmount(preset); setEditingAmount(false); }}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-xs border transition-colors',
|
||||
Number(usdAmount) === preset
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
${preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recipient */}
|
||||
<div className="grid gap-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
|
||||
Recipient
|
||||
</label>
|
||||
<Input
|
||||
id="hd-recipient-input"
|
||||
value={recipientInput}
|
||||
onChange={(e) => setRecipientInput(e.target.value)}
|
||||
placeholder="bc1… or npub…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{recipient && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{recipient.pubkey ? (
|
||||
<>Sending to a Nostr user's on-chain address.</>
|
||||
) : (
|
||||
<>Sending to a raw Bitcoin address.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy disclaimer for raw addresses */}
|
||||
{isRawAddress && (
|
||||
<BitcoinPublicDisclaimer
|
||||
acknowledged={acknowledgedPublic}
|
||||
onAcknowledgedChange={setAcknowledgedPublic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Network fee</span>
|
||||
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
|
||||
>
|
||||
{estimatedFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(estimatedFeeSats, btcPrice)}</>
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
<span className="opacity-60">·</span>
|
||||
{FEE_SPEED_LABELS[feeSpeed]}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<div className="grid gap-0.5">
|
||||
{getUniqueFeeSpeeds(feeRates).map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
|
||||
feeSpeed === speed && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span>{FEE_SPEED_LABELS[speed]}</span>
|
||||
{feeRates && (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{getRateForSpeed(feeRates, speed)} sat/vB
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showBalance && totalBalance > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sendDisabled}
|
||||
className={cn(
|
||||
'w-full',
|
||||
confirmArmed && !sendMutation.isPending && 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
)}
|
||||
>
|
||||
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{sendButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Success screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SuccessScreenProps {
|
||||
txid: string;
|
||||
amountSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
|
||||
const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto flex size-28 items-center justify-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
|
||||
/>
|
||||
<Check className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop" strokeWidth={3} aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Bitcoin sent</h2>
|
||||
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
|
||||
{usdDisplay || `${amountSats.toLocaleString()} sats`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button type="button" variant="outline" asChild className="w-full">
|
||||
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
|
||||
<ExternalLink className="size-4 mr-2" />
|
||||
View transaction
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="w-full">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
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 { deriveReceiveAddress, type DerivedAddress } from '@/lib/hdwallet/derivation';
|
||||
import {
|
||||
type AccountScanResult,
|
||||
fetchHdTransactions,
|
||||
type HdTransaction,
|
||||
scanAccount,
|
||||
} from '@/lib/hdwallet/scan';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted UI cursor (per user)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We persist a single integer per user: the "preferred receive index" — the
|
||||
// index of the address we are *currently advertising* on the wallet page.
|
||||
// The chain-scan source of truth is the relay-derived `firstUnusedIndex`,
|
||||
// but if the user explicitly bumps to a fresh address (or rotates back),
|
||||
// we honour that until the chain catches up.
|
||||
//
|
||||
// On native, this lives in the Keychain / KeyStore via secureStorage. On web
|
||||
// it's localStorage. Either way it's not secret — losing it means we fall
|
||||
// back to firstUnusedIndex on next login.
|
||||
|
||||
const STORAGE_KEY = (pubkey: string) => `hdwallet:cursor:${pubkey}`;
|
||||
|
||||
interface PersistedCursor {
|
||||
/** Currently-displayed receive index. */
|
||||
receiveIndex: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CURSOR: PersistedCursor = { receiveIndex: 0 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query refresh cadence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Re-scan + refresh balances every 60 s. Slower than the single-address
|
||||
* wallet (30 s) because each scan can be 10–40 Esplora calls. */
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UseHdWalletResult {
|
||||
/** Availability status — mirrors `useHdWalletAccess`. */
|
||||
availability: HdWalletAvailability;
|
||||
/** Currently-advertised receive address (the one the UI shows). */
|
||||
currentReceiveAddress?: DerivedAddress;
|
||||
/** Full scan result — UTXOs, used addresses, etc. */
|
||||
scan?: AccountScanResult;
|
||||
/** Aggregated wallet-level transaction history (newest first). */
|
||||
transactions?: HdTransaction[];
|
||||
/** Confirmed + pending balance in sats. */
|
||||
totalBalance: number;
|
||||
/** Pending (mempool) balance in sats. May be negative for outgoing. */
|
||||
pendingBalance: number;
|
||||
/** Initial scan in progress. */
|
||||
isLoading: boolean;
|
||||
/** Either scan or tx-history loading. */
|
||||
isFetching: boolean;
|
||||
/** Scan error, if any. */
|
||||
error: unknown;
|
||||
/** Trigger a manual scan + tx refresh. */
|
||||
refetch: () => Promise<unknown>;
|
||||
/** Advance the receive cursor to the next unused address. Persisted. */
|
||||
nextReceiveAddress: () => DerivedAddress | undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Top-level HD wallet hook. Returns the cached scan, balance, transactions,
|
||||
* and the current receive address.
|
||||
*
|
||||
* The hook is safe to call regardless of login state — it returns
|
||||
* `availability.status !== 'available'` for non-nsec users without doing any
|
||||
* derivation or network work.
|
||||
*/
|
||||
export function useHdWallet(): UseHdWalletResult {
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const availability = useHdWalletAccess();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const pubkey = availability.status === 'available' ? availability.pubkey : '';
|
||||
const account = availability.status === 'available' ? availability.account : undefined;
|
||||
|
||||
// ── Persisted receive cursor ─────────────────────────────────
|
||||
// Key by pubkey so account-switching doesn't leak indices across users.
|
||||
const [cursor, setCursor] = useSecureLocalStorage<PersistedCursor>(
|
||||
pubkey ? STORAGE_KEY(pubkey) : 'hdwallet:cursor:none',
|
||||
DEFAULT_CURSOR,
|
||||
);
|
||||
|
||||
// ── Scan query ───────────────────────────────────────────────
|
||||
const scanKey = ['hdwallet-scan', esploraBaseUrl, pubkey];
|
||||
const {
|
||||
data: scan,
|
||||
isLoading: scanLoading,
|
||||
isFetching: scanFetching,
|
||||
error: scanError,
|
||||
refetch: refetchScan,
|
||||
} = useQuery<AccountScanResult>({
|
||||
queryKey: scanKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!account) throw new Error('HD wallet account unavailable');
|
||||
return scanAccount(account, esploraBaseUrl, signal);
|
||||
},
|
||||
enabled: !!account,
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
staleTime: REFRESH_INTERVAL_MS / 2,
|
||||
});
|
||||
|
||||
// ── Transaction history query ────────────────────────────────
|
||||
const {
|
||||
data: transactions,
|
||||
isFetching: txFetching,
|
||||
refetch: refetchTxs,
|
||||
} = useQuery<HdTransaction[]>({
|
||||
queryKey: ['hdwallet-txs', esploraBaseUrl, pubkey, scan?.receive.used.length, scan?.change.used.length],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!scan) return [];
|
||||
return fetchHdTransactions(scan, esploraBaseUrl, signal);
|
||||
},
|
||||
enabled: !!scan,
|
||||
refetchInterval: REFRESH_INTERVAL_MS,
|
||||
staleTime: REFRESH_INTERVAL_MS / 2,
|
||||
});
|
||||
|
||||
// ── Current receive address ──────────────────────────────────
|
||||
//
|
||||
// Resolution rules, in order:
|
||||
// 1. If the persisted cursor is *behind* the chain-derived
|
||||
// firstUnusedIndex, the persisted index has been used by a sender →
|
||||
// auto-advance to firstUnusedIndex. (No address reuse.)
|
||||
// 2. If the persisted cursor is *ahead* of firstUnusedIndex (user clicked
|
||||
// "next" multiple times without any deposits), honour it.
|
||||
// 3. Otherwise use the chain-derived firstUnusedIndex.
|
||||
const currentReceiveAddress = useMemo<DerivedAddress | undefined>(() => {
|
||||
if (!account) return undefined;
|
||||
const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
|
||||
const resolved = Math.max(chainNextUnused, cursor.receiveIndex);
|
||||
return deriveReceiveAddress(account, resolved);
|
||||
}, [account, scan, cursor.receiveIndex]);
|
||||
|
||||
// ── Advance to next receive address ──────────────────────────
|
||||
const nextReceiveAddress = useCallback((): DerivedAddress | undefined => {
|
||||
if (!account) return undefined;
|
||||
const chainNextUnused = scan?.receive.firstUnusedIndex ?? 0;
|
||||
const current = Math.max(chainNextUnused, cursor.receiveIndex);
|
||||
const next = current + 1;
|
||||
setCursor({ receiveIndex: next });
|
||||
return deriveReceiveAddress(account, next);
|
||||
}, [account, scan, cursor.receiveIndex, setCursor]);
|
||||
|
||||
// ── Unified refetch ──────────────────────────────────────────
|
||||
const refetch = useCallback(async () => {
|
||||
// Invalidate UTXO/balance queries used by the send dialog too.
|
||||
await queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
return Promise.all([refetchScan(), refetchTxs()]);
|
||||
}, [queryClient, refetchScan, refetchTxs]);
|
||||
|
||||
return {
|
||||
availability,
|
||||
currentReceiveAddress,
|
||||
scan,
|
||||
transactions,
|
||||
totalBalance: scan?.totalBalance ?? 0,
|
||||
pendingBalance: scan?.pendingBalance ?? 0,
|
||||
isLoading: scanLoading,
|
||||
isFetching: scanFetching || txFetching,
|
||||
error: scanError,
|
||||
refetch,
|
||||
nextReceiveAddress,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { deriveAccountFromNsec, type HdAccount } from '@/lib/hdwallet/derivation';
|
||||
|
||||
/**
|
||||
* Aggregate availability of the HD wallet for the active login.
|
||||
*
|
||||
* The HD wallet derives all of its keys from the user's raw Nostr secret key.
|
||||
* Only the `nsec` login type stores that key in a form we can read; both the
|
||||
* NIP-07 browser extension and NIP-46 remote bunker keep the key elsewhere
|
||||
* (the extension never exposes it; the bunker never sends it). Without the
|
||||
* raw secret we cannot derive child keys, so the HD wallet is gated to nsec
|
||||
* logins.
|
||||
*/
|
||||
export type HdWalletAvailability =
|
||||
/** User logged in with nsec — full HD wallet access. */
|
||||
| { status: 'available'; account: HdAccount; nsecBytes: Uint8Array; pubkey: string }
|
||||
/** Not logged in at all. */
|
||||
| { status: 'logged-out' }
|
||||
/** Logged in, but the login type doesn't expose the secret key. */
|
||||
| { status: 'unsupported'; loginType: 'extension' | 'bunker' | 'other' };
|
||||
|
||||
/**
|
||||
* Hook that returns whether the HD wallet is usable for the active login,
|
||||
* and (when usable) the derived BIP86 account.
|
||||
*
|
||||
* **Security note**: the returned `account` holds private extended keys in
|
||||
* memory for as long as the consumer holds the reference. This is unavoidable
|
||||
* for a wallet that signs locally — the nsec is already in plaintext
|
||||
* localStorage in the same threat model.
|
||||
*
|
||||
* The hook intentionally re-derives on every login change rather than caching
|
||||
* across logouts, so a fresh login starts from a clean derivation.
|
||||
*/
|
||||
export function useHdWalletAccess(): HdWalletAvailability {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const activeLogin = logins[0];
|
||||
|
||||
return useMemo<HdWalletAvailability>(() => {
|
||||
if (!user || !activeLogin) return { status: 'logged-out' };
|
||||
|
||||
if (activeLogin.type !== 'nsec') {
|
||||
const loginType =
|
||||
activeLogin.type === 'extension'
|
||||
? 'extension'
|
||||
: activeLogin.type === 'bunker'
|
||||
? 'bunker'
|
||||
: 'other';
|
||||
return { status: 'unsupported', loginType };
|
||||
}
|
||||
|
||||
// Decode the nsec → 32-byte secret key, then derive the BIP86 account.
|
||||
const decoded = nip19.decode(activeLogin.data.nsec);
|
||||
if (decoded.type !== 'nsec') {
|
||||
// Defensive — should be impossible given the discriminated union.
|
||||
return { status: 'unsupported', loginType: 'other' };
|
||||
}
|
||||
const nsecBytes = decoded.data;
|
||||
const account = deriveAccountFromNsec(nsecBytes);
|
||||
|
||||
return { status: 'available', account, nsecBytes, pubkey: user.pubkey };
|
||||
}, [user, activeLogin]);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { toXOnly } from 'bitcoinjs-lib';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { hkdf } from '@noble/hashes/hkdf';
|
||||
import { sha256 } from '@noble/hashes/sha2';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import { ECPairFactory, type ECPairAPI } from 'ecpair';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HD wallet derivation (BIP86 — Taproot single-key, key-path-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// This wallet derives a full BIP32 hierarchy from the user's Nostr secret key
|
||||
// (nsec). There is no separate BIP39 mnemonic — the 32-byte secret key is
|
||||
// stretched through HKDF-SHA-256 with an app-specific info string to a 64-byte
|
||||
// BIP32 seed, then run through standard BIP86 derivation:
|
||||
//
|
||||
// m / 86' / 0' / 0' / change / index
|
||||
//
|
||||
// `change ∈ {0, 1}` distinguishes the receive chain (external, advertised to
|
||||
// senders) from the change chain (internal, only used as our own change
|
||||
// outputs). Industry standard: never reuse addresses. The wallet always
|
||||
// advances to the next unused index on the receive chain when an address is
|
||||
// shown, and emits change to a fresh index on the change chain.
|
||||
//
|
||||
// Output script: P2TR with the derived xonly pubkey as `internalPubkey` (no
|
||||
// tapscript tree — key-path spends only), per BIP86.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** HKDF info string. Change ⇒ all derived wallets change. Do not edit. */
|
||||
const HKDF_INFO = 'agora-hdwallet:bip86:v1';
|
||||
|
||||
/** Standard BIP86 account base path. */
|
||||
const BIP86_ACCOUNT_PATH = "m/86'/0'/0'";
|
||||
|
||||
/** External (receive) chain index. */
|
||||
export const RECEIVE_CHAIN = 0;
|
||||
/** Internal (change) chain index. */
|
||||
export const CHANGE_CHAIN = 1;
|
||||
|
||||
/** Network — mainnet only. Testnet support is intentionally omitted. */
|
||||
const NETWORK = bitcoin.networks.bitcoin;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ECC initialisation (lazy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _ECPair: ECPairAPI | null = null;
|
||||
let _eccInitialized = false;
|
||||
|
||||
/** Initialize bitcoinjs-lib's ECC backend exactly once. */
|
||||
function ensureEcc(): void {
|
||||
if (!_eccInitialized) {
|
||||
bitcoin.initEccLib(ecc);
|
||||
_eccInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getECPair(): ECPairAPI {
|
||||
ensureEcc();
|
||||
if (!_ECPair) _ECPair = ECPairFactory(ecc);
|
||||
return _ECPair;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stretch a Nostr secret key (32 bytes) into a 64-byte BIP32 seed via
|
||||
* HKDF-SHA-256.
|
||||
*
|
||||
* - The Nostr secret key serves as input keying material (IKM).
|
||||
* - A fixed app-specific `info` string domain-separates the output from any
|
||||
* other use of the same key (e.g. NIP-44 ECDH). This means the Bitcoin
|
||||
* wallet cannot be recovered by anyone holding only a NIP-44 conversation
|
||||
* key, only by someone holding the raw secret key itself.
|
||||
* - No salt: deterministic output for the same nsec across all devices.
|
||||
*
|
||||
* @param nsecBytes 32-byte raw Nostr secret key (the `data` field from
|
||||
* `nip19.decode(nsec)`).
|
||||
* @returns 64-byte seed suitable for `HDKey.fromMasterSeed`.
|
||||
*/
|
||||
export function nsecToBip32Seed(nsecBytes: Uint8Array): Uint8Array {
|
||||
if (nsecBytes.length !== 32) {
|
||||
throw new Error('nsec must be 32 bytes');
|
||||
}
|
||||
return hkdf(sha256, nsecBytes, undefined, HKDF_INFO, 64);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HD key handles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Result of deriving the account-level xpub. */
|
||||
export interface HdAccount {
|
||||
/** Account-level extended public key. Used to derive receive/change chains. */
|
||||
accountNode: HDKey;
|
||||
/** External-chain extended public key (m/86'/0'/0'/0). */
|
||||
receiveNode: HDKey;
|
||||
/** Internal/change-chain extended public key (m/86'/0'/0'/1). */
|
||||
changeNode: HDKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the BIP86 account hierarchy from a raw Nostr secret key.
|
||||
*
|
||||
* Returns extended **private** keys (so signing is possible). For balance
|
||||
* scanning, prefer `deriveWatchOnlyAccount` which only needs the xpub.
|
||||
*/
|
||||
export function deriveAccountFromNsec(nsecBytes: Uint8Array): HdAccount {
|
||||
const seed = nsecToBip32Seed(nsecBytes);
|
||||
const root = HDKey.fromMasterSeed(seed);
|
||||
const accountNode = root.derive(BIP86_ACCOUNT_PATH);
|
||||
const receiveNode = accountNode.deriveChild(RECEIVE_CHAIN);
|
||||
const changeNode = accountNode.deriveChild(CHANGE_CHAIN);
|
||||
return { accountNode, receiveNode, changeNode };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Address derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A single derived address with everything needed to spend from it. */
|
||||
export interface DerivedAddress {
|
||||
/** Bech32m P2TR address (bc1p…). */
|
||||
address: string;
|
||||
/** 32-byte x-only internal pubkey (hex). */
|
||||
internalPubkeyHex: string;
|
||||
/** Chain (0 = receive, 1 = change). */
|
||||
chain: 0 | 1;
|
||||
/** Address index within the chain. */
|
||||
index: number;
|
||||
/** Full BIP32 path, e.g. `m/86'/0'/0'/0/3`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a single P2TR address from a chain extended key (either the receive
|
||||
* or change node). The chain index is supplied so the returned `path` is
|
||||
* accurate.
|
||||
*/
|
||||
export function deriveAddress(chainNode: HDKey, chain: 0 | 1, index: number): DerivedAddress {
|
||||
ensureEcc();
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
throw new Error(`Invalid address index: ${index}`);
|
||||
}
|
||||
|
||||
const child = chainNode.deriveChild(index);
|
||||
const pubkey = child.publicKey;
|
||||
if (!pubkey) throw new Error('HDKey is missing a public key');
|
||||
|
||||
// BIP86: drop the parity byte to get the 32-byte x-only key.
|
||||
const internalPubkey = Buffer.from(toXOnly(Buffer.from(pubkey)));
|
||||
|
||||
const { address } = bitcoin.payments.p2tr({
|
||||
internalPubkey,
|
||||
network: NETWORK,
|
||||
});
|
||||
if (!address) throw new Error('Failed to derive P2TR address');
|
||||
|
||||
return {
|
||||
address,
|
||||
internalPubkeyHex: internalPubkey.toString('hex'),
|
||||
chain,
|
||||
index,
|
||||
path: `${BIP86_ACCOUNT_PATH}/${chain}/${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: derive a single receive address at the given index. */
|
||||
export function deriveReceiveAddress(account: HdAccount, index: number): DerivedAddress {
|
||||
return deriveAddress(account.receiveNode, RECEIVE_CHAIN, index);
|
||||
}
|
||||
|
||||
/** Convenience: derive a single change address at the given index. */
|
||||
export function deriveChangeAddress(account: HdAccount, index: number): DerivedAddress {
|
||||
return deriveAddress(account.changeNode, CHANGE_CHAIN, index);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signing keys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derive the 32-byte raw private key for a specific (chain, index) leaf.
|
||||
*
|
||||
* The caller is responsible for zeroing the returned buffer when done (best
|
||||
* effort — JS does not guarantee this). This function is the only place where
|
||||
* leaf private keys are materialised.
|
||||
*/
|
||||
export function deriveLeafPrivateKey(
|
||||
account: HdAccount,
|
||||
chain: 0 | 1,
|
||||
index: number,
|
||||
): Uint8Array {
|
||||
const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode;
|
||||
const child = chainNode.deriveChild(index);
|
||||
if (!child.privateKey) {
|
||||
throw new Error('Derived HDKey has no private key (xpub-only?)');
|
||||
}
|
||||
// Defensive copy — @scure/bip32 holds an internal reference.
|
||||
return new Uint8Array(child.privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the BIP-341 TapTweaked signing keypair for a given leaf. Returns an
|
||||
* ECPair instance whose private scalar is `priv + H_tapTweak(P)` mod n.
|
||||
*
|
||||
* Used by the PSBT signer.
|
||||
*/
|
||||
export function deriveLeafTaprootSigner(
|
||||
account: HdAccount,
|
||||
chain: 0 | 1,
|
||||
index: number,
|
||||
): ReturnType<ECPairAPI['fromPrivateKey']> {
|
||||
const ECPair = getECPair();
|
||||
const privKey = deriveLeafPrivateKey(account, chain, index);
|
||||
try {
|
||||
const keyPair = ECPair.fromPrivateKey(Buffer.from(privKey));
|
||||
const internalPubkey = toXOnly(keyPair.publicKey);
|
||||
return keyPair.tweak(bitcoin.crypto.taggedHash('TapTweak', internalPubkey));
|
||||
} finally {
|
||||
// Best-effort wipe of the local copy.
|
||||
privKey.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network constant export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { NETWORK as HD_WALLET_NETWORK };
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
type AddressData,
|
||||
fetchAddressData,
|
||||
fetchTransactions,
|
||||
fetchUTXOs,
|
||||
type UTXO,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
CHANGE_CHAIN,
|
||||
type DerivedAddress,
|
||||
deriveAddress,
|
||||
type HdAccount,
|
||||
RECEIVE_CHAIN,
|
||||
} from './derivation';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gap-limit chain scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// BIP44 gap limit: a wallet considers a chain "fully scanned" after observing
|
||||
// `GAP_LIMIT` consecutive addresses that have never been used (zero history).
|
||||
// Industry standard is 20.
|
||||
//
|
||||
// We scan in batches of `SCAN_BATCH_SIZE` to amortise round-trip latency
|
||||
// while still bounding fan-out on the Esplora server.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Standard BIP44 gap limit. */
|
||||
export const GAP_LIMIT = 20;
|
||||
|
||||
/** Number of addresses fetched per request batch. */
|
||||
const SCAN_BATCH_SIZE = 5;
|
||||
|
||||
/** Hard ceiling on addresses scanned per chain. Protects against bugs/loops. */
|
||||
const MAX_INDEX = 10_000;
|
||||
|
||||
/** Information about a single derived address that has been observed. */
|
||||
export interface ScannedAddress {
|
||||
derived: DerivedAddress;
|
||||
data: AddressData;
|
||||
utxos: UTXO[];
|
||||
}
|
||||
|
||||
/** Full scan result for a single chain (receive or change). */
|
||||
export interface ChainScanResult {
|
||||
/** All addresses with any history (tx_count > 0 on either confirmed or mempool). */
|
||||
used: ScannedAddress[];
|
||||
/** All addresses currently holding spendable UTXOs (incl. unconfirmed). */
|
||||
withBalance: ScannedAddress[];
|
||||
/** Index of the first address with no history (the "next" address to advertise). */
|
||||
firstUnusedIndex: number;
|
||||
/** Whether the scan hit MAX_INDEX without finding a clean gap. */
|
||||
hitMaxIndex: boolean;
|
||||
}
|
||||
|
||||
/** Combined receive+change scan result for an entire account. */
|
||||
export interface AccountScanResult {
|
||||
receive: ChainScanResult;
|
||||
change: ChainScanResult;
|
||||
/** All UTXOs across both chains. */
|
||||
utxos: Array<UTXO & { address: string; chain: 0 | 1; index: number }>;
|
||||
/** Confirmed + pending balance in satoshis, summed across both chains. */
|
||||
totalBalance: number;
|
||||
/** Sum of `pendingBalance` across all addresses (positive = incoming, negative = outgoing). */
|
||||
pendingBalance: number;
|
||||
/** Map from address → derived metadata. Used by the tx aggregator and signer. */
|
||||
addressMap: Map<string, DerivedAddress>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this address ever been used? "Used" means it has any history at all,
|
||||
* confirmed or in the mempool. We treat the address as advertised-and-burned
|
||||
* the moment a sender touches it.
|
||||
*/
|
||||
function isUsed(data: AddressData): boolean {
|
||||
return data.txCount > 0 || data.pendingTxCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single chain (receive or change) until `GAP_LIMIT` consecutive
|
||||
* unused addresses are observed.
|
||||
*/
|
||||
async function scanChain(
|
||||
account: HdAccount,
|
||||
chain: 0 | 1,
|
||||
esploraBaseUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChainScanResult> {
|
||||
const chainNode = chain === RECEIVE_CHAIN ? account.receiveNode : account.changeNode;
|
||||
|
||||
const used: ScannedAddress[] = [];
|
||||
const withBalance: ScannedAddress[] = [];
|
||||
let firstUnusedIndex = 0;
|
||||
let firstUnusedSet = false;
|
||||
let consecutiveUnused = 0;
|
||||
let index = 0;
|
||||
let hitMaxIndex = false;
|
||||
|
||||
while (consecutiveUnused < GAP_LIMIT) {
|
||||
if (index >= MAX_INDEX) {
|
||||
hitMaxIndex = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Build the next batch of addresses to scan.
|
||||
const batch: DerivedAddress[] = [];
|
||||
for (let i = 0; i < SCAN_BATCH_SIZE && consecutiveUnused + i < GAP_LIMIT && index + i < MAX_INDEX; i++) {
|
||||
batch.push(deriveAddress(chainNode, chain, index + i));
|
||||
}
|
||||
if (batch.length === 0) break;
|
||||
|
||||
// Fetch address data in parallel. UTXOs are only fetched for addresses
|
||||
// that turn out to be used — we avoid speculative UTXO calls for the
|
||||
// ~20 "tail" addresses at the end of every scan.
|
||||
const dataResults = await Promise.all(
|
||||
batch.map(async (d) => {
|
||||
signal?.throwIfAborted();
|
||||
const data = await fetchAddressData(d.address, esploraBaseUrl);
|
||||
return { d, data };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { d, data } of dataResults) {
|
||||
if (isUsed(data)) {
|
||||
// Used — reset gap counter, fetch UTXOs for spending.
|
||||
signal?.throwIfAborted();
|
||||
const utxos = await fetchUTXOs(d.address, esploraBaseUrl);
|
||||
const sa: ScannedAddress = { derived: d, data, utxos };
|
||||
used.push(sa);
|
||||
if (utxos.length > 0 || data.totalBalance > 0) withBalance.push(sa);
|
||||
consecutiveUnused = 0;
|
||||
// Do NOT update firstUnusedIndex here — we want the first index that
|
||||
// has never been used, so it stays pointed at the earliest gap.
|
||||
} else {
|
||||
if (!firstUnusedSet) {
|
||||
firstUnusedIndex = d.index;
|
||||
firstUnusedSet = true;
|
||||
}
|
||||
consecutiveUnused++;
|
||||
}
|
||||
}
|
||||
|
||||
index += batch.length;
|
||||
}
|
||||
|
||||
// Edge case: the chain has zero used addresses. firstUnusedIndex stays 0.
|
||||
if (!firstUnusedSet) firstUnusedIndex = 0;
|
||||
|
||||
return { used, withBalance, firstUnusedIndex, hitMaxIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan both chains (receive and change) for an HD account and aggregate the
|
||||
* results.
|
||||
*
|
||||
* @param account The derived HD account.
|
||||
* @param esploraBaseUrl Esplora REST root, no trailing slash.
|
||||
* @param signal Optional abort signal.
|
||||
*/
|
||||
export async function scanAccount(
|
||||
account: HdAccount,
|
||||
esploraBaseUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AccountScanResult> {
|
||||
// Both chains in parallel — they're independent of each other.
|
||||
const [receive, change] = await Promise.all([
|
||||
scanChain(account, RECEIVE_CHAIN, esploraBaseUrl, signal),
|
||||
scanChain(account, CHANGE_CHAIN, esploraBaseUrl, signal),
|
||||
]);
|
||||
|
||||
const addressMap = new Map<string, DerivedAddress>();
|
||||
for (const sa of receive.used) addressMap.set(sa.derived.address, sa.derived);
|
||||
for (const sa of change.used) addressMap.set(sa.derived.address, sa.derived);
|
||||
|
||||
const utxos: AccountScanResult['utxos'] = [];
|
||||
let totalBalance = 0;
|
||||
let pendingBalance = 0;
|
||||
|
||||
for (const chainResult of [receive, change]) {
|
||||
for (const sa of chainResult.used) {
|
||||
totalBalance += sa.data.totalBalance;
|
||||
pendingBalance += sa.data.pendingBalance;
|
||||
for (const u of sa.utxos) {
|
||||
utxos.push({
|
||||
...u,
|
||||
address: sa.derived.address,
|
||||
chain: sa.derived.chain,
|
||||
index: sa.derived.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { receive, change, utxos, totalBalance, pendingBalance, addressMap };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregated transaction history
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aggregated transaction record for an HD wallet. Unlike the per-address
|
||||
* `Transaction` from `bitcoin.ts`, this one merges all on-chain activity
|
||||
* across every owned address so a single send-with-change tx shows up as one
|
||||
* row rather than two.
|
||||
*/
|
||||
export interface HdTransaction {
|
||||
txid: string;
|
||||
/** Net satoshi change across the entire wallet (positive = received, negative = sent). */
|
||||
amount: number;
|
||||
/** Send or receive (based on net amount sign). */
|
||||
type: 'receive' | 'send';
|
||||
confirmed: boolean;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch per-address transaction lists for every used address and combine
|
||||
* them by txid. A single transaction that hits multiple owned addresses
|
||||
* (e.g. send-with-change) is merged into one record whose `amount` is the
|
||||
* net wallet-level change.
|
||||
*/
|
||||
export async function fetchHdTransactions(
|
||||
result: AccountScanResult,
|
||||
esploraBaseUrl: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HdTransaction[]> {
|
||||
const allUsed = [...result.receive.used, ...result.change.used];
|
||||
if (allUsed.length === 0) return [];
|
||||
|
||||
// Fetch each address's tx list in parallel. Each call returns a simplified
|
||||
// per-address view from `fetchTransactions` (net positive/negative).
|
||||
const perAddress = await Promise.all(
|
||||
allUsed.map(async (sa) => {
|
||||
signal?.throwIfAborted();
|
||||
const txs = await fetchTransactions(sa.derived.address, esploraBaseUrl);
|
||||
return txs.map((tx) => ({
|
||||
...tx,
|
||||
// `fetchTransactions` returns Math.abs(net); recover the signed value.
|
||||
signedAmount: tx.type === 'receive' ? tx.amount : -tx.amount,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
// Merge by txid — sum signed amounts so that send-with-change collapses.
|
||||
const merged = new Map<string, {
|
||||
txid: string;
|
||||
netSats: number;
|
||||
confirmed: boolean;
|
||||
timestamp?: number;
|
||||
}>();
|
||||
|
||||
for (const list of perAddress) {
|
||||
for (const tx of list) {
|
||||
const existing = merged.get(tx.txid);
|
||||
if (existing) {
|
||||
existing.netSats += tx.signedAmount;
|
||||
// Once confirmed, stay confirmed.
|
||||
existing.confirmed = existing.confirmed || tx.confirmed;
|
||||
// Prefer the earliest known timestamp.
|
||||
if (tx.timestamp && (!existing.timestamp || tx.timestamp < existing.timestamp)) {
|
||||
existing.timestamp = tx.timestamp;
|
||||
}
|
||||
} else {
|
||||
merged.set(tx.txid, {
|
||||
txid: tx.txid,
|
||||
netSats: tx.signedAmount,
|
||||
confirmed: tx.confirmed,
|
||||
timestamp: tx.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out: HdTransaction[] = Array.from(merged.values()).map((m) => ({
|
||||
txid: m.txid,
|
||||
amount: Math.abs(m.netSats),
|
||||
type: m.netSats >= 0 ? 'receive' : 'send',
|
||||
confirmed: m.confirmed,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
|
||||
// Sort newest first. Unconfirmed (no timestamp) go to the top.
|
||||
out.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 out;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
|
||||
import {
|
||||
BITCOIN_DUST_LIMIT,
|
||||
estimateFee,
|
||||
type UTXO,
|
||||
validateBitcoinAddress,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
CHANGE_CHAIN,
|
||||
deriveAddress,
|
||||
deriveLeafTaprootSigner,
|
||||
type HdAccount,
|
||||
HD_WALLET_NETWORK,
|
||||
} from './derivation';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HD wallet transaction construction & signing
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A "spendable" UTXO is one of our own UTXOs together with the (chain, index)
|
||||
// pair that derived its address. We need both to:
|
||||
//
|
||||
// 1. Set `tapInternalKey` on the PSBT input correctly per BIP-371.
|
||||
// 2. Reconstruct the BIP-341 tweaked private key when signing.
|
||||
//
|
||||
// Change always goes to a fresh address on the internal (change) chain —
|
||||
// never to a receive-chain address — to keep the on-chain heuristic
|
||||
// "change-from-same-owner is the smaller output on the change chain" intact.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A UTXO owned by the HD wallet, annotated with derivation info. */
|
||||
export interface HdSpendableUtxo extends UTXO {
|
||||
/** The Bitcoin address the UTXO belongs to. */
|
||||
address: string;
|
||||
/** Chain index (0 = receive, 1 = change). */
|
||||
chain: 0 | 1;
|
||||
/** Address index within the chain. */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** Result of building an unsigned PSBT for the HD wallet. */
|
||||
export interface HdUnsignedPsbt {
|
||||
/** Hex-encoded unsigned PSBT. */
|
||||
psbtHex: string;
|
||||
/** Network fee in satoshis. */
|
||||
fee: number;
|
||||
/** Whether a change output was added. */
|
||||
hasChange: boolean;
|
||||
/** Address used for change (if any). */
|
||||
changeAddress?: string;
|
||||
/** Per-input (chain, index) so signing knows which key to derive. */
|
||||
inputDerivations: Array<{ chain: 0 | 1; index: number }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coin selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Branch-and-bound is overkill here. A "largest-first" coin selector is fine
|
||||
* for a wallet whose UTXO set is bounded by gap-limit scanning:
|
||||
*
|
||||
* - Picks the smallest set of inputs that covers `target + fee`.
|
||||
* - Prefers confirmed UTXOs over unconfirmed.
|
||||
* - Returns the candidate set OR `null` if balance is insufficient.
|
||||
*
|
||||
* This deliberately avoids the privacy pitfall of the "smallest first"
|
||||
* heuristic (which signals "I am consolidating dust" to chain analysis).
|
||||
*/
|
||||
function selectUtxos(
|
||||
utxos: readonly HdSpendableUtxo[],
|
||||
target: number,
|
||||
feeRate: number,
|
||||
): { selected: HdSpendableUtxo[]; total: number } | null {
|
||||
// Confirmed first, then largest first within each group.
|
||||
const sorted = [...utxos].sort((a, b) => {
|
||||
if (a.status.confirmed !== b.status.confirmed) return a.status.confirmed ? -1 : 1;
|
||||
return b.value - a.value;
|
||||
});
|
||||
|
||||
const selected: HdSpendableUtxo[] = [];
|
||||
let total = 0;
|
||||
|
||||
for (const utxo of sorted) {
|
||||
selected.push(utxo);
|
||||
total += utxo.value;
|
||||
|
||||
// Fee for current set, assuming 2 outputs (recipient + change). If we
|
||||
// can cover target+fee even without change, we'll get a chance to drop
|
||||
// the change output below.
|
||||
const feeWithChange = estimateFee(selected.length, 2, feeRate);
|
||||
if (total >= target + feeWithChange) return { selected, total };
|
||||
|
||||
const feeNoChange = estimateFee(selected.length, 1, feeRate);
|
||||
if (total >= target + feeNoChange) return { selected, total };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PSBT build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an unsigned P2TR PSBT for the HD wallet.
|
||||
*
|
||||
* Inputs are chosen by `selectUtxos`. Change (if any) goes to a fresh address
|
||||
* on the internal chain, derived at `account.changeNode / nextChangeIndex`.
|
||||
*
|
||||
* @param account HD account (used to derive change address & re-derive input keys).
|
||||
* @param ownedUtxos Candidate UTXOs to spend (must include `chain`/`index`).
|
||||
* @param toAddress Recipient Bitcoin address.
|
||||
* @param amountSats Amount to send in satoshis (must be >= dust limit).
|
||||
* @param feeRate Fee rate in sat/vB.
|
||||
* @param nextChangeIndex The next unused index on the change chain.
|
||||
*/
|
||||
export function buildHdUnsignedPsbt(
|
||||
account: HdAccount,
|
||||
ownedUtxos: readonly HdSpendableUtxo[],
|
||||
toAddress: string,
|
||||
amountSats: number,
|
||||
feeRate: number,
|
||||
nextChangeIndex: number,
|
||||
): HdUnsignedPsbt {
|
||||
if (!validateBitcoinAddress(toAddress)) {
|
||||
throw new Error(`Invalid Bitcoin address: ${toAddress}`);
|
||||
}
|
||||
if (!Number.isInteger(amountSats) || amountSats < BITCOIN_DUST_LIMIT) {
|
||||
throw new Error(`Amount must be at least ${BITCOIN_DUST_LIMIT} sats.`);
|
||||
}
|
||||
if (!Number.isFinite(feeRate) || feeRate <= 0) {
|
||||
throw new Error('Fee rate must be positive.');
|
||||
}
|
||||
|
||||
const selection = selectUtxos(ownedUtxos, amountSats, feeRate);
|
||||
if (!selection) {
|
||||
const total = ownedUtxos.reduce((s, u) => s + u.value, 0);
|
||||
throw new Error(
|
||||
`Insufficient funds. Need at least ${amountSats.toLocaleString()} sats + fees, ` +
|
||||
`have ${total.toLocaleString()} sats spendable.`,
|
||||
);
|
||||
}
|
||||
const { selected, total: totalInput } = selection;
|
||||
|
||||
// Recompute fee for the final selected set.
|
||||
const feeWithChange = estimateFee(selected.length, 2, feeRate);
|
||||
const changeIfKept = totalInput - amountSats - feeWithChange;
|
||||
const hasChange = changeIfKept >= BITCOIN_DUST_LIMIT;
|
||||
const numOutputs = hasChange ? 2 : 1;
|
||||
const fee = estimateFee(selected.length, numOutputs, feeRate);
|
||||
const change = totalInput - amountSats - fee;
|
||||
|
||||
if (change < 0) {
|
||||
throw new Error(
|
||||
`Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, ` +
|
||||
`have ${totalInput.toLocaleString()} sats.`,
|
||||
);
|
||||
}
|
||||
|
||||
bitcoin.initEccLib(ecc);
|
||||
const psbt = new bitcoin.Psbt({ network: HD_WALLET_NETWORK });
|
||||
const inputDerivations: HdUnsignedPsbt['inputDerivations'] = [];
|
||||
|
||||
for (const utxo of selected) {
|
||||
// Re-derive the input's internal key from the account hierarchy. We do
|
||||
// not trust the address string for this — the chain/index pair is the
|
||||
// single source of truth.
|
||||
const derived = deriveAddress(
|
||||
utxo.chain === CHANGE_CHAIN ? account.changeNode : account.receiveNode,
|
||||
utxo.chain,
|
||||
utxo.index,
|
||||
);
|
||||
if (derived.address !== utxo.address) {
|
||||
throw new Error(
|
||||
`UTXO address mismatch at ${utxo.chain}/${utxo.index}: ` +
|
||||
`expected ${derived.address}, got ${utxo.address}`,
|
||||
);
|
||||
}
|
||||
const internalPubkey = Buffer.from(derived.internalPubkeyHex, 'hex');
|
||||
|
||||
psbt.addInput({
|
||||
hash: utxo.txid,
|
||||
index: utxo.vout,
|
||||
witnessUtxo: {
|
||||
script: bitcoin.payments.p2tr({
|
||||
internalPubkey,
|
||||
network: HD_WALLET_NETWORK,
|
||||
}).output!,
|
||||
value: BigInt(utxo.value),
|
||||
},
|
||||
tapInternalKey: internalPubkey,
|
||||
});
|
||||
inputDerivations.push({ chain: utxo.chain, index: utxo.index });
|
||||
}
|
||||
|
||||
psbt.addOutput({ address: toAddress, value: BigInt(amountSats) });
|
||||
|
||||
let changeAddress: string | undefined;
|
||||
if (hasChange) {
|
||||
const changeDerived = deriveAddress(account.changeNode, CHANGE_CHAIN, nextChangeIndex);
|
||||
changeAddress = changeDerived.address;
|
||||
psbt.addOutput({ address: changeAddress, value: BigInt(change) });
|
||||
}
|
||||
|
||||
return {
|
||||
psbtHex: psbt.toHex(),
|
||||
fee,
|
||||
hasChange,
|
||||
changeAddress,
|
||||
inputDerivations,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PSBT signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sign every input in a PSBT using its corresponding HD-derived tweaked key.
|
||||
*
|
||||
* `inputDerivations` MUST be aligned 1:1 with the PSBT's inputs in order.
|
||||
* (`buildHdUnsignedPsbt` returns them in the right order.)
|
||||
*
|
||||
* @returns Hex-encoded signed (but not finalised) PSBT.
|
||||
*/
|
||||
export function signHdPsbt(
|
||||
psbtHex: string,
|
||||
inputDerivations: ReadonlyArray<{ chain: 0 | 1; index: number }>,
|
||||
account: HdAccount,
|
||||
): string {
|
||||
bitcoin.initEccLib(ecc);
|
||||
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: HD_WALLET_NETWORK });
|
||||
|
||||
if (psbt.inputCount !== inputDerivations.length) {
|
||||
throw new Error(
|
||||
`PSBT input count (${psbt.inputCount}) does not match derivations ` +
|
||||
`length (${inputDerivations.length}).`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < psbt.inputCount; i++) {
|
||||
const { chain, index } = inputDerivations[i];
|
||||
const tweakedSigner = deriveLeafTaprootSigner(account, chain, index);
|
||||
psbt.signInput(i, tweakedSigner);
|
||||
}
|
||||
|
||||
return psbt.toHex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise a signed PSBT and extract the raw transaction hex.
|
||||
*/
|
||||
export function finalizeHdPsbt(psbtHex: string): string {
|
||||
bitcoin.initEccLib(ecc);
|
||||
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: HD_WALLET_NETWORK });
|
||||
psbt.finalizeAllInputs();
|
||||
return psbt.extractTransaction().toHex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: build → sign → finalise in one call.
|
||||
*/
|
||||
export function createHdTransaction(
|
||||
account: HdAccount,
|
||||
ownedUtxos: readonly HdSpendableUtxo[],
|
||||
toAddress: string,
|
||||
amountSats: number,
|
||||
feeRate: number,
|
||||
nextChangeIndex: number,
|
||||
): { txHex: string; fee: number; hasChange: boolean; changeAddress?: string } {
|
||||
const built = buildHdUnsignedPsbt(account, ownedUtxos, toAddress, amountSats, feeRate, nextChangeIndex);
|
||||
const signed = signHdPsbt(built.psbtHex, built.inputDerivations, account);
|
||||
const txHex = finalizeHdPsbt(signed);
|
||||
return { txHex, fee: built.fee, hasChange: built.hasChange, changeAddress: built.changeAddress };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Max-sendable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the maximum amount sendable to a single recipient if every owned
|
||||
* UTXO is consumed and no change is produced.
|
||||
*/
|
||||
export function maxHdSendable(utxos: readonly HdSpendableUtxo[], feeRate: number): number {
|
||||
const total = utxos.reduce((s, u) => s + u.value, 0);
|
||||
const fee = estimateFee(utxos.length, 1, feeRate);
|
||||
return Math.max(0, total - fee);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import {
|
||||
Bitcoin,
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
Send,
|
||||
ShieldOff,
|
||||
ArrowRight,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { HDSendBitcoinDialog } from '@/components/HDSendBitcoinDialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { satsToUSD, formatBTC } from '@/lib/bitcoin';
|
||||
import type { HdTransaction } from '@/lib/hdwallet/scan';
|
||||
|
||||
export function HDWalletPage() {
|
||||
const { config } = useAppContext();
|
||||
const {
|
||||
availability,
|
||||
currentReceiveAddress,
|
||||
transactions,
|
||||
totalBalance,
|
||||
pendingBalance,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
nextReceiveAddress,
|
||||
} = useHdWallet();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
const [copiedAddress, setCopiedAddress] = useState(false);
|
||||
const [txOpen, setTxOpen] = useState(false);
|
||||
const [sendOpen, setSendOpen] = useState(false);
|
||||
|
||||
useSeoMeta({
|
||||
title: `HD Wallet | ${config.appName}`,
|
||||
description: 'Hierarchical-deterministic Bitcoin wallet derived from your Nostr nsec.',
|
||||
});
|
||||
|
||||
const address = currentReceiveAddress?.address ?? '';
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopiedAddress(true);
|
||||
setTimeout(() => setCopiedAddress(false), 2000);
|
||||
} catch {
|
||||
// clipboard unavailable
|
||||
}
|
||||
};
|
||||
|
||||
const truncatedAddress = address
|
||||
? `${address.slice(0, 12)}...${address.slice(-8)}`
|
||||
: '';
|
||||
|
||||
// ── Logged out ────────────────────────────────────────────────
|
||||
if (availability.status === 'logged-out') {
|
||||
return (
|
||||
<main>
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<KeyRound className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-xl font-bold">HD Bitcoin Wallet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A hierarchical wallet derived from your Nostr identity. Fresh address per receive,
|
||||
full transaction history, no address reuse.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs pt-2">
|
||||
Requires login with an nsec (your Nostr private key).
|
||||
</p>
|
||||
</div>
|
||||
<LoginArea className="max-w-60" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Logged in, but signer doesn't expose the secret key ─────
|
||||
if (availability.status === 'unsupported') {
|
||||
return (
|
||||
<main>
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center max-w-md mx-auto">
|
||||
<div className="p-4 rounded-full bg-muted">
|
||||
<ShieldOff className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-bold">HD wallet unavailable</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{availability.loginType === 'extension'
|
||||
? 'Your browser extension keeps your secret key isolated, so we can\'t derive child keys for an HD wallet.'
|
||||
: availability.loginType === 'bunker'
|
||||
? 'Your remote signer (NIP-46 bunker) keeps your secret key on the bunker side, so we can\'t derive child keys for an HD wallet.'
|
||||
: "Your login type doesn't expose the secret key needed to derive an HD wallet."}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm pt-2">
|
||||
The single-address wallet at <Link to="/wallet" className="underline">/wallet</Link>{' '}
|
||||
works for every login type.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/wallet">
|
||||
Go to standard wallet
|
||||
<ArrowRight className="size-4 ml-2" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Available — full HD wallet UI ────────────────────────────
|
||||
return (
|
||||
<main>
|
||||
<div className="flex flex-col items-center px-4 pt-8 pb-4 space-y-6 max-w-sm mx-auto">
|
||||
{/* Balance */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Skeleton className="h-10 w-40 rounded-lg" />
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-sm text-destructive">Failed to scan wallet</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<span className="text-4xl font-bold tracking-tight">
|
||||
{btcPrice ? satsToUSD(totalBalance, btcPrice) : '---'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBTC(totalBalance)} BTC
|
||||
</span>
|
||||
|
||||
{pendingBalance !== 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
{btcPrice
|
||||
? `${satsToUSD(Math.abs(pendingBalance), btcPrice)} pending`
|
||||
: 'pending'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
{!isLoading && !error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSendOpen(true)}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Send className="size-3.5 mr-1.5" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<HDSendBitcoinDialog
|
||||
isOpen={sendOpen}
|
||||
onClose={() => setSendOpen(false)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
|
||||
{/* QR Code + address */}
|
||||
{address && (
|
||||
<>
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={address} size={200} level="M" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={copyAddress}
|
||||
className="flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-mono text-muted-foreground hover:bg-muted/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{truncatedAddress}
|
||||
{copiedAddress ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>Address #{currentReceiveAddress?.index ?? 0}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
onClick={() => nextReceiveAddress()}
|
||||
className="hover:text-foreground underline-offset-4 hover:underline transition-colors cursor-pointer"
|
||||
>
|
||||
New address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
{transactions && transactions.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setTxOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
Transactions
|
||||
<ChevronDown className={`size-3 transition-transform duration-200 ${txOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<TxAccordion open={txOpen}>
|
||||
<div className="w-full divide-y">
|
||||
{transactions.map((tx) => (
|
||||
<TxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
|
||||
))}
|
||||
</div>
|
||||
</TxAccordion>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Standard wallet link */}
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Bitcoin className="size-3" />
|
||||
Standard single-address wallet
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (mirrors WalletPage.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div
|
||||
className="w-full grid transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div ref={contentRef} className="overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTxDate(timestamp?: number): string {
|
||||
if (!timestamp) return 'Pending';
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function TxRow({ tx, btcPrice }: { tx: HdTransaction; btcPrice?: number }) {
|
||||
const isReceive = tx.type === 'receive';
|
||||
return (
|
||||
<Link
|
||||
to={`/i/bitcoin:tx:${tx.txid}`}
|
||||
className="flex items-center justify-between py-3 hover:bg-muted/50 transition-colors rounded-lg -mx-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center size-8 rounded-full ${
|
||||
isReceive
|
||||
? 'bg-green-500/10 text-green-600 dark:text-green-400'
|
||||
: 'bg-red-500/10 text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isReceive
|
||||
? <ArrowDownLeft className="size-4" />
|
||||
: <ArrowUpRight className="size-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${
|
||||
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{isReceive ? '+' : '-'}
|
||||
{btcPrice ? satsToUSD(tx.amount, btcPrice) : `${formatBTC(tx.amount)} BTC`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatBTC(tx.amount)} BTC</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user