diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index f07d0959..03d851b5 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -84,7 +84,6 @@ 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 }))); @@ -280,7 +279,6 @@ export function AppRouter() { /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 372fecfb..7913b4be 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -54,7 +54,7 @@ import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyLis import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; import { useComments } from '@/hooks/useComments'; -import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks'; import { useCommunityMembers } from '@/hooks/useCommunityMembers'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -239,7 +239,7 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin } function PledgeShelfCard({ pledge }: { pledge: Action }) { - const { btcPrice } = useBitcoinWallet(); + const { data: btcPrice } = useBtcPrice(); const author = useAuthor(pledge.pubkey); const metadata = author.data?.metadata; const displayName = getDisplayName(metadata, pledge.pubkey); diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index 2edb72d5..089776c4 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -104,7 +104,7 @@ interface ResolvedRecipient { /** * 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) + * - npub1… → P2TR derived from the Nostr pubkey * - nprofile1… → P2TR derived from the encoded pubkey * * Returns `null` for unparseable input. The caller should treat `null` as @@ -156,12 +156,11 @@ interface SendResult { } /** - * "Send Bitcoin" dialog for the HD wallet at `/hdwallet`. + * "Send Bitcoin" dialog for the HD wallet at `/wallet`. * - * 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 + * Provides a large editable USD amount, preset chips, fee speed picker, two-tap + * arming for large amounts, and a privacy disclaimer for raw addresses. 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) { @@ -205,7 +204,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin const ownedUtxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]); const totalBalance = useMemo(() => ownedUtxos.reduce((s, u) => s + u.value, 0), [ownedUtxos]); - // Silent-payment UTXOs are scanned and displayed on /hdwallet but cannot + // Silent-payment UTXOs are scanned and displayed on /wallet but cannot // yet be spent by this dialog: the BIP-352 receive output uses a tweaked // private key not derivable from the BIP-86 (chain, index) pair the PSBT // signer expects. Detect the case where the wallet's _only_ funds are SP diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx deleted file mode 100644 index 4c162d43..00000000 --- a/src/components/SendBitcoinDialog.tsx +++ /dev/null @@ -1,1229 +0,0 @@ -import { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { nip19 } from 'nostr-tools'; -import { - AlertTriangle, - Bitcoin, - Check, - ExternalLink, - Loader2, - UserRoundCheck, - X, -} from 'lucide-react'; -import { useQuery, 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer'; -import { ZapSuccessScreen } from '@/components/ZapSuccessScreen'; -import { EmojifiedText } from '@/components/CustomEmoji'; -import { getAvatarShape } from '@/lib/avatarShape'; -import { genUserName } from '@/lib/genUserName'; -import { cn } from '@/lib/utils'; - -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useToast } from '@/hooks/useToast'; -import { useAppContext } from '@/hooks/useAppContext'; -import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles'; -import { useAuthor } from '@/hooks/useAuthor'; -import { useNip05Resolve } from '@/hooks/useNip05Resolve'; -import { detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier'; -import { isNostrId } from '@/lib/nostrId'; -import { notificationSuccess } from '@/lib/haptics'; -import { withAgoraTag } from '@/lib/agoraNoteTags'; -import { - nostrPubkeyToBitcoinAddress, - validateBitcoinAddress, - fetchUTXOs, - getFeeRates, - buildUnsignedPsbt, - finalizePsbt, - broadcastTransaction, - estimateFee, - satsToUSD, - isLargeAmount, - type FeeRates, -} from '@/lib/bitcoin'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const USD_PRESETS = [1, 5, 10, 25, 100]; - -type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy'; - -const FEE_SPEED_LABELS: Record = { - 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; - } -} - -/** Deduplicate fee tiers that share the same sat/vB rate. */ -function getUniqueFeeSpeeds(rates: FeeRates | undefined): FeeSpeed[] { - if (!rates) return FEE_SPEED_ORDER; - const seen = new Set(); - 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; -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** - * A recipient resolved to send Bitcoin to. Always carries a Bitcoin address. - * When `pubkey` is set, the recipient is also a Nostr identity — meaning we - * can publish a kind 8333 profile-zap attesting the send. - */ -interface ResolvedRecipient { - /** P2TR Bitcoin address to send to. */ - address: string; - /** Hex Nostr pubkey, when the recipient is a Nostr user. */ - pubkey?: string; - /** Optional profile metadata for display. */ - profile?: SearchProfile; - /** Raw text the user originally typed (for re-display on backspace). */ - raw: string; -} - -interface SendBitcoinDialogProps { - isOpen: boolean; - onClose: () => void; - /** BTC/USD price — passed from the parent to avoid a duplicate fetch. */ - btcPrice?: number; -} - -interface SendResult { - txid: string; - amountSats: number; - /** Set when the recipient was a Nostr user (kind 8333 published). */ - recipientPubkey?: string; - /** Bitcoin network fee in satoshis. */ - fee: number; -} - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -/** - * Wallet "Send Bitcoin" dialog. Mirrors the `OnchainZapContent` UX for the - * standalone wallet flow: - * - * - Single screen, no review step. Big editable USD amount on top, USD preset - * chips below. - * - Recipient picker with profile autocomplete (same lookups as the global - * search bar — profiles only, plus pasted npub/nprofile/nip05/hex), and - * fallback acceptance of a raw Bitcoin address (bc1…). - * - Fee speed shown as a small line below the send button; popover for picking. - * - When the recipient is a Nostr identity, publishes a kind 8333 onchain-zap - * event after broadcast (no `e`/`a` tag → profile-level zap, per NIP.md). - */ -export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) { - const { user } = useCurrentUser(); - const { canSignPsbt, signPsbt } = useBitcoinSigner(); - const { mutateAsync: publishEvent } = useNostrPublish(); - const { toast } = useToast(); - const { config } = useAppContext(); - const { esploraApis } = config; - const queryClient = useQueryClient(); - - // ── Form state ─────────────────────────────────────────────── - const [recipient, setRecipient] = useState(null); - const [usdAmount, setUsdAmount] = useState(5); - const [feeSpeed, setFeeSpeed] = useState('halfHour'); - const [error, setError] = useState(''); - const [editingAmount, setEditingAmount] = useState(false); - const [feePopoverOpen, setFeePopoverOpen] = useState(false); - const [success, setSuccess] = useState(null); - - const amountInputRef = useRef(null); - const feeSpeedUserChanged = useRef(false); - - const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : ''; - - // ── Data fetching ──────────────────────────────────────────── - - const { data: utxos } = useQuery({ - queryKey: ['bitcoin-utxos', esploraApis, senderAddress], - queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal), - enabled: !!senderAddress && isOpen && canSignPsbt, - staleTime: 30_000, - }); - - const { data: feeRates } = useQuery({ - queryKey: ['bitcoin-fee-rates', esploraApis], - queryFn: ({ signal }) => getFeeRates(esploraApis, signal), - enabled: isOpen && canSignPsbt, - staleTime: 30_000, - }); - - const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]); - - const currentFeeRate = useMemo(() => { - if (!feeRates) return 0; - return getRateForSpeed(feeRates, feeSpeed); - }, [feeRates, feeSpeed]); - - // ── USD → sats conversion ──────────────────────────────────── - - const amountSats = useMemo(() => { - if (!btcPrice) return 0; - const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; - if (!Number.isFinite(usd) || usd <= 0) return 0; - const btc = usd / btcPrice; - return Math.round(btc * 100_000_000); - }, [usdAmount, btcPrice]); - - const estimatedFeeSats = useMemo(() => { - if (!utxos?.length || !currentFeeRate || !amountSats) return 0; - // Estimate with 2 outputs first, then check whether change would be dust - const fee2 = estimateFee(utxos.length, 2, currentFeeRate); - const change = totalBalance - amountSats - fee2; - const numOutputs = change > 546 ? 2 : 1; - return estimateFee(utxos.length, numOutputs, currentFeeRate); - }, [utxos, currentFeeRate, amountSats, totalBalance]); - - const totalSats = amountSats + estimatedFeeSats; - const insufficient = totalBalance > 0 && totalSats > totalBalance; - const showBalance = insufficient || (amountSats > 0 && totalBalance === 0); - - // Auto-tune the fee speed so the network fee stays under 40% of the send - // amount, unless the user has manually picked a speed. Mirrors OnchainZapContent. - useEffect(() => { - if (feeSpeedUserChanged.current) return; - if (!utxos?.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(utxos.length, 2, rate); - const change = totalBalance - amountSats - fee2; - const outputs = change > 546 ? 2 : 1; - const fee = estimateFee(utxos.length, outputs, rate); - if (fee <= threshold) { - target = speed; - break; - } - } - - setFeeSpeed((prev) => (prev === target ? prev : target)); - }, [amountSats, feeRates, utxos, totalBalance]); - - const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => { - feeSpeedUserChanged.current = true; - setFeeSpeed(speed); - setFeePopoverOpen(false); - }, []); - - // ── Two-tap "arm" for large amounts ────────────────────────── - - const isLarge = isLargeAmount(totalSats, btcPrice); - const [confirmArmed, setConfirmArmed] = useState(false); - - // ── Raw Bitcoin address privacy acknowledgement ───────────── - // - // When the recipient is a raw on-chain address (no Nostr pubkey attached), - // Bitcoin's public ledger means the send can be linked back to the sender's - // wallet forever. Require an explicit checkbox before unlocking the Send - // button, and force the two-tap arm regardless of amount. - - const isRawAddress = !!recipient && !recipient.pubkey; - const [acknowledgedPublic, setAcknowledgedPublic] = useState(false); - - useEffect(() => { - setConfirmArmed(false); - setAcknowledgedPublic(false); - }, [amountSats, currentFeeRate, btcPrice, recipient?.address]); - - // For raw addresses we always require the two-tap arm, not just for large sends. - const requiresArm = isLarge || isRawAddress; - - // ── Big 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' | 'publishing'>('idle'); - - const sendMutation = useMutation({ - mutationFn: async () => { - if (!user) throw new Error('You must be logged in.'); - if (!recipient) throw new Error('Choose a recipient.'); - if (!canSignPsbt || !signPsbt) throw new Error("Your login doesn't support sending Bitcoin."); - if (!utxos?.length) throw new Error('No spendable Bitcoin available.'); - if (!feeRates) throw new Error('Fee rates not loaded.'); - if (recipient.pubkey === user.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.'); - - setProgress('building'); - const rate = getRateForSpeed(feeRates, feeSpeed); - const { psbtHex, fee } = buildUnsignedPsbt( - user.pubkey, - recipient.address, - amountSats, - utxos, - rate, - ); - - setProgress('signing'); - const signedHex = await signPsbt(psbtHex); - const txHex = finalizePsbt(signedHex); - - setProgress('broadcasting'); - const txid = await broadcastTransaction(txHex, esploraApis); - - // When the recipient is a Nostr identity, publish a kind 8333 profile zap - // attesting the send. Per NIP.md, omitting `e`/`a` targets the recipient's - // profile (a tip to the pubkey, not a specific event). - if (recipient.pubkey) { - setProgress('publishing'); - try { - await publishEvent({ - kind: 8333, - content: '', - tags: withAgoraTag([ - ['i', `bitcoin:tx:${txid}`], - ['p', recipient.pubkey], - ['amount', String(amountSats)], - ['alt', `On-chain zap: ${amountSats.toLocaleString()} sats`], - ]), - }); - } catch (err) { - // The Bitcoin transaction already broadcast — the kind 8333 is a - // best-effort attestation. Surface the failure but don't blow up - // the success screen. - console.warn('Failed to publish kind 8333 zap event:', err); - } - } - - return { - txid, - amountSats, - recipientPubkey: recipient.pubkey, - fee, - }; }, - onSuccess: (result) => { - notificationSuccess(); - setSuccess(result); - // Invalidate caches that track balances / zaps - queryClient.invalidateQueries({ queryKey: ['bitcoin-wallet'] }); - queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] }); - queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] }); - queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] }); - if (result.recipientPubkey) { - queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] }); - } - }, - onError: (err) => { - toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' }); - }, - onSettled: () => { - setProgress('idle'); - }, - }); - - // ── Send handler ───────────────────────────────────────────── - - const handleSend = useCallback(async () => { - setError(''); - if (!user) { setError('You must be logged in.'); return; } - if (!recipient) { setError('Choose a recipient.'); return; } - if (recipient.pubkey === user.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 (!utxos?.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; - } - - try { - await sendMutation.mutateAsync(); - } catch { - // Toast handled in onError; nothing further to do here. - } - }, [user, recipient, btcPrice, amountSats, utxos, insufficient, isRawAddress, acknowledgedPublic, requiresArm, confirmArmed, sendMutation]); - - // ── Reset on close ─────────────────────────────────────────── - - const handleClose = useCallback(() => { - setRecipient(null); - setUsdAmount(5); - setError(''); - setFeeSpeed('halfHour'); - setSuccess(null); - setConfirmArmed(false); - setAcknowledgedPublic(false); - setEditingAmount(false); - feeSpeedUserChanged.current = false; - onClose(); - }, [onClose]); - - // ── Render ─────────────────────────────────────────────────── - - const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; - const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0; - const totalUsdString = btcPrice ? satsToUSD(totalSats, btcPrice) : ''; - const uniqueFeeSpeeds = useMemo(() => getUniqueFeeSpeeds(feeRates), [feeRates]); - const isPending = sendMutation.isPending; - - // ── Unsupported signer ─────────────────────────────────────── - - if (isOpen && !canSignPsbt) { - return ( - - - - - Sending Not Available - - - - - Your login doesn't support sending Bitcoin. Log in with your secret key to send. - - - - - - ); - } - - return ( - - -
- - {success ? 'Success' : 'Send Bitcoin'} - - -
- -
- {success ? ( - success.recipientPubkey ? ( - - ) : ( - - ) - ) : ( -
- {/* Recipient picker */} - { setRecipient(v); setError(''); }} - /> - - {/* Big editable USD amount */} -
- {editingAmount ? ( -
- $ - { setUsdAmount(e.target.value); setError(''); }} - onBlur={commitAmountEdit} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); commitAmountEdit(); } - }} - aria-label="Amount in USD" - className={`bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${insufficient ? 'text-destructive' : ''}`} - style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }} - /> -
- ) : ( - - )} -
- - {/* Preset chips */} - { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }} - className="grid grid-cols-5 gap-1 w-full" - > - {USD_PRESETS.map((v) => ( - - ${v} - - ))} - - - {/* Error */} - {error && ( -

{error}

- )} - - {/* Privacy warning for raw Bitcoin addresses */} - {isRawAddress && ( - { - setAcknowledgedPublic(checked); - setError(''); - // Re-arming required after toggling the acknowledgement. - setConfirmArmed(false); - }} - /> - )} - - {/* Send button */} - - - {/* Fee line / picker */} - {amountSats > 0 && ( -
- - - - - -
- {uniqueFeeSpeeds.map((speed) => { - const rate = feeRates ? getRateForSpeed(feeRates, speed) : 0; - const selected = speed === feeSpeed; - return ( - - ); - })} -
-
-
- - {showBalance && !insufficient && btcPrice && ( - - Balance: {satsToUSD(totalBalance, btcPrice)} - - )} -
- )} -
- )} -
-
-
- ); -} - -function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string { - switch (progress) { - case 'building': return 'Building…'; - case 'signing': return 'Signing…'; - case 'broadcasting': return 'Broadcasting…'; - case 'publishing': return 'Publishing…'; - default: return 'Sending…'; - } -} - -// ═══════════════════════════════════════════════════════════════════ -// Recipient picker -// ═══════════════════════════════════════════════════════════════════ - -interface RecipientPickerProps { - value: ResolvedRecipient | null; - onChange: (value: ResolvedRecipient | null) => void; -} - -/** - * Combobox that lets the user pick a Nostr profile from autocomplete, paste a - * Nostr identifier (npub/nprofile/nip05/hex), or type a raw Bitcoin address. - * - * The dropdown intentionally only surfaces profile-shaped suggestions — no - * Wikipedia / Internet Archive / country / nav-item rows. That keeps the - * picker focused: every selection is something the user can actually be paid. - */ -function RecipientPicker({ value, onChange }: RecipientPickerProps) { - const [query, setQuery] = useState(''); - const [open, setOpen] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(-1); - const containerRef = useRef(null); - const inputRef = useRef(null); - - const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query); - - const identifierMatch = useMemo(() => { - const m = detectIdentifier(query); - if (!m) return null; - // Only pubkey-resolvable identifiers belong in this picker. - switch (m.type) { - case 'npub': - case 'nprofile': - case 'nip05': - case 'hex': - return m; - default: - return null; - } - }, [query]); - - // Raw bitcoin address fallback — only when nothing else matches. - const trimmed = query.trim(); - const looksLikeBtcAddress = !identifierMatch - && trimmed.length > 0 - && !profiles?.length - && validateBitcoinAddress(trimmed); - - // Deduplicate: if the identifier resolves to a pubkey that's also in the - // profile results, drop it from the profile list. - const identifierPubkey = useMemo(() => { - if (!identifierMatch) return undefined; - if (identifierMatch.type === 'npub' || identifierMatch.type === 'nprofile') return identifierMatch.pubkey; - if (identifierMatch.type === 'hex') return identifierMatch.hex; - return undefined; // nip05 resolves async; handled by IdentifierRow - }, [identifierMatch]); - - const filteredProfiles = useMemo(() => { - if (!profiles || !identifierPubkey) return profiles ?? []; - return profiles.filter((p) => p.pubkey !== identifierPubkey); - }, [profiles, identifierPubkey]); - - const hasIdentifier = !!identifierMatch; - const hasBtcAddress = !!looksLikeBtcAddress; - const profileCount = filteredProfiles.length; - const totalItems = (hasIdentifier ? 1 : 0) + profileCount + (hasBtcAddress ? 1 : 0); - - // Open the dropdown whenever we have any suggestion or a non-empty query. - useEffect(() => { - if (trimmed.length === 0) { - setOpen(false); - return; - } - if (hasIdentifier || hasBtcAddress || profileCount > 0 || isFetching) { - setOpen(true); - } - }, [trimmed, hasIdentifier, hasBtcAddress, profileCount, isFetching]); - - useEffect(() => { - setSelectedIndex(-1); - }, [profiles, identifierMatch, looksLikeBtcAddress]); - - // Close on outside click - useEffect(() => { - function handler(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - const selectProfile = useCallback((profile: SearchProfile) => { - const address = nostrPubkeyToBitcoinAddress(profile.pubkey); - if (!address) return; - onChange({ - address, - pubkey: profile.pubkey, - profile, - raw: query, - }); - setQuery(''); - setOpen(false); - inputRef.current?.blur(); - }, [onChange, query]); - - const selectPubkey = useCallback((pubkey: string, raw: string) => { - if (!isNostrId(pubkey)) return; - const address = nostrPubkeyToBitcoinAddress(pubkey); - if (!address) return; - onChange({ address, pubkey, raw }); - setQuery(''); - setOpen(false); - inputRef.current?.blur(); - }, [onChange]); - - const selectBtcAddress = useCallback((address: string) => { - onChange({ address, raw: address }); - setQuery(''); - setOpen(false); - inputRef.current?.blur(); - }, [onChange]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - setOpen(false); - inputRef.current?.blur(); - return; - } - - if (e.key === 'Enter') { - e.preventDefault(); - if (open && selectedIndex >= 0 && selectedIndex < totalItems) { - // Order: [identifier?, ...profiles, btcAddress?] - let idx = selectedIndex; - if (hasIdentifier) { - if (idx === 0) { - // IdentifierRow handles its own selection via DOM click — the - // selectedPubkey may need NIP-05 resolution. - const items = containerRef.current?.querySelectorAll('[data-recipient-item]'); - (items?.[selectedIndex] as HTMLElement | undefined)?.click(); - return; - } - idx -= 1; - } - if (idx < profileCount) { - selectProfile(filteredProfiles[idx]); - return; - } - idx -= profileCount; - if (hasBtcAddress && idx === 0) { - selectBtcAddress(trimmed); - return; - } - } - return; - } - - if (!open || totalItems === 0) return; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); - } - }; - - // ── Selected-chip view ───────────────────────────────────── - - if (value) { - return ( - onChange(null)} /> - ); - } - - // ── Input + dropdown ─────────────────────────────────────── - - return ( -
- setQuery(e.target.value)} - onFocus={() => { if (trimmed.length > 0) setOpen(true); }} - onKeyDown={handleKeyDown} - placeholder="Search people, paste npub, or enter a Bitcoin address" - autoComplete="off" - role="combobox" - aria-expanded={open} - aria-haspopup="listbox" - aria-autocomplete="list" - className="rounded-full" - /> - - {open && totalItems > 0 && ( -
-
- {hasIdentifier && ( - - )} - {filteredProfiles.map((profile, i) => ( - - ))} - {hasBtcAddress && ( - - )} -
-
- )} - - {/* Empty state */} - {open && trimmed.length > 0 && !isFetching && totalItems === 0 && ( -
-
- No matches. Paste an npub or a Bitcoin address. -
-
- )} -
- ); -} - -// ── Selected recipient chip ─────────────────────────────────── - -function SelectedRecipientChip({ - value, - onClear, -}: { - value: ResolvedRecipient; - onClear: () => void; -}) { - const { pubkey, profile, address } = value; - // Author lookup only when we have a pubkey but no inline profile. - const author = useAuthor(profile ? undefined : pubkey); - const metadata = profile?.metadata ?? author.data?.metadata; - const tags = profile?.event.tags ?? author.data?.event?.tags ?? []; - - const displayName = pubkey - ? metadata?.name || metadata?.display_name || genUserName(pubkey) - : 'Bitcoin address'; - - const subtitle = pubkey - ? metadata?.nip05 ?? nip19.npubEncode(pubkey) - : `${address.slice(0, 12)}…${address.slice(-8)}`; - - return ( -
- {pubkey ? ( - - - - {displayName[0]?.toUpperCase() || '?'} - - - ) : ( -
- -
- )} -
-
To
-
- {pubkey ? {displayName} : displayName} -
-
- {subtitle} -
-
- -
- ); -} - -// ── Profile dropdown row ────────────────────────────────────── - -function ProfileRow({ - profile, - isFollowed, - isSelected, - onClick, -}: { - profile: SearchProfile; - isFollowed: boolean; - isSelected: boolean; - onClick: (profile: SearchProfile) => void; -}) { - const { metadata, pubkey } = profile; - const displayName = metadata.name || metadata.display_name || genUserName(pubkey); - const subtitle = metadata.nip05 ?? nip19.npubEncode(pubkey); - - return ( - - ); -} - -// ── Identifier (npub / nprofile / nip05 / hex) dropdown row ─── - -function IdentifierRow({ - match, - isSelected, - onSelectPubkey, -}: { - match: IdentifierMatch; - isSelected: boolean; - onSelectPubkey: (pubkey: string, raw: string) => void; -}) { - // Resolve nip05 → pubkey asynchronously. For other types we already have - // the pubkey inline. - const nip05Id = match.type === 'nip05' ? match.identifier : undefined; - const { data: nip05Pubkey, isLoading: isResolvingNip05 } = useNip05Resolve(nip05Id); - - const pubkey = match.type === 'npub' || match.type === 'nprofile' - ? match.pubkey - : match.type === 'hex' - ? match.hex - : nip05Pubkey ?? undefined; - - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const displayName = pubkey - ? metadata?.name || metadata?.display_name || genUserName(pubkey) - : match.type === 'nip05' ? match.identifier : ''; - - const subtitle = match.type === 'nip05' - ? match.identifier - : pubkey - ? metadata?.nip05 ?? `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}` - : ''; - - const handleClick = useCallback(() => { - if (!pubkey) return; - const raw = match.type === 'nip05' ? match.identifier - : match.type === 'hex' ? match.hex - : match.raw; - onSelectPubkey(pubkey, raw); - }, [pubkey, match, onSelectPubkey]); - - if (isResolvingNip05) { - return ( -
-
-
-
-
-
-
- ); - } - - if (!pubkey) { - // nip05 didn't resolve — drop the row entirely - return null; - } - - return ( - - ); -} - -// ── Raw bitcoin address dropdown row ────────────────────────── - -function BtcAddressRow({ - address, - isSelected, - onClick, -}: { - address: string; - isSelected: boolean; - onClick: (address: string) => void; -}) { - return ( - - ); -} - -// ═══════════════════════════════════════════════════════════════════ -// Success view for raw-address sends (no Nostr identity) -// ═══════════════════════════════════════════════════════════════════ - -interface RawAddressSuccessProps { - txid: string; - amountSats: number; - btcPrice: number | undefined; - onClose: () => void; -} - -/** - * Lighter-weight success screen for raw-address sends. No avatar / recipient - * card because the user typed a bare Bitcoin address — we have no Nostr - * identity to attribute the send to. - */ -function RawAddressSuccess({ txid, amountSats, btcPrice, onClose }: RawAddressSuccessProps) { - const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : ''; - - return ( -
-
- -
- - - -
- -
-

Bitcoin sent

-
- {usdDisplay || `${amountSats.toLocaleString()} sats`} -
-
- -
- - -
-
- ); -} diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 6071caeb..423c8a42 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -291,7 +291,7 @@ export interface AppConfig { esploraApis: string[]; /** * Base URL for Trezor's Blockbook API, used exclusively by the HD wallet at - * `/hdwallet`. Blockbook's xpub endpoint (`/api/v2/xpub/`) lets + * `/wallet`. Blockbook's xpub endpoint (`/api/v2/xpub/`) lets * the HD wallet scan, balance, and pull tx history for the entire account * in a single HTTP call, where the equivalent Esplora workflow would be * dozens of per-address calls. @@ -312,7 +312,7 @@ export interface AppConfig { 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. + * used by the HD wallet at `/wallet` 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 diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts deleted file mode 100644 index 852bbab7..00000000 --- a/src/hooks/useBitcoinWallet.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; - -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useAppContext } from '@/hooks/useAppContext'; -import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin'; - -/** - * Hook that derives a Bitcoin Taproot address from the current user's Nostr - * pubkey and fetches the on-chain balance from the configured Esplora-compatible - * API (default: mempool.space). - * - * Balance auto-refreshes every 30 seconds while the component is mounted. - * BTC/USD price refreshes every 60 seconds. - */ -export function useBitcoinWallet() { - const { user } = useCurrentUser(); - const { config } = useAppContext(); - const { esploraApis } = config; - - const bitcoinAddress = useMemo(() => { - if (!user) return ''; - return nostrPubkeyToBitcoinAddress(user.pubkey); - }, [user]); - - const { - data: addressData, - isLoading, - error, - refetch, - } = useQuery({ - queryKey: ['bitcoin-balance', esploraApis, bitcoinAddress], - queryFn: ({ signal }) => fetchAddressData(bitcoinAddress, esploraApis, signal), - enabled: !!bitcoinAddress, - refetchInterval: 30_000, - }); - - const { data: btcPrice } = useQuery({ - queryKey: ['btc-price', esploraApis], - queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal), - refetchInterval: 60_000, - staleTime: 30_000, - }); - - const { - data: transactions, - isLoading: isLoadingTxs, - } = useQuery({ - queryKey: ['bitcoin-txs', esploraApis, bitcoinAddress], - queryFn: ({ signal }) => fetchTransactions(bitcoinAddress, esploraApis, signal), - enabled: !!bitcoinAddress, - refetchInterval: 30_000, - }); - - return { - /** The derived bc1p... Taproot address. */ - bitcoinAddress, - /** Balance and transaction data (undefined while loading). */ - addressData, - /** Current BTC price in USD. */ - btcPrice, - /** Transaction history for the address. */ - transactions, - /** Whether the initial balance fetch is in progress. */ - isLoading, - /** Whether transactions are still loading. */ - isLoadingTxs, - /** Error from the balance query, if any. */ - error, - /** Manually trigger a balance refresh. */ - refetch, - /** The current user's hex pubkey (convenience). */ - pubkey: user?.pubkey ?? '', - }; -} diff --git a/src/hooks/useHdBtcPrice.ts b/src/hooks/useHdBtcPrice.ts index 7398d832..48cd553f 100644 --- a/src/hooks/useHdBtcPrice.ts +++ b/src/hooks/useHdBtcPrice.ts @@ -9,7 +9,7 @@ import { useAppContext } from '@/hooks/useAppContext'; * * Why a dedicated hook instead of the app-wide {@link useBtcPrice}? * - * `/hdwallet` deliberately isolates its network surface to the single + * `/wallet` deliberately isolates its network surface to the single * Blockbook endpoint the user has configured — no Esplora, no * mempool.space `/v1/prices`. This hook keeps that contract: if Blockbook * is reachable, the HD wallet has everything it needs; if it isn't, diff --git a/src/lib/hdwallet/blockbook.ts b/src/lib/hdwallet/blockbook.ts index 8952aa9a..4d3132a9 100644 --- a/src/lib/hdwallet/blockbook.ts +++ b/src/lib/hdwallet/blockbook.ts @@ -584,7 +584,7 @@ export async function fetchBlockTime( // // Blockbook tracks fiat rates for the coin it serves. The WS API takes a // list of ISO currency codes and returns a `{ ts, rates: { [ccy]: number } }` -// payload. We use this so /hdwallet's USD display sources from the same +// payload. We use this so /wallet's USD display sources from the same // server as its balance and tx data — no extra HTTP dependency on // mempool.space. // --------------------------------------------------------------------------- diff --git a/src/pages/ActionDetailPage.tsx b/src/pages/ActionDetailPage.tsx index 15abae84..d250a28c 100644 --- a/src/pages/ActionDetailPage.tsx +++ b/src/pages/ActionDetailPage.tsx @@ -15,7 +15,7 @@ import { import { useAction, type Action } from '@/hooks/useActions'; import { useAuthor } from '@/hooks/useAuthor'; -import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useComments } from '@/hooks/useComments'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals'; @@ -75,7 +75,7 @@ export function ActionDetailPage({ pubkey, identifier }: ActionDetailPageProps) } function PledgeDetailContent({ action }: { action: Action }) { - const { btcPrice } = useBitcoinWallet(); + const { data: btcPrice } = useBtcPrice(); const author = useAuthor(action.pubkey); const navigate = useNavigate(); const { toast } = useToast(); diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index 3c0d24f8..7d98fbda 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -7,7 +7,7 @@ import { nip19 } from 'nostr-tools'; import { useActions, type Action } from '@/hooks/useActions'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries'; @@ -256,7 +256,7 @@ type SortOption = 'recent' | 'bounty' | 'deadline'; export default function ActionsPage() { const { user } = useCurrentUser(); - const { btcPrice } = useBitcoinWallet(); + const { data: btcPrice } = useBtcPrice(); const navigate = useNavigate(); const [selectedCountry, setSelectedCountry] = useState(undefined); diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index d7bf0ec6..ab15587e 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -43,7 +43,7 @@ import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { Progress } from '@/components/ui/progress'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; import { useAuthor } from '@/hooks/useAuthor'; -import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useCampaign } from '@/hooks/useCampaign'; import { useCampaignDonations } from '@/hooks/useCampaignDonations'; import { useComments } from '@/hooks/useComments'; @@ -123,7 +123,7 @@ export function CampaignDetailPage({ pubkey, identifier, relays }: CampaignDetai function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const { user } = useCurrentUser(); - const { btcPrice } = useBitcoinWallet(); + const { data: btcPrice } = useBtcPrice(); const author = useAuthor(campaign.pubkey); const { data: stats, isLoading: statsLoading } = useCampaignDonations(campaign); const navigate = useNavigate(); diff --git a/src/pages/CreateActionPage.tsx b/src/pages/CreateActionPage.tsx index 69864d4b..83e1e7e7 100644 --- a/src/pages/CreateActionPage.tsx +++ b/src/pages/CreateActionPage.tsx @@ -23,7 +23,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useManageableOrganizations } from '@/hooks/useManageableOrganizations'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; @@ -63,7 +63,7 @@ export function CreateActionPage() { const queryClient = useQueryClient(); const { mutateAsync: createEvent } = useNostrPublish(); const { toast } = useToast(); - const { btcPrice } = useBitcoinWallet(); + const { data: btcPrice } = useBtcPrice(); const browserTimezone = useMemo( () => Intl.DateTimeFormat().resolvedOptions().timeZone, diff --git a/src/pages/HDWalletPage.tsx b/src/pages/HDWalletPage.tsx deleted file mode 100644 index 8dd11b2f..00000000 --- a/src/pages/HDWalletPage.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import { useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useSeoMeta } from '@unhead/react'; -import { - Copy, - Check, - RefreshCw, - ChevronDown, - ArrowDownLeft, - ArrowUpRight, - Send, - ShieldOff, - ArrowRight, - KeyRound, - Radar, -} 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 { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} 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'; - -export function HDWalletPage() { - const { config } = useAppContext(); - const { - availability, - currentReceiveAddress, - silentPaymentAddress, - transactions, - totalBalance, - pendingBalance, - isLoading, - isFetching, - error, - refetch, - nextReceiveAddress, - } = useHdWallet(); - const sp = useHdWalletSp(); - const { data: btcPrice } = useHdBtcPrice(); - - const [copiedAddress, setCopiedAddress] = useState(false); - const [copiedSp, setCopiedSp] = useState(false); - 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}`, - description: 'Hierarchical-deterministic Bitcoin wallet derived from your Nostr nsec.', - }); - - const address = currentReceiveAddress?.address ?? ''; - const spAddress = silentPaymentAddress?.address ?? ''; - - const copyAddress = async () => { - if (!address) return; - try { - await navigator.clipboard.writeText(address); - setCopiedAddress(true); - setTimeout(() => setCopiedAddress(false), 2000); - } catch { - // clipboard unavailable - } - }; - - const copySpAddress = async () => { - if (!spAddress) return; - try { - await navigator.clipboard.writeText(spAddress); - setCopiedSp(true); - setTimeout(() => setCopiedSp(false), 2000); - } catch { - // clipboard unavailable - } - }; - - const truncatedAddress = address - ? `${address.slice(0, 12)}...${address.slice(-8)}` - : ''; - - const truncatedSpAddress = spAddress - ? `${spAddress.slice(0, 12)}...${spAddress.slice(-8)}` - : ''; - - // ── Logged out ──────────────────────────────────────────────── - if (availability.status === 'logged-out') { - return ( -
-
-
- -
-
-

HD Bitcoin Wallet

-

- A hierarchical wallet derived from your Nostr identity. Fresh address per receive, - full transaction history, no address reuse. -

-

- Requires login with an nsec (your Nostr private key). -

-
- -
-
- ); - } - - // ── Logged in, but signer doesn't expose the secret key ───── - if (availability.status === 'unsupported') { - return ( -
-
-
- -
-
-

HD wallet unavailable

-

- {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."} -

-

- The single-address wallet at /wallet{' '} - works for every login type. -

-
- -
-
- ); - } - - // ── Available — full HD wallet UI ──────────────────────────── - return ( -
-
- {/* Balance */} - {isLoading ? ( -
- - -
- ) : error ? ( -
-

Failed to scan wallet

- -
- ) : ( - - )} - - {/* Send + Receive */} - {!isLoading && !error && ( -
- - -
- )} - - setSendOpen(false)} - btcPrice={btcPrice} - /> - - - - {/* Receive Dialog */} - - - - Receive Bitcoin - - Share an address to receive bitcoin. - - - - - - On-chain - - Silent payment - - - - {/* ── On-chain (BIP86 single-use) ──────────────── */} - - {address && ( -
-

- Fresh address each time. Bump to a new index after sharing - for privacy. -

- -
- -
- - - -
- Address #{currentReceiveAddress?.index ?? 0} - · - -
-
- )} -
- - {/* ── Silent payment (BIP-352 static) ──────────── */} - - {spAddress && ( -
-

- Static receive identifier. Share once and reuse forever — - senders derive a unique on-chain address per payment. -

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

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

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

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

- )} - -
- ) : null} -
- )} -
-
-
-
- - {/* Transactions */} - {transactions && transactions.length > 0 && ( - <> - - - -
- {transactions.map((tx) => ( - - ))} -
-
- - )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Helpers (mirrors WalletPage.tsx) -// --------------------------------------------------------------------------- - -function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) { - const contentRef = useRef(null); - return ( -
-
- {children} -
-
- ); -} - -function formatTxDate(timestamp?: number): string { - if (!timestamp) return 'Pending'; - const date = new Date(timestamp * 1000); - const now = new Date(); - // Clamp negative diffs (timestamp slightly in the future) to "Today" rather - // than rendering "-1d ago". Real block timestamps can run a few seconds - // ahead of the local clock, and synthetic estimates may overshoot. - const diffDays = Math.max( - 0, - 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'; - const isSilent = tx.source === 'silent-payment'; - return ( - -
-
- {isReceive - ? - : } -
-
-

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

-

{formatTxDate(tx.timestamp)}

-
-
-
-

- {isReceive ? '+' : '-'} - {btcPrice ? satsToUSD(tx.amount, btcPrice) : `${formatBTC(tx.amount)} BTC`} -

-

{formatBTC(tx.amount)} BTC

-
- - ); -} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 7f1a3fc4..a5fdc61f 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,103 +1,204 @@ 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 } from 'lucide-react'; +import { + Copy, + Check, + RefreshCw, + ChevronDown, + ArrowDownLeft, + ArrowUpRight, + Send, + ShieldOff, + KeyRound, + Radar, +} 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 { SendBitcoinDialog } from '@/components/SendBitcoinDialog'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} 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 { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useHdWallet } from '@/hooks/useHdWallet'; +import { useHdWalletSp } from '@/hooks/useHdWalletSp'; +import { useHdBtcPrice } from '@/hooks/useHdBtcPrice'; import { satsToUSD, formatBTC } from '@/lib/bitcoin'; -import type { Transaction } from '@/lib/bitcoin'; +import type { HdTransaction } from '@/lib/hdwallet/scan'; export function WalletPage() { const { config } = useAppContext(); - const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, error, refetch } = useBitcoinWallet(); + const { + availability, + currentReceiveAddress, + silentPaymentAddress, + transactions, + totalBalance, + pendingBalance, + isLoading, + isFetching, + error, + refetch, + nextReceiveAddress, + } = useHdWallet(); + const sp = useHdWalletSp(); + const { data: btcPrice } = useHdBtcPrice(); const [copiedAddress, setCopiedAddress] = useState(false); + const [copiedSp, setCopiedSp] = useState(false); const [txOpen, setTxOpen] = useState(false); const [sendOpen, setSendOpen] = useState(false); + const [receiveOpen, setReceiveOpen] = useState(false); + const [spScanOpen, setSpScanOpen] = useState(false); useSeoMeta({ title: `Wallet | ${config.appName}`, - description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.', + description: 'Hierarchical-deterministic Bitcoin wallet derived from your Nostr nsec.', }); + const address = currentReceiveAddress?.address ?? ''; + const spAddress = silentPaymentAddress?.address ?? ''; + const copyAddress = async () => { - if (!bitcoinAddress) return; + if (!address) return; try { - await navigator.clipboard.writeText(bitcoinAddress); + await navigator.clipboard.writeText(address); setCopiedAddress(true); setTimeout(() => setCopiedAddress(false), 2000); } catch { - // clipboard API not available + // clipboard unavailable } }; - const truncatedAddress = bitcoinAddress - ? `${bitcoinAddress.slice(0, 12)}...${bitcoinAddress.slice(-8)}` + const copySpAddress = async () => { + if (!spAddress) return; + try { + await navigator.clipboard.writeText(spAddress); + setCopiedSp(true); + setTimeout(() => setCopiedSp(false), 2000); + } catch { + // clipboard unavailable + } + }; + + const truncatedAddress = address + ? `${address.slice(0, 12)}...${address.slice(-8)}` : ''; - return ( -
- {!user ? ( + const truncatedSpAddress = spAddress + ? `${spAddress.slice(0, 12)}...${spAddress.slice(-8)}` + : ''; + + // ── Logged out ──────────────────────────────────────────────── + if (availability.status === 'logged-out') { + return ( +
- +
-

Your Bitcoin Wallet

+

Bitcoin Wallet

- Log in to see your Bitcoin Taproot address derived from your Nostr identity. + A hierarchical wallet derived from your Nostr identity. Fresh address per receive, + full transaction history, no address reuse. +

+

+ Requires login with an nsec (your Nostr private key).

- ) : ( -
- {/* Balance */} - {isLoading ? ( -
- - -
- ) : error ? ( -
-

Failed to load balance

- -
- ) : addressData ? ( -
- - {btcPrice - ? satsToUSD(addressData.totalBalance, btcPrice) - : '---'} - - - {formatBTC(addressData.totalBalance)} BTC - +
+ ); + } - {addressData.pendingBalance !== 0 && ( - - - {btcPrice - ? `${satsToUSD(addressData.pendingBalance, btcPrice)} pending` - : 'pending'} - + // ── Logged in, but signer doesn't expose the secret key ───── + if (availability.status === 'unsupported') { + return ( +
+
+
+ +
+
+

Wallet unavailable

+

+ {availability.loginType === 'extension' + ? 'Your browser extension keeps your secret key isolated, so we can\'t derive child keys for your 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 your wallet.' + : "Your login type doesn't expose the secret key needed to derive your wallet."} +

+

+ Log out and sign in again with your nsec to use the wallet. +

+
+
+
+ ); + } + + // ── Available — full HD wallet UI ──────────────────────────── + return ( +
+
+ {/* Balance */} + {isLoading ? ( +
+ + +
+ ) : error ? ( +
+

Failed to scan wallet

+ +
+ ) : ( +
- ) : null} + + + {formatBTC(totalBalance)} BTC + - {/* Send button */} - {addressData && ( + {pendingBalance !== 0 && ( + + + {btcPrice + ? `${satsToUSD(Math.abs(pendingBalance), btcPrice)} pending` + : 'pending'} + + )} + + )} + + {/* Send + Receive */} + {!isLoading && !error && ( +
- )} - - setSendOpen(false)} - btcPrice={btcPrice} - /> - - {/* QR Code */} -
- +
+ )} - {/* Address + copy */} - + setSendOpen(false)} + btcPrice={btcPrice} + /> - {/* Transactions */} - {transactions && transactions.length > 0 && ( - <> - + - -
- {transactions.map((tx) => ( - - ))} -
-
- - )} + {/* Receive Dialog */} + + + + Receive Bitcoin + + Share an address to receive bitcoin. + + - {/* Recovery link for users with funds in a legacy Lightning wallet. */} - - Looking for your old wallet? - -
- )} + + + On-chain + + Silent payment + + + + {/* ── On-chain (BIP86 single-use) ──────────────── */} + + {address && ( +
+

+ Fresh address each time. Bump to a new index after sharing + for privacy. +

+ +
+ +
+ + + +
+ Address #{currentReceiveAddress?.index ?? 0} + · + +
+
+ )} +
+ + {/* ── Silent payment (BIP-352 static) ──────────── */} + + {spAddress && ( +
+

+ Static receive identifier. Share once and reuse forever — + senders derive a unique on-chain address per payment. +

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

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

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

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

+ )} + +
+ ) : null} +
+ )} +
+
+ + + + {/* Transactions */} + {transactions && transactions.length > 0 && ( + <> + + + +
+ {transactions.map((tx) => ( + + ))} +
+
+ + )} +
); } -/** Accordion wrapper using grid-template-rows for smooth height animation. */ +// --------------------------------------------------------------------------- +// Helpers (mirrors WalletPage.tsx) +// --------------------------------------------------------------------------- + function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) { const contentRef = useRef(null); - return (
}
-

{isReceive ? 'Received' : 'Sent'}

+

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

{formatTxDate(tx.timestamp)}

@@ -230,13 +448,9 @@ function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { 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`} -

-

- {formatBTC(tx.amount)} BTC + {btcPrice ? satsToUSD(tx.amount, btcPrice) : `${formatBTC(tx.amount)} BTC`}

+

{formatBTC(tx.amount)} BTC

);