Replace single-address /wallet with the HD wallet
Delete the old Taproot single-address wallet (WalletPage, SendBitcoinDialog, useBitcoinWallet) and rename HDWalletPage to WalletPage so the HD wallet now lives at /wallet. The /hdwallet route is gone. Five non-wallet callers (CreateActionPage, ActionsPage, ActionDetailPage, CommunityDetailPage, CampaignDetailPage) imported useBitcoinWallet only for its btcPrice field; they now use the standalone useBtcPrice hook. WalletRecoveryPage (legacy Breez/Spark sweep) is preserved at /wallet/recovery since it is a one-shot tool independent of the live wallet page. The 'wallet unavailable' branch no longer points users at a non-existent fallback wallet — it now tells extension/bunker users to sign in with their nsec instead.
This commit is contained in:
@@ -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() {
|
||||
/>
|
||||
<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 />} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/<descriptor>`) lets
|
||||
* `/wallet`. Blockbook's xpub endpoint (`/api/v2/xpub/<descriptor>`) 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
|
||||
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string | undefined>(undefined);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex flex-col items-center space-y-1 group cursor-pointer disabled:cursor-default rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring px-4 py-2"
|
||||
aria-label="Refresh balance"
|
||||
title="Click to refresh balance"
|
||||
>
|
||||
<span className="text-4xl font-bold tracking-tight group-hover:opacity-80 transition-opacity flex items-center gap-2">
|
||||
{btcPrice ? satsToUSD(totalBalance, btcPrice) : '---'}
|
||||
{isFetching && (
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Send + Receive */}
|
||||
{!isLoading && !error && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSendOpen(true)}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Send className="size-3.5 mr-1.5" />
|
||||
Send
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReceiveOpen(true)}
|
||||
className="rounded-full"
|
||||
disabled={!address}
|
||||
>
|
||||
<ArrowDownLeft className="size-3.5 mr-1.5" />
|
||||
Receive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HDSendBitcoinDialog
|
||||
isOpen={sendOpen}
|
||||
onClose={() => setSendOpen(false)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
|
||||
<HDSilentPaymentScanDialog open={spScanOpen} onOpenChange={setSpScanOpen} />
|
||||
|
||||
{/* Receive Dialog */}
|
||||
<Dialog open={receiveOpen} onOpenChange={setReceiveOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Receive Bitcoin</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share an address to receive bitcoin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="onchain" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="onchain">On-chain</TabsTrigger>
|
||||
<TabsTrigger value="silent" disabled={!spAddress}>
|
||||
Silent payment
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── On-chain (BIP86 single-use) ──────────────── */}
|
||||
<TabsContent value="onchain" className="mt-4">
|
||||
{address && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
Fresh address each time. Bump to a new index after sharing
|
||||
for privacy.
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={address} size={200} level="M" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Silent payment (BIP-352 static) ──────────── */}
|
||||
<TabsContent value="silent" className="mt-4">
|
||||
{spAddress && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
Static receive identifier. Share once and reuse forever —
|
||||
senders derive a unique on-chain address per payment.
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={spAddress} size={220} level="L" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={copySpAddress}
|
||||
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"
|
||||
>
|
||||
{truncatedSpAddress}
|
||||
{copiedSp ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{sp.unavailableReason === 'no-indexer' ? (
|
||||
<p className="text-xs text-orange-500 dark:text-orange-400 text-center max-w-xs">
|
||||
Scanning disabled — no BIP-352 indexer configured. Set{' '}
|
||||
<span className="font-mono">bip352IndexerUrl</span> in your app config to
|
||||
detect incoming silent payments.
|
||||
</p>
|
||||
) : sp.enabled ? (
|
||||
<div className="flex flex-col items-center gap-2 w-full">
|
||||
{sp.balance > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Silent payment balance:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{btcPrice
|
||||
? satsToUSD(sp.balance, btcPrice)
|
||||
: `${formatBTC(sp.balance)} BTC`}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSpScanOpen(true)}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Radar className="size-3.5 mr-1.5" />
|
||||
{sp.storage?.scanHeight && sp.storage.scanHeight > 0
|
||||
? 'Scan for new payments'
|
||||
: 'Scan for payments'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
</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();
|
||||
// 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 (
|
||||
<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 flex items-center gap-1.5">
|
||||
{isReceive ? 'Received' : 'Sent'}
|
||||
{isSilent && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
title="Detected via BIP-352 silent payment scan"
|
||||
>
|
||||
<Radar className="size-2.5" />
|
||||
silent
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+342
-128
@@ -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 (
|
||||
<main>
|
||||
{!user ? (
|
||||
const truncatedSpAddress = spAddress
|
||||
? `${spAddress.slice(0, 12)}...${spAddress.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">
|
||||
<Bitcoin className="size-8 text-primary" />
|
||||
<KeyRound className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-xl font-bold">Your Bitcoin Wallet</h2>
|
||||
<h2 className="text-xl font-bold">Bitcoin Wallet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
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.
|
||||
</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>
|
||||
) : (
|
||||
<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 load balance</p>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : addressData ? (
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<span className="text-4xl font-bold tracking-tight">
|
||||
{btcPrice
|
||||
? satsToUSD(addressData.totalBalance, btcPrice)
|
||||
: '---'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBTC(addressData.totalBalance)} BTC
|
||||
</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
{addressData.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(addressData.pendingBalance, btcPrice)} pending`
|
||||
: 'pending'}
|
||||
</span>
|
||||
// ── 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">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 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."}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm pt-2">
|
||||
Log out and sign in again with your nsec to use the wallet.
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex flex-col items-center space-y-1 group cursor-pointer disabled:cursor-default rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring px-4 py-2"
|
||||
aria-label="Refresh balance"
|
||||
title="Click to refresh balance"
|
||||
>
|
||||
<span className="text-4xl font-bold tracking-tight group-hover:opacity-80 transition-opacity flex items-center gap-2">
|
||||
{btcPrice ? satsToUSD(totalBalance, btcPrice) : '---'}
|
||||
{isFetching && (
|
||||
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBTC(totalBalance)} BTC
|
||||
</span>
|
||||
|
||||
{/* Send button */}
|
||||
{addressData && (
|
||||
{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>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Send + Receive */}
|
||||
{!isLoading && !error && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -107,70 +208,178 @@ export function WalletPage() {
|
||||
<Send className="size-3.5 mr-1.5" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<SendBitcoinDialog
|
||||
isOpen={sendOpen}
|
||||
onClose={() => setSendOpen(false)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={bitcoinAddress} size={200} level="M" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setReceiveOpen(true)}
|
||||
className="rounded-full"
|
||||
disabled={!address}
|
||||
>
|
||||
<ArrowDownLeft className="size-3.5 mr-1.5" />
|
||||
Receive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address + copy */}
|
||||
<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>
|
||||
<HDSendBitcoinDialog
|
||||
isOpen={sendOpen}
|
||||
onClose={() => setSendOpen(false)}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<HDSilentPaymentScanDialog open={spScanOpen} onOpenChange={setSpScanOpen} />
|
||||
|
||||
<TxAccordion open={txOpen}>
|
||||
<div className="w-full divide-y">
|
||||
{transactions.map((tx) => (
|
||||
<TxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
|
||||
))}
|
||||
</div>
|
||||
</TxAccordion>
|
||||
</>
|
||||
)}
|
||||
{/* Receive Dialog */}
|
||||
<Dialog open={receiveOpen} onOpenChange={setReceiveOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Receive Bitcoin</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share an address to receive bitcoin.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Recovery link for users with funds in a legacy Lightning wallet. */}
|
||||
<Link
|
||||
to="/wallet/recovery"
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline transition-colors"
|
||||
>
|
||||
Looking for your old wallet?
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="onchain" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="onchain">On-chain</TabsTrigger>
|
||||
<TabsTrigger value="silent" disabled={!spAddress}>
|
||||
Silent payment
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── On-chain (BIP86 single-use) ──────────────── */}
|
||||
<TabsContent value="onchain" className="mt-4">
|
||||
{address && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
Fresh address each time. Bump to a new index after sharing
|
||||
for privacy.
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={address} size={200} level="M" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Silent payment (BIP-352 static) ──────────── */}
|
||||
<TabsContent value="silent" className="mt-4">
|
||||
{spAddress && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
Static receive identifier. Share once and reuse forever —
|
||||
senders derive a unique on-chain address per payment.
|
||||
</p>
|
||||
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={spAddress} size={220} level="L" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={copySpAddress}
|
||||
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"
|
||||
>
|
||||
{truncatedSpAddress}
|
||||
{copiedSp ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{sp.unavailableReason === 'no-indexer' ? (
|
||||
<p className="text-xs text-orange-500 dark:text-orange-400 text-center max-w-xs">
|
||||
Scanning disabled — no BIP-352 indexer configured. Set{' '}
|
||||
<span className="font-mono">bip352IndexerUrl</span> in your app config to
|
||||
detect incoming silent payments.
|
||||
</p>
|
||||
) : sp.enabled ? (
|
||||
<div className="flex flex-col items-center gap-2 w-full">
|
||||
{sp.balance > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Silent payment balance:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{btcPrice
|
||||
? satsToUSD(sp.balance, btcPrice)
|
||||
: `${formatBTC(sp.balance)} BTC`}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSpScanOpen(true)}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Radar className="size-3.5 mr-1.5" />
|
||||
{sp.storage?.scanHeight && sp.storage.scanHeight > 0
|
||||
? 'Scan for new payments'
|
||||
: 'Scan for payments'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full grid transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
@@ -183,32 +392,30 @@ function TxAccordion({ open, children }: { open: boolean; children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a unix timestamp as a relative or absolute date. */
|
||||
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".
|
||||
const diffMs = Math.max(0, now.getTime() - date.getTime());
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
/** Single transaction row. */
|
||||
function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) {
|
||||
function TxRow({ tx, btcPrice }: { tx: HdTransaction; btcPrice?: number }) {
|
||||
const isReceive = tx.type === 'receive';
|
||||
|
||||
const isSilent = tx.source === 'silent-payment';
|
||||
return (
|
||||
<Link
|
||||
to={`/i/bitcoin:tx:${tx.txid}`}
|
||||
className="flex items-center justify-between py-3 px-1 hover:bg-muted/50 transition-colors rounded-lg -mx-1 px-2"
|
||||
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 ${
|
||||
@@ -221,7 +428,18 @@ function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) {
|
||||
: <ArrowUpRight className="size-4" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
|
||||
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||
{isReceive ? 'Received' : 'Sent'}
|
||||
{isSilent && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
title="Detected via BIP-352 silent payment scan"
|
||||
>
|
||||
<Radar className="size-2.5" />
|
||||
silent
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBTC(tx.amount)} BTC
|
||||
{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