Consolidate /bitcoin into /wallet, drop Lightning custody

Agora previously shipped two parallel wallets: a heavy 6,400-line Breez
SDK Lightning wallet at /wallet and a lightweight on-chain Taproot view
at /bitcoin derived from the user's Nostr pubkey. Maintaining two key
custody models, two send flows, two zap paths (Lightning via Spark,
on-chain via PSBT), and the Spark-specific UI (CreateWallet, mnemonic
backup/restore, lock screen, payment history, etc.) didn't pay for itself
once on-chain Bitcoin signing via NIP-07/NIP-46 became viable.

This consolidation aligns Agora with Ditto's wallet model:

  - The on-chain Taproot view from /bitcoin becomes the only /wallet UI.
  - /bitcoin redirects to /wallet for back-compat; sidebar and TopNav
    drop the duplicate Bitcoin entry.
  - The Breez/Spark wallet stack is removed: SparkWalletProvider,
    SparkWalletContext, all of src/components/SparkWallet/*, useSparkWallet,
    useCommunityBatchZaps, usePaymentContext, WalletSettingsContent, and
    LightningEffect are deleted (~6,400 lines).
  - Ditto's mature bitcoin/zap stack is ported: useOnchainZap (single-event
    on-chain zaps + kind 8333 receipts), OnchainZapContent, ZapDialog with
    Bitcoin/Lightning tabs, ZapSuccessScreen, BitcoinContentHeader, and the
    larger SendBitcoinDialog. useZaps loses its breezService branch and
    falls back to NWC → WebLN → manual QR.
  - bitcoin.ts now threads esploraBaseUrl through every call, matching
    AppConfig and allowing future relay/Esplora customization.
  - CommunityZapDialog is bitcoin-only; CommunityDetailPage drops the
    sibling Lightning trigger.

Lightning recovery remains intentional. A small "Looking for your old
wallet?" link on /wallet routes to /wallet/recovery, which lazy-loads
@breeztech/breez-sdk-spark (now in its own 67 KB chunk plus the WASM)
only when a user needs to evacuate funds. The recovery page:

  - Auto-detects the NIP-78 kind-30078 d="spark-wallet-backup" relay
    backup and offers one-click NIP-44 decrypt via the user's signer.
  - Accepts a manual 12-word mnemonic as fallback.
  - Connects Breez in-memory, sweeps the entire on-chain balance to the
    user's Nostr-derived Taproot address, then disconnects. Nothing is
    persisted; the old wallet is never "restored" — only evacuated.

Other small carry-overs from Ditto needed by the ported code:
useFormatMoney + AppConfig.currencyDisplay ("usd" | "sats"), and the
nostrId helper (HexId branded type + isNostrId validator).

48 files changed, 2,464 insertions(+), 9,743 deletions(-).
This commit is contained in:
Alex Gleason
2026-05-17 22:53:20 -05:00
parent 7e93dcba6c
commit 9190f62b9e
60 changed files with 5057 additions and 9778 deletions
-3
View File
@@ -18,7 +18,6 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import type { AppConfig } from "@/contexts/AppContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { SparkWalletProvider } from "@/contexts/SparkWalletContext";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
import AppRouter from "./AppRouter";
@@ -203,13 +202,11 @@ export function App() {
<NativeNotifications />
<NWCProvider>
<SparkWalletProvider>
<TooltipProvider>
<InitialSyncGate>
<AppRouter />
</InitialSyncGate>
</TooltipProvider>
</SparkWalletProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
+3 -2
View File
@@ -38,7 +38,6 @@ const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BitcoinPage = lazy(() => import("./pages/BitcoinPage").then(m => ({ default: m.BitcoinPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
@@ -79,6 +78,7 @@ const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ defa
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
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 WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
@@ -276,7 +276,8 @@ export function AppRouter() {
}
/>
<Route path="/wallet" element={<WalletPage />} />
<Route path="/bitcoin" element={<BitcoinPage />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/verified" element={<VerifiedPage />} />
+631
View File
@@ -0,0 +1,631 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</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 ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
+2 -8
View File
@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@@ -8,13 +7,13 @@ import { Card } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import {
CAMPAIGN_CATEGORY_LABELS,
type ParsedCampaign,
encodeCampaignNaddr,
} from '@/lib/campaign';
import { fetchBtcPrice } from '@/lib/bitcoin';
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -73,12 +72,7 @@ interface CampaignCardProps {
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
const author = useAuthor(campaign.pubkey);
const { data: stats } = useCampaignDonations(campaign.aTag);
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
staleTime: 30_000,
refetchInterval: 60_000,
});
const { data: btcPrice } = useBtcPrice();
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
const cover = sanitizeUrl(campaign.image);
+2 -17
View File
@@ -1,4 +1,4 @@
import { useMemo, useCallback, useRef, useState } from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
@@ -8,7 +8,6 @@ import {
CalendarDays,
Crown,
Info,
Link2,
Megaphone,
MessageCircle,
MoreVertical,
@@ -64,7 +63,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { LightningEffect, type LightningEffectHandle } from '@/components/LightningEffect';
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
@@ -184,7 +182,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const navigate = useNavigate();
const { toast } = useToast();
const { user } = useCurrentUser();
const lightningRef = useRef<LightningEffectHandle>(null);
// ── Member ban dialog state ────────────────────────────────────────────────
const [banDialogOpen, setBanDialogOpen] = useState(false);
@@ -538,7 +535,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
);
const handleCommunityZapLaunched = useCallback(() => {
lightningRef.current?.triggerLightning({ strikes: 4 });
// No-op for now; previously triggered a Lightning visual effect.
}, []);
// ── Render ──────────────────────────────────────────────────────────────────
@@ -547,7 +544,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
return (
<div className="max-w-2xl mx-auto pb-16">
<LightningEffect ref={lightningRef} />
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
{/* ── Hero banner + tabs ── */}
@@ -660,17 +656,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
onZapLaunched={handleCommunityZapLaunched}
/>
)}
{community && membership && (
<CommunityZapDialog
community={community}
members={membership.members}
membersLoading={membersLoading}
mode="bitcoin"
triggerClassName={bannerActionClassName}
triggerIcon={<Link2 className="size-5" />}
onZapLaunched={handleCommunityZapLaunched}
/>
)}
<button
type="button"
className={bannerActionClassName}
+63 -130
View File
@@ -17,13 +17,12 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { useAuthors } from '@/hooks/useAuthors';
import { useCommunityBatchZaps } from '@/hooks/useCommunityBatchZaps';
import { useCommunityOnchainZaps } from '@/hooks/useCommunityOnchainZaps';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { useToast } from '@/hooks/useToast';
import { BITCOIN_DUST_LIMIT, estimateFee, fetchUTXOs, getFeeRates, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import type { CommunityMember, ParsedCommunity } from '@/lib/communityUtils';
@@ -32,15 +31,13 @@ import { cn } from '@/lib/utils';
import { notificationSuccess } from '@/lib/haptics';
type RecipientRole = 'founder' | 'moderator' | 'member';
type RecipientStatus = 'ready' | 'loading' | 'missing-ln' | 'missing-btc' | 'removed' | 'self';
type CommunityZapMode = 'lightning' | 'bitcoin';
type RecipientStatus = 'ready' | 'missing-btc' | 'removed' | 'self';
interface RecipientView {
pubkey: string;
role: RecipientRole;
name: string;
picture?: string;
lightningAddress?: string;
bitcoinAddress?: string;
authorEvent?: NostrEvent;
status: RecipientStatus;
@@ -50,7 +47,6 @@ interface CommunityZapDialogProps {
community: ParsedCommunity;
members: CommunityMember[];
membersLoading: boolean;
mode?: CommunityZapMode;
triggerClassName?: string;
triggerIcon?: ReactNode;
onZapLaunched?: (details: { count: number; totalSats: number }) => void;
@@ -79,12 +75,11 @@ export function CommunityZapDialog({
community,
members,
membersLoading,
mode = 'lightning',
triggerClassName,
triggerIcon,
onZapLaunched,
}: CommunityZapDialogProps) {
const defaultAmount = mode === 'bitcoin' ? '1000' : '100';
const defaultAmount = '1000';
const [open, setOpen] = useState(false);
const [amount, setAmount] = useState(defaultAmount);
const [comment, setComment] = useState(`Zapped the whole ${community.name} community!`);
@@ -92,28 +87,28 @@ export function CommunityZapDialog({
const [isLaunching, setIsLaunching] = useState(false);
const { user } = useCurrentUser();
const sparkWallet = useSparkWallet();
const bitcoinWallet = useBitcoinWallet();
const { canSignPsbt } = useBitcoinSigner();
const { toast } = useToast();
const { zapCommunity } = useCommunityBatchZaps();
const { zapCommunityOnchain } = useCommunityOnchainZaps();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const pubkeys = useMemo(() => members.map((member) => member.pubkey), [members]);
const authors = useAuthors(pubkeys);
const senderBitcoinAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
const { data: bitcoinUtxos, isLoading: isLoadingBitcoinUtxos } = useQuery({
queryKey: ['bitcoin-utxos', senderBitcoinAddress],
queryFn: () => fetchUTXOs(senderBitcoinAddress),
enabled: open && mode === 'bitcoin' && !!senderBitcoinAddress,
queryKey: ['bitcoin-utxos', esploraBaseUrl, senderBitcoinAddress],
queryFn: () => fetchUTXOs(senderBitcoinAddress, esploraBaseUrl),
enabled: open && !!senderBitcoinAddress,
staleTime: 30_000,
});
const { data: feeRates, isLoading: isLoadingFeeRates } = useQuery({
queryKey: ['bitcoin-fee-rates'],
queryFn: getFeeRates,
enabled: open && mode === 'bitcoin',
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
queryFn: () => getFeeRates(esploraBaseUrl),
enabled: open,
staleTime: 30_000,
});
@@ -128,15 +123,12 @@ export function CommunityZapDialog({
return members.map((member) => {
const author = authors.data?.get(member.pubkey);
const metadata: NostrMetadata | undefined = author?.metadata;
const lightningAddress = metadata?.lud16 || metadata?.lud06;
const bitcoinAddress = nostrPubkeyToBitcoinAddress(member.pubkey);
const removed = removedPubkeys.has(member.pubkey);
const status: RecipientStatus = (() => {
if (user?.pubkey === member.pubkey) return 'self';
if (removed) return 'removed';
if (mode === 'bitcoin') return bitcoinAddress ? 'ready' : 'missing-btc';
if (authors.isLoading && !author?.event) return 'loading';
return lightningAddress && author?.event ? 'ready' : 'missing-ln';
return bitcoinAddress ? 'ready' : 'missing-btc';
})();
return {
@@ -144,42 +136,36 @@ export function CommunityZapDialog({
role: memberRole(member, community),
name: getDisplayName(metadata, member.pubkey),
picture: metadata?.picture,
lightningAddress,
bitcoinAddress,
authorEvent: author?.event,
status,
};
});
}, [authors.data, authors.isLoading, community, members, mode, removedPubkeys, user?.pubkey]);
}, [authors.data, community, members, removedPubkeys, user?.pubkey]);
const amountSats = parseInt(amount, 10);
const selectedRecipients = recipients.filter((recipient) => (
mode === 'lightning'
? recipient.status === 'ready' && recipient.authorEvent
: recipient.status === 'ready' && recipient.bitcoinAddress
));
const skippedCount = recipients.filter((recipient) => (
recipient.status === 'missing-ln' || recipient.status === 'missing-btc' || recipient.status === 'self'
)).length;
const selectedRecipients = recipients.filter(
(recipient) => recipient.status === 'ready' && recipient.bitcoinAddress,
);
const skippedCount = recipients.filter(
(recipient) => recipient.status === 'missing-btc' || recipient.status === 'self',
).length;
const removedCount = recipients.filter((recipient) => recipient.status === 'removed').length;
const totalSats = Number.isFinite(amountSats) && amountSats > 0
? amountSats * selectedRecipients.length
: 0;
const estimatedBitcoinFee = mode === 'bitcoin' && bitcoinUtxos?.length && feeRates && selectedRecipients.length > 0
const estimatedBitcoinFee = bitcoinUtxos?.length && feeRates && selectedRecipients.length > 0
? estimateFee(bitcoinUtxos.length, selectedRecipients.length + 1, feeRates.halfHourFee)
: 0;
const bitcoinTotalSats = totalSats + estimatedBitcoinFee;
const bitcoinBalance = bitcoinWallet.addressData?.totalBalance ?? 0;
const walletReady = mode === 'lightning'
? sparkWallet.isEnabled && sparkWallet.isInitialized
: !!user && canSignPsbt && !!bitcoinWallet.addressData && !!bitcoinUtxos?.length && !!feeRates;
const walletReady = !!user && canSignPsbt && !!bitcoinWallet.addressData && !!bitcoinUtxos?.length && !!feeRates;
const canSubmit = !!user
&& walletReady
&& selectedRecipients.length > 0
&& Number.isFinite(amountSats)
&& amountSats > 0
&& (mode === 'lightning' || amountSats >= BITCOIN_DUST_LIMIT)
&& (mode === 'lightning' ? sparkWallet.balance >= totalSats : bitcoinBalance >= bitcoinTotalSats)
&& amountSats >= BITCOIN_DUST_LIMIT
&& bitcoinBalance >= bitcoinTotalSats
&& !isLaunching;
const toggleRemoved = (pubkey: string, remove: boolean) => {
@@ -199,9 +185,7 @@ export function CommunityZapDialog({
if (!walletReady) {
toast({
title: 'Wallet required',
description: mode === 'lightning'
? 'Set up your Agora Wallet to zap a community.'
: 'Log in with a Bitcoin-capable signer and fund your Bitcoin wallet.',
description: 'Log in with a Bitcoin-capable signer and fund your Bitcoin wallet.',
variant: 'destructive',
});
return;
@@ -210,7 +194,7 @@ export function CommunityZapDialog({
toast({ title: 'Invalid amount', description: 'Enter a positive amount in sats.', variant: 'destructive' });
return;
}
if (mode === 'bitcoin' && amountSats < BITCOIN_DUST_LIMIT) {
if (amountSats < BITCOIN_DUST_LIMIT) {
toast({
title: 'Amount too small',
description: `On-chain Bitcoin outputs must be at least ${BITCOIN_DUST_LIMIT.toLocaleString()} sats.`,
@@ -222,15 +206,7 @@ export function CommunityZapDialog({
toast({ title: 'No recipients', description: 'No selected members can receive zaps.', variant: 'destructive' });
return;
}
if (mode === 'lightning' && sparkWallet.balance < totalSats) {
toast({
title: 'Insufficient balance',
description: `You need at least ${totalSats.toLocaleString()} sats before Lightning fees.`,
variant: 'destructive',
});
return;
}
if (mode === 'bitcoin' && bitcoinBalance < bitcoinTotalSats) {
if (bitcoinBalance < bitcoinTotalSats) {
toast({
title: 'Insufficient balance',
description: `You need about ${bitcoinTotalSats.toLocaleString()} sats including miner fees.`,
@@ -239,59 +215,38 @@ export function CommunityZapDialog({
return;
}
const lightningRecipients = selectedRecipients
.filter((recipient): recipient is RecipientView & { authorEvent: NostrEvent } => !!recipient.authorEvent)
.map((recipient) => ({ pubkey: recipient.pubkey, authorEvent: recipient.authorEvent }));
const bitcoinRecipients = selectedRecipients.map((recipient) => ({ pubkey: recipient.pubkey }));
const launchedCount = mode === 'lightning' ? lightningRecipients.length : bitcoinRecipients.length;
const launchedTotal = mode === 'lightning' ? totalSats : bitcoinTotalSats;
setIsLaunching(true);
setOpen(false);
onZapLaunched?.({ count: launchedCount, totalSats: launchedTotal });
onZapLaunched?.({ count: bitcoinRecipients.length, totalSats: bitcoinTotalSats });
toast({
title: mode === 'lightning' ? `Zapping ${launchedCount} members...` : `Broadcasting Bitcoin zap...`,
description: mode === 'lightning'
? `${totalSats.toLocaleString()} sats are on the way.`
: `${totalSats.toLocaleString()} sats plus miner fees are being sent.`,
title: 'Broadcasting Bitcoin zap...',
description: `${totalSats.toLocaleString()} sats plus miner fees are being sent.`,
});
const zapPromise = mode === 'lightning'
? zapCommunity({ community, recipients: lightningRecipients, amountSats, comment })
: zapCommunityOnchain({ community, recipients: bitcoinRecipients, amountSats, comment });
void zapPromise.then((summary) => {
setIsLaunching(false);
if ('failed' in summary && summary.failed.length > 0) {
void zapCommunityOnchain({ community, recipients: bitcoinRecipients, amountSats, comment })
.then((summary) => {
setIsLaunching(false);
if (summary.publishFailed.length > 0) {
toast({
title: `Bitcoin sent, ${summary.published} of ${summary.attempted} receipts published`,
description: `Broadcast tx ${summary.txid.slice(0, 12)}... but ${summary.publishFailed.length} Nostr event${summary.publishFailed.length === 1 ? '' : 's'} failed.`,
variant: 'destructive',
});
return;
}
notificationSuccess();
toast({
title: `Zapped ${summary.succeeded} of ${summary.attempted} members`,
description: `${summary.failed.length} zap${summary.failed.length === 1 ? '' : 's'} failed.`,
variant: summary.succeeded > 0 ? 'default' : 'destructive',
title: `Bitcoin zapped ${summary.published} members`,
description: `${summary.totalSats.toLocaleString()} sats sent in tx ${summary.txid.slice(0, 12)}...`,
});
return;
}
if ('publishFailed' in summary && summary.publishFailed.length > 0) {
toast({
title: `Bitcoin sent, ${summary.published} of ${summary.attempted} receipts published`,
description: `Broadcast tx ${summary.txid.slice(0, 12)}... but ${summary.publishFailed.length} Nostr event${summary.publishFailed.length === 1 ? '' : 's'} failed.`,
variant: 'destructive',
});
return;
}
notificationSuccess();
toast({
title: mode === 'lightning'
? `Zapped ${summary.succeeded} members`
: `Bitcoin zapped ${summary.published} members`,
description: mode === 'lightning'
? `${summary.totalSats.toLocaleString()} sats sent to ${community.name}.`
: `${summary.totalSats.toLocaleString()} sats sent in tx ${summary.txid.slice(0, 12)}...`,
})
.catch((error) => {
setIsLaunching(false);
const message = error instanceof Error ? error.message : 'Community zap failed.';
toast({ title: 'Community zap failed', description: message, variant: 'destructive' });
});
}).catch((error) => {
setIsLaunching(false);
const message = error instanceof Error ? error.message : 'Community zap failed.';
toast({ title: 'Community zap failed', description: message, variant: 'destructive' });
});
};
return (
@@ -303,8 +258,8 @@ export function CommunityZapDialog({
'inline-flex items-center justify-center',
triggerClassName ?? 'p-2 rounded-full shadow-md bg-white text-black hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors',
)}
aria-label={mode === 'lightning' ? 'Zap community' : 'Bitcoin zap community'}
title={mode === 'lightning' ? 'Zap community' : 'Bitcoin zap community'}
aria-label="Bitcoin zap community"
title="Bitcoin zap community"
>
{triggerIcon ?? <Zap className="size-5" />}
</button>
@@ -313,12 +268,10 @@ export function CommunityZapDialog({
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
<DialogTitle className="flex items-center gap-2">
<Zap className="size-5 text-amber-500" />
{mode === 'lightning' ? 'Zap Community' : 'Bitcoin Zap Community'}
Bitcoin Zap Community
</DialogTitle>
<DialogDescription>
{mode === 'lightning'
? 'Send a real Nostr zap to each selected active member.'
: 'Send one on-chain Bitcoin transaction to all selected members.'}
Send one on-chain Bitcoin transaction to all selected members.
</DialogDescription>
</DialogHeader>
@@ -330,7 +283,7 @@ export function CommunityZapDialog({
<Label htmlFor="community-zap-amount">Amount per member</Label>
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
Balance <span className="tabular-nums text-foreground">
{(mode === 'lightning' ? sparkWallet.balance : bitcoinBalance).toLocaleString()} sats
{bitcoinBalance.toLocaleString()} sats
</span>
</div>
</div>
@@ -365,12 +318,10 @@ export function CommunityZapDialog({
{!walletReady && (
<div className="flex items-start gap-3 rounded-2xl border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<Wallet className="size-4 mt-0.5 shrink-0" />
{mode === 'lightning'
? 'Set up and unlock your Agora Wallet before zapping the community.'
: 'Log in with a Bitcoin-capable signer and fund your Bitcoin wallet before zapping the community.'}
Log in with a Bitcoin-capable signer and fund your Bitcoin wallet before zapping the community.
</div>
)}
{mode === 'bitcoin' && walletReady && (
{walletReady && (
<div className="rounded-2xl border border-border bg-muted/40 p-3 text-sm">
{Number.isFinite(amountSats) && amountSats > 0 && amountSats < BITCOIN_DUST_LIMIT && (
<p className="mb-2 text-xs text-destructive">
@@ -402,7 +353,7 @@ export function CommunityZapDialog({
{membersLoading || authors.isLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : null}
{mode === 'bitcoin' && (isLoadingBitcoinUtxos || isLoadingFeeRates) ? (
{isLoadingBitcoinUtxos || isLoadingFeeRates ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : null}
</div>
@@ -413,7 +364,6 @@ export function CommunityZapDialog({
key={recipient.pubkey}
recipient={recipient}
amountSats={amountSats}
mode={mode}
onRemove={() => toggleRemoved(recipient.pubkey, true)}
onRestore={() => toggleRemoved(recipient.pubkey, false)}
/>
@@ -427,8 +377,7 @@ export function CommunityZapDialog({
disabled={!canSubmit}
isLaunching={isLaunching}
selectedCount={selectedRecipients.length}
totalSats={mode === 'lightning' ? totalSats : bitcoinTotalSats}
mode={mode}
totalSats={bitcoinTotalSats}
onComplete={handleSubmit}
/>
</div>
@@ -444,14 +393,12 @@ function HoldToZapButton({
isLaunching,
selectedCount,
totalSats,
mode,
onComplete,
}: {
disabled: boolean;
isLaunching: boolean;
selectedCount: number;
totalSats: number;
mode: CommunityZapMode;
onComplete: () => void;
}) {
const [progress, setProgress] = useState(0);
@@ -500,9 +447,6 @@ function HoldToZapButton({
if (disabled || isLaunching) cancelHold();
}, [disabled, isLaunching]);
const actionLabel = mode === 'lightning' ? 'zap' : 'send';
const holdingLabel = mode === 'lightning' ? 'Keep holding to zap...' : 'Keep holding to send...';
return (
<Button
type="button"
@@ -547,9 +491,9 @@ function HoldToZapButton({
Launching...
</>
) : holding ? (
holdingLabel
'Keep holding to send...'
) : (
`Hold to ${actionLabel} ${selectedCount} · ${totalSats.toLocaleString()} sats`
`Hold to send ${selectedCount} · ${totalSats.toLocaleString()} sats`
)}
</span>
</Button>
@@ -559,22 +503,17 @@ function HoldToZapButton({
function RecipientRow({
recipient,
amountSats,
mode,
onRemove,
onRestore,
}: {
recipient: RecipientView;
amountSats: number;
mode: CommunityZapMode;
onRemove: () => void;
onRestore: () => void;
}) {
const isReady = recipient.status === 'ready';
const isRemoved = recipient.status === 'removed';
const isUnavailable = recipient.status === 'missing-ln'
|| recipient.status === 'missing-btc'
|| recipient.status === 'loading'
|| recipient.status === 'self';
const isUnavailable = recipient.status === 'missing-btc' || recipient.status === 'self';
return (
<div className={cn('flex items-center gap-3 px-5 py-3', (isRemoved || isUnavailable) && 'opacity-55')}>
@@ -590,17 +529,11 @@ function RecipientRow({
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{recipient.status === 'loading'
? 'Loading profile...'
: recipient.status === 'self'
{recipient.status === 'self'
? 'You · skipped'
: mode === 'bitcoin' && recipient.bitcoinAddress
: recipient.bitcoinAddress
? shortAddress(recipient.bitcoinAddress)
: mode === 'bitcoin'
? 'No Bitcoin address · skipped'
: recipient.lightningAddress
? shortAddress(recipient.lightningAddress)
: 'No Lightning address · skipped'}
: 'No Bitcoin address · skipped'}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
-231
View File
@@ -1,231 +0,0 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
export interface LightningTriggerOptions {
/** Number of clustered strikes to fire. */
strikes?: number;
}
export interface LightningEffectHandle {
triggerLightning: (options?: LightningTriggerOptions) => void;
}
interface LightningEffectProps {
/** Manual is one-shot only; weather auto-triggers intermittent strikes. */
mode?: 'manual' | 'weather';
}
interface Point {
x: number;
y: number;
}
interface Segment {
from: Point;
to: Point;
}
interface Strike {
startedAt: number;
duration: number;
segments: Segment[];
}
const MAX_SEGMENTS = 80;
function randomBetween(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function prefersReducedMotion(): boolean {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
function addDisplacedSegments(
segments: Segment[],
from: Point,
to: Point,
depth: number,
offset: number,
): void {
if (segments.length >= MAX_SEGMENTS) return;
if (depth <= 0) {
segments.push({ from, to });
return;
}
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy) || 1;
const normalX = -dy / length;
const normalY = dx / length;
const midpoint = {
x: (from.x + to.x) / 2 + normalX * randomBetween(-offset, offset),
y: (from.y + to.y) / 2 + normalY * randomBetween(-offset, offset),
};
addDisplacedSegments(segments, from, midpoint, depth - 1, offset * 0.52);
addDisplacedSegments(segments, midpoint, to, depth - 1, offset * 0.52);
if (segments.length < MAX_SEGMENTS && Math.random() < 0.36) {
const angle = Math.atan2(dy, dx) + randomBetween(-0.95, 0.95);
const branchLength = length * randomBetween(0.25, 0.55);
const branchEnd = {
x: midpoint.x + Math.cos(angle) * branchLength,
y: midpoint.y + Math.sin(angle) * branchLength,
};
addDisplacedSegments(segments, midpoint, branchEnd, depth - 1, offset * 0.42);
}
}
function createStrike(width: number, height: number): Strike {
const start = {
x: randomBetween(width * 0.08, width * 0.92),
y: randomBetween(-height * 0.04, height * 0.14),
};
const end = {
x: start.x + randomBetween(-width * 0.35, width * 0.35),
y: randomBetween(height * 0.72, height * 1.05),
};
const segments: Segment[] = [];
const length = Math.hypot(end.x - start.x, end.y - start.y);
addDisplacedSegments(segments, start, end, 4, length * 0.15);
return {
startedAt: performance.now(),
duration: randomBetween(320, 480),
segments,
};
}
function opacityFor(age: number, duration: number): number {
if (age < 60) return age / 60;
if (age < 140) return 1;
return Math.max(0, 1 - (age - 140) / (duration - 140));
}
export const LightningEffect = forwardRef<LightningEffectHandle, LightningEffectProps>(
function LightningEffect({ mode = 'manual' }, ref) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const rafRef = useRef(0);
const strikesRef = useRef<Strike[]>([]);
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const weatherTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resize = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
const ctx = canvas.getContext('2d');
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}, []);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
const now = performance.now();
strikesRef.current = strikesRef.current.filter((strike) => now - strike.startedAt <= strike.duration);
for (const strike of strikesRef.current) {
const age = now - strike.startedAt;
const opacity = opacityFor(age, strike.duration);
if (age < 45) {
ctx.fillStyle = `rgba(255,255,255,${0.03 * opacity})`;
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
}
const passes = [
{ width: 8, alpha: 0.15, shadow: 0 },
{ width: 3, alpha: 0.4, shadow: 0 },
{ width: 1, alpha: 1, shadow: 12 },
];
for (const pass of passes) {
ctx.save();
ctx.lineWidth = pass.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = pass.width === 1
? `rgba(255,255,255,${opacity})`
: `rgba(192,232,255,${pass.alpha * opacity})`;
ctx.shadowBlur = pass.shadow;
ctx.shadowColor = '#88ccff';
ctx.beginPath();
for (const segment of strike.segments) {
ctx.moveTo(segment.from.x, segment.from.y);
ctx.lineTo(segment.to.x, segment.to.y);
}
ctx.stroke();
ctx.restore();
}
}
if (strikesRef.current.length > 0) {
rafRef.current = requestAnimationFrame(draw);
} else {
rafRef.current = 0;
}
}, []);
const startStrike = useCallback(() => {
if (prefersReducedMotion()) return;
const canvas = canvasRef.current;
if (!canvas) return;
strikesRef.current.push(createStrike(window.innerWidth, window.innerHeight));
if (!rafRef.current) rafRef.current = requestAnimationFrame(draw);
}, [draw]);
const clearScheduledTimers = useCallback(() => {
for (const timer of timersRef.current) clearTimeout(timer);
timersRef.current = [];
}, []);
useImperativeHandle(ref, () => ({
triggerLightning: (options) => {
const count = Math.max(1, Math.min(options?.strikes ?? 1, 5));
for (let i = 0; i < count; i++) {
const timer = setTimeout(startStrike, i * randomBetween(70, 130));
timersRef.current.push(timer);
}
},
}), [startStrike]);
useEffect(() => {
resize();
window.addEventListener('resize', resize);
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(rafRef.current);
clearScheduledTimers();
if (weatherTimerRef.current) clearTimeout(weatherTimerRef.current);
};
}, [clearScheduledTimers, resize]);
useEffect(() => {
if (mode !== 'weather') return;
const schedule = () => {
weatherTimerRef.current = setTimeout(() => {
startStrike();
schedule();
}, randomBetween(1500, 4000));
};
schedule();
return () => {
if (weatherTimerRef.current) clearTimeout(weatherTimerRef.current);
weatherTimerRef.current = null;
};
}, [mode, startStrike]);
return <canvas ref={canvasRef} className="pointer-events-none fixed inset-0 z-[300]" aria-hidden="true" />;
},
);
+626
View File
@@ -0,0 +1,626 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { AlertTriangle, Loader2, Bitcoin, Copy, Check } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { useNostrLogin } from '@nostrify/react/login';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
fetchBtcPrice,
getFeeRates,
estimateFee,
isLargeAmount,
satsToUSD,
formatSats,
} from '@/lib/bitcoin';
import type { NostrEvent } from '@nostrify/nostrify';
const USD_PRESETS = [1, 5, 10, 25, 100];
const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
fastest: '~10 min',
halfHour: '~30 min',
hour: '~1 hour',
economy: '~1 day',
};
const FEE_SPEED_ORDER: OnchainFeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
/**
* Given the raw mempool fee rates (sat/vB), return a deduplicated list of
* speed tiers. When multiple tiers share the same rate (common when the
* mempool is empty and everything collapses to 1 sat/vB), we keep only the
* fastest-labeled tier for that rate. This prevents rows like "~10 min 2
* sat/vB / ~30 min 2 sat/vB / ~1 hour 2 sat/vB" in the UI.
*/
function getRateForSpeed(rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number }, speed: OnchainFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
function getUniqueFeeSpeeds(
rates: { fastestFee: number; halfHourFee: number; hourFee: number; economyFee: number } | undefined,
): OnchainFeeSpeed[] {
if (!rates) return FEE_SPEED_ORDER;
const seen = new Set<number>();
const result: OnchainFeeSpeed[] = [];
for (const speed of FEE_SPEED_ORDER) {
const rate = getRateForSpeed(rates, speed);
if (!seen.has(rate)) {
seen.add(rate);
result.push(speed);
}
}
return result;
}
interface OnchainZapContentProps {
target: NostrEvent;
/** Called with the tx result when a zap successfully broadcasts. */
onSuccess?: (result: { txid: string; amountSats: number }) => void;
/** Called when the user dismisses without a send (e.g. "Done" in the
* unsupported-signer QR fallback). */
onClose?: () => void;
}
/**
* Bitcoin zap flow. Publishes a BTC transaction paying the target author's
* derived Taproot address, then publishes a kind 8333 event linking the tx
* to the target event.
*
* UX mirrors the Lightning zap flow: one screen, one button, no review step.
* Balance, fee breakdown, and confirmation are all hidden unless needed.
*/
export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapContentProps) {
const { user } = useCurrentUser();
const { capability } = useBitcoinSigner();
const { logins } = useNostrLogin();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const loginType = logins[0]?.type;
const [usdAmount, setUsdAmount] = useState<number | string>(5);
const [feeSpeed, setFeeSpeed] = useState<OnchainFeeSpeed>('halfHour');
const [error, setError] = useState('');
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
const [editingAmount, setEditingAmount] = useState(false);
const amountInputRef = useRef<HTMLInputElement>(null);
// Tracks whether the user has manually picked a fee speed. Once true, we
// stop auto-adjusting the fee in response to amount changes.
const feeSpeedUserChanged = useRef(false);
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
const recipientAddress = useMemo(() => nostrPubkeyToBitcoinAddress(target.pubkey), [target.pubkey]);
const truncatedRecipient = recipientAddress
? `${recipientAddress.slice(0, 10)}${recipientAddress.slice(-8)}`
: '';
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
staleTime: 30_000,
});
const { data: utxos } = useQuery({
queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
enabled: !!senderAddress && capability !== 'unsupported',
staleTime: 30_000,
});
const { data: feeRates } = useQuery({
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
queryFn: () => getFeeRates(esploraBaseUrl),
enabled: capability !== 'unsupported',
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]);
// Convert the USD amount to sats
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;
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-adjust fee speed when the amount changes, unless the user has
// already picked a speed manually. Aim for a fee below 40% of the amount
// by stepping down through the unique speed tiers. If every tier still
// blows past 40% (tiny amount), fall back to the cheapest tier so we at
// least minimize the hit.
useEffect(() => {
if (feeSpeedUserChanged.current) return;
if (!utxos?.length || !feeRates || amountSats <= 0) return;
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
const threshold = amountSats * 0.4;
let target: OnchainFeeSpeed = 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: OnchainFeeSpeed) => {
feeSpeedUserChanged.current = true;
setFeeSpeed(speed);
setFeePopoverOpen(false);
}, []);
// For large amounts, require a two-tap confirmation on the primary button.
// This catches fat-finger sends without nagging on normal amounts.
const isLarge = isLargeAmount(totalSats, btcPrice);
const [confirmArmed, setConfirmArmed] = useState(false);
// Re-arm (i.e. clear confirmation) whenever the amount, fee rate, or price
// moves — so editing after arming forces another deliberate click.
useEffect(() => {
setConfirmArmed(false);
}, [amountSats, currentFeeRate, btcPrice]);
const { zapAsync, isZapping, progress } = useOnchainZap(target, (result) => {
// Forward the txid + amount so the dialog can render its success screen.
onSuccess?.({ txid: result.txid, amountSats: result.amountSats });
});
const handleZap = useCallback(async () => {
setError('');
if (!user) { setError('You must be logged in.'); return; }
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
// `capability === 'unsupported'` is already handled by the UI replacement
// above; 'supported' and 'unknown' both proceed (the latter may fail at
// sign time, which will then flip the UI to the unsupported state).
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. Receive some first."); return; }
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
// Two-tap safety for large amounts: first click arms, second click sends.
if (isLarge && !confirmArmed) {
setConfirmArmed(true);
return;
}
try {
await zapAsync({ amountSats, comment: '', feeSpeed });
// onSuccess (passed to useOnchainZap) closes the dialog; toast is shown by the hook.
} catch (err) {
// Capability errors flip the UI via `reportSignerUnsupported` in the
// hook's `onError`; no need to surface a form-level error for those.
const msg = err instanceof Error ? err.message : 'Zap failed';
const isCapability = /does not support|doesn't support|signpsbt|sign_psbt/i.test(msg);
if (!isCapability) setError(msg);
}
}, [user, target.pubkey, btcPrice, amountSats, utxos, insufficient, zapAsync, feeSpeed, isLarge, confirmArmed]);
// ── Signer not supported ──────────────────────────────────────
// The user's signer can't sign PSBTs locally (extension without signPsbt,
// or a bunker that rejected sign_psbt). Instead of a dead-end, show a QR
// they can scan with any external Bitcoin wallet. We can't observe the
// resulting txid, so we don't publish a kind 8333 — the user is warned
// that the zap won't be attributed to them on Nostr.
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]);
// Clicking the big amount flips it into edit mode. Auto-focus and
// select-all so typing overwrites the current value.
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [editingAmount]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
// Normalize empty string to 0 so the display doesn't show "$" alone.
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
setUsdAmount(0);
}
}, [usdAmount]);
if (user && capability === 'unsupported') {
return (
<UnsupportedSignerQR
recipientAddress={recipientAddress}
truncatedRecipient={truncatedRecipient}
amountSats={amountSats}
btcPrice={btcPrice}
usdAmount={usdAmount}
setUsdAmount={setUsdAmount}
loginType={loginType}
onClose={onClose}
/>
);
}
return (
<div className="grid gap-4 px-4 py-4 w-full overflow-hidden">
{/* Amount — big number on top, editable by clicking. */}
<div className="flex flex-col items-center pt-2">
{editingAmount ? (
<div className="flex items-baseline justify-center">
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
<input
ref={amountInputRef}
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={usdAmount}
onChange={(e) => { 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` }}
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
aria-label="Edit amount"
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
<span className={`text-4xl font-semibold tabular-nums ${insufficient ? 'text-destructive' : ''}`}>
{hasValidAmount ? currentUsd : 0}
</span>
</button>
)}
</div>
{/* Preset buttons sit under the big number. */}
<ToggleGroup
type="single"
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }}
className="grid grid-cols-5 gap-1 w-full"
>
{USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
${v}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* Error */}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Button
onClick={handleZap}
disabled={!btcPrice || amountSats <= 0 || isZapping || insufficient}
variant={(insufficient || isLarge) && !isZapping ? 'destructive' : 'default'}
className="w-full"
>
{isZapping ? (
<>
<Loader2 className="size-4 mr-1.5 animate-spin" />
{progressLabel(progress)}
</>
) : insufficient ? (
<>Not enough Bitcoin</>
) : isLarge && confirmArmed ? (
<>Tap again to send {totalUsdString}</>
) : (
<>Send {totalUsdString || (hasValidAmount ? `$${currentUsd}` : '')}</>
)}
</Button>
{/* Fee line — click to open speed picker */}
{amountSats > 0 && (
<div className="flex items-center justify-center gap-3 -mt-1 text-xs">
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<span>
Fee{' '}
{estimatedFeeSats > 0 && btcPrice
? `${satsToUSD(estimatedFeeSats, btcPrice)}`
: '…'}
<span className="opacity-60"> · {FEE_SPEED_LABELS[feeSpeed]}</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent align="center" sideOffset={6} className="w-56 p-1">
<div className="flex flex-col">
{uniqueFeeSpeeds.map((speed) => {
const rate = feeRates ? getRateForSpeed(feeRates, speed) : 0;
const selected = speed === feeSpeed;
return (
<button
key={speed}
type="button"
onClick={() => handleFeeSpeedChange(speed)}
className={`flex items-center justify-between px-2 py-1.5 rounded-sm text-xs text-left hover:bg-muted transition-colors ${selected ? 'bg-muted font-medium' : ''}`}
>
<span>{FEE_SPEED_LABELS[speed]}</span>
<span className="text-muted-foreground">{rate} sat/vB</span>
</button>
);
})}
</div>
</PopoverContent>
</Popover>
{showBalance && !insufficient && btcPrice && (
<span className="text-muted-foreground">
Balance: {satsToUSD(totalBalance, btcPrice)}
</span>
)}
</div>
)}
</div>
);
}
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 'Processing…';
}
}
// ──────────────────────────────────────────────────────────────
// Unsupported-signer QR fallback
// ──────────────────────────────────────────────────────────────
interface UnsupportedSignerQRProps {
recipientAddress: string;
truncatedRecipient: string;
amountSats: number;
btcPrice: number | undefined;
usdAmount: number | string;
setUsdAmount: (v: number | string) => void;
loginType: string | undefined;
onClose?: () => void;
}
/**
* Fallback shown when the user's signer can't sign PSBTs locally. Renders a
* BIP-21 QR the user can scan with any external Bitcoin wallet. Because we
* never see the resulting tx, we skip publishing the kind 8333 zap event and
* explicitly warn the user about that.
*/
function UnsupportedSignerQR({
recipientAddress,
truncatedRecipient,
amountSats,
btcPrice,
usdAmount,
setUsdAmount,
loginType,
onClose,
}: UnsupportedSignerQRProps) {
const { toast } = useToast();
const [copied, setCopied] = useState<'address' | 'uri' | null>(null);
// BIP-21 URI. Include `amount` (in BTC, 8 decimals) only when > 0 so an
// empty-amount placeholder QR doesn't include `?amount=0`.
const bip21 = useMemo(() => {
if (!recipientAddress) return '';
if (amountSats <= 0) return `bitcoin:${recipientAddress}`;
const btc = (amountSats / 100_000_000).toFixed(8);
return `bitcoin:${recipientAddress}?amount=${btc}`;
}, [recipientAddress, amountSats]);
const explanation =
loginType === 'extension'
? "Your browser extension can't sign Bitcoin transactions."
: loginType === 'bunker'
? "Your remote signer can't sign Bitcoin transactions."
: "Your signer can't sign Bitcoin transactions.";
const copy = useCallback(
async (value: string, which: 'address' | 'uri', label: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(which);
toast({ title: 'Copied', description: `${label} copied to clipboard` });
setTimeout(() => setCopied(null), 2000);
} catch {
toast({ title: 'Copy failed', description: 'Please copy manually.', variant: 'destructive' });
}
},
[toast],
);
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
const hasAmount = amountSats > 0;
return (
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
<p className="text-xs text-muted-foreground">
{explanation} You can still zap by scanning this QR from any Bitcoin wallet.
</p>
{/* Amount presets (USD) */}
<ToggleGroup
type="single"
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) setUsdAmount(Number(v)); }}
className="grid grid-cols-5 gap-1 w-full"
>
{USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
${v}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
<Input
type="number"
inputMode="decimal"
min={0}
step="0.01"
placeholder="Custom amount (USD)"
value={usdAmount}
onChange={(e) => setUsdAmount(e.target.value)}
className="pl-6"
/>
</div>
{/* QR / placeholder */}
<div className="flex justify-center">
{hasAmount && bip21 ? (
<div className="bg-white p-3 rounded-xl" aria-label="Bitcoin payment QR code">
<QRCodeCanvas value={bip21} size={220} level="M" className="block" />
</div>
) : (
<div className="size-[220px] rounded-xl border border-dashed flex items-center justify-center text-xs text-muted-foreground text-center px-4">
{btcPrice
? 'Choose an amount above to generate a payment QR.'
: 'Loading BTC price…'}
</div>
)}
</div>
{/* Amount summary */}
{hasAmount && btcPrice && (
<div className="text-center text-sm">
<span className="font-medium">
{currentUsd > 0 ? `$${currentUsd}` : ''}
</span>
<span className="text-muted-foreground">
{' · '}{formatSats(amountSats)} sats
</span>
</div>
)}
{/* Recipient */}
{recipientAddress && (
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 min-w-0">
<Bitcoin className="size-3.5 text-orange-500 shrink-0" />
<span className="shrink-0">To:</span>
<span className="font-mono truncate" title={recipientAddress}>{truncatedRecipient}</span>
</div>
</div>
)}
{/* Copy buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(recipientAddress, 'address', 'Address')}
disabled={!recipientAddress}
className="text-xs"
>
{copied === 'address' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
Copy address
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copy(bip21, 'uri', 'Payment link')}
disabled={!hasAmount || !bip21}
className="text-xs"
>
{copied === 'uri' ? <Check className="size-3.5 mr-1.5" /> : <Copy className="size-3.5 mr-1.5" />}
Copy link
</Button>
</div>
{/* Warning: no kind 8333 will be published */}
<Alert>
<AlertTriangle className="size-4" />
<AlertDescription className="text-xs">
Because we can't see your transaction, this zap won't show up as yours on Nostr. The recipient will still get the Bitcoin.
</AlertDescription>
</Alert>
{onClose && (
<Button type="button" variant="secondary" onClick={onClose} className="w-full">
Done
</Button>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
-630
View File
@@ -1,630 +0,0 @@
/**
* Create Wallet Component
* Handles new wallet creation flow with mnemonic backup and Lightning address setup
*/
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Loader2,
Wallet,
Shield,
Download,
CloudUpload,
Zap,
Check,
X,
ArrowRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { MnemonicDisplay } from "./MnemonicDisplay";
import { WasmUnsupportedError } from "./WasmUnsupportedError";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useNostrPublish } from "@/hooks/useNostrPublish";
import { useToast } from "@/hooks/useToast";
import { useQueryClient } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { checkWasmSupport } from "@/lib/checkWasmSupport";
interface CreateWalletProps {
onComplete?: () => void;
onCancel?: () => void;
}
type Step = "create" | "backup" | "confirm" | "lightning-address";
/** Get step configuration for progress indicator with translations */
function getSteps(t: (key: string) => string): { id: Step; label: string; shortLabel: string }[] {
return [
{ id: "create", label: t('wallet2.createWallet'), shortLabel: t('auth.generateKey') },
{ id: "backup", label: t('walletSettings.backupTitle'), shortLabel: t('auth.downloadKey') },
{ id: "confirm", label: t('dialogs.confirmBackup'), shortLabel: t('common.confirm') },
{
id: "lightning-address",
label: t('walletSettings.lightningTitle'),
shortLabel: t('wallet.address'),
},
];
}
/** Progress indicator component */
function StepIndicator({ currentStep, steps }: { currentStep: Step; steps: ReturnType<typeof getSteps> }) {
const currentIndex = steps.findIndex((s) => s.id === currentStep);
return (
<div className="mb-6">
{/* Mobile: Show current step text */}
<div className="sm:hidden text-center mb-2">
<span className="text-sm text-muted-foreground">
Step {currentIndex + 1} of {steps.length}
</span>
</div>
{/* Progress bar and steps */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isCompleted = index < currentIndex;
const isCurrent = index === currentIndex;
const isUpcoming = index > currentIndex;
return (
<div
key={step.id}
className="flex items-center flex-1 last:flex-none"
>
{/* Step circle */}
<div className="flex flex-col items-center">
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors",
isCompleted && "bg-primary text-primary-foreground",
isCurrent &&
"bg-primary text-primary-foreground ring-4 ring-primary/20",
isUpcoming && "bg-muted text-muted-foreground",
)}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div>
{/* Step label - hidden on mobile */}
<span
className={cn(
"hidden sm:block text-xs mt-1.5 text-center max-w-[70px]",
isCurrent
? "text-foreground font-medium"
: "text-muted-foreground",
)}
>
{step.shortLabel}
</span>
</div>
{/* Connector line */}
{index < steps.length - 1 && (
<div
className={cn(
"flex-1 h-0.5 mx-2",
index < currentIndex ? "bg-primary" : "bg-muted",
)}
/>
)}
</div>
);
})}
</div>
</div>
);
}
export function CreateWallet({ onComplete, onCancel }: CreateWalletProps) {
const { t } = useTranslation();
const STEPS = getSteps(t);
const [step, setStep] = useState<Step>("create");
const [mnemonic, setMnemonic] = useState<string>("");
const [isCreating, setIsCreating] = useState(false);
const [hasBackedUp, setHasBackedUp] = useState(false);
const [syncToRelay, setSyncToRelay] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// WASM support check for iOS Lockdown Mode detection
const [wasmSupported, setWasmSupported] = useState<boolean | null>(null);
const [wasmError, setWasmError] = useState<string | null>(null);
// Lightning address state
const [lnUsername, setLnUsername] = useState("");
const [isCheckingUsername, setIsCheckingUsername] = useState(false);
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(
null,
);
const [isRegistering, setIsRegistering] = useState(false);
const [registeredAddress, setRegisteredAddress] = useState<string | null>(
null,
);
const {
createWallet,
syncToRelays,
exportBackup,
checkLightningAddressAvailable,
registerLightningAddress,
} = useSparkWallet();
// Check WASM support on mount
useEffect(() => {
checkWasmSupport().then((result) => {
setWasmSupported(result.supported);
if (!result.supported) {
setWasmError(result.reason || "WebAssembly is not supported");
}
});
}, []);
const { user, metadata } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const handleCreate = async () => {
setIsCreating(true);
try {
const newMnemonic = await createWallet();
setMnemonic(newMnemonic);
setStep("backup");
} catch (error) {
console.error("Failed to create wallet:", error);
} finally {
setIsCreating(false);
}
};
const handleExportBackup = async () => {
if (!mnemonic) return;
try {
await exportBackup(mnemonic);
} catch (error) {
console.error("Failed to export backup:", error);
}
};
const handleConfirmComplete = async () => {
if (syncToRelay && mnemonic && user) {
setIsSyncing(true);
try {
await syncToRelays(mnemonic);
} catch (error) {
console.error("Failed to sync to relays:", error);
} finally {
setIsSyncing(false);
}
}
// Proceed to Lightning address setup
setStep("lightning-address");
};
const handleCheckUsername = async () => {
if (!lnUsername.trim()) return;
setIsCheckingUsername(true);
setUsernameAvailable(null);
try {
const available = await checkLightningAddressAvailable(
lnUsername.trim().toLowerCase(),
);
setUsernameAvailable(available);
} catch (error) {
console.error("Failed to check username:", error);
setUsernameAvailable(null);
} finally {
setIsCheckingUsername(false);
}
};
const handleRegisterAddress = async () => {
if (!lnUsername.trim() || !usernameAvailable) return;
setIsRegistering(true);
try {
const address = await registerLightningAddress(
lnUsername.trim().toLowerCase(),
);
setRegisteredAddress(address);
// Optionally update user's Nostr profile with the new Lightning address
if (user && metadata) {
try {
const updatedMetadata = { ...metadata, lud16: address };
// Clean up empty values
for (const key in updatedMetadata) {
if (updatedMetadata[key] === "") {
delete updatedMetadata[key];
}
}
await publishEvent({
kind: 0,
content: JSON.stringify(updatedMetadata),
});
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
toast({
title: "Profile updated",
description:
"Your Lightning address has been added to your Nostr profile.",
});
} catch (error) {
console.error("Failed to update profile:", error);
// Don't fail the whole flow if profile update fails
}
}
} catch (error) {
console.error("Failed to register Lightning address:", error);
toast({
title: "Registration failed",
description:
error instanceof Error
? error.message
: "Failed to register Lightning address",
variant: "destructive",
});
} finally {
setIsRegistering(false);
}
};
const handleFinalComplete = () => {
// Clear mnemonic from memory after completion
setMnemonic("");
onComplete?.();
};
if (step === "create") {
// Show loading while checking WASM support
if (wasmSupported === null) {
return (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Checking device compatibility...
</p>
</div>
</CardContent>
</Card>
);
}
// Show error if WASM is not supported (iOS Lockdown Mode)
if (wasmSupported === false) {
return (
<WasmUnsupportedError
technicalDetails={wasmError ?? undefined}
onBack={onCancel}
/>
);
}
return (
<Card>
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Wallet className="h-6 w-6 text-primary" />
</div>
<CardTitle>{t('wallet2.createWallet')}</CardTitle>
<CardDescription>
Create a new self-custodial Lightning wallet
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium text-foreground">{t('wallet2.selfCustodial')}</p>
<p>{t('wallet2.selfCustodialDesc')}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Wallet className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="font-medium text-foreground">{t('wallet2.instantPayments')}</p>
<p>
Send and receive Lightning payments instantly with low fees.
</p>
</div>
</div>
</div>
<div className="flex gap-2 pt-4">
{onCancel && (
<Button variant="outline" onClick={onCancel} className="flex-1">
Cancel
</Button>
)}
<Button
onClick={handleCreate}
disabled={isCreating}
className="flex-1"
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('common.loading')}
</>
) : (
t('wallet2.createWallet')
)}
</Button>
</div>
</CardContent>
</Card>
);
}
if (step === "backup") {
return (
<Card>
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<CardTitle>Backup Your Wallet</CardTitle>
<CardDescription>
Save your 12-word recovery phrase. This is the only way to restore
your wallet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<MnemonicDisplay mnemonic={mnemonic} showWarning={true} />
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleExportBackup}
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
Download Backup
</Button>
</div>
<Button onClick={() => setStep("confirm")} className="w-full">
I've Saved My Recovery Phrase
</Button>
</CardContent>
</Card>
);
}
if (step === "confirm") {
return (
<Card>
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<CardTitle>Confirm Backup</CardTitle>
<CardDescription>
Please confirm you have saved your recovery phrase securely.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<Checkbox
id="backed-up"
checked={hasBackedUp}
onCheckedChange={(checked) => setHasBackedUp(checked === true)}
/>
<Label
htmlFor="backed-up"
className="text-sm leading-relaxed cursor-pointer"
>
I have written down my 12-word recovery phrase and stored it in
a safe place. I understand that losing this phrase means losing
access to my funds.
</Label>
</div>
{user && (
<div className="flex items-start space-x-3">
<Checkbox
id="sync-relay"
checked={syncToRelay}
onCheckedChange={(checked) =>
setSyncToRelay(checked === true)
}
/>
<Label
htmlFor="sync-relay"
className="text-sm leading-relaxed cursor-pointer"
>
<div className="flex items-center gap-2">
<CloudUpload className="h-4 w-4" />
Encrypt and backup to Nostr relays
</div>
<p className="text-muted-foreground mt-1">
Your backup will be encrypted with your Nostr key and stored
on relays for easy recovery.
</p>
</Label>
</div>
)}
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={() => setStep("backup")}
className="flex-1"
>
Back
</Button>
<Button
onClick={handleConfirmComplete}
disabled={!hasBackedUp || isSyncing}
className="flex-1"
>
{isSyncing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Syncing...
</>
) : (
<>
Continue
<ArrowRight className="h-4 w-4 ml-2" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
);
}
// Lightning Address step
return (
<Card>
<CardHeader className="text-center">
<StepIndicator currentStep={step} steps={STEPS} />
<div className="mx-auto w-12 h-12 bg-yellow-500/10 rounded-full flex items-center justify-center mb-4">
<Zap className="h-6 w-6 text-yellow-500" />
</div>
<CardTitle>Set Up Lightning Address</CardTitle>
<CardDescription>
Get a Lightning address so others can easily send you payments
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{registeredAddress ? (
// Success state
<div className="space-y-4">
<Alert className="border-primary/50 bg-primary/10">
<Check className="h-4 w-4 text-primary" />
<AlertDescription className="text-primary">
Your Lightning address is ready!
</AlertDescription>
</Alert>
<div className="p-4 bg-muted rounded-lg text-center overflow-hidden">
<p className="text-sm text-muted-foreground mb-1">
Your Lightning address
</p>
<p className="text-lg font-mono font-medium break-all">
{registeredAddress}
</p>
</div>
<p className="text-sm text-muted-foreground text-center">
This address has been automatically added to your Nostr profile.
Anyone can now send you Lightning payments using this address.
</p>
<Button onClick={handleFinalComplete} className="w-full">
<Check className="h-4 w-4 mr-2" />
Complete Setup
</Button>
</div>
) : (
// Registration form
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ln-username">Choose your username</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="ln-username"
value={lnUsername}
onChange={(e) => {
setLnUsername(
e.target.value.toLowerCase().replace(/[^a-z0-9]/g, ""),
);
setUsernameAvailable(null);
}}
placeholder="satoshi"
className="pr-10"
/>
{usernameAvailable !== null && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{usernameAvailable ? (
<Check className="h-4 w-4 text-primary" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
</div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={handleCheckUsername}
disabled={!lnUsername.trim() || isCheckingUsername}
>
{isCheckingUsername ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Check"
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Your address will be:{" "}
<span className="font-mono">
{lnUsername || "username"}@breez.tips
</span>
</p>
{usernameAvailable === false && (
<p className="text-sm text-red-600">
This username is already taken. Please try another.
</p>
)}
{usernameAvailable === true && (
<p className="text-sm text-primary">
This username is available!
</p>
)}
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={handleFinalComplete}
className="flex-1"
>
Skip for now
</Button>
<Button
onClick={handleRegisterAddress}
disabled={!usernameAvailable || isRegistering}
className="flex-1"
>
{isRegistering ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Registering...
</>
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Get Address
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
@@ -1,90 +0,0 @@
/**
* Lock Timeout Settings Component
* Allows users to configure auto-lock timeout and manually lock the wallet
*/
import { Lock, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import type { LockTimeoutMinutes } from '@/lib/spark/types';
const TIMEOUT_OPTIONS: { value: LockTimeoutMinutes; label: string }[] = [
{ value: 0, label: 'Never (disabled)' },
{ value: 1, label: '1 minute' },
{ value: 5, label: '5 minutes' },
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 60, label: '1 hour' },
];
interface LockTimeoutSettingsProps {
className?: string;
}
export function LockTimeoutSettings({ className }: LockTimeoutSettingsProps) {
const { lockTimeout, setLockTimeout, lockWallet, isInitialized } = useSparkWallet();
const handleTimeoutChange = (value: string) => {
const timeout = parseInt(value, 10) as LockTimeoutMinutes;
setLockTimeout(timeout);
};
return (
<div className={className}>
<div className="space-y-4">
{/* Auto-lock timeout setting */}
<div className="space-y-2">
<Label htmlFor="lock-timeout" className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Auto-lock timeout
</Label>
<Select
value={lockTimeout.toString()}
onValueChange={handleTimeoutChange}
>
<SelectTrigger id="lock-timeout" className="w-full">
<SelectValue placeholder="Select timeout" />
</SelectTrigger>
<SelectContent>
{TIMEOUT_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{lockTimeout === 0
? 'Your wallet will stay unlocked until you manually lock it or close the browser.'
: `Your wallet will automatically lock after ${lockTimeout} minute${lockTimeout > 1 ? 's' : ''} of inactivity.`}
</p>
</div>
{/* Manual lock button */}
{isInitialized && (
<div className="pt-2 border-t">
<Button
variant="outline"
onClick={lockWallet}
className="w-full"
>
<Lock className="h-4 w-4 mr-2" />
Lock Wallet Now
</Button>
<p className="text-xs text-muted-foreground mt-2">
Manually lock your wallet. You'll need to authenticate with your Nostr key to unlock.
</p>
</div>
)}
</div>
</div>
);
}
@@ -1,241 +0,0 @@
/**
* Mnemonic Display Component
* Displays the 12-word recovery phrase with copy functionality
*
* SECURITY: Clipboard is auto-cleared after 30 seconds
*/
import { useState, useRef, useEffect } from 'react';
import { Copy, Check, Eye, EyeOff, AlertTriangle, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/useToast';
import { logger } from '@/lib/logger';
/** Clipboard auto-clear timeout in milliseconds (30 seconds) */
const CLIPBOARD_CLEAR_TIMEOUT = 30000;
interface MnemonicDisplayProps {
mnemonic: string;
showWarning?: boolean;
}
export function MnemonicDisplay({ mnemonic, showWarning = true }: MnemonicDisplayProps) {
const [isVisible, setIsVisible] = useState(false);
const [copied, setCopied] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [countdown, setCountdown] = useState(0);
const { toast } = useToast();
const clearTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
const words = mnemonic.split(' ');
// Cleanup on unmount
useEffect(() => {
return () => {
if (clearTimeoutRef.current) {
clearTimeout(clearTimeoutRef.current);
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
};
}, []);
const clearClipboard = async () => {
try {
// Clear clipboard by writing empty string
await navigator.clipboard.writeText('');
logger.debug('[MnemonicDisplay] Clipboard cleared');
setCopied(false);
setCountdown(0);
} catch (error) {
logger.warn('[MnemonicDisplay] Failed to clear clipboard:', error);
}
};
const handleCopyConfirmed = async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
setShowCopyConfirm(false);
// Start countdown
const totalSeconds = CLIPBOARD_CLEAR_TIMEOUT / 1000;
setCountdown(totalSeconds);
// Update countdown every second
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
countdownIntervalRef.current = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
return 0;
}
return prev - 1;
});
}, 1000);
toast({
title: 'Copied to clipboard',
description: `Recovery phrase copied. Clipboard will be cleared in ${totalSeconds} seconds for security.`,
});
// Schedule clipboard clear
if (clearTimeoutRef.current) {
clearTimeout(clearTimeoutRef.current);
}
clearTimeoutRef.current = setTimeout(async () => {
await clearClipboard();
toast({
title: 'Clipboard cleared',
description: 'Recovery phrase removed from clipboard for security.',
});
}, CLIPBOARD_CLEAR_TIMEOUT);
} catch (error) {
logger.error('[MnemonicDisplay] Failed to copy:', error);
toast({
title: 'Copy failed',
description: 'Could not copy to clipboard',
variant: 'destructive',
});
}
};
const handleCopyClick = () => {
// If already copied and countdown is active, just show status
if (copied && countdown > 0) {
toast({
title: 'Already copied',
description: `Clipboard will be cleared in ${countdown} seconds.`,
});
return;
}
// Show confirmation dialog
setShowCopyConfirm(true);
};
return (
<div className="space-y-4">
{showWarning && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Write down these 12 words and store them safely. Anyone with this phrase can access your funds. Never share it with anyone.
</AlertDescription>
</Alert>
)}
<Card>
<CardContent className="pt-4">
<div className="grid grid-cols-3 gap-2">
{words.map((word, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 bg-muted rounded-md text-sm"
>
<span className="text-muted-foreground w-5 text-right">
{index + 1}.
</span>
<span className={isVisible ? '' : 'blur-sm select-none'}>
{word}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsVisible(!isVisible)}
className="flex-1"
>
{isVisible ? (
<>
<EyeOff className="h-4 w-4 mr-2" />
Hide
</>
) : (
<>
<Eye className="h-4 w-4 mr-2" />
Reveal
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCopyClick}
className="flex-1"
>
{copied && countdown > 0 ? (
<>
<Clock className="h-4 w-4 mr-2 text-amber-600" />
Clears in {countdown}s
</>
) : copied ? (
<>
<Check className="h-4 w-4 mr-2 text-primary" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
</div>
{/* Copy Confirmation Dialog */}
<AlertDialog open={showCopyConfirm} onOpenChange={setShowCopyConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Copy Recovery Phrase?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="text-left space-y-2">
<p>
Your recovery phrase will be copied to the clipboard. This is sensitive information that gives full access to your wallet.
</p>
<p className="font-medium">
For security, the clipboard will be automatically cleared after 30 seconds.
</p>
<p className="text-amber-600 dark:text-amber-400">
Never share this phrase with anyone or paste it into untrusted applications.
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleCopyConfirmed}>
Copy to Clipboard
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,79 +0,0 @@
/**
* Mnemonic Input Component
* Input for entering 12-word recovery phrase
*/
import { useState, useCallback } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle, XCircle } from 'lucide-react';
import { breezService } from '@/lib/spark/breezService';
interface MnemonicInputProps {
value: string;
onChange: (value: string) => void;
error?: string;
}
export function MnemonicInput({ value, onChange, error }: MnemonicInputProps) {
const [isValid, setIsValid] = useState<boolean | null>(null);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value.toLowerCase().trim();
onChange(newValue);
// Validate if we have 12 words
const words = newValue.split(/\s+/).filter(w => w.length > 0);
if (words.length === 12) {
const valid = breezService.validateMnemonic(newValue);
setIsValid(valid);
} else if (words.length > 0) {
setIsValid(null);
} else {
setIsValid(null);
}
}, [onChange]);
const wordCount = value.split(/\s+/).filter(w => w.length > 0).length;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="mnemonic">Recovery Phrase</Label>
<span className="text-xs text-muted-foreground">
{wordCount}/12 words
</span>
</div>
<Textarea
id="mnemonic"
value={value}
onChange={handleChange}
placeholder="Enter your 12-word recovery phrase..."
rows={3}
className="font-mono text-sm"
/>
{isValid === true && (
<div className="flex items-center gap-2 text-sm text-primary">
<CheckCircle className="h-4 w-4" />
Valid recovery phrase
</div>
)}
{isValid === false && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4" />
Invalid recovery phrase
</div>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}
@@ -1,425 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Zap,
User,
Clock,
Hash,
FileText,
ExternalLink,
Copy,
Check,
ArrowDownLeft,
ArrowUpRight,
Receipt,
Fingerprint,
} from 'lucide-react';
import { useEnrichedPayment } from '@/hooks/usePaymentContext';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useToast } from '@/hooks/useToast';
import { getDisplayName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import type { BreezPaymentInfo } from '@/lib/spark/breezService';
import { format } from 'date-fns';
interface PaymentDetailDialogProps {
payment: BreezPaymentInfo | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function DetailRow({
label,
value,
copyValue,
icon: Icon,
copyable = false,
className
}: {
label: string;
value: string | number;
copyValue?: string;
icon: React.ComponentType<{ className?: string }>;
copyable?: boolean;
className?: string;
}) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const handleCopy = async () => {
await navigator.clipboard.writeText(copyValue ?? String(value));
setCopied(true);
toast({ title: 'Copied', description: `${label} copied to clipboard` });
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={cn("flex items-start justify-between gap-4 py-2", className)}>
<div className="flex items-center gap-2 text-muted-foreground min-w-0">
<Icon className="h-4 w-4 shrink-0" />
<span className="text-sm">{label}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-right break-all">{value}</span>
{copyable && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3 w-3 text-primary" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
)}
</div>
</div>
);
}
export function PaymentDetailDialog({ payment, open, onOpenChange }: PaymentDetailDialogProps) {
const isMobile = useIsMobile();
const { toast } = useToast();
const { context, author } = useEnrichedPayment(payment);
if (!payment) return null;
const isReceived = payment.paymentType === 'receive';
const isZap = context?.isZap && !isReceived;
const targetEvent = context?.targetEvent;
const targetProfile = context?.targetProfile;
const metadata = author?.metadata;
const zapRequest = context?.zapRequest;
// Generate target link
let targetLink: string | undefined;
let targetNip19: string | undefined;
if (isZap && targetEvent) {
targetNip19 = nip19.noteEncode(targetEvent.id);
targetLink = `/${targetNip19}`;
} else if (isZap && targetProfile) {
targetNip19 = nip19.npubEncode(targetProfile);
targetLink = `/${targetNip19}`;
}
const displayName = getDisplayName(metadata, targetProfile || targetEvent?.pubkey || '');
const lightningAddress = metadata?.lud16 || metadata?.lud06;
// Extract comment from zap request
const zapComment = zapRequest?.tags.find(([name]) => name === 'comment')?.[1];
const content = (
<ScrollArea className="max-h-[60vh] px-1">
<div className="space-y-6">
{/* Payment Type Header */}
<Card className={cn(
"border-2",
isReceived ? "bg-primary/5 dark:bg-primary/10 border-primary/30 dark:border-primary/40" : "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900"
)}>
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
"w-12 h-12 rounded-full flex items-center justify-center",
isReceived ? "bg-primary/10 text-primary" : "bg-red-100 text-red-600"
)}>
{isReceived ? (
<ArrowDownLeft className="h-6 w-6" />
) : (
<ArrowUpRight className="h-6 w-6" />
)}
</div>
<div>
<p className="font-semibold text-lg">
{isReceived ? "Received" : isZap ? "Zapped" : "Sent"}
</p>
<p className="text-sm text-muted-foreground">
{format(payment.timestamp * 1000, 'MMM d, yyyy h:mm a')}
</p>
</div>
</div>
<div className="text-right">
<p className={cn(
"text-2xl font-bold",
isReceived ? "text-primary" : "text-red-600"
)}>
{isReceived ? "+" : "-"}{payment.amount.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">sats</p>
</div>
</div>
</CardContent>
</Card>
{/* Zap Target Information */}
{isZap && (targetEvent || targetProfile) && (
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
Zap Target
</h3>
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-3 mb-4">
<Avatar className="h-12 w-12">
<AvatarImage src={metadata?.picture} />
<AvatarFallback>
<User className="h-6 w-6" />
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{displayName}</p>
{lightningAddress && (
<p className="text-xs text-muted-foreground truncate">
{lightningAddress}
</p>
)}
{metadata?.nip05 && (
<p className="text-xs text-muted-foreground truncate">
{metadata.nip05}
</p>
)}
</div>
</div>
{targetLink && (
<Link to={targetLink}>
<Button variant="outline" className="w-full gap-2" size="sm">
<ExternalLink className="h-4 w-4" />
View {targetEvent ? 'Post' : 'Profile'}
</Button>
</Link>
)}
{zapComment && (
<div className="mt-3 p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground mb-1">Your message:</p>
<p className="text-sm italic">"{zapComment}"</p>
</div>
)}
{targetEvent && (
<div className="mt-3 p-3 bg-muted/50 rounded-md">
<p className="text-xs text-muted-foreground mb-1">Submission preview:</p>
<p className="text-sm line-clamp-3">{targetEvent.content}</p>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Technical Details */}
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Receipt className="h-4 w-4" />
Technical Details
</h3>
<Card>
<CardContent className="pt-4 pb-4 space-y-0 divide-y">
<DetailRow
label="Payment ID"
value={payment.id}
icon={Fingerprint}
copyable
/>
<DetailRow
label="Amount"
value={`${payment.amount.toLocaleString()} sats`}
icon={Zap}
/>
{payment.fees > 0 && (
<DetailRow
label="Fees"
value={`${payment.fees.toLocaleString()} sats`}
icon={Receipt}
/>
)}
<DetailRow
label="Type"
value={payment.paymentType === 'receive' ? 'Received' : 'Sent'}
icon={isReceived ? ArrowDownLeft : ArrowUpRight}
/>
<DetailRow
label="Status"
value={payment.status}
icon={Clock}
/>
<DetailRow
label="Timestamp"
value={format(payment.timestamp * 1000, 'PPpp')}
icon={Clock}
/>
{payment.paymentHash && (
<DetailRow
label="Payment Hash"
value={payment.paymentHash.slice(0, 16) + '...' + payment.paymentHash.slice(-16)}
copyValue={payment.paymentHash}
icon={Hash}
copyable
/>
)}
{payment.preimage && (
<DetailRow
label="Preimage"
value={payment.preimage.slice(0, 16) + '...' + payment.preimage.slice(-16)}
copyValue={payment.preimage}
icon={Fingerprint}
copyable
/>
)}
{payment.description && !isZap && (
<DetailRow
label="Description"
value={payment.description}
icon={FileText}
/>
)}
</CardContent>
</Card>
</div>
{/* Zap Request Details (if available) */}
{isZap && zapRequest && (
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Zap className="h-4 w-4 text-yellow-500" />
Zap Request Details
</h3>
<Card>
<CardContent className="pt-4 pb-4 space-y-0 divide-y">
<DetailRow
label="Event ID"
value={zapRequest.id.slice(0, 16) + '...' + zapRequest.id.slice(-16)}
icon={Hash}
copyable
/>
<DetailRow
label="Created At"
value={format(zapRequest.created_at * 1000, 'PPpp')}
icon={Clock}
/>
<DetailRow
label="Sender"
value={nip19.npubEncode(zapRequest.pubkey).slice(0, 16) + '...'}
icon={User}
copyable
/>
{targetEvent && (
<DetailRow
label="Zapped Event"
value={targetEvent.id.slice(0, 16) + '...' + targetEvent.id.slice(-16)}
icon={FileText}
copyable
/>
)}
{targetProfile && (
<DetailRow
label="Zapped Profile"
value={nip19.npubEncode(targetProfile).slice(0, 16) + '...'}
icon={User}
copyable
/>
)}
</CardContent>
</Card>
</div>
)}
{/* Invoice (if available) */}
{payment.invoice && (
<div className="space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Receipt className="h-4 w-4" />
Lightning Invoice
</h3>
<Card>
<CardContent className="pt-4 pb-4">
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 rounded-md overflow-x-auto break-all whitespace-pre-wrap">
{payment.invoice}
</pre>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6"
onClick={async () => {
await navigator.clipboard.writeText(payment.invoice!);
toast({
title: 'Copied',
description: 'Invoice copied to clipboard'
});
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</ScrollArea>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Payment Details
</DrawerTitle>
<DrawerDescription>
Transaction information
</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-6">
{content}
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Payment Details
</DialogTitle>
<DialogDescription>
Transaction information
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -1,334 +0,0 @@
/**
* Payment History Component
* Displays list of recent payments
*/
import { useState } from 'react';
import { ArrowDownLeft, ArrowUpRight, Clock, Loader2, RefreshCw, Zap, User } from "lucide-react";
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useEnrichedPayment } from "@/hooks/usePaymentContext";
import { PaymentDetailDialog } from '@/components/SparkWallet/PaymentDetailDialog';
import { getDisplayName } from "@/lib/genUserName";
import { cn } from "@/lib/utils";
import type { BreezPaymentInfo } from "@/lib/spark/breezService";
interface PaymentHistoryProps {
limit?: number;
className?: string;
}
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function PaymentItem({ payment, onClick }: { payment: BreezPaymentInfo; onClick: () => void }) {
const isReceived = payment.paymentType === "receive";
const amount = Number(payment.amount);
const timestamp = Number(payment.timestamp);
const { context, author } = useEnrichedPayment(payment);
const isZap = context?.isZap && !isReceived;
const targetEvent = context?.targetEvent;
const targetProfile = context?.targetProfile;
const zapRequest = context?.zapRequest;
const metadata = author?.metadata;
// Determine zap type based on target
let zapType = "Zapped";
let targetLink: string | undefined;
let targetLabel: string | undefined;
if (isZap) {
// Determine type by event kind or lack thereof
if (targetEvent) {
if (targetEvent.kind === 1111) {
// Check if this is a challenge submission (has k:36639 tag)
const isSubmission = targetEvent.tags.some(
([name, value]) => name === 'k' && value === '36639'
);
zapType = isSubmission ? "Zapped submission" : "Zapped post";
} else if (targetEvent.kind === 1) {
zapType = "Zapped post";
} else if (targetEvent.kind === 30023) {
zapType = "Zapped article";
} else {
zapType = "Zapped event";
}
// Link to the event
const noteId = nip19.noteEncode(targetEvent.id);
targetLink = `/${noteId}`;
targetLabel = getDisplayName(metadata, targetEvent.pubkey);
} else if (targetProfile) {
// Profile zap (no event)
zapType = "Zapped profile";
const npub = nip19.npubEncode(targetProfile);
targetLink = `/${npub}`;
targetLabel = getDisplayName(metadata, targetProfile);
} else if (zapRequest) {
// We have zap request but no target event loaded yet
// Try to determine type from 'k' tag in zap request
const kindTag = zapRequest.tags.find(([name]) => name === 'k')?.[1];
const hasEventTarget = zapRequest.tags.some(([name]) => name === 'e' || name === 'a');
if (kindTag) {
const kind = parseInt(kindTag);
if (kind === 1111) {
// Check if parent is a challenge (k:36639 tag)
const isSubmission = kindTag === '36639';
zapType = isSubmission ? "Zapped submission" : "Zapped post";
} else if (kind === 1) {
zapType = "Zapped post";
} else if (kind === 30023) {
zapType = "Zapped article";
} else if (kind >= 30000 && kind < 40000) {
zapType = "Zapped event";
} else {
zapType = "Zapped post";
}
} else if (!hasEventTarget) {
zapType = "Zapped profile";
}
}
}
return (
<div
onClick={onClick}
className="flex items-center justify-between py-3 border-b last:border-0 cursor-pointer hover:bg-muted/50 -mx-2 px-2 rounded-md transition-colors"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
isReceived
? "bg-primary/10 text-primary"
: "bg-red-100 text-red-600",
)}
>
{isReceived ? (
<ArrowDownLeft className="h-4 w-4" />
) : (
<ArrowUpRight className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-sm">
{isReceived ? "Received" : isZap ? zapType : "Sent"}
</p>
{isZap && <Zap className="h-3 w-3 text-yellow-500" />}
</div>
{/* Show author/target info for zaps */}
{isZap && targetLink && (
<Link
to={targetLink}
className="flex items-center gap-1.5 mt-1 hover:underline max-w-full"
>
{metadata?.picture ? (
<Avatar className="h-4 w-4">
<AvatarImage src={metadata.picture} />
<AvatarFallback>
<User className="h-2 w-2" />
</AvatarFallback>
</Avatar>
) : (
<User className="h-3 w-3 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground truncate">
{targetLabel}
</span>
</Link>
)}
{/* Fallback: show timestamp if not a zap or still loading */}
{(!isZap || !targetLink) && (
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3" />
{formatDate(timestamp)}
</p>
)}
</div>
</div>
<div className="text-right shrink-0 ml-3">
<p
className={cn(
"font-medium",
isReceived ? "text-primary" : "text-red-600",
)}
>
{isReceived ? "+" : "-"}
{amount.toLocaleString()} sats
</p>
{payment.status && payment.status !== 'completed' && (
<p className="text-xs text-muted-foreground capitalize">
{payment.status}
</p>
)}
</div>
</div>
);
}
function PaymentSkeleton() {
return (
<div className="flex items-center justify-between py-3 border-b">
<div className="flex items-center gap-3">
<Skeleton className="w-8 h-8 rounded-full" />
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<div className="text-right">
<Skeleton className="h-4 w-24 mb-1" />
<Skeleton className="h-3 w-16 ml-auto" />
</div>
</div>
);
}
export function PaymentHistory({ limit, className }: PaymentHistoryProps) {
const {
payments,
isLoadingPayments,
hasMorePayments,
loadMorePayments,
refreshPayments,
isInitialized,
} = useSparkWallet();
const [selectedPayment, setSelectedPayment] = useState<BreezPaymentInfo | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handlePaymentClick = (payment: BreezPaymentInfo) => {
setSelectedPayment(payment);
setDetailDialogOpen(true);
};
const handleRefresh = async () => {
setIsManualRefresh(true);
try {
await refreshPayments();
} finally {
setIsManualRefresh(false);
}
};
// If limit is specified, only show that many; otherwise show all loaded payments
const displayPayments = limit ? payments.slice(0, limit) : payments;
if (!isInitialized) {
return (
<Card className={className}>
<CardContent className="py-6 text-center">
<p className="text-muted-foreground text-sm">
Connect wallet to view history
</p>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Recent Transactions</CardTitle>
{!limit && displayPayments.length > 0 && (
<p className="text-xs text-muted-foreground">
Showing {displayPayments.length} payments
</p>
)}
</div>
<button
onClick={handleRefresh}
disabled={isManualRefresh}
className="inline-flex min-w-24 items-center justify-end gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground disabled:opacity-70"
>
{isManualRefresh ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>Refresh</span>
</button>
</div>
</CardHeader>
<CardContent>
{isLoadingPayments && payments.length === 0 ? (
<div className="space-y-0">
{[1, 2, 3].map((i) => (
<PaymentSkeleton key={i} />
))}
</div>
) : displayPayments.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground text-sm">No payments yet</p>
<p className="text-xs text-muted-foreground mt-1">
Your transaction history will appear here
</p>
</div>
) : (
<>
<div className="space-y-0">
{displayPayments.map((payment) => (
<PaymentItem
key={payment.id}
payment={payment}
onClick={() => handlePaymentClick(payment)}
/>
))}
</div>
{/* Load More button - only show when not limiting and there are more payments */}
{!limit && hasMorePayments && (
<Button
variant="ghost"
onClick={loadMorePayments}
disabled={isLoadingPayments}
className="w-full mt-3 text-sm"
>
{isLoadingPayments ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</>
) : (
"Load More"
)}
</Button>
)}
</>
)}
</CardContent>
{/* Payment Detail Dialog */}
<PaymentDetailDialog
payment={selectedPayment}
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
/>
</Card>
);
}
@@ -1,383 +0,0 @@
/**
* Receive Payment Component
* Shows QR codes and addresses for receiving payments
* Auto-detects when invoice is paid via SDK events
*/
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Copy,
Check,
Zap,
Bitcoin,
Loader2,
CheckCircle2,
RefreshCw,
AtSign,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useToast } from "@/hooks/useToast";
import { Link } from "react-router-dom";
import QRCode from "qrcode";
import type { SdkEvent } from "@breeztech/breez-sdk-spark/web";
interface ReceivePaymentProps {
defaultAmount?: number;
onClose?: () => void;
}
export function ReceivePayment({
defaultAmount,
onClose,
}: ReceivePaymentProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("lightning");
const [amount, setAmount] = useState(defaultAmount?.toString() ?? "");
const [description, setDescription] = useState("");
const [invoice, setInvoice] = useState<string | null>(null);
const [bitcoinAddress, setBitcoinAddress] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [isPaid, setIsPaid] = useState(false);
const [paidAmount, setPaidAmount] = useState<number | null>(null);
const {
createInvoice,
getBitcoinAddress,
isInitialized,
subscribeToPaymentEvents,
refreshBalance,
lightningAddress,
getLightningAddress,
} = useSparkWallet();
const { toast } = useToast();
// Fetch addresses on mount
useEffect(() => {
if (isInitialized) {
getBitcoinAddress().then(setBitcoinAddress).catch(console.error);
// Fetch lightning address if not already loaded
if (!lightningAddress) {
getLightningAddress();
}
}
}, [isInitialized, getBitcoinAddress, lightningAddress, getLightningAddress]);
// Subscribe to payment events to detect when invoice is paid
useEffect(() => {
if (!isInitialized || !invoice) return;
const handlePaymentEvent = (event: SdkEvent) => {
console.log("[ReceivePayment] Got SDK event:", event.type);
if (event.type === "paymentSucceeded") {
// A payment was received! Check if it matches our invoice amount
const receivedAmount = parseInt(amount);
// Show paid state
setIsPaid(true);
setPaidAmount(receivedAmount);
// Refresh balance
refreshBalance();
// Show success toast
toast({
title: "Payment received!",
description: `You received ${receivedAmount.toLocaleString()} sats`,
});
}
};
const unsubscribe = subscribeToPaymentEvents(handlePaymentEvent);
return () => {
unsubscribe();
};
}, [
isInitialized,
invoice,
amount,
subscribeToPaymentEvents,
refreshBalance,
toast,
]);
// Generate QR code when content changes
useEffect(() => {
const generateQR = async () => {
let content: string | null = null;
if (activeTab === "lightning" && invoice) {
content = invoice;
} else if (activeTab === "lnaddress" && lightningAddress) {
content = lightningAddress;
} else if (activeTab === "bitcoin" && bitcoinAddress) {
content = bitcoinAddress;
}
if (content) {
try {
const url = await QRCode.toDataURL(content.toUpperCase(), {
width: 256,
margin: 2,
color: { dark: "#000000", light: "#FFFFFF" },
});
setQrCodeUrl(url);
} catch (error) {
console.error("Failed to generate QR code:", error);
}
} else {
setQrCodeUrl(null);
}
};
generateQR();
}, [activeTab, invoice, lightningAddress, bitcoinAddress]);
const handleGenerateInvoice = async () => {
const amountSat = parseInt(amount);
if (!amountSat || amountSat <= 0) {
toast({
title: "Invalid amount",
description: "Please enter a valid amount in sats",
variant: "destructive",
});
return;
}
setIsGenerating(true);
try {
const newInvoice = await createInvoice(
amountSat,
description || "Payment",
);
setInvoice(newInvoice);
} catch (error) {
toast({
title: "Failed to create invoice",
description: error instanceof Error ? error.message : "Unknown error",
variant: "destructive",
});
} finally {
setIsGenerating(false);
}
};
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: "Copied to clipboard" });
setTimeout(() => setCopied(false), 2000);
};
const getCurrentAddress = () => {
if (activeTab === "lightning") return invoice;
if (activeTab === "lnaddress") return lightningAddress;
if (activeTab === "bitcoin") return bitcoinAddress;
return null;
};
const currentAddress = getCurrentAddress();
return (
<div className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="lightning">
<Zap className="h-4 w-4 mr-1" />
Invoice
</TabsTrigger>
<TabsTrigger value="lnaddress">
<AtSign className="h-4 w-4 mr-1" />
Address
</TabsTrigger>
<TabsTrigger value="bitcoin">
<Bitcoin className="h-4 w-4 mr-1" />
Bitcoin
</TabsTrigger>
</TabsList>
<TabsContent value="lightning" className="space-y-4 mt-4">
{isPaid ? (
// Payment received - show success state
<div className="space-y-4">
<div className="text-center py-8">
<div className="flex justify-center mb-4">
<div className="rounded-full bg-primary/10 p-4">
<CheckCircle2 className="h-12 w-12 text-primary" />
</div>
</div>
<h3 className="text-xl font-bold text-primary mb-2">
Payment Received!
</h3>
<p className="text-3xl font-bold mb-1">
{(paidAmount ?? parseInt(amount)).toLocaleString()} sats
</p>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
<Button
onClick={() => {
setIsPaid(false);
setPaidAmount(null);
setInvoice(null);
setAmount("");
setDescription("");
}}
className="w-full"
>
<RefreshCw className="h-4 w-4 mr-2" />
Receive Another Payment
</Button>
</div>
) : !invoice ? (
<div className="space-y-4">
<div>
<Label htmlFor="amount">{t('wallet2.amount')} (sats)</Label>
<Input
id="amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onWheel={(e) => (e.target as HTMLInputElement).blur()}
placeholder={t('forms.enterAmount')}
/>
</div>
<div>
<Label htmlFor="description">Description (optional)</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What's this payment for?"
/>
</div>
<Button
onClick={handleGenerateInvoice}
disabled={isGenerating}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
t('wallet2.generateInvoice')
)}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">
Waiting for payment...
</p>
<p className="text-2xl font-bold">
{parseInt(amount).toLocaleString()} sats
</p>
</div>
<Button
variant="outline"
onClick={() => setInvoice(null)}
className="w-full"
>
Create New Invoice
</Button>
</div>
)}
</TabsContent>
<TabsContent value="lnaddress" className="mt-4">
{lightningAddress ? (
<p className="text-sm text-muted-foreground text-center mb-4">
Share your Lightning Address to receive payments
</p>
) : (
<div className="space-y-4">
<Alert>
<AtSign className="h-4 w-4" />
<AlertDescription>
You don't have a Lightning Address yet. Set one up in wallet
settings to receive payments easily.
</AlertDescription>
</Alert>
<Link to="/settings?tab=wallet">
<Button variant="outline" className="w-full">
Set Up Lightning Address
</Button>
</Link>
</div>
)}
</TabsContent>
<TabsContent value="bitcoin" className="mt-4">
<p className="text-sm text-muted-foreground text-center mb-4">
Receive on-chain Bitcoin (may take longer to confirm)
</p>
</TabsContent>
</Tabs>
{/* QR Code Display - hide when paid */}
{!isPaid && (currentAddress || qrCodeUrl) && (
<Card>
<CardContent className="pt-4">
{qrCodeUrl ? (
<div className="flex justify-center">
<img src={qrCodeUrl} alt="QR Code" className="w-48 h-48" />
</div>
) : (
<div className="flex justify-center">
<div className="w-48 h-48 bg-muted animate-pulse rounded" />
</div>
)}
{currentAddress && (
<div className="mt-4 space-y-2">
<Input
value={currentAddress}
readOnly
className="font-mono text-xs"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
onClick={() => handleCopy(currentAddress)}
className="w-full"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2 text-primary" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy Address
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
)}
{onClose && (
<Button variant="ghost" onClick={onClose} className="w-full">
Close
</Button>
)}
</div>
);
}
@@ -1,449 +0,0 @@
/**
* Restore Wallet Component
* Handles wallet restoration from mnemonic, relay backup, or file
*
* Security: Implements rate limiting with exponential backoff on failed
* restore attempts to prevent brute-force attacks.
*/
import { useState, useRef, useEffect, useCallback } from "react";
import {
Loader2,
Key,
Cloud,
FileUp,
AlertCircle,
Clock,
Calendar,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { MnemonicInput } from "./MnemonicInput";
import { WasmUnsupportedError } from "./WasmUnsupportedError";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { checkWasmSupport } from "@/lib/checkWasmSupport";
import {
checkRestoreRateLimit,
recordFailedRestoreAttempt,
recordSuccessfulRestore,
formatLockoutTime,
} from "@/lib/spark/rateLimiter";
interface RestoreWalletProps {
onComplete?: () => void;
onCancel?: () => void;
}
export function RestoreWallet({ onComplete, onCancel }: RestoreWalletProps) {
const [activeTab, setActiveTab] = useState("relay");
const [mnemonic, setMnemonic] = useState("");
const [isRestoring, setIsRestoring] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// WASM support check
const [wasmSupported, setWasmSupported] = useState<boolean | null>(null);
const [wasmError, setWasmError] = useState<string | null>(null);
// Rate limiting state
const [isRateLimited, setIsRateLimited] = useState(false);
const [rateLimitSeconds, setRateLimitSeconds] = useState(0);
const [failedAttempts, setFailedAttempts] = useState(0);
const rateLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const {
restoreFromMnemonic,
restoreFromRelay,
restoreFromFile,
hasBackup,
backupTimestamp,
} = useSparkWallet();
const { user } = useCurrentUser();
// Check WASM support on mount
useEffect(() => {
checkWasmSupport().then((result) => {
setWasmSupported(result.supported);
if (!result.supported) {
setWasmError(result.reason || "WebAssembly is not supported");
}
});
}, []);
// Check rate limit on mount and update state
const checkRateLimit = useCallback(() => {
const {
isLimited,
remainingSeconds,
failedAttempts: attempts,
} = checkRestoreRateLimit();
setIsRateLimited(isLimited);
setRateLimitSeconds(remainingSeconds);
setFailedAttempts(attempts);
return isLimited;
}, []);
// Start countdown timer when rate limited
useEffect(() => {
// Check rate limit on mount
checkRateLimit();
// Clear any existing timer
if (rateLimitTimerRef.current) {
clearInterval(rateLimitTimerRef.current);
rateLimitTimerRef.current = null;
}
if (isRateLimited && rateLimitSeconds > 0) {
rateLimitTimerRef.current = setInterval(() => {
setRateLimitSeconds((prev) => {
if (prev <= 1) {
// Timer expired, check rate limit again
if (rateLimitTimerRef.current) {
clearInterval(rateLimitTimerRef.current);
rateLimitTimerRef.current = null;
}
setIsRateLimited(false);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => {
if (rateLimitTimerRef.current) {
clearInterval(rateLimitTimerRef.current);
rateLimitTimerRef.current = null;
}
};
// rateLimitSeconds is intentionally excluded - we only want to start timer when isRateLimited changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRateLimited, checkRateLimit]);
const handleRestoreFromMnemonic = async () => {
if (!mnemonic) return;
// Check rate limit before attempting
if (checkRateLimit()) {
return;
}
setIsRestoring(true);
setError(null);
try {
await restoreFromMnemonic(mnemonic);
// Success - clear rate limit state
recordSuccessfulRestore();
setMnemonic(""); // Clear from memory
onComplete?.();
} catch (err) {
// Record failed attempt and apply rate limiting
const {
isLocked,
lockoutSeconds,
failedAttempts: attempts,
} = recordFailedRestoreAttempt();
setFailedAttempts(attempts);
if (isLocked) {
setIsRateLimited(true);
setRateLimitSeconds(lockoutSeconds);
setError(
`Too many failed attempts. Please wait ${formatLockoutTime(lockoutSeconds)} before trying again.`,
);
} else {
setError(
err instanceof Error ? err.message : "Failed to restore wallet",
);
}
} finally {
setIsRestoring(false);
}
};
const handleRestoreFromRelay = async () => {
if (!user) {
setError("You must be logged in to restore from relay backup");
return;
}
setIsRestoring(true);
setError(null);
try {
await restoreFromRelay();
onComplete?.();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to restore from relay",
);
} finally {
setIsRestoring(false);
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!user) {
setError("You must be logged in to restore from file");
return;
}
setIsRestoring(true);
setError(null);
try {
await restoreFromFile(file);
onComplete?.();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to restore from file",
);
} finally {
setIsRestoring(false);
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
// Show loading while checking WASM support
if (wasmSupported === null) {
return (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Checking browser compatibility...
</p>
</div>
</CardContent>
</Card>
);
}
// Show error if WASM is not supported
if (wasmSupported === false) {
return (
<WasmUnsupportedError
technicalDetails={wasmError ?? undefined}
onBack={onCancel}
/>
);
}
return (
<Card>
<CardHeader className="text-center">
<CardTitle>Restore Wallet</CardTitle>
<CardDescription>
Restore your existing Spark wallet using one of the methods below.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="relay" className="text-xs" disabled={!user}>
<Cloud className="h-3 w-3 mr-1" />
Relay
</TabsTrigger>
<TabsTrigger value="file" className="text-xs" disabled={!user}>
<FileUp className="h-3 w-3 mr-1" />
File
</TabsTrigger>
<TabsTrigger value="mnemonic" className="text-xs">
<Key className="h-3 w-3 mr-1" />
Phrase
</TabsTrigger>
</TabsList>
<TabsContent value="mnemonic" className="space-y-4 mt-4">
{/* Rate limit warning */}
{isRateLimited && (
<Alert variant="destructive">
<Clock className="h-4 w-4" />
<AlertDescription>
Too many failed attempts. Please wait{" "}
<span className="font-mono font-bold">
{formatLockoutTime(rateLimitSeconds)}
</span>{" "}
before trying again.
</AlertDescription>
</Alert>
)}
{/* Failed attempts warning (before lockout) */}
{!isRateLimited && failedAttempts > 0 && failedAttempts < 3 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{failedAttempts} failed attempt{failedAttempts > 1 ? "s" : ""}
.
{failedAttempts === 2 &&
" One more failed attempt will trigger a temporary lockout."}
</AlertDescription>
</Alert>
)}
<MnemonicInput
value={mnemonic}
onChange={setMnemonic}
error={
activeTab === "mnemonic" && !isRateLimited
? (error ?? undefined)
: undefined
}
/>
<Button
onClick={handleRestoreFromMnemonic}
disabled={
isRestoring ||
isRateLimited ||
mnemonic.split(/\s+/).filter((w) => w).length !== 12
}
className="w-full"
>
{isRestoring ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Restoring...
</>
) : isRateLimited ? (
<>
<Clock className="h-4 w-4 mr-2" />
Wait {formatLockoutTime(rateLimitSeconds)}
</>
) : (
"Restore Wallet"
)}
</Button>
</TabsContent>
<TabsContent value="relay" className="space-y-4 mt-4">
{!user ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
You must be logged in to restore from relay backup.
</AlertDescription>
</Alert>
) : (
<>
<div className="text-center py-6">
<Cloud className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground">
{hasBackup
? "A backup was found on Nostr relays. Click below to restore."
: "Searching for encrypted backup on Nostr relays..."}
</p>
{hasBackup && backupTimestamp && (
<p className="text-xs text-muted-foreground mt-2 flex items-center justify-center gap-1">
<Calendar className="h-3 w-3" />
Backed up{" "}
{new Date(backupTimestamp * 1000).toLocaleDateString()}
</p>
)}
</div>
{error && activeTab === "relay" && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleRestoreFromRelay}
disabled={isRestoring || !hasBackup}
className="w-full"
>
{isRestoring ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Restoring...
</>
) : (
"Restore from Relay"
)}
</Button>
</>
)}
</TabsContent>
<TabsContent value="file" className="space-y-4 mt-4">
{!user ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
You must be logged in to restore from file backup.
</AlertDescription>
</Alert>
) : (
<>
<div className="text-center py-6">
<FileUp className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground">
Select your encrypted backup file to restore.
</p>
</div>
{error && activeTab === "file" && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
disabled={isRestoring}
className="w-full"
>
{isRestoring ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Restoring...
</>
) : (
<>
<FileUp className="h-4 w-4 mr-2" />
Select Backup File
</>
)}
</Button>
</>
)}
</TabsContent>
</Tabs>
{onCancel && (
<Button variant="ghost" onClick={onCancel} className="w-full mt-4">
Cancel
</Button>
)}
</CardContent>
</Card>
);
}
-459
View File
@@ -1,459 +0,0 @@
/**
* Send Payment Component
* Handles sending Lightning and Bitcoin on-chain payments
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Zap, Send, AlertCircle, ScanLine, Bitcoin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { useToast } from '@/hooks/useToast';
import { useBarcodeScanner } from '@/hooks/useBarcodeScanner';
/** On-chain confirmation speed options */
type OnchainConfirmationSpeed = 'fast' | 'medium' | 'slow';
/** Fee quote for a specific confirmation speed */
interface SpeedFeeQuote {
userFeeSat: number;
l1BroadcastFeeSat: number;
}
/** Complete fee quote for on-chain send */
interface OnchainFeeQuote {
id: string;
expiresAt: number;
speedFast: SpeedFeeQuote;
speedMedium: SpeedFeeQuote;
speedSlow: SpeedFeeQuote;
}
interface SendPaymentProps {
defaultInvoice?: string;
defaultAddress?: string;
defaultAmount?: number;
onSuccess?: () => void;
onClose?: () => void;
}
export function SendPayment({
defaultInvoice,
defaultAddress,
defaultAmount,
onSuccess,
onClose,
}: SendPaymentProps) {
const { t } = useTranslation();
const [input, setInput] = useState(defaultInvoice ?? defaultAddress ?? '');
const [amount, setAmount] = useState(defaultAmount?.toString() ?? '');
const [comment, setComment] = useState('');
const [parsedType, setParsedType] = useState<string | null>(null);
const [parsedAmount, setParsedAmount] = useState<number | null>(null);
const [isParsing, setIsParsing] = useState(false);
const [isSending, setIsSending] = useState(false);
const [error, setError] = useState<string | null>(null);
// On-chain specific state
const [onchainFeeQuote, setOnchainFeeQuote] = useState<OnchainFeeQuote | null>(null);
const [confirmationSpeed, setConfirmationSpeed] = useState<OnchainConfirmationSpeed>('medium');
const [preparedOnchainPayment, setPreparedOnchainPayment] = useState<unknown>(null);
const { payInvoice, payLightningAddress, payBitcoinAddress, parseInput, prepareBitcoinPayment, balance, isInitialized } = useSparkWallet();
const { toast } = useToast();
const { scanBarcode, isScanning: isScanningQR, isSupported: isScannerSupported } = useBarcodeScanner();
// Parse input when it changes
useEffect(() => {
const parse = async () => {
if (!input.trim() || !isInitialized) {
setParsedType(null);
setParsedAmount(null);
setOnchainFeeQuote(null);
setPreparedOnchainPayment(null);
return;
}
setIsParsing(true);
setError(null);
setOnchainFeeQuote(null);
setPreparedOnchainPayment(null);
try {
const result = await parseInput(input.trim());
setParsedType(result.type);
if (result.amountSat) {
setParsedAmount(result.amountSat);
setAmount(result.amountSat.toString());
} else {
setParsedAmount(null);
}
// For Bitcoin addresses, prepare the payment to get fee quotes
if (result.type === 'bitcoinAddress') {
try {
const prepared = await prepareBitcoinPayment(input.trim());
setPreparedOnchainPayment(prepared);
// Extract fee quote from prepared response
const paymentMethod = (prepared as { paymentMethod?: { feeQuote?: OnchainFeeQuote } })?.paymentMethod;
if (paymentMethod?.feeQuote) {
setOnchainFeeQuote(paymentMethod.feeQuote);
}
} catch (prepareError) {
console.warn('[SendPayment] Failed to prepare Bitcoin payment:', prepareError);
// Don't set error - user can still see it's a valid address
}
}
} catch {
setParsedType('unknown');
setError('Could not parse input');
} finally {
setIsParsing(false);
}
};
const debounce = setTimeout(parse, 500);
return () => clearTimeout(debounce);
}, [input, isInitialized, parseInput, prepareBitcoinPayment]);
const handleSend = async () => {
if (!input.trim()) return;
const amountSat = parseInt(amount);
// Validate balance
if (parsedType === 'bolt11Invoice') {
const sendAmount = parsedAmount ?? amountSat;
if (sendAmount > balance) {
setError('Insufficient balance');
return;
}
} else if (parsedType === 'lnurlPay' || parsedType === 'lightningAddress') {
if (!amountSat || amountSat <= 0) {
setError('Please enter an amount');
return;
}
if (amountSat > balance) {
setError('Insufficient balance');
return;
}
} else if (parsedType === 'bitcoinAddress') {
if (!amountSat || amountSat <= 0) {
setError('Please enter an amount');
return;
}
// Check balance including estimated fee
const estimatedFee = getSelectedFee();
if (amountSat + estimatedFee > balance) {
setError(`Insufficient balance (need ${(amountSat + estimatedFee).toLocaleString()} sats including fee)`);
return;
}
}
setIsSending(true);
setError(null);
try {
if (parsedType === 'bolt11Invoice') {
await payInvoice(input.trim());
toast({
title: 'Payment sent',
description: `Sent ${parsedAmount?.toLocaleString() ?? amountSat.toLocaleString()} sats`,
});
} else if (parsedType === 'lnurlPay' || parsedType === 'lightningAddress') {
await payLightningAddress(input.trim(), amountSat, comment || undefined);
toast({
title: 'Payment sent',
description: `Sent ${amountSat.toLocaleString()} sats to ${input.trim()}`,
});
} else if (parsedType === 'bitcoinAddress') {
// Use the prepared payment response if available, otherwise prepare fresh
let prepared = preparedOnchainPayment;
if (!prepared) {
prepared = await prepareBitcoinPayment(input.trim(), amountSat);
}
await payBitcoinAddress(prepared, confirmationSpeed);
toast({
title: 'Bitcoin payment sent',
description: `Sent ${amountSat.toLocaleString()} sats on-chain to ${input.trim().slice(0, 12)}...`,
});
} else {
throw new Error('Unsupported payment type');
}
setInput('');
setAmount('');
setComment('');
setOnchainFeeQuote(null);
setPreparedOnchainPayment(null);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Payment failed');
} finally {
setIsSending(false);
}
};
/** Get the fee for the currently selected confirmation speed */
const getSelectedFee = (): number => {
if (!onchainFeeQuote) return 0;
switch (confirmationSpeed) {
case 'fast':
return onchainFeeQuote.speedFast.userFeeSat + onchainFeeQuote.speedFast.l1BroadcastFeeSat;
case 'medium':
return onchainFeeQuote.speedMedium.userFeeSat + onchainFeeQuote.speedMedium.l1BroadcastFeeSat;
case 'slow':
return onchainFeeQuote.speedSlow.userFeeSat + onchainFeeQuote.speedSlow.l1BroadcastFeeSat;
default:
return 0;
}
};
const needsAmount = parsedType === 'lnurlPay' || parsedType === 'lightningAddress' || parsedType === 'bitcoinAddress' || (parsedType === 'bolt11Invoice' && !parsedAmount);
const getInputLabel = () => {
switch (parsedType) {
case 'bolt11Invoice':
return t('wallet2.lightningInvoice');
case 'lnurlPay':
return 'LNURL';
case 'lightningAddress':
return 'Lightning Address';
case 'bitcoinAddress':
return 'Bitcoin Address';
default:
return 'Invoice or Address';
}
};
const handleScanQR = async () => {
const result = await scanBarcode();
if (result?.text) {
setInput(result.text);
}
};
return (
<div className="space-y-4">
<Card>
<CardContent className="pt-4 space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Available balance:</span>
<span className="font-medium">{balance.toLocaleString()} sats</span>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="input">{getInputLabel()}</Label>
{isScannerSupported && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleScanQR}
disabled={isScanningQR}
className="h-8 px-2"
>
{isScanningQR ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Scanning...
</>
) : (
<>
<ScanLine className="h-4 w-4 mr-1" />
Scan QR
</>
)}
</Button>
)}
</div>
<Textarea
id="input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Paste Lightning invoice, Lightning address, Bitcoin address, or LNURL..."
rows={3}
className="font-mono text-xs"
/>
{isParsing && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Parsing...
</p>
)}
{parsedType && !isParsing && parsedType !== 'unknown' && (
<p className="text-xs text-primary flex items-center gap-1">
{parsedType === 'bitcoinAddress' ? (
<Bitcoin className="h-3 w-3" />
) : (
<Zap className="h-3 w-3" />
)}
{parsedType === 'bolt11Invoice' && 'Valid Lightning invoice'}
{parsedType === 'lnurlPay' && 'Valid LNURL'}
{parsedType === 'lightningAddress' && 'Valid Lightning address'}
{parsedType === 'bitcoinAddress' && 'Valid Bitcoin address (on-chain)'}
</p>
)}
</div>
{needsAmount && (
<div className="space-y-2">
<Label htmlFor="amount">{t('wallet2.amount')} (sats)</Label>
<Input
id="amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
onWheel={(e) => (e.target as HTMLInputElement).blur()}
placeholder="Enter amount"
disabled={parsedType === 'bolt11Invoice' && !!parsedAmount}
/>
</div>
)}
{parsedAmount && parsedType === 'bolt11Invoice' && (
<div className="text-center py-2">
<p className="text-2xl font-bold">{parsedAmount.toLocaleString()} sats</p>
<p className="text-sm text-muted-foreground">Invoice amount</p>
</div>
)}
{(parsedType === 'lnurlPay' || parsedType === 'lightningAddress') && (
<div className="space-y-2">
<Label htmlFor="comment">Comment (optional)</Label>
<Input
id="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Add a message"
/>
</div>
)}
{/* On-chain fee selection */}
{parsedType === 'bitcoinAddress' && onchainFeeQuote && (
<div className="space-y-3">
<Label>Confirmation Speed</Label>
<RadioGroup
value={confirmationSpeed}
onValueChange={(value) => setConfirmationSpeed(value as OnchainConfirmationSpeed)}
className="grid grid-cols-3 gap-2"
>
<div className="relative">
<RadioGroupItem
value="fast"
id="speed-fast"
className="peer sr-only"
/>
<Label
htmlFor="speed-fast"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-3 hover:bg-primary/10 peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<span className="text-xs font-medium">Fast</span>
<span className="text-xs text-muted-foreground">~10 min</span>
<span className="text-xs font-semibold">
{(onchainFeeQuote.speedFast.userFeeSat + onchainFeeQuote.speedFast.l1BroadcastFeeSat).toLocaleString()} sats
</span>
</Label>
</div>
<div className="relative">
<RadioGroupItem
value="medium"
id="speed-medium"
className="peer sr-only"
/>
<Label
htmlFor="speed-medium"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-3 hover:bg-primary/10 peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<span className="text-xs font-medium">Medium</span>
<span className="text-xs text-muted-foreground">~30 min</span>
<span className="text-xs font-semibold">
{(onchainFeeQuote.speedMedium.userFeeSat + onchainFeeQuote.speedMedium.l1BroadcastFeeSat).toLocaleString()} sats
</span>
</Label>
</div>
<div className="relative">
<RadioGroupItem
value="slow"
id="speed-slow"
className="peer sr-only"
/>
<Label
htmlFor="speed-slow"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-3 hover:bg-primary/10 peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
>
<span className="text-xs font-medium">Slow</span>
<span className="text-xs text-muted-foreground">~1 hour</span>
<span className="text-xs font-semibold">
{(onchainFeeQuote.speedSlow.userFeeSat + onchainFeeQuote.speedSlow.l1BroadcastFeeSat).toLocaleString()} sats
</span>
</Label>
</div>
</RadioGroup>
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-2">
<span className="font-medium">Network fee:</span>{' '}
{getSelectedFee().toLocaleString()} sats
</div>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleSend}
disabled={
isSending ||
isParsing ||
!parsedType ||
parsedType === 'unknown' ||
(needsAmount && (!amount || parseInt(amount) <= 0)) ||
(parsedType === 'bitcoinAddress' && !onchainFeeQuote)
}
className="w-full"
>
{isSending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
{parsedType === 'bitcoinAddress' ? (
<Bitcoin className="h-4 w-4 mr-2" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
{parsedType === 'bitcoinAddress' ? 'Send On-Chain' : 'Send Payment'}
</>
)}
</Button>
</CardContent>
</Card>
{onClose && (
<Button variant="ghost" onClick={onClose} className="w-full">
Cancel
</Button>
)}
</div>
);
}
@@ -1,452 +0,0 @@
/**
* Unclaimed Deposits Component
* Displays on-chain deposits that need manual claiming
*/
import { useState, useEffect } from 'react';
import { AlertTriangle, Bitcoin, Loader2, RefreshCw, ArrowRight, ExternalLink } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { cn } from '@/lib/utils';
import type { UnclaimedDepositInfo, RecommendedFeesInfo } from '@/lib/spark/breezService';
interface UnclaimedDepositsProps {
className?: string;
}
interface ClaimDialogState {
isOpen: boolean;
deposit: UnclaimedDepositInfo | null;
recommendedFees: RecommendedFeesInfo | null;
isLoading: boolean;
isClaiming: boolean;
}
interface RefundDialogState {
isOpen: boolean;
deposit: UnclaimedDepositInfo | null;
destinationAddress: string;
isRefunding: boolean;
}
function DepositItem({
deposit,
onClaim,
onRefund
}: {
deposit: UnclaimedDepositInfo;
onClaim: () => void;
onRefund: () => void;
}) {
const shortTxid = `${deposit.txid.slice(0, 8)}...${deposit.txid.slice(-8)}`;
return (
<div className="flex flex-col gap-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center">
<Bitcoin className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<p className="font-medium">
{deposit.amountSats.toLocaleString()} sats
</p>
<a
href={`https://mempool.space/tx/${deposit.txid}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
>
{shortTxid}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
<Badge variant="outline" className="text-orange-600 border-orange-300">
Pending
</Badge>
</div>
{deposit.claimError && (
<Alert variant="default" className="bg-orange-50 dark:bg-orange-950/30 border-orange-200 dark:border-orange-800">
<AlertTriangle className="h-4 w-4 text-orange-600" />
<AlertDescription className="text-sm">
{deposit.claimError.type === 'maxDepositClaimFeeExceeded' && (
<>
Auto-claim failed: network fee ({deposit.claimError.requiredFeeRateSatPerVbyte} sat/vB)
exceeds wallet limit. Manual approval required.
</>
)}
{deposit.claimError.type === 'missingUtxo' && (
<>UTXO not found. The deposit may still be confirming.</>
)}
{deposit.claimError.type === 'generic' && (
<>{deposit.claimError.message || 'An error occurred while claiming.'}</>
)}
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
onClick={onClaim}
className="flex-1"
size="sm"
>
<ArrowRight className="h-4 w-4 mr-2" />
Claim Now
</Button>
<Button
onClick={onRefund}
variant="outline"
size="sm"
>
Refund
</Button>
</div>
</div>
);
}
export function UnclaimedDeposits({ className }: UnclaimedDepositsProps) {
const {
unclaimedDeposits,
isLoadingDeposits,
refreshUnclaimedDeposits,
getRecommendedFees,
claimDeposit,
refundDeposit,
isInitialized,
} = useSparkWallet();
const [claimDialog, setClaimDialog] = useState<ClaimDialogState>({
isOpen: false,
deposit: null,
recommendedFees: null,
isLoading: false,
isClaiming: false,
});
const [refundDialog, setRefundDialog] = useState<RefundDialogState>({
isOpen: false,
deposit: null,
destinationAddress: '',
isRefunding: false,
});
// Refresh unclaimed deposits when wallet initializes
useEffect(() => {
if (isInitialized) {
refreshUnclaimedDeposits();
}
}, [isInitialized, refreshUnclaimedDeposits]);
const handleOpenClaimDialog = async (deposit: UnclaimedDepositInfo) => {
setClaimDialog({
isOpen: true,
deposit,
recommendedFees: null,
isLoading: true,
isClaiming: false,
});
try {
const fees = await getRecommendedFees();
setClaimDialog(prev => ({
...prev,
recommendedFees: fees,
isLoading: false,
}));
} catch (error) {
console.error('Failed to get recommended fees:', error);
setClaimDialog(prev => ({
...prev,
isLoading: false,
}));
}
};
const handleClaim = async (useNetworkFee: boolean = false) => {
if (!claimDialog.deposit) return;
setClaimDialog(prev => ({ ...prev, isClaiming: true }));
try {
if (useNetworkFee) {
await claimDeposit(
claimDialog.deposit.txid,
claimDialog.deposit.vout,
claimDialog.deposit.claimError?.requiredFeeSats || 0
);
} else {
// Use the required fee from the error
const requiredFee = claimDialog.deposit.claimError?.requiredFeeSats || 0;
await claimDeposit(
claimDialog.deposit.txid,
claimDialog.deposit.vout,
requiredFee
);
}
setClaimDialog({
isOpen: false,
deposit: null,
recommendedFees: null,
isLoading: false,
isClaiming: false,
});
} catch (error) {
console.error('Failed to claim deposit:', error);
setClaimDialog(prev => ({ ...prev, isClaiming: false }));
}
};
const handleOpenRefundDialog = (deposit: UnclaimedDepositInfo) => {
setRefundDialog({
isOpen: true,
deposit,
destinationAddress: '',
isRefunding: false,
});
};
const handleRefund = async () => {
if (!refundDialog.deposit || !refundDialog.destinationAddress) return;
setRefundDialog(prev => ({ ...prev, isRefunding: true }));
try {
// Use economy fee rate for refunds
const fees = await getRecommendedFees();
await refundDeposit(
refundDialog.deposit.txid,
refundDialog.deposit.vout,
refundDialog.destinationAddress,
fees.economyFee
);
setRefundDialog({
isOpen: false,
deposit: null,
destinationAddress: '',
isRefunding: false,
});
} catch (error) {
console.error('Failed to refund deposit:', error);
setRefundDialog(prev => ({ ...prev, isRefunding: false }));
}
};
// Avoid inserting/removing a loading card below the balance during routine
// background syncs. Only render this section when there is something useful
// for the user to act on.
if (!isInitialized || unclaimedDeposits.length === 0) {
return null;
}
return (
<>
<Card className={cn('border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20', className)}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
<CardTitle className="text-lg">Pending Deposits</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refreshUnclaimedDeposits()}
disabled={isLoadingDeposits}
>
{isLoadingDeposits ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
<CardDescription>
These on-chain deposits need manual approval to claim
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{unclaimedDeposits.map((deposit) => (
<DepositItem
key={`${deposit.txid}:${deposit.vout}`}
deposit={deposit}
onClaim={() => handleOpenClaimDialog(deposit)}
onRefund={() => handleOpenRefundDialog(deposit)}
/>
))}
</CardContent>
</Card>
{/* Claim Dialog */}
<Dialog
open={claimDialog.isOpen}
onOpenChange={(open) => !open && setClaimDialog(prev => ({ ...prev, isOpen: false }))}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim On-Chain Deposit</DialogTitle>
<DialogDescription>
Approve the network fee to claim this deposit to your Lightning balance.
</DialogDescription>
</DialogHeader>
{claimDialog.deposit && (
<div className="space-y-4">
<div className="p-4 bg-muted rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-medium">{claimDialog.deposit.amountSats.toLocaleString()} sats</span>
</div>
{claimDialog.deposit.claimError?.requiredFeeSats && (
<div className="flex justify-between">
<span className="text-muted-foreground">Required Fee</span>
<span className="font-medium text-orange-600">
{claimDialog.deposit.claimError.requiredFeeSats.toLocaleString()} sats
</span>
</div>
)}
{claimDialog.deposit.claimError?.requiredFeeRateSatPerVbyte && (
<div className="flex justify-between">
<span className="text-muted-foreground">Fee Rate</span>
<span className="text-muted-foreground">
{claimDialog.deposit.claimError.requiredFeeRateSatPerVbyte} sat/vB
</span>
</div>
)}
<div className="flex justify-between pt-2 border-t">
<span className="text-muted-foreground">You'll Receive</span>
<span className="font-medium text-primary">
~{(claimDialog.deposit.amountSats - (claimDialog.deposit.claimError?.requiredFeeSats || 0)).toLocaleString()} sats
</span>
</div>
</div>
{claimDialog.isLoading ? (
<div className="py-4 text-center">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
<p className="text-sm text-muted-foreground mt-2">Loading fee estimates...</p>
</div>
) : claimDialog.recommendedFees && (
<div className="text-sm text-muted-foreground">
<p>Current network fees:</p>
<ul className="mt-1 space-y-1">
<li>Fastest: {claimDialog.recommendedFees.fastestFee} sat/vB</li>
<li>Normal: {claimDialog.recommendedFees.halfHourFee} sat/vB</li>
<li>Economy: {claimDialog.recommendedFees.economyFee} sat/vB</li>
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setClaimDialog(prev => ({ ...prev, isOpen: false }))}
disabled={claimDialog.isClaiming}
>
Cancel
</Button>
<Button
onClick={() => handleClaim(false)}
disabled={claimDialog.isClaiming || claimDialog.isLoading}
>
{claimDialog.isClaiming ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Claiming...
</>
) : (
'Approve & Claim'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Refund Dialog */}
<Dialog
open={refundDialog.isOpen}
onOpenChange={(open) => !open && setRefundDialog(prev => ({ ...prev, isOpen: false }))}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Refund Deposit</DialogTitle>
<DialogDescription>
Send this deposit back to a Bitcoin address. Network fees will be deducted.
</DialogDescription>
</DialogHeader>
{refundDialog.deposit && (
<div className="space-y-4">
<div className="p-4 bg-muted rounded-lg">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-medium">{refundDialog.deposit.amountSats.toLocaleString()} sats</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="destination">Destination Address</Label>
<Input
id="destination"
placeholder="bc1q..."
value={refundDialog.destinationAddress}
onChange={(e) => setRefundDialog(prev => ({
...prev,
destinationAddress: e.target.value
}))}
/>
<p className="text-xs text-muted-foreground">
Enter a Bitcoin address to receive the refund
</p>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setRefundDialog(prev => ({ ...prev, isOpen: false }))}
disabled={refundDialog.isRefunding}
>
Cancel
</Button>
<Button
onClick={handleRefund}
disabled={refundDialog.isRefunding || !refundDialog.destinationAddress}
variant="destructive"
>
{refundDialog.isRefunding ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Refunding...
</>
) : (
'Refund'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,227 +0,0 @@
/**
* Wallet Balance Component
* Displays wallet balance with refresh capability and USD/sats toggle
*
* Defaults to USD display. Click the dollar icon to switch to sats,
* click the lightning icon to switch back to USD.
*/
import { useState } from "react";
import { RefreshCw, Eye, EyeOff, Bitcoin, DollarSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useSatsToUsd } from "@/hooks/useExchangeRate";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { cn } from "@/lib/utils";
import { logger } from "@/lib/logger";
type DisplayMode = "usd" | "sats";
interface WalletBalanceProps {
className?: string;
showRefresh?: boolean;
compact?: boolean;
}
export function WalletBalance({
className,
showRefresh = true,
compact = false,
}: WalletBalanceProps) {
const [isHidden, setIsHidden] = useLocalStorage(
"wallet-balance-hidden",
false,
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [displayMode, setDisplayMode] = useState<DisplayMode>("usd");
const { balance, isInitialized, isConnecting, syncWallet, isSyncing } =
useSparkWallet();
const usdValue = useSatsToUsd(balance);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
// Use syncWallet to trigger full sync including checking for new deposits
await syncWallet();
} catch (error) {
logger.error("Failed to sync wallet:", error);
} finally {
setIsRefreshing(false);
}
};
const isLoading = isRefreshing || isSyncing;
const toggleDisplayMode = () => {
setDisplayMode((prev) => (prev === "usd" ? "sats" : "usd"));
};
const formatSatsBalance = (sats: number) => {
if (isHidden) return "••••••";
return sats.toLocaleString();
};
const formatUsdBalance = (usd: number | null) => {
if (isHidden) return "••••••";
if (usd === null) return "---";
return usd.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
};
if (isConnecting) {
return (
<Card className={className}>
<CardContent className={compact ? "py-3" : "py-6"}>
<Skeleton className="h-8 w-32 mx-auto" />
<Skeleton className="h-4 w-20 mx-auto mt-2" />
</CardContent>
</Card>
);
}
if (!isInitialized) {
return (
<Card className={className}>
<CardContent className={cn("text-center", compact ? "py-3" : "py-6")}>
<p className="text-muted-foreground text-sm">Wallet not connected</p>
</CardContent>
</Card>
);
}
if (compact) {
return (
<div className={cn("flex items-center gap-2", className)}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0"
onClick={toggleDisplayMode}
title={displayMode === "usd" ? "Switch to sats" : "Switch to USD"}
>
{displayMode === "usd" ? (
<DollarSign className="h-4 w-4 text-primary" />
) : (
<Bitcoin className="h-4 w-4 text-orange-500" />
)}
</Button>
<span className="font-medium">
{displayMode === "usd" ? (
<>${formatUsdBalance(usdValue)}</>
) : (
<>{formatSatsBalance(balance)} sats</>
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsHidden(!isHidden)}
>
{isHidden ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3" />
)}
</Button>
</div>
);
}
return (
<Card className={className}>
<CardContent className="py-6">
<div className="text-center">
{/* Main balance display */}
<div className="flex items-center justify-center gap-2 mb-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
onClick={toggleDisplayMode}
title={displayMode === "usd" ? "Switch to sats" : "Switch to USD"}
>
{displayMode === "usd" ? (
<DollarSign className="h-6 w-6 text-primary" />
) : (
<Bitcoin className="h-6 w-6 text-orange-500" />
)}
</Button>
{displayMode === "usd" ? (
<>
<span className="text-3xl font-bold">
${formatUsdBalance(usdValue)}
</span>
</>
) : (
<>
<span className="text-3xl font-bold">
{formatSatsBalance(balance)}
</span>
<span className="text-lg text-muted-foreground">sats</span>
</>
)}
</div>
{/* Secondary display (shows the other unit) */}
<div className="text-sm text-muted-foreground mb-4">
{displayMode === "usd" ? (
<>{formatSatsBalance(balance)} sats</>
) : usdValue !== null ? (
<> ${formatUsdBalance(usdValue)}</>
) : (
<Skeleton className="h-4 w-16 mx-auto" />
)}
</div>
{/* Syncing indicator when balance is zero */}
{balance === 0 && isSyncing && (
<div className="text-xs text-muted-foreground mb-2 flex items-center justify-center gap-1">
<RefreshCw className="h-3 w-3 animate-spin" />
Syncing wallet...
</div>
)}
<div className="flex items-center justify-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsHidden(!isHidden)}
>
{isHidden ? (
<>
<Eye className="h-4 w-4 mr-1" />
Show
</>
) : (
<>
<EyeOff className="h-4 w-4 mr-1" />
Hide
</>
)}
</Button>
{showRefresh && (
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<RefreshCw
className={cn("h-4 w-4 mr-1", isLoading && "animate-spin")}
/>
{isLoading ? "Syncing..." : "Sync"}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
@@ -1,87 +0,0 @@
/**
* Wallet Lock Screen Component
* Displays when the wallet is locked and allows unlocking via Nostr signer
*/
import { useState } from 'react';
import { Lock, Unlock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
interface WalletLockScreenProps {
className?: string;
}
export function WalletLockScreen({ className }: WalletLockScreenProps) {
const [isUnlocking, setIsUnlocking] = useState(false);
const [error, setError] = useState<string | null>(null);
const { unlockWallet } = useSparkWallet();
const { user } = useCurrentUser();
const handleUnlock = async () => {
if (!user) {
setError('You must be logged in to unlock your wallet.');
return;
}
setIsUnlocking(true);
setError(null);
try {
await unlockWallet();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unlock wallet');
} finally {
setIsUnlocking(false);
}
};
return (
<Card className={className}>
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-muted flex items-center justify-center">
<Lock className="h-8 w-8 text-muted-foreground" />
</div>
<CardTitle>Wallet Locked</CardTitle>
<CardDescription>
Your wallet has been locked for security.
{!user && ' Please log in to unlock.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleUnlock}
disabled={isUnlocking || !user}
className="w-full"
size="lg"
>
{isUnlocking ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Unlocking...
</>
) : (
<>
<Unlock className="h-4 w-4 mr-2" />
Unlock Wallet
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Unlocking will decrypt your wallet using your Nostr key.
</p>
</CardContent>
</Card>
);
}
@@ -1,117 +0,0 @@
/**
* WASM Unsupported Error Component
*
* Displays a friendly error message when WebAssembly is not supported
* in the user's browser (e.g., iOS Lockdown Mode, outdated browsers)
*/
import { AlertTriangle, ExternalLink } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
interface WasmUnsupportedErrorProps {
/** Error message from WASM check (optional) */
technicalDetails?: string;
/** Callback when user clicks "Go Back" button (optional) */
onBack?: () => void;
/** Show minimal version without card wrapper */
minimal?: boolean;
}
export function WasmUnsupportedError({
technicalDetails,
onBack,
minimal = false
}: WasmUnsupportedErrorProps) {
const content = (
<>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Browser Not Supported</AlertTitle>
<AlertDescription className="mt-2 space-y-3">
<p>
Your web browser does not support the technology required for this wallet (WebAssembly).
</p>
<div className="space-y-2">
<p className="font-medium">Common causes:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>
<strong>iOS Lockdown Mode:</strong> Disable in Settings Privacy & Security
</li>
<li>
<strong>Outdated browser:</strong> Update to the latest version of your browser
</li>
<li>
<strong>Privacy extensions:</strong> Some privacy tools disable WebAssembly
</li>
</ul>
</div>
<div className="space-y-2">
<p className="font-medium">Recommended browsers:</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Chrome, Firefox, Safari, or Edge (latest versions)</li>
<li>Make sure Lockdown Mode is disabled (iOS/macOS)</li>
</ul>
</div>
<div className="pt-2">
<a
href="https://developer.mozilla.org/en-US/docs/WebAssembly#browser_compatibility"
target="_blank"
rel="noopener noreferrer"
className="text-sm inline-flex items-center gap-1 hover:underline"
>
Learn more about browser compatibility
<ExternalLink className="h-3 w-3" />
</a>
</div>
</AlertDescription>
</Alert>
{technicalDetails && (
<div className="mt-4">
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer hover:text-foreground">
Technical details
</summary>
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-x-auto">
{technicalDetails}
</pre>
</details>
</div>
)}
{onBack && (
<div className="mt-4">
<Button variant="outline" onClick={onBack} className="w-full">
Go Back
</Button>
</div>
)}
</>
);
if (minimal) {
return <div className="space-y-4">{content}</div>;
}
return (
<Card>
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 bg-destructive/10 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Wallet Unavailable</CardTitle>
<CardDescription>
This wallet cannot run in your current browser environment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{content}
</CardContent>
</Card>
);
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Spark Wallet Components
* Export all wallet-related components
*/
export { CreateWallet } from './CreateWallet';
export { RestoreWallet } from './RestoreWallet';
export { MnemonicDisplay } from './MnemonicDisplay';
export { MnemonicInput } from './MnemonicInput';
export { WalletBalance } from './WalletBalance';
export { ReceivePayment } from './ReceivePayment';
export { SendPayment } from './SendPayment';
export { PaymentHistory } from './PaymentHistory';
export { UnclaimedDeposits } from './UnclaimedDeposits';
export { WasmUnsupportedError } from './WasmUnsupportedError';
-1
View File
@@ -174,7 +174,6 @@ function SecondaryMobileLinks({ onClose }: { onClose: () => void }) {
const items: { label: string; to: string }[] = [
{ label: 'Wallet', to: '/wallet' },
{ label: 'Bitcoin', to: '/bitcoin' },
{ label: 'Notifications', to: '/notifications' },
{ label: 'Profile', to: `/${nip19.npubEncode(user.pubkey)}` },
{ label: 'Settings', to: '/settings' },
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Zap } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { useAuthor } from '@/hooks/useAuthor';
import { useFormatMoney } from '@/hooks/useFormatMoney';
import { useVerifiedOnchainZap } from '@/hooks/useOnchainZaps';
import { extractZapAmount, extractZapMessage } from '@/hooks/useEventInteractions';
import { genUserName } from '@/lib/genUserName';
import { isNostrId } from '@/lib/nostrId';
interface ZapContentProps {
/** The zap event itself (kind 9735 Lightning receipt or kind 8333 on-chain). */
event: NostrEvent;
/**
* If set, this is a profile-targeted zap and this pubkey is the
* recipient (from the event's `p` tag). Renders a muted
* "Zapped @recipient" context line above the amount. Omit for
* note-zaps — those use `zappedBy` overlays on the target note.
*/
recipientPubkey?: string;
}
/**
* Renders the body of a standalone zap card: a muted "Zapped @recipient"
* context line (for profile-targeted zaps), the prominent amber amount,
* and the optional sender comment. Used inside `NoteCard`'s content
* block for kind 9735 / 8333 events.
*/
export function ZapContent({ event, recipientPubkey }: ZapContentProps) {
const isOnchain = event.kind === 8333;
// For on-chain zaps, verify the claimed amount against the underlying
// Bitcoin transaction. Lightning zaps are trusted via the LNURL
// server's signature, so we read the amount directly.
const verified = useVerifiedOnchainZap(isOnchain ? event : undefined);
const isVerifying = isOnchain && verified === undefined;
const failedVerification = isOnchain && verified === null;
const sats = useMemo(() => {
if (isOnchain) {
if (verified?.amountSats) return verified.amountSats;
const amountTag = event.tags.find(([n]) => n === 'amount');
const n = amountTag?.[1] ? parseInt(amountTag[1], 10) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
return Math.floor(extractZapAmount(event) / 1000);
}, [event, isOnchain, verified]);
// Lightning zap messages live inside the embedded NIP-57 zap-request
// JSON; on-chain zaps put the comment directly in `content`.
const message = isOnchain ? event.content.trim() : extractZapMessage(event);
const { format: formatMoney } = useFormatMoney();
return (
<div className="mt-2 space-y-2">
{recipientPubkey && isNostrId(recipientPubkey) && (
<ZapRecipientLine pubkey={recipientPubkey} />
)}
{sats > 0 && (
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-3xl font-bold text-amber-500 tabular-nums">
{formatMoney(sats)}
</span>
{failedVerification ? (
<span className="text-xs text-muted-foreground">unverified</span>
) : isVerifying ? (
<span className="text-xs text-muted-foreground">verifying</span>
) : null}
</div>
)}
{message && (
<p className="text-[15px] leading-relaxed text-foreground whitespace-pre-wrap break-words">
{message}
</p>
)}
</div>
);
}
/** Muted "⚡ Zapped @recipient" context line, modeled on ProfileCommentContext. */
function ZapRecipientLine({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
return (
<div className="flex items-center gap-x-1 text-sm text-muted-foreground min-w-0 overflow-hidden">
<Zap className="size-3.5 text-amber-500 shrink-0" />
<span className="shrink-0">Zapped</span>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
</div>
);
}
+425 -297
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile } from 'lucide-react';
import { useState, useEffect, useRef, useMemo, useCallback, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, X, Bitcoin, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { openUrl } from '@/lib/downloadFile';
import { impactMedium } from '@/lib/haptics';
import { HelpTip } from '@/components/HelpTip';
@@ -11,26 +12,26 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { EmojiPicker } from '@/components/EmojiPicker';
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { OnchainZapContent } from '@/components/OnchainZapContent';
import { ZapSuccessScreen } from '@/components/ZapSuccessScreen';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useToast } from '@/hooks/useToast';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useAppContext } from '@/hooks/useAppContext';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInsertText } from '@/hooks/useInsertText';
import { canZap } from '@/lib/canZap';
import {
fetchBtcPrice,
isLargeAmount,
satsToUSD,
} from '@/lib/bitcoin';
import type { Event } from 'nostr-tools';
import QRCode from 'qrcode';
import type { WebLNProvider } from "@webbtc/webln-types";
import type { WebLNProvider } from '@webbtc/webln-types';
interface ZapDialogProps {
target: Event;
@@ -38,240 +39,248 @@ interface ZapDialogProps {
className?: string;
}
const presetAmounts = [
{ amount: 21, icon: Sparkle },
{ amount: 50, icon: Sparkles },
{ amount: 100, icon: Zap },
{ amount: 250, icon: Star },
{ amount: 1000, icon: Rocket },
];
// USD presets for the Lightning tab. Lightning zaps are expected to be
// much smaller than on-chain sends (which have a fixed per-tx fee floor),
// so the presets stay in tip-jar territory.
const LIGHTNING_USD_PRESETS = [0.1, 0.5, 1, 2, 5];
interface ZapContentProps {
/** Format a preset button label without trailing zeros ($0.10 → $0.10, $1 → $1). */
function formatPresetLabel(usd: number): string {
return usd < 1 ? `$${usd.toFixed(2)}` : `$${usd}`;
}
interface LightningZapContentProps {
invoice: string | null;
amount: number | string;
comment: string;
usdAmount: number | string;
amountSats: number;
btcPrice: number | undefined;
isZapping: boolean;
qrCodeUrl: string;
copied: boolean;
webln: WebLNProvider | null;
insufficient: boolean;
isLarge: boolean;
confirmArmed: boolean;
error: string;
handleZap: () => void;
handleCopy: () => void;
openInWallet: () => void;
setAmount: (amount: number | string) => void;
setComment: (comment: string) => void;
inputRef: React.RefObject<HTMLInputElement | null>;
commentTextareaRef: React.RefObject<HTMLTextAreaElement | null>;
insertEmoji: (emoji: string) => void;
insertAtCursor: (params: { start: number; end: number; replacement: string }) => void;
customEmojis: Array<{ shortcode: string; url: string }>;
zap: (amount: number, comment: string) => void;
setUsdAmount: (amount: number | string) => void;
setError: (msg: string) => void;
editingAmount: boolean;
setEditingAmount: (v: boolean) => void;
amountInputRef: React.RefObject<HTMLInputElement | null>;
commitAmountEdit: () => void;
payWithWebLN: () => void;
}
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
/**
* Lightning zap flow. Mirrors the onchain tab: one screen, one button, no
* comment field. Amount is denominated in USD and converted to sats at
* payment time using the same BTC price query the onchain tab uses.
*
* Defined outside `ZapDialog` as a `forwardRef` to keep the amount input
* from losing focus on parent re-renders.
*/
const LightningZapContent = forwardRef<HTMLDivElement, LightningZapContentProps>(({
invoice,
amount,
comment,
usdAmount,
amountSats,
btcPrice,
isZapping,
qrCodeUrl,
copied,
webln,
insufficient,
isLarge,
confirmArmed,
error,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
commentTextareaRef,
insertEmoji,
insertAtCursor,
customEmojis,
zap,
}, ref) => (
<div ref={ref}>
{invoice ? (
<div className="flex flex-col h-full min-h-0">
{/* Payment amount display */}
<div className="text-center pt-4">
<div className="text-2xl font-bold">{amount} sats</div>
</div>
setUsdAmount,
setError,
editingAmount,
setEditingAmount,
amountInputRef,
commitAmountEdit,
payWithWebLN,
}, ref) => {
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
const hasValidAmount = Number.isFinite(currentUsd) && currentUsd > 0;
const usdString = btcPrice && amountSats > 0 ? satsToUSD(amountSats, btcPrice) : '';
// When btcPrice hasn't loaded yet, fall back to formatting the raw USD
// input so small values like 0.1 still render as "$0.10".
const fallbackUsd = hasValidAmount
? (currentUsd < 1 ? `$${currentUsd.toFixed(2)}` : `$${currentUsd}`)
: '';
const usdDisplay = usdString || fallbackUsd;
<Separator className="my-4" />
<div className="flex flex-col justify-center min-h-0 flex-1 px-5">
{/* QR Code */}
<div className="flex justify-center">
<Card className="p-3 w-[min(240px,70vw,35vh)] mx-auto">
<CardContent className="p-0 flex justify-center">
{qrCodeUrl ? (
<img
src={qrCodeUrl}
alt="Lightning Invoice QR Code"
className="w-full h-auto aspect-square object-contain"
/>
) : (
<div className="w-full aspect-square bg-muted animate-pulse rounded" />
)}
</CardContent>
</Card>
</div>
{/* Invoice input */}
<div className="space-y-2 mt-4">
<Label htmlFor="invoice">Lightning Invoice</Label>
<div className="flex gap-2 min-w-0">
<Input
id="invoice"
value={invoice}
readOnly
className="font-mono text-base md:text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
onClick={(e) => e.currentTarget.select()}
/>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Payment buttons */}
<div className="space-y-3 mt-4">
{webln && (
<Button
onClick={() => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
}}
disabled={isZapping}
className="w-full"
size="lg"
>
<Zap className="h-4 w-4 mr-2" />
{isZapping ? "Processing..." : "Pay with WebLN"}
</Button>
)}
<Button
variant="outline"
onClick={openInWallet}
className="w-full"
size="lg"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Lightning Wallet
</Button>
<div className="text-xs text-muted-foreground text-center pb-3">
Scan the QR code or copy the invoice to pay with any Lightning wallet.
</div>
if (invoice) {
return (
<div ref={ref} className="grid gap-3 px-4 py-4 w-full overflow-hidden">
{/* Amount header — USD only; sats are an implementation detail. */}
<div className="flex flex-col items-center pt-1">
<div className="text-3xl font-semibold tabular-nums">
{usdDisplay}
</div>
</div>
</div>
) : (
<>
<div className="grid gap-3 px-4 py-4 w-full overflow-hidden">
<ToggleGroup
type="single"
value={String(amount)}
onValueChange={(value) => {
if (value) {
setAmount(parseInt(value, 10));
}
}}
className="grid grid-cols-5 gap-1 w-full"
>
{presetAmounts.map(({ amount: presetAmount, icon: Icon }) => (
<ToggleGroupItem
key={presetAmount}
value={String(presetAmount)}
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
>
<Icon className="h-4 w-4 mb-1" />
<span className="truncate">{presetAmount}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-muted" />
<span className="text-xs text-muted-foreground">OR</span>
<div className="h-px flex-1 bg-muted" />
{/* QR code */}
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl" aria-label="Lightning invoice QR code">
<QRCodeCanvas value={invoice.toUpperCase()} size={220} level="M" className="block" />
</div>
</div>
{/* Invoice copy row */}
<div className="flex gap-2 min-w-0">
<Input
ref={inputRef}
id="custom-amount"
type="number"
placeholder="Custom amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full"
id="invoice"
value={invoice}
readOnly
aria-label="Lightning invoice"
className="font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis"
onClick={(e) => e.currentTarget.select()}
/>
<div className="relative">
<Textarea
ref={commentTextareaRef}
id="custom-comment"
placeholder="Add a comment (optional)"
value={comment}
onChange={(e) => setComment(e.target.value)}
className="w-full resize-none"
rows={2}
/>
<EmojiShortcodeAutocomplete
textareaRef={commentTextareaRef}
content={comment}
onInsertEmoji={insertAtCursor}
/>
</div>
<div className="flex items-center">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-1.5 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
>
<Smile className="size-4" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<EmojiPicker
customEmojis={customEmojis}
onSelect={(selection) => {
const text = selection.type === 'native' ? selection.emoji : `:${selection.shortcode}:`;
insertEmoji(text);
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="px-4 pb-4">
<Button onClick={handleZap} className="w-full" disabled={isZapping} size="default">
{isZapping ? (
'Creating invoice...'
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
aria-label="Copy invoice"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Zap {amount} sats
</>
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</>
)}
</div>
));
ZapContent.displayName = 'ZapContent';
{/* Payment actions */}
<div className="grid gap-2">
{webln && (
<Button
type="button"
onClick={payWithWebLN}
disabled={isZapping}
className="w-full"
>
{isZapping ? (
<>
<Loader2 className="size-4 mr-1.5 animate-spin" />
Processing
</>
) : (
'Pay with WebLN'
)}
</Button>
)}
<Button
type="button"
variant={webln ? 'outline' : 'default'}
onClick={openInWallet}
className="w-full"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Lightning Wallet
</Button>
</div>
<p className="text-[11px] text-muted-foreground text-center">
Scan the QR or copy the invoice to pay with any Lightning wallet.
</p>
</div>
);
}
return (
<div ref={ref} className="grid gap-3 px-4 py-4 w-full overflow-hidden">
{/* Amount — big number on top, editable by clicking. Matches OnchainZapContent. */}
<div className="flex flex-col items-center pt-2">
{editingAmount ? (
<div className="flex items-baseline justify-center">
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
<input
ref={amountInputRef}
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={usdAmount}
onChange={(e) => { 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` }}
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
aria-label="Edit amount"
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<span className={`text-4xl font-semibold ${insufficient ? 'text-destructive' : 'text-muted-foreground'}`}>$</span>
<span className={`text-4xl font-semibold tabular-nums ${insufficient ? 'text-destructive' : ''}`}>
{hasValidAmount ? (currentUsd < 1 ? currentUsd.toFixed(2) : currentUsd) : 0}
</span>
</button>
)}
</div>
{/* Presets — compact. Lightning zaps lean small, so the defaults stay
in tip-jar territory. */}
<ToggleGroup
type="single"
value={LIGHTNING_USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); setEditingAmount(false); } }}
className="grid grid-cols-5 gap-1 w-full"
>
{LIGHTNING_USD_PRESETS.map((v) => (
<ToggleGroupItem
key={v}
value={String(v)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
{formatPresetLabel(v)}
</ToggleGroupItem>
))}
</ToggleGroup>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Button
type="button"
onClick={handleZap}
disabled={!btcPrice || amountSats <= 0 || isZapping}
variant={isLarge && !isZapping ? 'destructive' : 'default'}
className="w-full"
>
{isZapping ? (
<>
<Loader2 className="size-4 mr-1.5 animate-spin" />
Creating invoice
</>
) : isLarge && confirmArmed ? (
<>Tap again to send {usdDisplay}</>
) : (
<>Send {usdDisplay}</>
)}
</Button>
</div>
);
});
LightningZapContent.displayName = 'LightningZapContent';
export function ZapDialog({ target, children, className }: ZapDialogProps) {
const [open, setOpen] = useState(false);
@@ -279,61 +288,91 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const { config } = useAppContext();
const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>('');
const { esploraBaseUrl } = config;
// Success state: populated by either zap rail's onSuccess callback.
// When set, we replace the tab UI with <ZapSuccessScreen />.
const [success, setSuccess] = useState<
| { kind: 'onchain'; amountSats: number; txid: string }
| { kind: 'lightning'; amountSats: number }
| null
>(null);
const handleLightningSuccess = useCallback(
({ amountSats }: { amountSats: number }) => {
setSuccess({ kind: 'lightning', amountSats });
},
[],
);
const { zap, isZapping, invoice, setInvoice } = useZaps(
target,
webln,
activeNWC,
handleLightningSuccess,
);
// USD-denominated state (matches OnchainZapContent). The sats amount is
// derived just before we hit the LNURL endpoint.
const [usdAmount, setUsdAmount] = useState<number | string>(0.5);
const [copied, setCopied] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const commentTextareaRef = useRef<HTMLTextAreaElement>(null);
const { feedSettings } = useFeedSettings();
const { emojis: allCustomEmojis } = useCustomEmojis();
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
const { insertAtCursor, insertEmoji } = useInsertText(commentTextareaRef, comment, setComment);
const [editingAmount, setEditingAmount] = useState(false);
const [error, setError] = useState('');
const [confirmArmed, setConfirmArmed] = useState(false);
const amountInputRef = useRef<HTMLInputElement>(null);
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
staleTime: 30_000,
});
// Convert the USD amount to sats for the actual Lightning payment.
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 isLarge = isLargeAmount(amountSats, btcPrice);
// Lightning has no local balance concept (the wallet / LNURL handles that),
// so `insufficient` stays false — kept for symmetry with the onchain props.
const insufficient = false;
// Default tab: Bitcoin. Users can switch to Lightning if available.
// If the user's signer can't sign PSBTs AND Lightning is available, we
// transparently default to Lightning instead of showing an unusable
// Bitcoin tab as the primary option.
const { capability: btcCapability } = useBitcoinSigner();
const hasLightning = canZap(author?.metadata);
const bitcoinUnsupported = btcCapability === 'unsupported';
const [activeTab, setActiveTab] = useState<'onchain' | 'lightning'>(
bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain',
);
// Re-arm (clear confirmation) whenever the amount moves — editing after
// arming forces another deliberate click. Mirrors OnchainZapContent.
useEffect(() => {
if (target) {
setComment(`Zapped with ${config.appName}!`);
setConfirmArmed(false);
}, [amountSats]);
// Focus + select-all when the amount is clicked into edit mode.
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [target, config.appName]);
}, [editingAmount]);
// Generate QR code
useEffect(() => {
let isCancelled = false;
const generateQR = async () => {
if (!invoice) {
setQrCodeUrl('');
return;
}
try {
const url = await QRCode.toDataURL(invoice.toUpperCase(), {
width: 512,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
if (!isCancelled) {
setQrCodeUrl(url);
}
} catch (err) {
if (!isCancelled) {
console.error('Failed to generate QR code:', err);
}
}
};
generateQR();
return () => {
isCancelled = true;
};
}, [invoice]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
setUsdAmount(0);
}
}, [usdAmount]);
const handleCopy = async () => {
if (invoice) {
@@ -349,55 +388,93 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const openInWallet = () => {
if (invoice) {
const lightningUrl = `lightning:${invoice}`;
openUrl(lightningUrl);
openUrl(`lightning:${invoice}`);
}
};
useEffect(() => {
if (open) {
setAmount(100);
setUsdAmount(0.5);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
setEditingAmount(false);
setError('');
setConfirmArmed(false);
setSuccess(null);
setActiveTab(bitcoinUnsupported && hasLightning ? 'lightning' : 'onchain');
} else {
setAmount(100);
setUsdAmount(0.5);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
setEditingAmount(false);
setError('');
setConfirmArmed(false);
setSuccess(null);
}
// `bitcoinUnsupported`/`hasLightning` deliberately excluded — we only
// want to reset the active tab on open/close, not on every capability
// re-render. The mid-session flip is handled by the effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, setInvoice]);
// Previously, if Bitcoin capability flipped to `unsupported` mid-session we
// auto-switched to Lightning because the Bitcoin tab was a dead-end. The
// Bitcoin tab now shows a QR fallback for unsupported signers, so users
// should be free to click into it. We only bias the *initial* tab choice
// toward Lightning (above, in the useState initializer and the open-reset
// effect); manual navigation into Bitcoin is respected.
const handleZap = () => {
setError('');
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
if (amountSats <= 0) { setError('Enter an amount.'); return; }
// Two-tap safety for large amounts: first click arms, second click sends.
if (isLarge && !confirmArmed) {
setConfirmArmed(true);
return;
}
impactMedium();
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
zap(amountSats, '');
};
const contentProps = {
const payWithWebLN = () => {
if (amountSats > 0) {
zap(amountSats, '');
}
};
const lightningContentProps: LightningZapContentProps = {
invoice,
amount,
comment,
usdAmount,
amountSats,
btcPrice,
isZapping,
qrCodeUrl,
copied,
webln,
insufficient,
isLarge,
confirmArmed,
error,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
commentTextareaRef,
insertEmoji,
insertAtCursor,
customEmojis,
zap,
setUsdAmount,
setError,
editingAmount,
setEditingAmount,
amountInputRef,
commitAmountEdit,
payWithWebLN,
};
const canZap = !!user && user.pubkey !== target.pubkey && !!(author?.metadata?.lud06 || author?.metadata?.lud16);
// Zap button shows for any logged-in user except when targeting oneself.
// On-chain is always available; Lightning is offered as an in-dialog option
// when the author has a Lightning address.
const canOpenZap = !!user && user.pubkey !== target.pubkey;
if (!canZap) {
if (!canOpenZap) {
return <>{children}</>;
}
@@ -411,7 +488,20 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
<DialogContent className="max-w-[425px] rounded-2xl p-0 gap-0 border-border overflow-hidden max-h-[95vh] [&>button]:hidden" data-testid="zap-modal">
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold flex items-center gap-1.5">
{invoice ? 'Lightning Payment' : 'Send a Zap'} <HelpTip faqId="what-are-zaps" />
{success
? 'Success'
: invoice
? 'Lightning Payment'
: 'Send Bitcoin'}{' '}
{!success && (
<HelpTip
faqId={
invoice || activeTab === 'lightning'
? 'send-bitcoin-lightning'
: 'send-bitcoin-onchain'
}
/>
)}
</DialogTitle>
<button
onClick={() => setOpen(false)}
@@ -420,11 +510,49 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
<X className="size-5" />
</button>
</div>
<p className="px-4 -mt-1 mb-1 text-sm text-muted-foreground">
{invoice ? 'Pay with Bitcoin Lightning Network' : 'Send a small Bitcoin payment to support the creator.'}
</p>
<div className="overflow-y-auto">
<ZapContent {...contentProps} />
{success ? (
<ZapSuccessScreen
recipientPubkey={target.pubkey}
amountSats={success.amountSats}
btcPrice={btcPrice}
txid={success.kind === 'onchain' ? success.txid : undefined}
onClose={() => setOpen(false)}
/>
) : hasLightning ? (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-2 h-9">
<TabsTrigger value="onchain" className="gap-1.5 text-xs">
<Bitcoin className="size-3.5" /> Bitcoin
</TabsTrigger>
<TabsTrigger value="lightning" className="gap-1.5 text-xs">
<Zap className="size-3.5" /> Lightning
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="onchain" className="mt-0">
<OnchainZapContent
target={target}
onSuccess={({ txid, amountSats }) =>
setSuccess({ kind: 'onchain', amountSats, txid })
}
onClose={() => setOpen(false)}
/>
</TabsContent>
<TabsContent value="lightning" className="mt-0">
<LightningZapContent {...lightningContentProps} />
</TabsContent>
</Tabs>
) : (
<OnchainZapContent
target={target}
onSuccess={({ txid, amountSats }) =>
setSuccess({ kind: 'onchain', amountSats, txid })
}
onClose={() => setOpen(false)}
/>
)}
</div>
</DialogContent>
</Dialog>
+166
View File
@@ -0,0 +1,166 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Check, ExternalLink } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { getAvatarShape } from '@/lib/avatarShape';
import { useAuthor } from '@/hooks/useAuthor';
import { satsToUSD } from '@/lib/bitcoin';
import { genUserName } from '@/lib/genUserName';
interface ZapSuccessScreenProps {
/** Recipient pubkey (hex). Used to resolve the author avatar + name. */
recipientPubkey: string;
/** Amount sent in satoshis. */
amountSats: number;
/** Current BTC/USD price for display; optional, falls back to sats only. */
btcPrice: number | undefined;
/** Bitcoin txid (onchain only). Enables the "View transaction" link to the in-app tx detail page. */
txid?: string;
/** Close handler invoked by the "Done" button. */
onClose: () => void;
}
/**
* Grand confirmation screen shown after a successful Bitcoin send in the
* ZapDialog. Replaces the previous toast-and-auto-close behavior with a
* dedicated celebration moment: animated checkmark, expanding halo, a
* confetti-adjacent sparkle burst, the amount sent, the recipient, and
* a "View transaction" shortcut when we have a txid on hand.
*
* Respects `prefers-reduced-motion`: the entrance animations collapse to a
* simple fade and the sparkle burst is suppressed.
*/
export function ZapSuccessScreen({
recipientPubkey,
amountSats,
btcPrice,
txid,
onClose,
}: ZapSuccessScreenProps) {
const { data: author } = useAuthor(recipientPubkey);
const metadata = author?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(recipientPubkey);
const avatarShape = getAvatarShape(metadata);
const usdDisplay = useMemo(
() => (btcPrice ? satsToUSD(amountSats, btcPrice) : ''),
[amountSats, btcPrice],
);
// Sparkle burst positions: 8 particles radiating outward from the
// checkmark, each with a slightly offset delay so the burst reads organic
// rather than synchronised.
const sparkles = useMemo(
() =>
Array.from({ length: 8 }, (_, i) => {
const angle = (i / 8) * Math.PI * 2;
const radius = 58;
return {
id: i,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
delay: 0.15 + (i % 4) * 0.05,
hue: i % 2 === 0 ? 'bg-amber-400' : 'bg-orange-500',
};
}),
[],
);
return (
<div
role="status"
aria-live="polite"
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
>
{/* Soft radial glow behind the whole card. Pure decoration. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
/>
{/* Check + halo + sparkles */}
<div className="relative mx-auto flex size-28 items-center justify-center">
{/* Expanding halo ring */}
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
/>
{/* Solid gradient disc */}
<span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
/>
{/* Checkmark */}
<Check
className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop"
strokeWidth={3}
aria-hidden
/>
{/* Sparkle burst */}
<div aria-hidden className="pointer-events-none absolute inset-0 motion-reduce:hidden">
{sparkles.map((s) => (
<span
key={s.id}
className={`absolute left-1/2 top-1/2 size-1.5 rounded-full ${s.hue} motion-safe:animate-success-spark`}
style={
{
'--spark-x': `${s.x}px`,
'--spark-y': `${s.y}px`,
animationDelay: `${s.delay}s`,
} as React.CSSProperties
}
/>
))}
</div>
</div>
{/* Headline + amount */}
<div className="grid gap-1">
<h2 className="text-lg font-semibold tracking-tight">
Bitcoin sent
</h2>
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
{usdDisplay || `${amountSats.toLocaleString()} sats`}
</div>
</div>
{/* Recipient card */}
<div className="mx-auto flex items-center gap-3 rounded-full border border-border/70 bg-muted/40 pl-2 pr-4 py-2 max-w-full">
<Avatar shape={avatarShape} className="size-8 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 text-left">
<div className="text-[11px] text-muted-foreground leading-tight">To</div>
<div className="text-sm font-medium truncate max-w-[220px]">{displayName}</div>
</div>
</div>
{/* Actions */}
<div className="grid gap-2">
{txid && (
<Button
type="button"
variant="outline"
asChild
className="w-full"
>
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
<ExternalLink className="size-4 mr-2" />
View transaction
</Link>
</Button>
)}
<Button type="button" onClick={onClose} className="w-full">
Done
</Button>
</div>
</div>
);
}
+8
View File
@@ -16,6 +16,8 @@ export type Theme = "light" | "dark" | "system" | "custom";
*/
export type ContentWarningPolicy = "blur" | "hide" | "show";
/** Whether monetary amounts (zaps, balances, etc.) are displayed in USD or sats. */
export type CurrencyDisplay = "usd" | "sats";
/** How to handle events with a NIP-36 content-warning tag. */
export type NsfwPolicy = "blur" | "hide" | "show";
@@ -275,6 +277,12 @@ export interface AppConfig {
* extension is appended by the price call. Default: "https://mempool.space/api".
*/
esploraBaseUrl: string;
/**
* Display preference for monetary amounts (zap totals, balances, send forms).
* - "usd" (default): convert sats to USD using the live BTC price.
* - "sats": always show raw satoshi counts.
*/
currencyDisplay?: CurrencyDisplay;
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
sidebarWidgets: WidgetConfig[];
/** Base URL for the AI chat-completions provider (OpenAI-compatible /v1 endpoint). */
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin';
import { useAppContext } from '@/hooks/useAppContext';
/**
* Fetch full address details (balance + recent txs) via the configured
* Esplora-compatible API (default: mempool.space). Also fetches the
* current BTC/USD price for display.
*/
export function useBitcoinAddress(address: string) {
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const { data: addressDetail, isLoading, error, refetch } = useQuery({
queryKey: ['bitcoin-address-detail', esploraBaseUrl, address],
queryFn: () => fetchAddressDetail(address, esploraBaseUrl),
enabled: !!address,
refetchInterval: 30_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
refetchInterval: 60_000,
staleTime: 30_000,
});
return { addressDetail, btcPrice, isLoading, error, refetch };
}
+82 -13
View File
@@ -4,15 +4,45 @@ import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers';
/**
* Three possible states for Bitcoin PSBT signing capability:
*
* - `supported` — Known to work (nsec login, or extension that exposes `signPsbt`).
* - `unsupported` — Known not to work (extension without `signPsbt`, or a remote
* signer that has already rejected a `sign_psbt` request).
* - `unknown` — Cannot be determined in advance. Applies to NIP-46 bunker
* logins, since NIP-46 has no standard capability-discovery
* RPC. Treat as "attempt, then propagate the error if it
* fails" — the UI should allow the attempt but fall back to
* the unsupported state when a `sign_psbt` call rejects with
* a capability error (see `reportSignerUnsupported`).
*/
export type BitcoinSignerCapability = 'supported' | 'unsupported' | 'unknown';
/**
* Module-level registry of bunker pubkeys that have been observed to reject
* `sign_psbt`. Persists for the lifetime of the page so that the user doesn't
* see the "attempt then fail" path twice for the same bunker.
*/
const knownUnsupportedBunkers = new Set<string>();
/**
* Mark a bunker (keyed by user pubkey) as known-unsupported for PSBT signing.
* Subsequent renders of `useBitcoinSigner` will return `'unsupported'` for
* that user.
*/
export function reportSignerUnsupported(pubkey: string): void {
knownUnsupportedBunkers.add(pubkey);
// Dispatch a DOM event so hook consumers can re-render without plumbing a
// shared store through the app. Listened to by `useBitcoinSigner`.
window.dispatchEvent(new CustomEvent('bitcoin-signer-unsupported', { detail: pubkey }));
}
/**
* Clear the unsupported-bunker memo for a pubkey (or all pubkeys). Called
* when the user logs out or switches accounts, so that a re-login with a
* potentially-upgraded bunker doesn't inherit the previous rejection.
*/
export function clearSignerUnsupported(pubkey?: string): void {
if (pubkey === undefined) {
knownUnsupportedBunkers.clear();
@@ -22,11 +52,29 @@ export function clearSignerUnsupported(pubkey?: string): void {
window.dispatchEvent(new CustomEvent('bitcoin-signer-cleared', { detail: pubkey ?? '*' }));
}
/**
* Hook that exposes Bitcoin PSBT signing capability for the current login.
*
* Capability is probed eagerly for known login types so that the UI can
* replace itself with an "unsupported" state before the user attempts to
* sign anything (rather than surfacing a toast after the fact).
*
* - **nsec** → always `'supported'` (local signing).
* - **extension** → probes `window.nostr.signPsbt`. Returns `'supported'` if
* present, `'unsupported'` if absent, or `'unknown'` while
* still waiting for the extension to inject `window.nostr`.
* - **bunker** → `'unknown'` by default (NIP-46 has no capability RPC).
* Flips to `'unsupported'` for the session once a
* `sign_psbt` attempt has rejected with a capability error
* (see `reportSignerUnsupported`).
*/
export function useBitcoinSigner() {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const loginType = logins[0]?.type;
// ── Extension: probe window.nostr.signPsbt ───────────────────
const [extensionProbe, setExtensionProbe] = useState<BitcoinSignerCapability>(() => {
if (loginType !== 'extension') return 'unknown';
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
@@ -37,7 +85,7 @@ export function useBitcoinSigner() {
useEffect(() => {
if (loginType !== 'extension') return;
// Re-probe periodically in case the extension injects `window.nostr` late.
let cancelled = false;
const probe = () => {
const n = (globalThis as { nostr?: Record<string, unknown> }).nostr;
@@ -45,25 +93,28 @@ export function useBitcoinSigner() {
setExtensionProbe(typeof n.signPsbt === 'function' ? 'supported' : 'unsupported');
return true;
};
if (probe()) return;
const interval = setInterval(() => {
if (cancelled) return;
if (probe()) clearInterval(interval);
}, 250);
// Stop polling after 3 s — if the extension hasn't shown up by then it
// likely isn't going to.
const stop = setTimeout(() => clearInterval(interval), 3000);
return () => {
cancelled = true;
clearInterval(interval);
clearTimeout(stop);
};
return () => { cancelled = true; clearInterval(interval); clearTimeout(stop); };
}, [loginType]);
// ── Bunker: listen for capability-failure events ─────────────
const [bunkerUnsupported, setBunkerUnsupported] = useState(() =>
user ? knownUnsupportedBunkers.has(user.pubkey) : false,
);
// Reset memoised state whenever the active user changes (login/logout/
// account switch) so a fresh login with a potentially-upgraded signer
// isn't permanently tainted by a previous session's rejection. On full
// logout we also clear the module-level registry entirely, so logging back
// in lets the user try their bunker again.
useEffect(() => {
if (!user) {
setBunkerUnsupported(false);
@@ -71,16 +122,16 @@ export function useBitcoinSigner() {
return;
}
setBunkerUnsupported(knownUnsupportedBunkers.has(user.pubkey));
}, [user]);
}, [user?.pubkey]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (loginType !== 'bunker' || !user) return;
const onUnsupported = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
const onUnsupported = (e: Event) => {
const detail = (e as CustomEvent<string>).detail;
if (detail === user.pubkey) setBunkerUnsupported(true);
};
const onCleared = (event: Event) => {
const detail = (event as CustomEvent<string>).detail;
const onCleared = (e: Event) => {
const detail = (e as CustomEvent<string>).detail;
if (detail === '*' || detail === user.pubkey) setBunkerUnsupported(false);
};
window.addEventListener('bitcoin-signer-unsupported', onUnsupported);
@@ -91,16 +142,20 @@ export function useBitcoinSigner() {
};
}, [loginType, user]);
// ── Aggregate capability ─────────────────────────────────────
const capability: BitcoinSignerCapability = useMemo(() => {
if (!user) return 'unsupported';
switch (loginType) {
case 'nsec':
// Local signing is always available for nsec logins.
return 'supported';
case 'extension':
return extensionProbe;
case 'bunker':
return bunkerUnsupported ? 'unsupported' : 'unknown';
default:
// Unknown login type: fall back to the structural check.
return hasBtcSigning(user.signer) ? 'unknown' : 'unsupported';
}
}, [user, loginType, extensionProbe, bunkerUnsupported]);
@@ -112,14 +167,28 @@ export function useBitcoinSigner() {
}, [user, capability]);
return {
/** Detailed capability state. See {@link BitcoinSignerCapability}. */
capability,
/** True when capability is `'supported'` or `'unknown'` (attempt allowed). */
canSignPsbt: capability !== 'unsupported' && btcSigner !== null,
/**
* Sign a hex-encoded PSBT. Throws if the signer doesn't support it.
* The returned hex is a signed (but not finalized) PSBT.
*/
signPsbt: btcSigner
? (psbtHex: string) => btcSigner.signPsbt(psbtHex)
: null,
};
}
/**
* Classify a signer error as a "capability error" (the signer fundamentally
* cannot sign PSBTs) versus a transient/operational error (network blip,
* user cancellation, malformed PSBT, etc.).
*
* Used by `useOnchainZap` to decide whether a failed send should flip the
* UI into the `'unsupported'` state or just surface a normal error toast.
*/
export function isSignerCapabilityError(err: unknown): boolean {
if (!err) return false;
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
+30
View File
@@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin';
import { useAppContext } from '@/hooks/useAppContext';
/**
* Fetch full transaction details for a Bitcoin txid via the configured
* Esplora-compatible API (default: mempool.space). Also fetches the
* current BTC/USD price for display.
*/
export function useBitcoinTx(txid: string) {
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const { data: tx, isLoading, error } = useQuery({
queryKey: ['bitcoin-tx-detail', esploraBaseUrl, txid],
queryFn: () => fetchTxDetail(txid, esploraBaseUrl),
enabled: !!txid,
staleTime: 60_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
refetchInterval: 60_000,
staleTime: 30_000,
});
return { tx, btcPrice, isLoading, error };
}
+27 -12
View File
@@ -2,15 +2,21 @@ import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
fetchAddressData,
fetchBtcPrice,
fetchTransactions,
nostrPubkeyToBitcoinAddress,
} from '@/lib/bitcoin';
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 { esploraBaseUrl } = config;
const bitcoinAddress = useMemo(() => {
if (!user) return '';
@@ -23,15 +29,15 @@ export function useBitcoinWallet() {
error,
refetch,
} = useQuery({
queryKey: ['bitcoin-balance', bitcoinAddress],
queryFn: () => fetchAddressData(bitcoinAddress),
queryKey: ['bitcoin-balance', esploraBaseUrl, bitcoinAddress],
queryFn: () => fetchAddressData(bitcoinAddress, esploraBaseUrl),
enabled: !!bitcoinAddress,
refetchInterval: 30_000,
});
const { data: btcPrice } = useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
refetchInterval: 60_000,
staleTime: 30_000,
});
@@ -40,21 +46,30 @@ export function useBitcoinWallet() {
data: transactions,
isLoading: isLoadingTxs,
} = useQuery({
queryKey: ['bitcoin-txs', bitcoinAddress],
queryFn: () => fetchTransactions(bitcoinAddress),
queryKey: ['bitcoin-txs', esploraBaseUrl, bitcoinAddress],
queryFn: () => fetchTransactions(bitcoinAddress, esploraBaseUrl),
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 ?? '',
};
}
+5 -2
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { fetchBtcPrice } from '@/lib/bitcoin';
import { useAppContext } from '@/hooks/useAppContext';
/**
* Tiny standalone hook for the spot BTC→USD price.
@@ -14,9 +15,11 @@ import { fetchBtcPrice } from '@/lib/bitcoin';
* share a single in-flight request and TanStack Query dedupes naturally.
*/
export function useBtcPrice() {
const { config } = useAppContext();
const { esploraBaseUrl } = config;
return useQuery({
queryKey: ['btc-price'],
queryFn: fetchBtcPrice,
queryKey: ['btc-price', esploraBaseUrl],
queryFn: () => fetchBtcPrice(esploraBaseUrl),
refetchInterval: 60_000,
staleTime: 30_000,
});
-101
View File
@@ -1,101 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import { createZapInvoice } from '@/lib/createZapInvoice';
import { breezService } from '@/lib/spark/breezService';
import type { ParsedCommunity } from '@/lib/communityUtils';
export interface CommunityBatchZapRecipient {
pubkey: string;
authorEvent: NostrEvent;
}
export interface CommunityBatchZapArgs {
community: ParsedCommunity;
recipients: CommunityBatchZapRecipient[];
amountSats: number;
comment: string;
}
export interface CommunityBatchZapFailure {
pubkey: string;
reason: string;
}
export interface CommunityBatchZapSummary {
attempted: number;
succeeded: number;
failed: CommunityBatchZapFailure[];
totalSats: number;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Send real NIP-57 profile zaps to a selected set of community members. */
export function useCommunityBatchZaps() {
const { user } = useCurrentUser();
const { config } = useAppContext();
const sparkWallet = useSparkWallet();
async function zapCommunity({
community,
recipients,
amountSats,
comment,
}: CommunityBatchZapArgs): Promise<CommunityBatchZapSummary> {
if (!user) throw new Error('You must be logged in to zap a community.');
if (!user.signer) throw new Error('No signer available.');
if (!sparkWallet.isEnabled || !sparkWallet.isInitialized) {
throw new Error('Your Agora Wallet is not ready.');
}
if (!Number.isFinite(amountSats) || amountSats <= 0) {
throw new Error('Enter a valid amount.');
}
if (recipients.length === 0) {
throw new Error('No selected members can receive zaps.');
}
const failed: CommunityBatchZapFailure[] = [];
let succeeded = 0;
for (const recipient of recipients) {
try {
const invoice = await createZapInvoice({
recipientEvent: recipient.authorEvent,
recipientPubkey: recipient.pubkey,
amountSats,
comment,
relays: config.relayMetadata.relays.map((relay) => relay.url),
signer: user.signer,
extraTags: [
['A', community.aTag],
['K', '34550'],
],
});
await breezService.sendPayment(invoice);
succeeded++;
} catch (error) {
console.error('Community batch zap failed for recipient', recipient.pubkey, error);
failed.push({ pubkey: recipient.pubkey, reason: errorMessage(error) });
}
}
await Promise.allSettled([
sparkWallet.refreshBalance(),
sparkWallet.refreshPayments(),
]);
return {
attempted: recipients.length,
succeeded,
failed,
totalSats: succeeded * amountSats,
};
}
return { zapCommunity };
}
+6 -3
View File
@@ -1,5 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -60,6 +61,8 @@ export function useCommunityOnchainZaps() {
const { canSignPsbt, signPsbt } = useBitcoinSigner();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
async function zapCommunityOnchain({
community,
@@ -91,8 +94,8 @@ export function useCommunityOnchainZaps() {
});
const [utxos, rates] = await Promise.all([
fetchUTXOs(senderAddress),
getFeeRates(),
fetchUTXOs(senderAddress, esploraBaseUrl),
getFeeRates(esploraBaseUrl),
]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
@@ -117,7 +120,7 @@ export function useCommunityOnchainZaps() {
}
const txHex = finalizePsbt(signedHex);
const txid = await broadcastTransaction(txHex);
const txid = await broadcastTransaction(txHex, esploraBaseUrl);
const publishFailed: CommunityOnchainZapPublishFailure[] = [];
let published = 0;
+5 -2
View File
@@ -1,5 +1,6 @@
import { useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { isSignerCapabilityError, reportSignerUnsupported, useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
@@ -78,6 +79,8 @@ export function useDonateCampaign() {
const { canSignPsbt, signPsbt } = useBitcoinSigner();
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
async function donateToCampaign({
campaign,
@@ -117,7 +120,7 @@ export function useDonateCampaign() {
return { address, amountSats: s.amountSats };
});
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress), getFeeRates()]);
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraBaseUrl), getFeeRates(esploraBaseUrl)]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
}
@@ -141,7 +144,7 @@ export function useDonateCampaign() {
}
const txHex = finalizePsbt(signedHex);
const txid = await broadcastTransaction(txHex);
const txid = await broadcastTransaction(txHex, esploraBaseUrl);
// Publish one kind 8333 receipt per recipient. The on-chain tx is already
// final at this point; we record per-recipient publish failures rather
+77
View File
@@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext';
import { fetchBtcPrice, satsToUSD } from '@/lib/bitcoin';
import { formatNumber } from '@/lib/formatNumber';
import type { CurrencyDisplay } from '@/contexts/AppContext';
interface FormatMoneyOptions {
/**
* Layout for the formatted string.
* - "long" (default): `"6,300 sats"` / `"$2.50"`. Used in card headers and detail rows.
* - "compact": `"6.3k"` / `"$2.50"`. Used in tight action bars where the unit/icon
* is supplied alongside; the function omits the trailing "sats" so the bolt
* icon next to it carries the unit. USD always includes the `$`.
*/
layout?: 'long' | 'compact';
}
export interface FormatMoneyResult {
/** Format a satoshi amount according to the user's currency preference. */
format: (sats: number, options?: FormatMoneyOptions) => string;
/** The active currency preference. Useful for choosing surrounding copy. */
currency: CurrencyDisplay;
/** The fetched BTC/USD price, if available. Undefined while loading or on failure. */
btcPrice: number | undefined;
}
/**
* Format a satoshi amount as a string according to the user's currency preference.
*
* When `currencyDisplay === 'usd'` (the default) and a BTC price is available,
* the amount is converted to USD. If the price hasn't loaded yet or the request
* failed, the function falls back to the sats representation so we never block
* the UI on a network round-trip.
*
* The BTC price is fetched via TanStack Query with a `['btc-price', esploraBaseUrl]`
* key — the same key used by the wallet, zap dialogs, and on-chain zap flows — so
* a single request is deduped across the whole app.
*/
export function useFormatMoney(): FormatMoneyResult {
const { config } = useAppContext();
const currency: CurrencyDisplay = config.currencyDisplay ?? 'usd';
// Reuse the shared price query so all callers share one cached fetch.
const { data: btcPrice } = useQuery({
queryKey: ['btc-price', config.esploraBaseUrl],
queryFn: () => fetchBtcPrice(config.esploraBaseUrl),
// Prices move; 60 s is fine for display formatting.
staleTime: 60_000,
// Don't pop a UI error if the price endpoint is down; we just fall back to sats.
retry: 1,
enabled: currency === 'usd',
});
const format = useCallback(
(sats: number, options?: FormatMoneyOptions): string => {
const layout = options?.layout ?? 'long';
// USD mode with a known price → render dollars. We never round to zero
// for a non-zero zap; show the cent value so the user sees that any zap
// happened.
if (currency === 'usd' && btcPrice && Number.isFinite(btcPrice) && btcPrice > 0) {
return satsToUSD(sats, btcPrice);
}
// Sats mode, or USD mode without a price → render sats.
if (layout === 'compact') {
return formatNumber(sats);
}
return `${formatNumber(sats)} ${sats === 1 ? 'sat' : 'sats'}`;
},
[currency, btcPrice],
);
return { format, currency, btcPrice };
}
+225
View File
@@ -0,0 +1,225 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { notificationSuccess } from '@/lib/haptics';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
getFeeRates,
buildUnsignedPsbt,
finalizePsbt,
broadcastTransaction,
estimateFee,
} from '@/lib/bitcoin';
import type { FeeRates } from '@/lib/bitcoin';
export type OnchainFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
/**
* Resolves the fee rate for a given speed preset from a FeeRates bundle.
*/
function feeRateForSpeed(rates: FeeRates, speed: OnchainFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
interface OnchainZapArgs {
/** Amount to zap in satoshis. */
amountSats: number;
/** Optional comment to include in the kind 8333 event content. */
comment?: string;
/** Fee speed preset. Defaults to "halfHour". */
feeSpeed?: OnchainFeeSpeed;
}
interface OnchainZapResult {
/** The broadcast Bitcoin transaction ID. */
txid: string;
/** Amount sent in satoshis. */
amountSats: number;
/** Fee paid in satoshis. */
fee: number;
/** The published kind 8333 event. */
event: NostrEvent;
}
/**
* Hook for sending on-chain (Bitcoin L1) zaps to a Nostr event or profile.
*
* Flow:
* 1. Build, sign, and broadcast a Bitcoin transaction paying the target
* author's derived Taproot address.
* 2. Publish a kind 8333 "onchain zap" event referencing the txid, the
* target event (`e` or `a` tag), and the recipient's pubkey.
*
* Unlike NIP-57 Lightning zaps, this works for *any* Nostr user — there is
* no LNURL dependency because every pubkey has a derived Taproot address.
*/
export function useOnchainZap(
target: NostrEvent,
onSuccess?: (result: OnchainZapResult) => void,
) {
const { user } = useCurrentUser();
const { canSignPsbt, signPsbt } = useBitcoinSigner();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const queryClient = useQueryClient();
const [isZapping, setIsZapping] = useState(false);
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'>('idle');
const mutation = useMutation<OnchainZapResult, Error, OnchainZapArgs>({
mutationFn: async ({ amountSats, comment = '', feeSpeed = 'halfHour' }) => {
if (!user) throw new Error('You must be logged in to zap.');
if (user.pubkey === target.pubkey) throw new Error("You can't zap yourself.");
if (!canSignPsbt || !signPsbt) {
throw new Error(
"Your login doesn't support sending Bitcoin. Log in with your secret key to send Bitcoin zaps.",
);
}
if (!Number.isFinite(amountSats) || amountSats <= 0) {
throw new Error('Invalid amount.');
}
setIsZapping(true);
setProgress('building');
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey);
if (!senderAddress || !recipientAddress) {
throw new Error('Failed to derive Bitcoin address.');
}
// Fetch UTXOs and fee rates
const [utxos, rates] = await Promise.all([
fetchUTXOs(senderAddress, esploraBaseUrl),
getFeeRates(esploraBaseUrl),
]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
}
const feeRate = feeRateForSpeed(rates, feeSpeed);
const totalBalance = utxos.reduce((s, u) => s + u.value, 0);
const estFee = estimateFee(utxos.length, 2, feeRate);
if (amountSats + estFee > totalBalance) {
throw new Error(
`Insufficient funds. Need ~${(amountSats + estFee).toLocaleString()} sats, have ${totalBalance.toLocaleString()}.`,
);
}
// Build unsigned PSBT
const { psbtHex, fee } = buildUnsignedPsbt(
user.pubkey,
recipientAddress,
amountSats,
utxos,
feeRate,
);
// Sign
setProgress('signing');
const signedHex = await signPsbt(psbtHex);
const txHex = finalizePsbt(signedHex);
// Broadcast
setProgress('broadcasting');
const txid = await broadcastTransaction(txHex, esploraBaseUrl);
// Publish kind 8333 event
setProgress('publishing');
const isAddressable = target.kind >= 30000 && target.kind < 40000;
const tags: string[][] = [
['i', `bitcoin:tx:${txid}`],
['p', target.pubkey],
['amount', String(amountSats)],
];
if (isAddressable) {
const dTag = target.tags.find(([n]) => n === 'd')?.[1] ?? '';
tags.push(['a', `${target.kind}:${target.pubkey}:${dTag}`]);
}
// Always include `e` for a concrete event reference (even for addressable events)
tags.push(['e', target.id]);
tags.push(['alt', `Bitcoin zap: ${amountSats.toLocaleString()} sats`]);
const event = await publishEvent({
kind: 8333,
content: comment,
tags,
});
return { txid, amountSats, fee, event };
},
onSuccess: (result) => {
notificationSuccess();
// Optimistically mark the target as zapped-by-me so the action-bar
// bolt icon fills immediately, without waiting for the relay to echo
// the kind 8333 back through useUserZap's REQ.
queryClient.setQueryData(['user-zap', target.id], true);
// Invalidate caches that track zaps / balances
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
queryClient.invalidateQueries({ queryKey: ['event-interactions'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
// If the caller opted into handling success themselves (e.g. the
// ZapDialog shows a grand confirmation screen and owns the dismiss),
// skip the built-in toast — the screen is the feedback.
if (onSuccess) {
onSuccess(result);
} else {
toast({
title: 'Bitcoin zap sent!',
description: `Broadcast txid ${result.txid.slice(0, 12)}… (fee ${result.fee.toLocaleString()} sats)`,
});
}
},
onError: (err) => {
// If the signer turned out to not support PSBT signing (common for
// NIP-46 bunkers where capability can't be probed up front), mark the
// signer as unsupported for the rest of the session. The dialog UI
// watches this state and replaces itself with an "unsupported" panel
// instead of relying on this toast.
if (isSignerCapabilityError(err) && user) {
reportSignerUnsupported(user.pubkey);
return;
}
toast({
title: 'Bitcoin zap failed',
description: err.message,
variant: 'destructive',
});
},
onSettled: () => {
setIsZapping(false);
setProgress('idle');
},
});
return {
zap: mutation.mutate,
zapAsync: mutation.mutateAsync,
isZapping,
progress,
canZap: !!user && user.pubkey !== target.pubkey && canSignPsbt,
/** Whether the logged-in user has a PSBT-capable signer. */
canSignPsbt,
};
}
+215
View File
@@ -0,0 +1,215 @@
import { useQueries, useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { useAppContext } from '@/hooks/useAppContext';
/** A single verified on-chain zap, with the amount that actually paid the recipient on-chain. */
export interface OnchainZapEntry {
/** The kind 8333 event. */
event: NostrEvent;
/** Bitcoin transaction id (lowercase hex). */
txid: string;
/** Pubkey of the sender (the 8333 event author). */
senderPubkey: string;
/** Pubkey of the recipient (from `p` tag). */
recipientPubkey: string;
/** Verified amount in sats — sum of tx outputs that pay the recipient's derived Taproot address. */
amountSats: number;
/** Sender's self-reported amount (may differ from verified). */
claimedAmountSats: number;
/** Comment from the 8333 event content. */
comment: string;
/** Unix timestamp of the 8333 event. */
createdAt: number;
/** Whether the Bitcoin tx is confirmed on-chain. */
confirmed: boolean;
}
/** Parse the txid from a kind 8333 event's `i` tag. Returns null if missing or malformed. */
export function extractOnchainZapTxid(event: NostrEvent): string | null {
const iTag = event.tags.find(([n, v]) => n === 'i' && typeof v === 'string' && v.startsWith('bitcoin:tx:'));
if (!iTag?.[1]) return null;
const txid = iTag[1].slice('bitcoin:tx:'.length).toLowerCase();
if (!/^[0-9a-f]{64}$/.test(txid)) return null;
return txid;
}
/** Parse the claimed amount (sats) from a kind 8333 event. */
export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
const tag = event.tags.find(([n]) => n === 'amount');
if (!tag?.[1]) return 0;
const n = parseInt(tag[1], 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
/** Parse the recipient pubkey from a kind 8333 event (first `p` tag). */
export function extractOnchainZapRecipient(event: NostrEvent): string {
const tag = event.tags.find(([n]) => n === 'p');
return tag?.[1] ?? '';
}
/**
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
*
* Returns the verified amount (sum of tx outputs paying the recipient's
* derived Taproot address) and confirmation status. Returns `null` if the
* event is malformed or the transaction cannot be verified.
*
* A verified amount of 0 means the transaction exists but does not pay
* the claimed recipient — callers should discard such events.
*
* @param event The kind 8333 event to verify.
* @param esploraBaseUrl Esplora REST root used to fetch the tx detail.
*/
export async function verifyOnchainZap(
event: NostrEvent,
esploraBaseUrl: string,
): Promise<OnchainZapEntry | null> {
const txid = extractOnchainZapTxid(event);
const recipientPubkey = extractOnchainZapRecipient(event);
if (!txid || !recipientPubkey) return null;
// Reject self-zaps (sender == recipient). The sender already controls the
// destination address, so self-zaps are trivial to fabricate and contribute
// nothing meaningful to zap totals.
if (event.pubkey === recipientPubkey) return null;
const recipientAddress = nostrPubkeyToBitcoinAddress(recipientPubkey);
if (!recipientAddress) return null;
let detail;
try {
detail = await fetchTxDetail(txid, esploraBaseUrl);
} catch {
return null;
}
const amountSats = detail.outputs
.filter((o) => o.address === recipientAddress)
.reduce((sum, o) => sum + o.value, 0);
if (amountSats === 0) return null;
const claimed = extractOnchainZapClaimedAmount(event);
// If the sender is claiming more than the tx actually paid, cap it at the verified amount.
const effectiveClaim = Math.min(claimed || amountSats, amountSats);
return {
event,
txid,
senderPubkey: event.pubkey,
recipientPubkey,
amountSats: effectiveClaim,
claimedAmountSats: claimed,
comment: event.content,
createdAt: event.created_at,
confirmed: detail.confirmed,
};
}
/**
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
* each one on-chain. Returns only verified entries (deduped by txid).
*/
export function useOnchainZaps(target: NostrEvent | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const isAddressable = target && target.kind >= 30000 && target.kind < 40000;
const dTag = isAddressable
? target.tags.find(([n]) => n === 'd')?.[1] ?? ''
: '';
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
// Step 1: fetch the raw kind 8333 events for this target
const eventsQuery = useQuery({
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
queryFn: async ({ signal }) => {
if (!target) return [] as NostrEvent[];
const timeout = AbortSignal.timeout(5000);
const combined = AbortSignal.any([signal, timeout]);
const filters: Parameters<typeof nostr.query>[0] = [
{ kinds: [8333], '#e': [target.id], limit: 100 },
];
if (aCoord) {
filters.push({ kinds: [8333], '#a': [aCoord], limit: 100 });
}
const events = await nostr.query(filters, { signal: combined });
// Dedupe by event id, then by txid (one canonical zap per tx per target).
const byId = new Map<string, NostrEvent>();
for (const e of events) byId.set(e.id, e);
const byTxid = new Map<string, NostrEvent>();
for (const e of byId.values()) {
const txid = extractOnchainZapTxid(e);
if (!txid) continue;
const existing = byTxid.get(txid);
// Prefer the earliest event for each txid (first to claim this tx).
if (!existing || e.created_at < existing.created_at) {
byTxid.set(txid, e);
}
}
return Array.from(byTxid.values());
},
enabled: !!target,
staleTime: 30_000,
});
// Step 2: verify each event on-chain (parallel, cached per txid)
const events = eventsQuery.data ?? [];
const verifications = useQueries({
queries: events.map((event) => ({
queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, extractOnchainZapTxid(event), extractOnchainZapRecipient(event)],
queryFn: () => verifyOnchainZap(event, esploraBaseUrl),
staleTime: 60_000,
})),
});
const verified: OnchainZapEntry[] = verifications
.map((v) => v.data)
.filter((v): v is OnchainZapEntry => !!v);
// Sort by verified amount (largest first)
verified.sort((a, b) => b.amountSats - a.amountSats);
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
return {
zaps: verified,
totalSats,
count: verified.length,
isLoading,
};
}
/**
* Verify a single kind 8333 event against the Bitcoin blockchain and return
* the resulting `OnchainZapEntry`. Used by standalone surfaces (embedded
* cards, detail page) that need to display a verified amount without doing
* a full `#e`/`#a` fan-out.
*
* Returns `undefined` while loading, `null` if the event fails verification
* (invalid tx, wrong recipient, self-zap, etc.), or the entry.
*/
export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined {
const { config } = useAppContext();
const { esploraBaseUrl } = config;
const txid = event ? extractOnchainZapTxid(event) : null;
const recipient = event ? extractOnchainZapRecipient(event) : '';
const { data } = useQuery({
queryKey: ['onchain-zaps', 'verify', esploraBaseUrl, txid, recipient],
queryFn: () => verifyOnchainZap(event!, esploraBaseUrl),
enabled: !!event && !!txid && !!recipient,
staleTime: 60_000,
});
if (!event) return null;
return data;
}
-201
View File
@@ -1,201 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import type { NostrEvent } from '@nostrify/nostrify';
import type { BreezPaymentInfo } from '@/lib/spark/breezService';
export interface PaymentContext {
zapRequest?: NostrEvent;
targetEvent?: NostrEvent; // The event that was zapped
targetProfile?: string; // Pubkey if it was a profile zap
isZap: boolean;
}
/**
* Hook to fetch all zap receipts for the current user
* Cached globally to avoid repeated queries
*/
function useUserZapReceipts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['user-zap-receipts', user?.pubkey],
enabled: !!user?.pubkey,
staleTime: 60000, // 1 minute
queryFn: async (c) => {
if (!user?.pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
try {
// Query for zap receipts where the zapper (in description) is the current user
// We need to get recent receipts and filter client-side since we can't filter by description
const oneWeekAgo = Math.floor(Date.now() / 1000) - (7 * 24 * 60 * 60);
const receipts = await nostr.query(
[
{
kinds: [9735],
'#P': [user.pubkey],
since: oneWeekAgo,
limit: 500,
},
],
{ signal }
);
// Filter to only receipts where the current user was the zapper
return receipts.filter(receipt => {
const descriptionTag = receipt.tags.find(([name]) => name === 'description')?.[1];
if (!descriptionTag) return false;
try {
const zapRequest = JSON.parse(descriptionTag);
return zapRequest.pubkey === user.pubkey;
} catch {
return false;
}
});
} catch (error) {
console.warn('Failed to fetch zap receipts:', error);
return [];
}
},
});
}
/**
* Hook to fetch Nostr context for a payment (if it's a zap)
* Matches payment to zap receipt and extracts target info
*/
export function usePaymentContext(payment: BreezPaymentInfo) {
const { nostr } = useNostr();
const receiptsQuery = useUserZapReceipts();
const zapReceipts = receiptsQuery.data || [];
return useQuery({
queryKey: ['payment-context', payment.id, payment.invoice, zapReceipts.length],
enabled: payment.paymentType === 'send' && !!payment.invoice && zapReceipts.length > 0,
staleTime: 300000, // 5 minutes
queryFn: async (c) => {
if (!payment.invoice || zapReceipts.length === 0) {
return { isZap: false };
}
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
try {
// Find matching zap receipt by bolt11 invoice
const zapReceipt = zapReceipts.find(receipt => {
const bolt11 = receipt.tags.find(([name]) => name === 'bolt11')?.[1];
return bolt11 === payment.invoice;
});
if (!zapReceipt) {
return { isZap: false };
}
// Extract zap request from description
const descriptionTag = zapReceipt.tags.find(([name]) => name === 'description')?.[1];
let zapRequest: NostrEvent | undefined;
if (descriptionTag) {
try {
zapRequest = JSON.parse(descriptionTag) as NostrEvent;
} catch (error) {
console.warn('Failed to parse zap request:', error);
}
}
if (!zapRequest) {
return { isZap: true, zapRequest: undefined, targetEvent: undefined, targetProfile: undefined };
}
// Determine if this is an event zap or profile zap
// Event zaps have 'e' or 'a' tags in the zap request
const eventIdTag = zapRequest.tags.find(([name]) => name === 'e')?.[1];
const addrTag = zapRequest.tags.find(([name]) => name === 'a')?.[1];
const profileTag = zapRequest.tags.find(([name]) => name === 'p')?.[1];
let targetEvent: NostrEvent | undefined;
let targetProfile: string | undefined;
// Event zap - fetch the event
if (eventIdTag || addrTag) {
try {
if (eventIdTag) {
// Regular event zap
const events = await nostr.query(
[{ ids: [eventIdTag], limit: 1 }],
{ signal }
);
targetEvent = events[0];
} else if (addrTag) {
// Addressable event zap
const [kind, pubkey, identifier] = addrTag.split(':');
const events = await nostr.query(
[
{
kinds: [parseInt(kind)],
authors: [pubkey],
'#d': [identifier || ''],
limit: 1,
},
],
{ signal }
);
targetEvent = events[0];
}
} catch (error) {
console.warn('Failed to fetch target event:', error);
}
} else if (profileTag) {
// Profile zap
targetProfile = profileTag;
}
return {
isZap: true,
zapRequest,
targetEvent,
targetProfile,
};
} catch (error) {
console.warn('Failed to fetch payment context:', error);
return { isZap: false };
}
},
});
}
/**
* Hook to get enriched payment data with author information
*/
export function useEnrichedPayment(payment: BreezPaymentInfo | null) {
// Create dummy payment for hooks when payment is null
const dummyPayment: BreezPaymentInfo = {
id: '',
amount: 0,
fees: 0,
paymentType: 'send',
status: 'completed',
timestamp: 0,
};
const actualPayment = payment || dummyPayment;
const contextQuery = usePaymentContext(actualPayment);
const context = contextQuery.data;
// Get target author (either from target event or profile zap)
const targetPubkey = context?.targetEvent?.pubkey || context?.targetProfile;
const authorQuery = useAuthor(targetPubkey);
return {
...contextQuery,
context,
author: authorQuery.data,
isLoadingAuthor: authorQuery.isLoading,
};
}
-10
View File
@@ -1,10 +0,0 @@
/**
* useSparkWallet Hook
* Convenience hook for accessing Spark wallet functionality
*/
import { useSparkWalletContext } from '@/contexts/SparkWalletContext';
export function useSparkWallet() {
return useSparkWalletContext();
}
+7 -27
View File
@@ -1,22 +1,16 @@
import { useMemo } from 'react';
import { useNWC } from '@/hooks/useNWCContext';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import type { WebLNProvider } from '@webbtc/webln-types';
export interface WalletStatus {
hasNWC: boolean;
hasSpark: boolean;
sparkEnabled: boolean;
sparkConnected: boolean;
sparkBalance: number;
webln: WebLNProvider | null;
activeNWC: ReturnType<typeof useNWC>['getActiveConnection'] extends () => infer T ? T : null;
preferredMethod: 'spark' | 'nwc' | 'webln' | 'manual';
preferredMethod: 'nwc' | 'webln' | 'manual';
}
export function useWallet() {
const { connections, getActiveConnection } = useNWC();
const spark = useSparkWallet();
// Get the active connection directly - no memoization to avoid stale state
const activeNWC = getActiveConnection();
@@ -29,33 +23,19 @@ export function useWallet() {
return connections.length > 0 && connections.some(c => c.isConnected);
}, [connections]);
// Spark wallet status
const hasSpark = spark.hasWallet;
const sparkEnabled = spark.isEnabled;
const sparkConnected = spark.isInitialized;
const sparkBalance = spark.balance;
// Determine preferred payment method
// Priority: Spark > NWC > WebLN > Manual
const preferredMethod: WalletStatus['preferredMethod'] =
sparkEnabled && sparkConnected
? 'spark'
: activeNWC
? 'nwc'
: webln
? 'webln'
: 'manual';
const preferredMethod: WalletStatus['preferredMethod'] = activeNWC
? 'nwc'
: webln
? 'webln'
: 'manual';
const status: WalletStatus = {
hasNWC,
hasSpark,
sparkEnabled,
sparkConnected,
sparkBalance,
webln,
activeNWC,
preferredMethod,
};
return status;
}
}
+56 -66
View File
@@ -4,16 +4,12 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext';
import { useSparkWallet } from '@/hooks/useSparkWallet';
import type { NWCConnection } from '@/hooks/useNWC';
import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools';
import type { WebLNProvider } from '@webbtc/webln-types';
import { useQueryClient } from '@tanstack/react-query';
import { notificationSuccess } from '@/lib/haptics';
import { parseGoalEvent } from '@/lib/goalUtils';
import { createZapInvoice } from '@/lib/createZapInvoice';
import { breezService } from '@/lib/spark/breezService';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Hook for sending zaps to an event author.
@@ -32,7 +28,6 @@ export function useZaps(
const queryClient = useQueryClient();
const author = useAuthor(target?.pubkey);
const { sendPayment, getActiveConnection } = useNWC();
const sparkWallet = useSparkWallet();
const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null);
@@ -94,70 +89,71 @@ export function useZaps(
return;
}
const goalRelays = target.kind === 9041
? parseGoalEvent(target as unknown as NostrEvent)?.relays
: undefined;
// Get zap endpoint using the old reliable method
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
if (!zapEndpoint) {
toast({
title: 'Zap endpoint not found',
description: 'Could not find a zap endpoint for the author.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
// Create zap request - use appropriate event format based on kind
// For addressable events (30000-39999), pass the object to get 'a' tag
// For all other events, pass the ID string to get 'e' tag
const event = (target.kind >= 30000 && target.kind < 40000)
? target
: target.id;
const zapAmount = amount * 1000; // convert to millisats
const zapRequest = nip57.makeZapRequest({
profile: target.pubkey,
event: event,
amount: zapAmount,
relays: config.relayMetadata.relays.map(r => r.url),
comment
});
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
if (!user.signer) {
throw new Error('No signer available');
}
const signedZapRequest = await user.signer.signEvent(zapRequest);
try {
const newInvoice = await createZapInvoice({
recipientEvent: author.data.event,
recipientPubkey: target.pubkey,
target,
amountSats: amount,
comment,
relays: goalRelays && goalRelays.length > 0
? goalRelays
: config.relayMetadata.relays.map(r => r.url),
signer: user.signer,
});
const zapUrl = new URL(zapEndpoint);
zapUrl.searchParams.set('amount', String(zapAmount));
zapUrl.searchParams.set('nostr', JSON.stringify(signedZapRequest));
const res = await fetch(zapUrl.toString());
const responseText = await res.text();
let responseData: { pr?: string; reason?: string } = {};
try {
responseData = responseText ? JSON.parse(responseText) : {};
} catch (parseError) {
// Some LNURL providers return plain text/html for server errors.
console.warn('Failed to parse zap callback response as JSON', parseError);
}
if (!res.ok) {
const fallbackReason = responseText.trim() || 'Unknown error';
throw new Error(`HTTP ${res.status}: ${responseData.reason || fallbackReason}`);
}
const newInvoice = responseData.pr;
if (!newInvoice || typeof newInvoice !== 'string') {
throw new Error('Lightning service did not return a valid invoice');
}
// Get the current active NWC connection dynamically
const currentNWCConnection = getActiveConnection();
// Try self-custodial Agora Wallet first if it is ready and funded.
if (sparkWallet.isEnabled && sparkWallet.isInitialized && sparkWallet.balance >= amount) {
try {
await breezService.sendPayment(newInvoice);
await Promise.allSettled([
sparkWallet.refreshBalance(),
sparkWallet.refreshPayments(),
]);
setIsZapping(false);
setInvoice(null);
notificationSuccess();
queryClient.setQueryData(['user-zap', target.id], true);
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
if (onZapSuccess) {
onZapSuccess({ amountSats: amount });
} else {
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats from your Agora Wallet.`,
});
}
return;
} catch (sparkError) {
console.error('Agora Wallet payment failed, falling back:', sparkError);
const errorMessage = sparkError instanceof Error ? sparkError.message : 'Unknown wallet error';
toast({
title: 'Wallet payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
}
}
// Try NWC next if available and properly connected
// Try NWC first if available and properly connected
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
try {
await sendPayment(currentNWCConnection, newInvoice);
@@ -173,9 +169,6 @@ export function useZaps(
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
if (onZapSuccess) {
// Consumer (e.g. ZapDialog) owns the success UI — skip the
@@ -228,9 +221,6 @@ export function useZaps(
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
if (target.kind === 9041) {
queryClient.invalidateQueries({ queryKey: ['goal-progress', target.id] });
}
if (onZapSuccess) {
onZapSuccess({ amountSats: amount });
+63
View File
@@ -4,14 +4,39 @@ import type { NConnectSignerOpts } from '@nostrify/nostrify';
import { signPsbtLocal } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// BtcSigner interface
// ---------------------------------------------------------------------------
/**
* A Nostr signer extended with Bitcoin PSBT signing capability.
*
* Implementations receive a hex-encoded unsigned PSBT, sign all Taproot
* inputs whose `tapInternalKey` matches the signer's key, and return the
* hex-encoded signed (but not finalized) PSBT.
*/
export interface BtcSigner extends NostrSigner {
signPsbt(psbtHex: string): Promise<string>;
}
/** Runtime check for whether a signer supports `signPsbt`. */
export function hasBtcSigning(signer: NostrSigner): signer is BtcSigner {
return typeof (signer as BtcSigner).signPsbt === 'function';
}
// ---------------------------------------------------------------------------
// NSecSignerBtc — local nsec signing
// ---------------------------------------------------------------------------
/**
* Extends `NSecSigner` with local Taproot PSBT signing.
*
* `NSecSigner` stores the secret key in a JS `#private` field that subclasses
* cannot access. To work around this, the constructor accepts the raw secret
* key bytes, passes them to `super()`, and keeps its own copy in a true
* runtime-private `#secretKeyBytes` field so the key is not reachable via
* property enumeration or reflection on the instance.
*/
export class NSecSignerBtc extends NSecSigner implements BtcSigner {
readonly #secretKeyBytes: Uint8Array;
@@ -26,12 +51,23 @@ export class NSecSignerBtc extends NSecSigner implements BtcSigner {
}
}
// ---------------------------------------------------------------------------
// NBrowserSignerBtc — NIP-07 extension signing
// ---------------------------------------------------------------------------
/**
* Extends `NBrowserSigner` with NIP-07 `window.nostr.signPsbt()` support.
*
* Calls the extension's `signPsbt` method if available. If the extension does
* not expose `signPsbt`, an error is thrown with a user-friendly message.
*/
export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner {
constructor(opts?: { timeout?: number }) {
super(opts);
}
async signPsbt(psbtHex: string): Promise<string> {
// `awaitNostr` is TypeScript-private but JavaScript-public at runtime.
const nostr = await (this as unknown as { awaitNostr(): Promise<Record<string, unknown>> }).awaitNostr();
if (typeof nostr.signPsbt !== 'function') {
@@ -45,6 +81,17 @@ export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner {
}
}
// ---------------------------------------------------------------------------
// NConnectSignerBtc — NIP-46 remote signer
// ---------------------------------------------------------------------------
/**
* Heuristics for detecting whether a NIP-46 `sign_psbt` error reflects a
* missing-capability rejection (e.g. "method not supported", "unknown
* command") versus a transient operational failure (network, user rejection,
* malformed input). We have to match on strings because NIP-46 errors are
* plain strings without structured codes.
*/
const CAPABILITY_ERROR_PATTERNS = [
/unknown\s+(method|command)/i,
/not\s+(implemented|supported|found)/i,
@@ -58,12 +105,26 @@ function looksLikeCapabilityError(msg: string): boolean {
return CAPABILITY_ERROR_PATTERNS.some((re) => re.test(msg));
}
/**
* Extends `NConnectSigner` with NIP-46 `sign_psbt` RPC support.
*
* Sends a `sign_psbt` command over the NIP-46 relay channel. The remote
* signer handles the TapTweak and Schnorr signing internally.
*
* NIP-46 returns unstructured string errors, so we use pattern matching to
* distinguish capability failures (the signer doesn't know the method) from
* operational failures (network, user rejection, bad input). Only capability
* failures are re-wrapped with the "doesn't support sending Bitcoin" message
* that flips the UI into the unsupported state; everything else propagates
* unchanged so the caller can surface the real error.
*/
export class NConnectSignerBtc extends NConnectSigner implements BtcSigner {
constructor(opts: NConnectSignerOpts) {
super(opts);
}
async signPsbt(psbtHex: string): Promise<string> {
// `cmd` is TypeScript-private but JavaScript-public at runtime.
const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise<string> }).cmd;
try {
return await cmd.call(this, 'sign_psbt', [psbtHex]);
@@ -74,6 +135,8 @@ export class NConnectSignerBtc extends NConnectSigner implements BtcSigner {
`Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key. (${msg})`,
);
}
// Not a capability failure — propagate the original error so the user
// sees the actual reason (timeout, rejection, malformed PSBT, etc.).
throw error;
}
}
+38 -74
View File
@@ -7,18 +7,31 @@ import '@/lib/polyfills';
import {
isLargeAmount,
LARGE_AMOUNT_USD_THRESHOLD,
buildUnsignedMultiOutputPsbt,
estimateFee,
nostrPubkeyToBitcoinAddress,
npubToBitcoinAddress,
validateBitcoinAddress,
} from '@/lib/bitcoin';
import type { UTXO } from '@/lib/bitcoin';
// Initialise ECC once for this test file. In the running app, `main.tsx`
// does this at startup; in a test process `main.tsx` is never imported.
beforeAll(() => {
bitcoin.initEccLib(ecc);
});
/**
* Regression test vectors for key-path-only P2TR address derivation using the
* Nostr pubkey directly as the internal key (no script tree).
*
* Each vector was produced by the live `bitcoinjs-lib` + `@bitcoinerlab/secp256k1`
* toolchain and independently validated against the address's bech32m
* checksum. They serve as regression fixtures: if the derivation ever changes
* (library upgrade, ECC backend switch, etc.) these tests will fail loudly.
*
* Note: these are NOT the addresses in the BIP-341 wallet test vectors,
* because those vectors use a non-empty script tree (merkle root); our
* implementation uses a key-path-only spend path (empty merkle root), which
* is the correct derivation for mapping a Nostr pubkey to a spendable address.
*/
describe('nostrPubkeyToBitcoinAddress', () => {
it('derives the expected key-path-only Taproot address (fixture 1)', () => {
const internalPubkey = 'd6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d';
@@ -43,32 +56,41 @@ describe('nostrPubkeyToBitcoinAddress', () => {
it('produces a bech32m mainnet address that passes validation', () => {
const pubkey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2';
const address = nostrPubkeyToBitcoinAddress(pubkey);
expect(address.startsWith('bc1p')).toBe(true);
expect(validateBitcoinAddress(address)).toBe(true);
});
it('is deterministic', () => {
it('is deterministic — same input yields the same non-empty address', () => {
// Use a pubkey known to be a valid on-curve secp256k1 x-only point.
const pubkey = 'd6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d';
const a1 = nostrPubkeyToBitcoinAddress(pubkey);
const a2 = nostrPubkeyToBitcoinAddress(pubkey);
expect(a1).toBe(a2);
expect(a1).not.toBe('');
});
it('returns empty string for malformed pubkeys instead of throwing', () => {
// Too short.
expect(nostrPubkeyToBitcoinAddress('abc')).toBe('');
// Non-hex characters.
expect(nostrPubkeyToBitcoinAddress('z'.repeat(64))).toBe('');
// Empty string.
expect(nostrPubkeyToBitcoinAddress('')).toBe('');
// Odd length (not a whole number of bytes).
expect(nostrPubkeyToBitcoinAddress('a'.repeat(63))).toBe('');
});
it('returns empty string for hex that is not a valid secp256k1 x-only point', () => {
// Suppress the catch-block console.error for this test so it doesn't
// pollute the test output. The function is expected to log and return ''.
const origError = console.error;
console.error = () => {};
try {
// Valid 64-char hex, but not a valid on-curve secp256k1 x-only point.
expect(nostrPubkeyToBitcoinAddress('e7a2e3b5f1c8d4a6b9c0e1f2d3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2')).toBe('');
} finally {
console.error = origError;
@@ -85,10 +107,14 @@ describe('nostrPubkeyToBitcoinAddress', () => {
describe('npubToBitcoinAddress', () => {
it('decodes an npub and derives the matching Taproot address', () => {
// Any valid Nostr pubkey works — we just verify round-trip consistency.
const pubkey = '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2';
const npub = nip19.npubEncode(pubkey);
expect(npubToBitcoinAddress(npub)).toBe(nostrPubkeyToBitcoinAddress(pubkey));
const fromHex = nostrPubkeyToBitcoinAddress(pubkey);
const fromNpub = npubToBitcoinAddress(npub);
expect(fromNpub).toBe(fromHex);
});
it('throws on non-npub NIP-19 input', () => {
@@ -110,22 +136,28 @@ describe('validateBitcoinAddress', () => {
it('rejects malformed addresses', () => {
expect(validateBitcoinAddress('')).toBe(false);
expect(validateBitcoinAddress('not-an-address')).toBe(false);
// Valid-looking bech32m with broken checksum (flipped last char).
expect(validateBitcoinAddress('bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z6')).toBe(false);
});
});
describe('isLargeAmount', () => {
// Assume a BTC price of $100_000 for easy arithmetic. 1 BTC = $100k, so
// 1 sat = $0.001 and the $100 threshold corresponds to 100_000 sats.
const PRICE = 100_000;
it('returns true when the USD value is above the threshold', () => {
// 200,000 sats @ $100k/BTC = $200 — well above $100.
expect(isLargeAmount(200_000, PRICE)).toBe(true);
});
it('returns true at exactly the threshold', () => {
// 100,000 sats @ $100k/BTC = $100 — at the threshold (inclusive).
expect(isLargeAmount(100_000, PRICE)).toBe(true);
});
it('returns false below the threshold', () => {
// 50,000 sats @ $100k/BTC = $50 — below $100.
expect(isLargeAmount(50_000, PRICE)).toBe(false);
});
@@ -145,71 +177,3 @@ describe('isLargeAmount', () => {
expect(LARGE_AMOUNT_USD_THRESHOLD).toBe(100);
});
});
describe('buildUnsignedMultiOutputPsbt', () => {
const senderPubkey = 'd6889cb081036e0faefa3a35157ad71086b123b2b144b649798b494c300a961d';
const recipient1 = 'bc1pjxzw9tm6qatyapu3c409dg8k23p4hjlk4ehwwlsum3emjqsaetrqppyu2z';
const recipient2 = 'bc1p2jdrzv2w45xws7qlguk0acmz9clje8fasvhx3kv3cgpmhm8qtzhsq6fyhy';
const utxos: UTXO[] = [
{
txid: '00'.repeat(32),
vout: 0,
value: 50_000,
status: { confirmed: true },
},
];
it('builds one recipient output per payment plus change when economical', () => {
const { psbtHex, fee } = buildUnsignedMultiOutputPsbt(
senderPubkey,
[
{ address: recipient1, amountSats: 1_000 },
{ address: recipient2, amountSats: 2_000 },
],
utxos,
2,
);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
expect(psbt.txOutputs).toHaveLength(3);
expect(fee).toBe(estimateFee(1, 3, 2));
});
it('omits uneconomical dust change', () => {
const fee = estimateFee(1, 2, 2);
const { psbtHex } = buildUnsignedMultiOutputPsbt(
senderPubkey,
[
{ address: recipient1, amountSats: 10_000 },
{ address: recipient2, amountSats: 50_000 - 10_000 - fee - 100 },
],
utxos,
2,
);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
expect(psbt.txOutputs).toHaveLength(2);
});
it('rejects empty output sets', () => {
expect(() => buildUnsignedMultiOutputPsbt(senderPubkey, [], utxos, 2)).toThrow(/recipient/i);
});
it('rejects outputs below dust', () => {
expect(() => buildUnsignedMultiOutputPsbt(
senderPubkey,
[{ address: recipient1, amountSats: 1 }],
utxos,
2,
)).toThrow(/546/);
});
it('rejects insufficient funds', () => {
expect(() => buildUnsignedMultiOutputPsbt(
senderPubkey,
[{ address: recipient1, amountSats: 100_000 }],
utxos,
2,
)).toThrow(/insufficient/i);
});
});
+291 -53
View File
@@ -4,39 +4,68 @@ import { nip19 } from 'nostr-tools';
import * as ecc from '@bitcoinerlab/secp256k1';
import { ECPairFactory, type ECPairAPI } from 'ecpair';
const MEMPOOL_API = 'https://mempool.space/api';
export const BITCOIN_DUST_LIMIT = 546;
const VBYTES_PER_INPUT = 57.5;
const VBYTES_PER_OUTPUT = 43;
const VBYTES_OVERHEAD = 10.5;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
let _ECPair: ECPairAPI | null = null;
let eccInitialized = false;
/** Standard Bitcoin dust limit in satoshis. */
const DUST_LIMIT = 546;
/** Minimum non-dust output value in satoshis (BIP-141 / P2TR relay policy). */
export const BITCOIN_DUST_LIMIT = DUST_LIMIT;
function initBitcoinEcc(): void {
if (eccInitialized) return;
bitcoin.initEccLib(ecc);
eccInitialized = true;
/** A single output (recipient address + amount) for building a multi-output PSBT. */
export interface BitcoinPaymentOutput {
address: string;
amountSats: number;
}
/** Estimated vBytes per P2TR input. */
const VBYTES_PER_INPUT = 57.5;
/** Estimated vBytes per P2TR output. */
const VBYTES_PER_OUTPUT = 43;
/** Estimated vBytes for transaction overhead (version, locktime, etc.). */
const VBYTES_OVERHEAD = 10.5;
// ---------------------------------------------------------------------------
// ECC initialisation (lazy)
// ---------------------------------------------------------------------------
let _ECPair: ECPairAPI | null = null;
function getECPair(): ECPairAPI {
initBitcoinEcc();
if (!_ECPair) {
bitcoin.initEccLib(ecc);
_ECPair = ECPairFactory(ecc);
}
return _ECPair;
}
/**
* Strict 32-byte hex validator. Rejects anything that isn't exactly 64
* lowercase-or-uppercase hex characters.
*/
function isValidPubkeyHex(hex: string): boolean {
return typeof hex === 'string' && /^[0-9a-fA-F]{64}$/.test(hex);
}
/**
* Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address.
*
* Both Nostr and Bitcoin Taproot use secp256k1 with 32-byte x-only public keys
* (Schnorr / BIP-340), so the key can be used directly as a Taproot internal
* public key with no mathematical conversion.
*
* Returns an empty string if the input is malformed or not a valid x-only key
* on the secp256k1 curve.
*/
export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
if (!isValidPubkeyHex(pubkeyHex)) return '';
try {
initBitcoinEcc();
const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');
const { address } = bitcoin.payments.p2tr({
internalPubkey: pubkeyBuffer,
network: bitcoin.networks.bitcoin,
@@ -49,6 +78,10 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
}
}
/**
* Convert a bech32 `npub1...` identifier to a Bitcoin Taproot (P2TR) address.
* Decodes the npub to a hex pubkey, then delegates to {@link nostrPubkeyToBitcoinAddress}.
*/
export function npubToBitcoinAddress(npub: string): string {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
@@ -57,24 +90,44 @@ export function npubToBitcoinAddress(npub: string): string {
return nostrPubkeyToBitcoinAddress(decoded.data);
}
// ---------------------------------------------------------------------------
// Balance / Address data (wallet page)
// ---------------------------------------------------------------------------
/** Balance data returned by the Esplora API. */
export interface AddressData {
/** Confirmed on-chain balance in satoshis. */
balance: number;
/** Unconfirmed mempool balance in satoshis. */
pendingBalance: number;
/** Sum of confirmed + pending balance. */
totalBalance: number;
/** Total satoshis ever received (confirmed). */
totalReceived: number;
/** Total satoshis ever sent (confirmed). */
totalSent: number;
/** Confirmed transaction count. */
txCount: number;
/** Pending (mempool) transaction count. */
pendingTxCount: number;
}
export async function fetchAddressData(address: string): Promise<AddressData> {
const response = await fetch(`${MEMPOOL_API}/address/${address}`);
/**
* Fetch balance and transaction stats for a Bitcoin address from an
* Esplora-compatible REST API (e.g. mempool.space, Blockstream).
*
* @param address The Bitcoin address to look up.
* @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`).
*/
export async function fetchAddressData(address: string, baseUrl: string): Promise<AddressData> {
const response = await fetch(`${baseUrl}/address/${address}`);
if (!response.ok) {
throw new Error('Failed to fetch balance');
}
const data = await response.json();
const confirmedBalance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum;
const pendingBalance = data.mempool_stats.funded_txo_sum - data.mempool_stats.spent_txo_sum;
@@ -89,20 +142,39 @@ export async function fetchAddressData(address: string): Promise<AddressData> {
};
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/** Convert satoshis to a BTC string with up to 8 decimal places. */
export function satsToBTC(sats: number): string {
return (sats / 100_000_000).toFixed(8);
}
/**
* Convert satoshis to a BTC string with trailing zeros stripped.
* E.g. `formatBTC(100_000_000)` → `"1"`, `formatBTC(1_234_560)` → `"0.0123456"`.
*/
export function formatBTC(sats: number): string {
return satsToBTC(sats).replace(/\.?0+$/, '');
}
/** Format a satoshi amount with locale-aware thousand separators. */
export function formatSats(sats: number): string {
return sats.toLocaleString();
}
export async function fetchBtcPrice(): Promise<number> {
const response = await fetch(`${MEMPOOL_API}/v1/prices`);
/**
* Fetch the current BTC price in USD from a mempool.space-compatible API.
*
* Note: the `/v1/prices` endpoint is a mempool.space extension to the
* standard Esplora REST surface. Backends like Blockstream's Esplora do
* not expose it.
*
* @param baseUrl Esplora REST root, no trailing slash (e.g. `https://mempool.space/api`).
*/
export async function fetchBtcPrice(baseUrl: string): Promise<number> {
const response = await fetch(`${baseUrl}/v1/prices`);
if (!response.ok) {
throw new Error('Failed to fetch BTC price');
@@ -112,12 +184,23 @@ export async function fetchBtcPrice(): Promise<number> {
return data.USD;
}
/** Convert a BTC amount to satoshis (rounded to nearest integer). */
export function btcToSats(btc: number): number {
return Math.round(btc * 100_000_000);
}
/**
* USD threshold above which Bitcoin send/zap flows require explicit
* confirmation (two-tap). Chosen to catch meaningful dollar amounts without
* nagging on everyday $5$25 zaps.
*/
export const LARGE_AMOUNT_USD_THRESHOLD = 100;
/**
* Whether a given satoshi amount crosses the "large amount" threshold at the
* current BTC/USD price. Returns false when `btcPrice` is unavailable, so the
* UI does not arm confirmation without a known USD value.
*/
export function isLargeAmount(sats: number, btcPrice: number | undefined): boolean {
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return false;
if (!Number.isFinite(sats) || sats <= 0) return false;
@@ -125,6 +208,7 @@ export function isLargeAmount(sats: number, btcPrice: number | undefined): boole
return usd >= LARGE_AMOUNT_USD_THRESHOLD;
}
/** Convert satoshis to USD given a BTC price. */
export function satsToUSD(sats: number, btcPrice: number): string {
const btc = sats / 100_000_000;
return (btc * btcPrice).toLocaleString('en-US', {
@@ -135,10 +219,7 @@ export function satsToUSD(sats: number, btcPrice: number): string {
});
}
/**
* Like {@link satsToUSD} but rounded to the nearest whole dollar (no cents).
* Use for zap goal / campaign progress displays where cents are visual noise.
*/
/** Convert satoshis to a whole-dollar USD string (no cents). */
export function satsToUSDWhole(sats: number, btcPrice: number): string {
const btc = sats / 100_000_000;
return (btc * btcPrice).toLocaleString('en-US', {
@@ -149,22 +230,40 @@ export function satsToUSDWhole(sats: number, btcPrice: number): string {
});
}
/** Convert USD to satoshis given a BTC price. Returns 0 for invalid input. */
export function usdToSats(usd: number, btcPrice: number | undefined): number {
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return 0;
if (!Number.isFinite(usd) || usd <= 0) return 0;
return Math.round((usd / btcPrice) * 100_000_000);
}
// ---------------------------------------------------------------------------
// Wallet-page transaction list (simplified per-address view)
// ---------------------------------------------------------------------------
/** A simplified transaction relevant to a specific address. */
export interface Transaction {
/** Transaction ID (hex). */
txid: string;
/** Net satoshi change for the address (positive = received, negative = sent). */
amount: number;
/** Whether this is a receive or send relative to the address. */
type: 'receive' | 'send';
/** Whether the transaction is confirmed. */
confirmed: boolean;
/** Unix timestamp of the block (undefined if unconfirmed). */
timestamp?: number;
}
export async function fetchTransactions(address: string): Promise<Transaction[]> {
const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`);
/**
* Fetch transactions for a Bitcoin address from an Esplora-compatible API.
* Returns simplified transactions with net amount relative to the address.
*
* @param address The Bitcoin address to look up.
* @param baseUrl Esplora REST root, no trailing slash.
*/
export async function fetchTransactions(address: string, baseUrl: string): Promise<Transaction[]> {
const response = await fetch(`${baseUrl}/address/${address}/txs`);
if (!response.ok) {
throw new Error('Failed to fetch transactions');
@@ -177,6 +276,7 @@ export async function fetchTransactions(address: string): Promise<Transaction[]>
const vout = tx.vout as Array<{ scriptpubkey_address?: string; value: number }>;
const status = tx.status as { confirmed: boolean; block_time?: number };
// Sum sats flowing out of this address (inputs we owned)
const totalIn = vin.reduce((sum, input) => {
if (input.prevout?.scriptpubkey_address === address) {
return sum + input.prevout.value;
@@ -184,6 +284,7 @@ export async function fetchTransactions(address: string): Promise<Transaction[]>
return sum;
}, 0);
// Sum sats flowing into this address (outputs we own)
const totalOut = vout.reduce((sum, output) => {
if (output.scriptpubkey_address === address) {
return sum + output.value;
@@ -203,6 +304,11 @@ export async function fetchTransactions(address: string): Promise<Transaction[]>
});
}
// ---------------------------------------------------------------------------
// Full transaction detail (NIP-73 /i/bitcoin:tx:... page)
// ---------------------------------------------------------------------------
/** A single input in a full transaction. */
export interface TxInput {
txid: string;
vout: number;
@@ -211,13 +317,16 @@ export interface TxInput {
isCoinbase: boolean;
}
/** A single output in a full transaction. */
export interface TxOutput {
address?: string;
value: number;
scriptpubkeyType: string;
/** True if the output has been spent. */
spent: boolean;
}
/** Full transaction detail returned by the Esplora API. */
export interface TxDetail {
txid: string;
version: number;
@@ -231,15 +340,24 @@ export interface TxDetail {
blockTime?: number;
inputs: TxInput[];
outputs: TxOutput[];
/** Total value of all inputs (sats). */
totalInput: number;
/** Total value of all outputs (sats). */
totalOutput: number;
}
export async function fetchTxDetail(txid: string): Promise<TxDetail> {
const response = await fetch(`${MEMPOOL_API}/tx/${txid}`);
/**
* Fetch full transaction details from an Esplora-compatible API.
*
* @param txid The transaction ID (hex).
* @param baseUrl Esplora REST root, no trailing slash.
*/
export async function fetchTxDetail(txid: string, baseUrl: string): Promise<TxDetail> {
const response = await fetch(`${baseUrl}/tx/${txid}`);
if (!response.ok) throw new Error('Failed to fetch transaction');
const tx = await response.json();
const vin = tx.vin as Array<{
txid: string;
vout: number;
@@ -265,7 +383,7 @@ export async function fetchTxDetail(txid: string): Promise<TxDetail> {
address: output.scriptpubkey_address,
value: output.value,
scriptpubkeyType: output.scriptpubkey_type,
spent: false,
spent: false, // Esplora /tx endpoint doesn't include spending info
}));
const totalInput = inputs.reduce((sum, i) => sum + i.value, 0);
@@ -289,6 +407,11 @@ export async function fetchTxDetail(txid: string): Promise<TxDetail> {
};
}
// ---------------------------------------------------------------------------
// Full address detail (NIP-73 /i/bitcoin:address:... page)
// ---------------------------------------------------------------------------
/** Full address detail combining balance stats + recent transactions. */
export interface AddressDetail {
address: string;
balance: number;
@@ -298,13 +421,20 @@ export interface AddressDetail {
totalSent: number;
txCount: number;
pendingTxCount: number;
/** Most recent transactions (up to 25). */
recentTxs: Transaction[];
}
export async function fetchAddressDetail(address: string): Promise<AddressDetail> {
/**
* Fetch full address details (balance + recent txs) from an Esplora-compatible API.
*
* @param address The Bitcoin address to look up.
* @param baseUrl Esplora REST root, no trailing slash.
*/
export async function fetchAddressDetail(address: string, baseUrl: string): Promise<AddressDetail> {
const [addrData, txs] = await Promise.all([
fetchAddressData(address),
fetchTransactions(address),
fetchAddressData(address, baseUrl),
fetchTransactions(address, baseUrl),
]);
return {
@@ -314,9 +444,15 @@ export async function fetchAddressDetail(address: string): Promise<AddressDetail
};
}
// ---------------------------------------------------------------------------
// Sending: UTXOs, fee estimation, transaction construction, broadcast
// ---------------------------------------------------------------------------
/** An unspent transaction output. */
export interface UTXO {
txid: string;
vout: number;
/** Value in satoshis. */
value: number;
status: {
confirmed: boolean;
@@ -326,22 +462,39 @@ export interface UTXO {
};
}
export async function fetchUTXOs(address: string): Promise<UTXO[]> {
const response = await fetch(`${MEMPOOL_API}/address/${address}/utxo`);
/**
* Fetch UTXOs for a Bitcoin address from an Esplora-compatible API.
*
* @param address The Bitcoin address to look up.
* @param baseUrl Esplora REST root, no trailing slash.
*/
export async function fetchUTXOs(address: string, baseUrl: string): Promise<UTXO[]> {
const response = await fetch(`${baseUrl}/address/${address}/utxo`);
if (!response.ok) throw new Error('Failed to fetch UTXOs');
return response.json();
}
/** Fee rate estimates keyed by confirmation speed. */
export interface FeeRates {
/** ~10 min / next block (target 1). */
fastestFee: number;
/** ~30 min (target 3). */
halfHourFee: number;
/** ~1 hour (target 6). */
hourFee: number;
/** ~1 day (target 144). */
economyFee: number;
/** Minimum relay fee (target 504). */
minimumFee: number;
}
export async function getFeeRates(): Promise<FeeRates> {
const response = await fetch(`${MEMPOOL_API}/fee-estimates`);
/**
* Fetch recommended fee rates (sat/vB) from an Esplora-compatible API.
*
* @param baseUrl Esplora REST root, no trailing slash.
*/
export async function getFeeRates(baseUrl: string): Promise<FeeRates> {
const response = await fetch(`${baseUrl}/fee-estimates`);
if (!response.ok) throw new Error('Failed to fetch fee estimates');
const data = await response.json();
@@ -355,11 +508,22 @@ export async function getFeeRates(): Promise<FeeRates> {
};
}
/**
* Estimate the fee for a P2TR transaction in satoshis.
*
* @param numInputs Number of Taproot inputs.
* @param numOutputs Number of outputs (recipient + optional change).
* @param feeRate Fee rate in sat/vB.
*/
export function estimateFee(numInputs: number, numOutputs: number, feeRate: number): number {
const vBytes = numInputs * VBYTES_PER_INPUT + numOutputs * VBYTES_PER_OUTPUT + VBYTES_OVERHEAD;
return Math.ceil(vBytes * feeRate);
}
/**
* Validate a Bitcoin address (mainnet). Returns `true` if the address has a
* valid format and checksum, `false` otherwise.
*/
export function validateBitcoinAddress(address: string): boolean {
try {
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
@@ -369,8 +533,15 @@ export function validateBitcoinAddress(address: string): boolean {
}
}
export async function broadcastTransaction(txHex: string): Promise<string> {
const response = await fetch(`${MEMPOOL_API}/tx`, {
/**
* Broadcast a signed transaction hex to the Bitcoin network via an
* Esplora-compatible API. Returns the txid.
*
* @param txHex The signed transaction hex.
* @param baseUrl Esplora REST root, no trailing slash.
*/
export async function broadcastTransaction(txHex: string, baseUrl: string): Promise<string> {
const response = await fetch(`${baseUrl}/tx`, {
method: 'POST',
body: txHex,
});
@@ -383,21 +554,41 @@ export async function broadcastTransaction(txHex: string): Promise<string> {
return response.text();
}
/**
* Compute the maximum sendable amount (in sats) after fees.
*
* @param totalBalance Total spendable sats across all UTXOs.
* @param numInputs Number of UTXOs that will be consumed.
* @param feeRate Fee rate in sat/vB.
* @returns The max amount in sats, or 0 if the balance cannot cover fees.
*/
export function maxSendable(totalBalance: number, numInputs: number, feeRate: number): number {
// When sending max there is no change output, so only 1 output.
const fee = estimateFee(numInputs, 1, feeRate);
return Math.max(0, totalBalance - fee);
}
/** Result of building an unsigned PSBT. */
export interface UnsignedPsbt {
/** Hex-encoded unsigned PSBT. */
psbtHex: string;
/** Fee in satoshis. */
fee: number;
}
export interface BitcoinPaymentOutput {
address: string;
amountSats: number;
}
/**
* Build an unsigned Taproot PSBT ready for signing.
*
* This function constructs the PSBT with all inputs and outputs but does NOT
* sign it. The returned hex can be passed to any signer (local nsec, NIP-07
* extension, or NIP-46 remote signer).
*
* @param senderPubkeyHex 32-byte hex x-only public key of the sender.
* @param toAddress Recipient Bitcoin address.
* @param amountSats Amount to send in satoshis.
* @param utxos Available UTXOs (all will be consumed).
* @param feeRate Fee rate in sat/vB.
*/
export function buildUnsignedPsbt(
senderPubkeyHex: string,
toAddress: string,
@@ -405,20 +596,16 @@ export function buildUnsignedPsbt(
utxos: UTXO[],
feeRate: number,
): UnsignedPsbt {
if (!validateBitcoinAddress(toAddress)) {
throw new Error(`Invalid Bitcoin address: ${toAddress}`);
}
if (!Number.isInteger(amountSats) || amountSats < BITCOIN_DUST_LIMIT) {
throw new Error(`Bitcoin outputs must be at least ${BITCOIN_DUST_LIMIT} sats.`);
}
const internalPubkey = Buffer.from(senderPubkeyHex, 'hex');
// Derive change address (same Taproot address as sender)
const { address: changeAddress } = bitcoin.payments.p2tr({
internalPubkey,
network: bitcoin.networks.bitcoin,
});
if (!changeAddress) throw new Error('Failed to derive change address');
// Build PSBT, add all UTXOs as inputs
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
let totalInput = 0;
@@ -438,8 +625,11 @@ export function buildUnsignedPsbt(
totalInput += utxo.value;
}
// Estimate fee — first assume 2 outputs (recipient + change). Change at the
// dust limit exactly is still standard, so use >= (not >) per BIP-141/P2TR
// relay policy (minimum non-dust output is 546 sats).
const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate);
const hasChange = change2Out >= BITCOIN_DUST_LIMIT;
const hasChange = change2Out >= DUST_LIMIT;
const numOutputs = hasChange ? 2 : 1;
const fee = estimateFee(utxos.length, numOutputs, feeRate);
const change = totalInput - amountSats - fee;
@@ -450,6 +640,7 @@ export function buildUnsignedPsbt(
);
}
// Add outputs
psbt.addOutput({ address: toAddress, value: BigInt(amountSats) });
if (hasChange) {
@@ -459,6 +650,18 @@ export function buildUnsignedPsbt(
return { psbtHex: psbt.toHex(), fee };
}
/**
* Build an unsigned Taproot PSBT with multiple recipient outputs.
*
* Used by community batch zaps and campaign donations to fan out a single
* signed transaction to many recipients. All UTXOs are consumed; change (if
* above the dust limit) returns to the sender's Taproot address.
*
* @param senderPubkeyHex 32-byte hex x-only public key of the sender.
* @param outputs Recipient outputs. Each amount must be >= the dust limit.
* @param utxos Available UTXOs (all will be consumed).
* @param feeRate Fee rate in sat/vB.
*/
export function buildUnsignedMultiOutputPsbt(
senderPubkeyHex: string,
outputs: BitcoinPaymentOutput[],
@@ -473,8 +676,8 @@ export function buildUnsignedMultiOutputPsbt(
if (!validateBitcoinAddress(output.address)) {
throw new Error(`Invalid Bitcoin address: ${output.address}`);
}
if (!Number.isInteger(output.amountSats) || output.amountSats < BITCOIN_DUST_LIMIT) {
throw new Error(`Bitcoin outputs must be at least ${BITCOIN_DUST_LIMIT} sats.`);
if (!Number.isInteger(output.amountSats) || output.amountSats < DUST_LIMIT) {
throw new Error(`Bitcoin outputs must be at least ${DUST_LIMIT} sats.`);
}
}
@@ -507,7 +710,7 @@ export function buildUnsignedMultiOutputPsbt(
const totalOutput = outputs.reduce((sum, output) => sum + output.amountSats, 0);
const feeWithChange = estimateFee(utxos.length, outputs.length + 1, feeRate);
const changeWithChange = totalInput - totalOutput - feeWithChange;
const hasChange = changeWithChange >= BITCOIN_DUST_LIMIT;
const hasChange = changeWithChange >= DUST_LIMIT;
const numOutputs = outputs.length + (hasChange ? 1 : 0);
const fee = estimateFee(utxos.length, numOutputs, feeRate);
const change = totalInput - totalOutput - fee;
@@ -529,17 +732,32 @@ export function buildUnsignedMultiOutputPsbt(
return { psbtHex: psbt.toHex(), fee };
}
/**
* Sign a PSBT locally using a raw private key (nsec).
*
* Applies the BIP-341 TapTweak to the private key, signs all inputs whose
* `tapInternalKey` matches, and returns the signed (but not finalized) PSBT hex.
*
* @param psbtHex Hex-encoded unsigned PSBT.
* @param privateKeyHex 32-byte hex private key.
* @returns Hex-encoded signed PSBT (not finalized).
*/
export function signPsbtLocal(psbtHex: string, privateKeyHex: string): string {
initBitcoinEcc();
bitcoin.initEccLib(ecc);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex'));
const internalPubkey = toXOnly(keyPair.publicKey);
// Tweak private key for Taproot key-path spending (BIP-341)
const tweakedSigner = keyPair.tweak(
bitcoin.crypto.taggedHash('TapTweak', internalPubkey),
);
// Per the NIP spec: inputs whose `tapInternalKey` does not match the
// signer's x-only pubkey MUST be left unchanged. This matters for future
// multi-signer PSBTs; today `buildUnsignedPsbt` only ever adds the user's
// own UTXOs, so in practice every input matches.
let signedAny = false;
for (let i = 0; i < psbt.inputCount; i++) {
const input = psbt.data.inputs[i];
@@ -558,13 +776,32 @@ export function signPsbtLocal(psbtHex: string, privateKeyHex: string): string {
return psbt.toHex();
}
/**
* Finalize a signed PSBT and extract the raw transaction hex.
*
* @param psbtHex Hex-encoded signed PSBT.
* @returns Raw transaction hex ready for broadcast.
*/
export function finalizePsbt(psbtHex: string): string {
initBitcoinEcc();
bitcoin.initEccLib(ecc);
const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin });
psbt.finalizeAllInputs();
return psbt.extractTransaction().toHex();
}
/**
* Create, sign, and return a raw Bitcoin Taproot transaction.
*
* Convenience wrapper that calls {@link buildUnsignedPsbt},
* {@link signPsbtLocal}, and {@link finalizePsbt} in sequence.
*
* @param privateKeyHex 32-byte hex private key (from Nostr nsec).
* @param toAddress Recipient Bitcoin address.
* @param amountSats Amount to send in satoshis.
* @param utxos Available UTXOs (all will be consumed).
* @param feeRate Fee rate in sat/vB.
* @returns The signed transaction hex and the fee paid.
*/
export function createBitcoinTransaction(
privateKeyHex: string,
toAddress: string,
@@ -572,6 +809,7 @@ export function createBitcoinTransaction(
utxos: UTXO[],
feeRate: number,
): { txHex: string; fee: number } {
// Derive the x-only pubkey from the private key for buildUnsignedPsbt
const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex'));
const internalPubkey = toXOnly(keyPair.publicKey);
const senderPubkeyHex = Buffer.from(internalPubkey).toString('hex');
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { isNostrId } from './nostrId';
describe('isNostrId', () => {
it('accepts a valid 64-char lowercase hex string', () => {
const id = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
expect(isNostrId(id)).toBe(true);
});
it('rejects strings shorter than 64 chars', () => {
expect(isNostrId('deadbeef')).toBe(false);
expect(isNostrId('a'.repeat(63))).toBe(false);
});
it('rejects strings longer than 64 chars', () => {
expect(isNostrId('a'.repeat(65))).toBe(false);
});
it('rejects uppercase hex (Nostr canonicalises to lowercase)', () => {
expect(isNostrId('A'.repeat(64))).toBe(false);
expect(isNostrId('79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798')).toBe(false);
});
it('rejects non-hex characters', () => {
expect(isNostrId('z'.repeat(64))).toBe(false);
expect(isNostrId('g'.repeat(64))).toBe(false);
});
it('rejects surrounding whitespace', () => {
const id = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
expect(isNostrId(` ${id}`)).toBe(false);
expect(isNostrId(`${id} `)).toBe(false);
});
it('rejects empty / null / undefined / non-string inputs', () => {
expect(isNostrId('')).toBe(false);
expect(isNostrId(undefined)).toBe(false);
expect(isNostrId(null)).toBe(false);
expect(isNostrId(123)).toBe(false);
expect(isNostrId({})).toBe(false);
});
it('rejects the malformed length-9 hex string from the original bug report', () => {
// The crash that started this work: "padded hex string expected, got
// unpadded hex of length 9".
expect(isNostrId('123456789')).toBe(false);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { NSchema as n } from '@nostrify/nostrify';
/**
* Branded type for a validated 32-byte Nostr identifier (pubkey or event id):
* a 64-character lowercase hex string.
*
* Use `isNostrId` to produce a `HexId` from untrusted input, or `assertNostrId`
* to assert that a `string` is already a `HexId`. Functions consuming a `HexId`
* (e.g. `nip19.npubEncode` via wrappers) can trust it without re-validating.
*/
export type HexId = string & { readonly __brand: 'HexId' };
/**
* Canonical validator for 32-byte Nostr identifiers — pubkeys and event ids.
*
* Backed by Nostrify's {@link NSchema.id} so the rest of the stack inherits
* any future tightening upstream (e.g. case rules or whitespace handling).
*
* Use this **at the parse layer** whenever a pubkey or event id is extracted
* from untrusted event content (tag values, JSON-parsed content, URL params)
* before it reaches `nip19.*Encode`, `nostr.query` filters, or React route
* params. Malformed hex of the wrong length throws "padded hex string
* expected" from `@noble/hashes` deep inside `nip19`, which crashes the
* rendering subtree.
*
* Returns a type guard narrowing to {@link HexId} — the false branch retains
* the input's original type, so existing `string` callers keep working.
*
* Prefer the {@link tryNpubEncode}/{@link tryNeventEncode}/{@link tryNaddrEncode}
* wrappers from `@/lib/safeNip19` for non-throwing encodes at the render site.
*/
export function isNostrId(value: unknown): value is HexId {
return idSchema.safeParse(value).success;
}
const idSchema = n.id();
+1
View File
@@ -257,6 +257,7 @@ export const AppConfigSchema = z.object({
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
sandboxDomain: z.string().optional(),
esploraBaseUrl: z.string().url(),
currencyDisplay: z.enum(['usd', 'sats']).optional(),
sidebarWidgets: z.array(z.object({
id: z.string(),
height: z.number().optional(),
-8
View File
@@ -3,7 +3,6 @@ import {
Award,
BadgeCheck,
Bell,
Bitcoin,
Bird,
Blocks,
BookMarked,
@@ -127,13 +126,6 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
icon: WalletMinimal,
requiresAuth: true,
},
{
id: "bitcoin",
label: "Bitcoin",
path: "/bitcoin",
icon: Bitcoin,
requiresAuth: true,
},
{ id: "feed", label: "Feed", path: "/feed", icon: LogoIcon },
{ id: "campaigns", label: "Fundraisers", path: "/campaigns", icon: HandHeart },
{
+18 -1
View File
@@ -7,9 +7,26 @@
*/
import type { NostrEvent, NostrSigner } from "@nostrify/nostrify";
import type { SparkBackupData } from "./types";
import { logger } from "@/lib/logger";
/**
* Encrypted backup payload published to relays as kind 30078 (NIP-78).
*
* The `content` of the backup event is JSON-serialised SparkBackupData,
* whose `encryptedMnemonic` field is the NIP-44 ciphertext of the user's
* 12-word recovery phrase. Self-encryption (pubkey encrypts for itself)
* means only the same Nostr key can recover the mnemonic.
*/
interface SparkBackupData {
version: 2;
type: "spark-wallet-backup";
encryption: "nip44";
pubkey: string;
encryptedMnemonic: string;
createdAt: number; // milliseconds
createdBy: string;
}
/** NIP-78 kind for application-specific data */
const BACKUP_KIND = 30078;
-144
View File
@@ -1,144 +0,0 @@
/**
* Rate Limiter for Wallet Restore
*
* Implements exponential backoff on failed mnemonic restore attempts
* to prevent brute-force attacks on wallet recovery.
*
* Security: Rate limit state is stored in sessionStorage (cleared on tab close)
* to prevent cross-session tracking while still protecting against attacks.
*/
import { logger } from '@/lib/logger';
const RATE_LIMIT_KEY = 'spark-wallet-restore-rate-limit';
interface RateLimitState {
failedAttempts: number;
lastFailedAt: number;
lockedUntil: number;
}
/**
* Calculate lockout duration based on failed attempts (exponential backoff)
* - 1-2 failures: No lockout
* - 3 failures: 30 seconds
* - 4 failures: 2 minutes
* - 5 failures: 5 minutes
* - 6+ failures: 15 minutes
*/
function getLockoutDuration(failedAttempts: number): number {
if (failedAttempts < 3) return 0;
if (failedAttempts === 3) return 30 * 1000; // 30 seconds
if (failedAttempts === 4) return 2 * 60 * 1000; // 2 minutes
if (failedAttempts === 5) return 5 * 60 * 1000; // 5 minutes
return 15 * 60 * 1000; // 15 minutes for 6+ failures
}
/**
* Get current rate limit state from session storage
*/
function getRateLimitState(): RateLimitState {
try {
const stored = sessionStorage.getItem(RATE_LIMIT_KEY);
if (!stored) {
return { failedAttempts: 0, lastFailedAt: 0, lockedUntil: 0 };
}
return JSON.parse(stored) as RateLimitState;
} catch {
return { failedAttempts: 0, lastFailedAt: 0, lockedUntil: 0 };
}
}
/**
* Save rate limit state to session storage
*/
function saveRateLimitState(state: RateLimitState): void {
try {
sessionStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(state));
} catch (error) {
logger.error('[RateLimiter] Failed to save state:', error);
}
}
/**
* Check if restore attempts are currently rate limited
* @returns Object with isLimited flag and remainingSeconds if limited
*/
export function checkRestoreRateLimit(): { isLimited: boolean; remainingSeconds: number; failedAttempts: number } {
const state = getRateLimitState();
const now = Date.now();
if (state.lockedUntil > now) {
const remainingMs = state.lockedUntil - now;
const remainingSeconds = Math.ceil(remainingMs / 1000);
logger.debug('[RateLimiter] Rate limited for', remainingSeconds, 'seconds');
return { isLimited: true, remainingSeconds, failedAttempts: state.failedAttempts };
}
return { isLimited: false, remainingSeconds: 0, failedAttempts: state.failedAttempts };
}
/**
* Record a failed restore attempt and apply rate limiting
* @returns Object with lockout info
*/
export function recordFailedRestoreAttempt(): { isLocked: boolean; lockoutSeconds: number; failedAttempts: number } {
const state = getRateLimitState();
const now = Date.now();
// Increment failed attempts
const newFailedAttempts = state.failedAttempts + 1;
const lockoutDuration = getLockoutDuration(newFailedAttempts);
const lockedUntil = lockoutDuration > 0 ? now + lockoutDuration : 0;
const newState: RateLimitState = {
failedAttempts: newFailedAttempts,
lastFailedAt: now,
lockedUntil,
};
saveRateLimitState(newState);
logger.warn('[RateLimiter] Failed attempt', newFailedAttempts, 'lockout:', lockoutDuration / 1000, 'seconds');
return {
isLocked: lockoutDuration > 0,
lockoutSeconds: Math.ceil(lockoutDuration / 1000),
failedAttempts: newFailedAttempts,
};
}
/**
* Record a successful restore (clears rate limit state)
*/
export function recordSuccessfulRestore(): void {
try {
sessionStorage.removeItem(RATE_LIMIT_KEY);
logger.debug('[RateLimiter] Rate limit state cleared on success');
} catch (error) {
logger.error('[RateLimiter] Failed to clear state:', error);
}
}
/**
* Clear rate limit state (for manual reset or testing)
*/
export function clearRestoreRateLimit(): void {
try {
sessionStorage.removeItem(RATE_LIMIT_KEY);
logger.debug('[RateLimiter] Rate limit state manually cleared');
} catch (error) {
logger.error('[RateLimiter] Failed to clear state:', error);
}
}
/**
* Format remaining lockout time for display
*/
export function formatLockoutTime(seconds: number): string {
if (seconds < 60) {
return `${seconds} second${seconds !== 1 ? 's' : ''}`;
}
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
-481
View File
@@ -1,481 +0,0 @@
/**
* Spark Wallet Storage
*
* Secure storage using NIP-44 encryption for both localStorage and sessionStorage.
* Mnemonic is encrypted with the user's Nostr pubkey before storage.
* Based on Primal's sparkStorage.ts for compatibility.
*
* SECURITY: All sensitive data (mnemonic) is encrypted with NIP-44.
*/
import type { SparkWalletConfig, NostrSigner, LockTimeoutMinutes } from './types';
import { logger } from '@/lib/logger';
const SPARK_SEED_KEY_PREFIX = 'spark_seed_';
const SPARK_CONFIG_KEY = 'spark_config';
const MNEMONIC_SESSION_KEY = 'spark-wallet-mnemonic-session';
/**
* Get storage key for user's Spark seed
*/
function getSeedStorageKey(pubkey: string): string {
return `${SPARK_SEED_KEY_PREFIX}${pubkey}`;
}
/**
* Get storage key for user's Spark config
*/
function getConfigStorageKey(pubkey: string): string {
return `${SPARK_CONFIG_KEY}_${pubkey}`;
}
/**
* Save encrypted seed to localStorage using NIP-44 encryption
* @param seed - BIP39 mnemonic seed phrase
* @param pubkey - User's Nostr public key (used for encryption)
* @param signer - Nostr signer with nip44 methods
*/
export async function saveEncryptedSeed(
seed: string,
pubkey: string,
signer: NostrSigner
): Promise<void> {
try {
logger.debug('[SparkStorage] Saving encrypted seed with NIP-44...');
if (!signer.nip44) {
throw new Error('Signer does not support NIP-44 encryption');
}
// Encrypt the seed to self using NIP-44
const encryptedSeed = await signer.nip44.encrypt(pubkey, seed);
// Store in localStorage as JSON with version marker
const storageKey = getSeedStorageKey(pubkey);
const data = {
version: 'nip44_v1',
ciphertext: encryptedSeed,
};
localStorage.setItem(storageKey, JSON.stringify(data));
logger.debug('[SparkStorage] Seed saved successfully');
} catch (error) {
logger.error('[SparkStorage] Failed to save encrypted seed:', error);
throw new Error(`Failed to save Spark wallet seed: ${error}`);
}
}
/**
* Load and decrypt seed from localStorage
* @param pubkey - User's Nostr public key
* @param signer - Nostr signer with nip44 methods
* @returns Decrypted seed or null if not found
*/
export async function loadEncryptedSeed(
pubkey: string,
signer: NostrSigner
): Promise<string | null> {
try {
const storageKey = getSeedStorageKey(pubkey);
const storedData = localStorage.getItem(storageKey);
if (!storedData) {
logger.debug('[SparkStorage] No encrypted seed found');
return null;
}
logger.debug('[SparkStorage] Loading encrypted seed...');
if (!signer.nip44) {
throw new Error('Signer does not support NIP-44 encryption');
}
// Try to parse as JSON (new format)
try {
const data = JSON.parse(storedData);
if (data.version === 'nip44_v1' && data.ciphertext) {
// New format: NIP-44 encrypted
const seed = await signer.nip44.decrypt(pubkey, data.ciphertext);
logger.debug('[SparkStorage] Seed loaded with NIP-44');
return seed;
}
} catch {
// Not JSON, try as legacy plain encrypted string
logger.debug('[SparkStorage] Attempting legacy format...');
}
// Legacy format: plain NIP-44 encrypted string
const seed = await signer.nip44.decrypt(pubkey, storedData);
logger.debug('[SparkStorage] Seed loaded with legacy format');
// Auto-migrate to new format
await saveEncryptedSeed(seed, pubkey, signer);
return seed;
} catch (error) {
logger.error('[SparkStorage] Failed to load encrypted seed:', error);
throw new Error(`Failed to load Spark wallet seed: ${error}`);
}
}
/**
* Clear stored seed from localStorage
* @param pubkey - User's Nostr public key
*/
export function clearSeed(pubkey: string): void {
try {
const storageKey = getSeedStorageKey(pubkey);
localStorage.removeItem(storageKey);
logger.debug('[SparkStorage] Seed cleared');
} catch (error) {
logger.error('[SparkStorage] Failed to clear seed:', error);
throw error;
}
}
/**
* Check if a Spark wallet is configured for the user
* @param pubkey - User's Nostr public key
* @returns True if wallet is configured
*/
export function isSparkWalletConfigured(pubkey: string): boolean {
const storageKey = getSeedStorageKey(pubkey);
return localStorage.getItem(storageKey) !== null;
}
/**
* Save Spark wallet configuration
* @param pubkey - User's Nostr public key
* @param config - Wallet configuration
*/
export function saveSparkConfig(
pubkey: string,
config: SparkWalletConfig
): void {
try {
const storageKey = getConfigStorageKey(pubkey);
localStorage.setItem(storageKey, JSON.stringify(config));
logger.debug('[SparkStorage] Config saved');
} catch (error) {
logger.error('[SparkStorage] Failed to save config:', error);
throw error;
}
}
/**
* Load Spark wallet configuration
* @param pubkey - User's Nostr public key
* @returns Wallet configuration or null if not found
*/
export function loadSparkConfig(
pubkey: string
): SparkWalletConfig | null {
try {
const storageKey = getConfigStorageKey(pubkey);
const configJson = localStorage.getItem(storageKey);
if (!configJson) {
return null;
}
return JSON.parse(configJson) as SparkWalletConfig;
} catch (error) {
logger.error('[SparkStorage] Failed to load config:', error);
return null;
}
}
/**
* Clear Spark wallet configuration
* @param pubkey - User's Nostr public key
*/
export function clearSparkConfig(pubkey: string): void {
try {
const storageKey = getConfigStorageKey(pubkey);
localStorage.removeItem(storageKey);
logger.debug('[SparkStorage] Config cleared');
} catch (error) {
logger.error('[SparkStorage] Failed to clear config:', error);
throw error;
}
}
/**
* Clear all Spark wallet data for a user
* @param pubkey - User's Nostr public key
*/
export function clearAllSparkData(pubkey: string): void {
clearSeed(pubkey);
clearSparkConfig(pubkey);
clearMnemonicSession();
logger.debug('[SparkStorage] All Spark data cleared');
}
/**
* Store mnemonic in session storage with NIP-44 encryption.
* Session storage is cleared when the browser tab closes.
*
* SECURITY: Mnemonic is encrypted with NIP-44 before storage,
* protecting against XSS attacks and malicious browser extensions.
*
* @param mnemonic - The mnemonic to store
* @param pubkey - User's Nostr public key
* @param signer - Nostr signer with nip44 methods
*/
export async function storeMnemonicSession(
mnemonic: string,
pubkey: string,
signer: NostrSigner
): Promise<void> {
try {
if (!signer.nip44) {
throw new Error('Signer does not support NIP-44 encryption');
}
// Encrypt the mnemonic with NIP-44
const encrypted = await signer.nip44.encrypt(pubkey, mnemonic);
// Store as JSON with version marker
const data = {
version: 'nip44_v1',
ciphertext: encrypted,
pubkey, // Store pubkey to verify on retrieval
};
sessionStorage.setItem(MNEMONIC_SESSION_KEY, JSON.stringify(data));
logger.debug('[SparkStorage] Mnemonic session stored (encrypted)');
} catch (error) {
logger.error('[SparkStorage] Failed to store mnemonic session:', error);
}
}
/**
* Retrieve and decrypt mnemonic from session storage.
*
* @param pubkey - User's Nostr public key
* @param signer - Nostr signer with nip44 methods
* @returns Decrypted mnemonic or null if not found
*/
export async function getMnemonicSession(
pubkey: string,
signer: NostrSigner
): Promise<string | null> {
try {
const stored = sessionStorage.getItem(MNEMONIC_SESSION_KEY);
if (!stored) return null;
if (!signer.nip44) {
throw new Error('Signer does not support NIP-44 decryption');
}
const data = JSON.parse(stored);
// Verify pubkey matches (prevents using wrong key)
if (data.pubkey !== pubkey) {
logger.warn('[SparkStorage] Session pubkey mismatch, clearing');
clearMnemonicSession();
return null;
}
if (data.version === 'nip44_v1' && data.ciphertext) {
const mnemonic = await signer.nip44.decrypt(pubkey, data.ciphertext);
logger.debug('[SparkStorage] Mnemonic session retrieved (decrypted)');
return mnemonic;
}
// Unknown format
logger.warn('[SparkStorage] Unknown session format, clearing');
clearMnemonicSession();
return null;
} catch (error) {
logger.error('[SparkStorage] Failed to get mnemonic session:', error);
return null;
}
}
/**
* Clear mnemonic from session storage
*/
export function clearMnemonicSession(): void {
try {
sessionStorage.removeItem(MNEMONIC_SESSION_KEY);
logger.debug('[SparkStorage] Mnemonic session cleared');
} catch (error) {
logger.error('[SparkStorage] Failed to clear mnemonic session:', error);
}
}
/**
* Validate mnemonic format (basic validation)
* NOTE: This only checks format, not BIP39 validity.
* Use breezService.validateMnemonic() for full BIP39 validation.
*
* @param mnemonic - Mnemonic to validate
* @returns True if format appears valid
*/
export function validateMnemonicFormat(mnemonic: string): boolean {
// Basic validation: check word count (should be 12, 15, 18, 21, or 24 words)
const words = mnemonic.trim().split(/\s+/);
const validWordCounts = [12, 15, 18, 21, 24];
if (!validWordCounts.includes(words.length)) {
logger.warn('[SparkStorage] Invalid mnemonic word count');
return false;
}
// Check that all words are lowercase alphabetic
for (const word of words) {
if (!/^[a-z]+$/.test(word)) {
logger.warn('[SparkStorage] Invalid mnemonic word format');
return false;
}
}
return true;
}
// Legacy export for backward compatibility
export { validateMnemonicFormat as validateMnemonic };
/**
* Set the auto-lock timeout for the wallet
* @param timeout - Timeout in minutes (0 = disabled)
* @param pubkey - User's Nostr public key
*/
export function setLockTimeout(timeout: LockTimeoutMinutes, pubkey: string): void {
const config = loadSparkConfig(pubkey) || { hasWallet: true };
saveSparkConfig(pubkey, {
...config,
lockTimeout: timeout,
});
logger.debug('[SparkStorage] Lock timeout set to', timeout, 'minutes');
}
/**
* Get the current lock timeout setting
* @param pubkey - User's Nostr public key
* @returns Lock timeout in minutes (0 = disabled)
*/
export function getLockTimeout(pubkey: string): LockTimeoutMinutes {
const config = loadSparkConfig(pubkey);
return config?.lockTimeout ?? 0;
}
/**
* Update the last activity timestamp for auto-lock tracking
* @param pubkey - User's Nostr public key
*/
export function updateLastActivity(pubkey: string): void {
const config = loadSparkConfig(pubkey);
if (config) {
saveSparkConfig(pubkey, {
...config,
lastActivityAt: Date.now(),
});
}
}
/**
* Get the last activity timestamp
* @param pubkey - User's Nostr public key
* @returns Last activity timestamp or null if not set
*/
export function getLastActivity(pubkey: string): number | null {
const config = loadSparkConfig(pubkey);
return config?.lastActivityAt ?? null;
}
/**
* Check if wallet should be auto-locked based on inactivity
* @param pubkey - User's Nostr public key
* @returns True if wallet should be locked
*/
export function shouldAutoLock(pubkey: string): boolean {
const config = loadSparkConfig(pubkey);
const timeout = config?.lockTimeout;
if (timeout === undefined || timeout === 0) {
return false; // Auto-lock disabled
}
const lastActivity = config?.lastActivityAt;
if (!lastActivity) {
return false; // No activity recorded yet
}
const timeoutMs = timeout * 60 * 1000;
const timeSinceActivity = Date.now() - lastActivity;
return timeSinceActivity > timeoutMs;
}
// Legacy exports for backward compatibility
export {
loadSparkConfig as loadWalletConfig,
saveSparkConfig as saveWalletConfig,
clearSparkConfig as clearWalletConfig,
};
/**
* Update cached balance (legacy compatibility)
*/
export function updateCachedBalance(balance: number, pubkey?: string): void {
if (!pubkey) return;
const config = loadSparkConfig(pubkey) || { hasWallet: true, isEnabled: true };
saveSparkConfig(pubkey, {
...config,
cachedBalance: balance,
lastSynced: Date.now(),
});
}
/**
* Mark wallet as having been created/restored (legacy compatibility)
*/
export function markWalletCreated(pubkey?: string): void {
if (!pubkey) return;
const config = loadSparkConfig(pubkey) || {};
saveSparkConfig(pubkey, {
...config,
hasWallet: true,
isEnabled: true,
});
}
/**
* Mark wallet as removed (legacy compatibility)
*/
export function markWalletRemoved(pubkey?: string): void {
if (pubkey) {
clearAllSparkData(pubkey);
}
clearMnemonicSession();
}
/**
* Check if a wallet exists locally (legacy compatibility)
*/
export function hasLocalWallet(pubkey?: string): boolean {
if (!pubkey) return false;
return isSparkWalletConfigured(pubkey);
}
/**
* Check if wallet is enabled (legacy compatibility)
*/
export function isWalletEnabled(pubkey?: string): boolean {
if (!pubkey) return false;
const config = loadSparkConfig(pubkey);
return config?.hasWallet === true && config?.isEnabled !== false;
}
/**
* Set wallet enabled state (legacy compatibility)
*/
export function setWalletEnabled(enabled: boolean, pubkey?: string): void {
if (!pubkey) return;
const config = loadSparkConfig(pubkey) || { hasWallet: true };
saveSparkConfig(pubkey, {
...config,
isEnabled: enabled,
});
}
-99
View File
@@ -1,99 +0,0 @@
/**
* Spark Wallet Types
* TypeScript types for the Spark wallet integration
*/
import type { Payment, SdkEvent } from "@breeztech/breez-sdk-spark/web";
/** Nostr signer interface with NIP-44 support */
export interface NostrSigner {
getPublicKey(): Promise<string>;
signEvent(event: unknown): Promise<unknown>;
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
}
/** Wallet connection state */
export interface SparkWalletState {
isInitialized: boolean;
isConnecting: boolean;
balance: number;
pendingBalance: number;
sparkAddress: string | null;
bitcoinAddress: string | null;
}
/** Auto-lock timeout options in minutes (0 = disabled) */
export type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30 | 60;
/** Wallet configuration stored locally */
export interface SparkWalletConfig {
hasWallet?: boolean;
isEnabled?: boolean;
lastSynced?: number;
cachedBalance?: number;
network?: "mainnet" | "regtest";
createdAt?: number;
lud16?: string; // Lightning address if registered
encryptionVersion?: string;
/** Auto-lock timeout in minutes (0 = disabled) */
lockTimeout?: LockTimeoutMinutes;
/** Timestamp of last user activity (for auto-lock) */
lastActivityAt?: number;
}
/** File backup data structure (v2 format - compatible with zapcooking/sparkihonne) */
export interface SparkBackupData {
version: 2;
type: "spark-wallet-backup";
encryption: "nip44";
pubkey: string;
encryptedMnemonic: string;
createdAt: number; // milliseconds
createdBy: string;
}
/** Payment with additional UI metadata */
export interface SparkPayment extends Payment {
formattedAmount?: string;
formattedDate?: string;
}
/** Event listener callback type */
export type SparkEventCallback = (event: SdkEvent) => void;
/** Receive payment method options */
export type ReceiveMethod = "lightning" | "spark" | "bitcoin";
/** Send payment destination types */
export type SendDestination =
| { type: "bolt11"; invoice: string }
| { type: "lightningAddress"; address: string; amount: number }
| { type: "spark"; address: string; amount: number }
| { type: "bitcoin"; address: string; amount: number };
/** Wallet initialization options */
export interface WalletInitOptions {
mnemonic: string;
apiKey: string;
network?: "mainnet" | "regtest";
}
/** Parse result types for input classification */
export type ParsedInputType =
| "bolt11"
| "lnurl"
| "lightningAddress"
| "sparkAddress"
| "sparkInvoice"
| "bitcoinAddress"
| "unknown";
export interface ParsedInput {
type: ParsedInputType;
data: unknown;
amount?: number;
description?: string;
}
-207
View File
@@ -1,207 +0,0 @@
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { ArrowDownLeft, ArrowUpRight, Bitcoin, Check, ChevronDown, Copy, RefreshCw, Send } from 'lucide-react';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import { SendBitcoinDialog } from '@/components/SendBitcoinDialog';
import { Button } from '@/components/ui/button';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { formatBTC, satsToUSD } from '@/lib/bitcoin';
import type { Transaction } from '@/lib/bitcoin';
export function BitcoinPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, error, refetch } = useBitcoinWallet();
const [copiedAddress, setCopiedAddress] = useState(false);
const [txOpen, setTxOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
useSeoMeta({
title: `Bitcoin | ${config.appName}`,
description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.',
});
const copyAddress = async () => {
if (!bitcoinAddress) return;
try {
await navigator.clipboard.writeText(bitcoinAddress);
setCopiedAddress(true);
setTimeout(() => setCopiedAddress(false), 2000);
} catch {
// Clipboard API unavailable.
}
};
const truncatedAddress = bitcoinAddress ? `${bitcoinAddress.slice(0, 12)}...${bitcoinAddress.slice(-8)}` : '';
return (
<main>
<PageHeader title="Bitcoin" icon={<Bitcoin className="size-5" />} />
{!user ? (
<div className="flex flex-col items-center gap-6 px-8 py-20 text-center">
<div className="rounded-full bg-primary/10 p-4">
<Bitcoin className="size-8 text-primary" />
</div>
<div className="max-w-xs space-y-2">
<h2 className="text-xl font-bold">Your Bitcoin Wallet</h2>
<p className="text-sm text-muted-foreground">
Log in to see your Bitcoin Taproot address derived from your Nostr identity.
</p>
</div>
<LoginArea className="max-w-60" />
</div>
) : (
<div className="mx-auto flex max-w-sm flex-col items-center space-y-6 px-4 pb-4 pt-8">
{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="space-y-3 text-center">
<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>
{addressData.pendingBalance !== 0 && (
<span className="flex items-center gap-1 pt-1 text-xs text-orange-500 dark:text-orange-400">
<RefreshCw className="size-3 animate-spin" />
{btcPrice ? `${satsToUSD(addressData.pendingBalance, btcPrice)} pending` : 'pending'}
</span>
)}
</div>
) : null}
{addressData && (
<Button variant="outline" size="sm" onClick={() => setSendOpen(true)} className="rounded-full">
<Send className="size-3.5 mr-1.5" />
Send
</Button>
)}
<SendBitcoinDialog isOpen={sendOpen} onClose={() => setSendOpen(false)} btcPrice={btcPrice} />
{bitcoinAddress ? (
<div className="rounded-2xl bg-white p-4 shadow-sm">
<QRCodeCanvas value={bitcoinAddress} size={200} level="M" />
</div>
) : (
<Skeleton className="size-[232px] rounded-2xl" />
)}
{bitcoinAddress && (
<button
type="button"
onClick={copyAddress}
className="flex items-center gap-2 rounded-full border px-4 py-2 font-mono text-sm text-muted-foreground transition-colors hover:bg-muted/50"
>
{truncatedAddress}
{copiedAddress ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
)}
{transactions && transactions.length > 0 && (
<>
<button
type="button"
onClick={() => setTxOpen((open) => !open)}
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
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>
);
}
function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
return (
<div
className="grid w-full transition-[grid-template-rows] duration-300 ease-in-out"
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
>
<div ref={contentRef} className="overflow-hidden">
{children}
</div>
</div>
);
}
function formatTxDate(timestamp?: number): string {
if (!timestamp) return 'Pending';
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (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: Transaction; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="-mx-1 flex items-center justify-between rounded-lg px-2 py-3 transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className={`flex size-8 items-center justify-center rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}
>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{isReceive ? '+' : '-'}{btcPrice ? satsToUSD(tx.amount, btcPrice) : `${formatBTC(tx.amount)} BTC`}
</p>
<p className="text-xs text-muted-foreground">{formatBTC(tx.amount)} BTC</p>
</div>
</Link>
);
}
+229 -297
View File
@@ -1,312 +1,244 @@
/**
* Wallet Page
* Main wallet interface with balance, send, receive, and history.
*
* Ported from the legacy Agora (pathos) Wallet page. PageLayout is replaced
* with the agora-3 PageHeader + a 2xl content container.
*/
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight, Send } from 'lucide-react';
import { useState, useEffect, type ReactNode } from "react";
import { useSeoMeta } from "@unhead/react";
import { useTranslation } from "react-i18next";
import {
ArrowDownLeft,
ArrowUpRight,
Wallet as WalletIcon,
Plus,
RefreshCw,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CreateWallet,
RestoreWallet,
WalletBalance,
ReceivePayment,
SendPayment,
PaymentHistory,
UnclaimedDeposits,
WasmUnsupportedError,
} from "@/components/SparkWallet";
import { WalletLockScreen } from "@/components/SparkWallet/WalletLockScreen";
import { useSparkWallet } from "@/hooks/useSparkWallet";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useAppContext } from "@/hooks/useAppContext";
import { LoginArea } from "@/components/auth/LoginArea";
import { PageHeader } from "@/components/PageHeader";
import { checkWasmSupport } from "@/lib/checkWasmSupport";
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/PageHeader';
import { LoginArea } from '@/components/auth/LoginArea';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { SendBitcoinDialog } from '@/components/SendBitcoinDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { satsToUSD, formatBTC } from '@/lib/bitcoin';
import type { Transaction } from '@/lib/bitcoin';
type SetupMode = "choice" | "create" | "restore" | null;
export function WalletPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, error, refetch } = useBitcoinWallet();
const [copiedAddress, setCopiedAddress] = useState(false);
const [txOpen, setTxOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
useSeoMeta({
title: `Wallet | ${config.appName}`,
description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.',
});
const copyAddress = async () => {
if (!bitcoinAddress) return;
try {
await navigator.clipboard.writeText(bitcoinAddress);
setCopiedAddress(true);
setTimeout(() => setCopiedAddress(false), 2000);
} catch {
// clipboard API not available
}
};
const truncatedAddress = bitcoinAddress
? `${bitcoinAddress.slice(0, 12)}...${bitcoinAddress.slice(-8)}`
: '';
function WalletShell({ children }: { children: ReactNode }) {
return (
<main>
<PageHeader title="Wallet" icon={<WalletIcon className="size-5" />} />
<div className="max-w-2xl mx-auto px-4 py-4 sm:py-6">{children}</div>
<PageHeader title="Wallet" icon={<Wallet className="size-5" />} />
{!user ? (
<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" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">Your Bitcoin Wallet</h2>
<p className="text-muted-foreground text-sm">
Log in to see your Bitcoin Taproot address derived from your Nostr identity.
</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>
{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>
)}
</div>
) : null}
{/* Send button */}
{addressData && (
<Button
variant="outline"
size="sm"
onClick={() => setSendOpen(true)}
className="rounded-full"
>
<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" />
</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>
{/* 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>
</>
)}
{/* 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>
)}
</main>
);
}
export function WalletPage() {
const { t } = useTranslation();
const { config } = useAppContext();
const [setupMode, setSetupMode] = useState<SetupMode>(null);
const [activeTab, setActiveTab] = useState("overview");
const [wasmSupported, setWasmSupported] = useState<boolean | null>(null);
const [wasmError, setWasmError] = useState<string | null>(null);
const { hasWallet, balance, hasBackup, isCheckingBackup, isLocked } =
useSparkWallet();
const { user } = useCurrentUser();
useEffect(() => {
checkWasmSupport().then((result) => {
setWasmSupported(result.supported);
if (!result.supported) {
setWasmError(result.reason || "WebAssembly is not supported");
}
});
}, []);
useSeoMeta({
title: `${t("wallet.title")} | ${config.appName}`,
description:
"Manage your self-custodial Lightning wallet. Send and receive Bitcoin payments instantly.",
});
if (wasmSupported === null) {
return (
<WalletShell>
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Checking browser compatibility...
</p>
</div>
</CardContent>
</Card>
</WalletShell>
);
}
if (wasmSupported === false) {
return (
<WalletShell>
<WasmUnsupportedError technicalDetails={wasmError ?? undefined} />
</WalletShell>
);
}
if (!user) {
return (
<WalletShell>
<Card className="bg-primary/5 border-primary/20">
<CardContent className="pt-6">
<div className="text-center space-y-4">
<div className="w-16 h-16 mx-auto rounded-full bg-primary/20 flex items-center justify-center">
<WalletIcon className="h-8 w-8 text-primary" />
</div>
<h3 className="font-semibold text-lg">Wallet</h3>
<p className="text-muted-foreground">
You need to be logged in with your Nostr account to create or
access your wallet.
</p>
<LoginArea className="justify-center" />
</div>
</CardContent>
</Card>
</WalletShell>
);
}
if (hasWallet && isLocked) {
return (
<WalletShell>
<WalletLockScreen />
</WalletShell>
);
}
if (!hasWallet || setupMode === "create" || setupMode === "restore") {
if (setupMode === "create") {
return (
<WalletShell>
<CreateWallet
onComplete={() => setSetupMode(null)}
onCancel={() => setSetupMode("choice")}
/>
</WalletShell>
);
}
if (setupMode === "restore") {
return (
<WalletShell>
<RestoreWallet
onComplete={() => setSetupMode(null)}
onCancel={() => setSetupMode("choice")}
/>
</WalletShell>
);
}
return (
<WalletShell>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<WalletIcon className="h-8 w-8 text-primary" />
</div>
<CardTitle>{t('wallet.title')}</CardTitle>
<CardDescription>{t('wallet.selfCustodialWallet')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isCheckingBackup ? (
<div className="py-4 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t('wallet.checkingBackup')}
</p>
</div>
) : hasBackup ? (
<>
<Card className="bg-muted/50 border-dashed">
<CardContent className="py-4 text-center">
<p className="text-sm font-medium">
{t('wallet.backupFound')}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t('wallet.canRestore')}
</p>
</CardContent>
</Card>
<Button
onClick={() => setSetupMode("restore")}
className="w-full"
size="lg"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('wallet.restoreExistingWallet')}
</Button>
<Button
onClick={() => setSetupMode("create")}
variant="outline"
className="w-full"
size="lg"
>
<Plus className="h-4 w-4 mr-2" />
{t('wallet.createNewWallet')}
</Button>
</>
) : (
<>
<Button
onClick={() => setSetupMode("create")}
className="w-full"
size="lg"
>
<Plus className="h-4 w-4 mr-2" />
{t('wallet.createNewWallet')}
</Button>
<Button
onClick={() => setSetupMode("restore")}
variant="outline"
className="w-full"
size="lg"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('wallet.restoreExistingWallet')}
</Button>
</>
)}
</CardContent>
</Card>
</WalletShell>
);
}
/** Accordion wrapper using grid-template-rows for smooth height animation. */
function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) {
const contentRef = useRef<HTMLDivElement>(null);
return (
<WalletShell>
<WalletBalance className="mb-6" />
<UnclaimedDeposits className="mb-6" />
<div className="grid grid-cols-2 gap-4 mb-6">
<Button
size="lg"
className="h-16 text-lg"
onClick={() => setActiveTab("receive")}
>
<ArrowDownLeft className="h-5 w-5 mr-2" />
Receive
</Button>
<Button
size="lg"
variant="outline"
className="h-16 text-lg"
onClick={() => setActiveTab("send")}
disabled={balance === 0}
>
<ArrowUpRight className="h-5 w-5 mr-2" />
Send
</Button>
<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>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">{t('wallet.transactions')}</TabsTrigger>
<TabsTrigger value="receive">Receive</TabsTrigger>
<TabsTrigger value="send">Send</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<PaymentHistory />
</TabsContent>
<TabsContent value="receive" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Receive Payment</CardTitle>
<CardDescription>
Choose how you want to receive funds
</CardDescription>
</CardHeader>
<CardContent>
<ReceivePayment />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="send" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Send Payment</CardTitle>
<CardDescription>
Send Lightning payments to anyone
</CardDescription>
</CardHeader>
<CardContent>
<SendPayment onSuccess={() => setActiveTab("overview")} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</WalletShell>
</div>
);
}
export default WalletPage;
/** 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();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (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 }) {
const isReceive = tx.type === 'receive';
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"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive
? <ArrowDownLeft className="size-4" />
: <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground">{formatTxDate(tx.timestamp)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}
{btcPrice
? satsToUSD(tx.amount, btcPrice)
: `${formatBTC(tx.amount)} BTC`}
</p>
<p className="text-xs text-muted-foreground">
{formatBTC(tx.amount)} BTC
</p>
</div>
</Link>
);
}
+357
View File
@@ -0,0 +1,357 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react';
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Loader2,
ShieldAlert,
Wallet as WalletIcon,
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { LoginArea } from '@/components/auth/LoginArea';
import { PageHeader } from '@/components/PageHeader';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
import { logger } from '@/lib/logger';
type Step = 'input' | 'sweeping' | 'success' | 'error';
/**
* Standalone recovery page for the legacy Breez/Spark Lightning wallet.
*
* Lazy-loads the heavy Breez SDK only when the user actually starts a sweep,
* so the main bundle stays free of the Lightning custody runtime. The flow:
*
* 1. Detect a NIP-78 encrypted backup (kind 30078 `d=spark-wallet-backup`)
* on the user's relays, or accept a manual 12-word mnemonic.
* 2. Connect the Breez SDK in-memory, fetch the wallet balance.
* 3. Send the entire on-chain balance to the user's Nostr-key-derived
* Taproot address.
* 4. Disconnect; nothing is persisted to local storage.
*
* The page is intentionally single-purpose: there is no "send Lightning"
* UI, no payment history, no Lightning address. We just want to evacuate
* funds from the deprecated wallet into the user's deterministic Taproot
* address.
*/
export function WalletRecoveryPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const [step, setStep] = useState<Step>('input');
const [mnemonic, setMnemonic] = useState('');
const [error, setError] = useState<string | null>(null);
const [txid, setTxid] = useState<string | null>(null);
const [sweptSats, setSweptSats] = useState<number | null>(null);
const [progress, setProgress] = useState<string | null>(null);
useSeoMeta({
title: `Recover Old Wallet | ${config.appName}`,
description: 'Recover funds from a previous Lightning wallet and transfer them to your Nostr-derived Bitcoin address.',
});
const destinationAddress = useMemo(
() => (user ? nostrPubkeyToBitcoinAddress(user.pubkey) : null),
[user],
);
// Look for a NIP-78 relay backup so we can offer one-click decrypt-and-fill.
const backupQuery = useQuery({
queryKey: ['spark-relay-backup', user?.pubkey],
enabled: Boolean(user),
queryFn: async (c) => {
// Lazy-load the backup helpers so the legacy code path doesn't pull
// anything into the main wallet bundle.
const { fetchBackup } = await import('@/lib/spark/backup');
return fetchBackup(nostr, user!.pubkey, c.signal);
},
staleTime: 60_000,
retry: 1,
});
useEffect(() => {
if (!error) return;
setStep('error');
}, [error]);
if (!user) {
return (
<main>
<PageHeader title="Recover Old Wallet" icon={<WalletIcon className="size-5" />} />
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<WalletIcon className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-bold">Log in to recover</h2>
<p className="text-muted-foreground text-sm">
Log in with the Nostr identity that owned the old Lightning wallet so the funds can be swept to its Bitcoin address.
</p>
</div>
<LoginArea className="max-w-60" />
</div>
</main>
);
}
async function decryptRelayBackup() {
const backup = backupQuery.data;
if (!backup || !user) return;
if (!user.signer.nip44?.decrypt) {
setError('Your signer does not support NIP-44 decryption, which is required to read the relay backup. Paste your 12-word recovery phrase manually instead.');
return;
}
try {
const { decryptBackupEvent } = await import('@/lib/spark/backup');
const decrypted = await decryptBackupEvent(backup, user.signer);
if (!decrypted) {
setError('Could not decrypt the relay backup. Paste your 12-word recovery phrase manually instead.');
return;
}
setMnemonic(decrypted.trim());
toast({
title: 'Backup loaded',
description: 'Recovery phrase decrypted from your relays.',
});
} catch (err) {
logger.error('[WalletRecovery] decryptRelayBackup failed', err);
setError(err instanceof Error ? err.message : 'Failed to decrypt relay backup.');
}
}
async function startSweep() {
if (!user || !destinationAddress) return;
const trimmed = mnemonic.trim().toLowerCase().replace(/\s+/g, ' ');
const wordCount = trimmed.split(' ').filter(Boolean).length;
if (wordCount !== 12 && wordCount !== 24) {
setError('Recovery phrase must be 12 or 24 words.');
return;
}
setError(null);
setStep('sweeping');
// Lazy-import the Breez SDK so we only pay the WASM cost when actually
// recovering. Everything below stays scoped to this function so the SDK
// instance is discarded as soon as we're done.
try {
setProgress('Loading wallet SDK…');
const { breezService } = await import('@/lib/spark/breezService');
setProgress('Connecting to your old wallet…');
await breezService.connect(trimmed);
setProgress('Checking balance…');
const balance = await breezService.getBalance();
if (balance <= 0) {
await breezService.disconnect();
setError('Your old wallet has no spendable balance. There is nothing to recover.');
return;
}
setProgress(`Preparing transfer of ${balance.toLocaleString()} sats…`);
// Pass the whole balance as the amount; Breez will deduct the network
// fee from the prepared response and we let it use its default "medium"
// confirmation speed.
const prep = await breezService.prepareBitcoinPayment(destinationAddress, balance);
setProgress('Broadcasting transaction…');
const payment = await breezService.sendBitcoinPayment(prep, 'medium');
setProgress(null);
setSweptSats(balance);
setTxid(extractTxid(payment));
setStep('success');
try {
await breezService.disconnect();
} catch (err) {
// Disconnect is best-effort once funds are out.
logger.warn('[WalletRecovery] disconnect after sweep failed', err);
}
} catch (err) {
logger.error('[WalletRecovery] sweep failed', err);
setError(err instanceof Error ? err.message : 'Recovery failed. Please try again.');
}
}
return (
<main>
<PageHeader title="Recover Old Wallet" icon={<WalletIcon className="size-5" />} />
<div className="max-w-md mx-auto px-4 py-6 space-y-6">
<Button variant="ghost" size="sm" asChild className="-ml-2">
<Link to="/wallet">
<ArrowLeft className="size-4 mr-1.5" />
Back to wallet
</Link>
</Button>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight">Recover your old wallet</h2>
<p className="text-sm text-muted-foreground">
If you previously held funds in the Lightning wallet, you can sweep them to your Nostr-derived Bitcoin address. Your old wallet is not restored this is a one-time, one-way transfer.
</p>
</div>
{destinationAddress && (
<Card>
<CardHeader>
<CardTitle className="text-base">Funds will be sent to</CardTitle>
<CardDescription>Your Nostr-derived Taproot address.</CardDescription>
</CardHeader>
<CardContent>
<code className="block break-all rounded-md bg-muted px-3 py-2 text-xs font-mono">
{destinationAddress}
</code>
</CardContent>
</Card>
)}
{step === 'input' && (
<Card>
<CardHeader>
<CardTitle className="text-base">Recovery phrase</CardTitle>
<CardDescription>
Paste the 12-word recovery phrase from your old wallet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{backupQuery.isLoading && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking your relays for an encrypted backup
</p>
)}
{backupQuery.data && (
<Alert>
<ShieldAlert className="size-4" />
<AlertTitle>Backup found on your relays</AlertTitle>
<AlertDescription className="space-y-3">
<p>
We found an encrypted backup of your old wallet's recovery phrase published from this account.
</p>
<Button size="sm" variant="secondary" onClick={decryptRelayBackup}>
Decrypt and use it
</Button>
</AlertDescription>
</Alert>
)}
<Textarea
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
placeholder="word1 word2 word3 …"
rows={4}
autoComplete="off"
spellCheck={false}
className="font-mono text-sm"
/>
<Alert variant="default" className="border-amber-500/50 bg-amber-500/5 text-amber-900 dark:text-amber-100">
<AlertTriangle className="size-4 !text-amber-500" />
<AlertDescription className="text-xs">
Only paste a recovery phrase you trust. Anyone with this phrase can spend the funds. After the sweep completes, delete it from any place you copied it from.
</AlertDescription>
</Alert>
<Button onClick={startSweep} disabled={!mnemonic.trim()} className="w-full">
Sweep funds to my Bitcoin address
</Button>
</CardContent>
</Card>
)}
{step === 'sweeping' && (
<Card>
<CardContent className="py-10 flex flex-col items-center gap-4 text-center">
<Loader2 className="size-8 animate-spin text-primary" />
<p className="text-sm font-medium">{progress ?? 'Working'}</p>
<p className="text-xs text-muted-foreground max-w-xs">
Don't close this tab. This can take up to a minute while the old wallet syncs and the transaction is broadcast.
</p>
</CardContent>
</Card>
)}
{step === 'success' && (
<Card>
<CardContent className="py-8 flex flex-col items-center gap-4 text-center">
<div className="p-3 rounded-full bg-green-500/10">
<CheckCircle2 className="size-8 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-1">
<p className="text-lg font-bold">Recovery complete</p>
{sweptSats !== null && (
<p className="text-sm text-muted-foreground">
Sent {sweptSats.toLocaleString()} sats to your Bitcoin address.
</p>
)}
</div>
{txid && (
<Button variant="outline" asChild>
<Link to={`/i/bitcoin:tx:${txid}`}>View transaction</Link>
</Button>
)}
<Button variant="ghost" onClick={() => navigate('/wallet')}>
Back to wallet
</Button>
</CardContent>
</Card>
)}
{step === 'error' && (
<Card>
<CardContent className="py-6 space-y-4">
<Alert variant="destructive">
<AlertTriangle className="size-4" />
<AlertTitle>Recovery failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => {
setError(null);
setStep('input');
}}
>
Try again
</Button>
</CardContent>
</Card>
)}
</div>
</main>
);
}
/**
* Pull the txid out of whatever shape Breez returned. The payment-info object
* from `mapPaymentToInfo` has historically been a slim record; rather than
* couple to its exact shape we probe a few common fields.
*/
function extractTxid(payment: unknown): string | null {
if (!payment || typeof payment !== 'object') return null;
const p = payment as Record<string, unknown>;
for (const key of ['txid', 'txId', 'transactionId', 'paymentHash', 'id']) {
const value = p[key];
if (typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value)) {
return value.toLowerCase();
}
}
return null;
}
+4 -12
View File
@@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom';
import { PageHeader } from '@/components/PageHeader';
import { HelpTip } from '@/components/HelpTip';
import { WalletSettings } from '@/components/WalletSettings';
import { WalletSettingsContent } from '@/components/WalletSettingsContent';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -13,7 +12,7 @@ export function WalletSettingsPage() {
useSeoMeta({
title: `Wallet | Settings | ${config.appName}`,
description: 'Manage your Spark wallet, recovery phrase, lightning address, and external wallet connections.',
description: 'Manage your wallet connections',
});
if (!user) {
@@ -30,21 +29,14 @@ export function WalletSettingsPage() {
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold flex items-center gap-1.5">Wallet <HelpTip faqId="connect-wallet" /></h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage your built-in wallet, backups, and external connections
Manage wallet connections and payments
</p>
</div>
}
/>
<div className="p-4 space-y-8">
{/* Spark wallet: backup, recovery phrase, lightning address, security, danger zone */}
<WalletSettingsContent />
{/* External wallet connections (NWC + WebLN) */}
<section>
<h2 className="text-lg font-semibold mb-3">External Connections</h2>
<WalletSettings />
</section>
<div className="p-4">
<WalletSettings />
</div>
</main>
);