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:
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />;
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+1164
-267
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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' : ''}`;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user