Security hardening (audit F1-F4): secure Cashu seed in native Keychain/KeyStore with mandatory first-time backup; verify event signatures for trust scores (WoT + NIP-85 Relatr); add safeJson guard on config boot parse; delete dead CashuWallet.tsx with raw alert(mnemonic)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
52dc0adbc7
commit
f316c77806
13 changed files with 304 additions and 733 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -64,6 +64,7 @@
|
||||||
"@unhead/react": "^2.0.10",
|
"@unhead/react": "^2.0.10",
|
||||||
"bitcoinjs-lib": "^7.0.0",
|
"bitcoinjs-lib": "^7.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
|
@ -4646,6 +4647,12 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/capacitor-secure-storage-plugin": {
|
||||||
|
"version": "0.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
|
||||||
|
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@
|
||||||
"@unhead/react": "^2.0.10",
|
"@unhead/react": "^2.0.10",
|
||||||
"bitcoinjs-lib": "^7.0.0",
|
"bitcoinjs-lib": "^7.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ReactNode, useEffect } from 'react';
|
import { ReactNode, useEffect } from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { safeJson } from '@/lib/safeJson';
|
||||||
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
|
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
|
||||||
|
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
|
|
@ -43,7 +44,9 @@ export function AppProvider(props: AppProviderProps) {
|
||||||
{
|
{
|
||||||
serialize: JSON.stringify,
|
serialize: JSON.stringify,
|
||||||
deserialize: (value: string) => {
|
deserialize: (value: string) => {
|
||||||
const parsed = JSON.parse(value);
|
// Guard the parse: a corrupted localStorage blob must not throw before
|
||||||
|
// the Zod schema runs (that would crash-loop the app on boot).
|
||||||
|
const parsed = safeJson<unknown>(value, {});
|
||||||
return AppConfigSchema.partial().parse(parsed);
|
return AppConfigSchema.partial().parse(parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,704 +0,0 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
|
||||||
import { useMintManager } from '@/hooks/wallet/useMintManager';
|
|
||||||
import { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
|
|
||||||
import { useTokenOperations } from '@/hooks/wallet/useTokenOperations';
|
|
||||||
import { useHistoryManager } from '@/hooks/wallet/useHistoryManager';
|
|
||||||
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
|
||||||
import { generateQRCode } from '@/lib/wallet/qrCodeGenerator';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
Wallet,
|
|
||||||
ArrowDownCircle,
|
|
||||||
ArrowUpCircle,
|
|
||||||
History,
|
|
||||||
Copy,
|
|
||||||
ExternalLink,
|
|
||||||
AlertCircle,
|
|
||||||
Shield,
|
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export function CashuWallet() {
|
|
||||||
const {
|
|
||||||
coco,
|
|
||||||
repositories,
|
|
||||||
balances,
|
|
||||||
mints,
|
|
||||||
totalBalance,
|
|
||||||
isInitialized,
|
|
||||||
isLoading: walletLoading,
|
|
||||||
error: walletError,
|
|
||||||
mnemonic,
|
|
||||||
getEncodedToken,
|
|
||||||
refreshMints,
|
|
||||||
} = useCashu();
|
|
||||||
|
|
||||||
const usdValue = useSatsToUsd(totalBalance);
|
|
||||||
|
|
||||||
const {
|
|
||||||
activeMintUrl,
|
|
||||||
setActiveMintUrl,
|
|
||||||
mintUrl,
|
|
||||||
setMintUrl,
|
|
||||||
handleAddMint,
|
|
||||||
handleRemoveMintClick,
|
|
||||||
handleConfirmRemoveMint,
|
|
||||||
mintToRemove,
|
|
||||||
showRemoveMintModal,
|
|
||||||
setShowRemoveMintModal,
|
|
||||||
getCleanMintLabel,
|
|
||||||
} = useMintManager({ coco, mints, repositories, refreshMints });
|
|
||||||
|
|
||||||
const {
|
|
||||||
quoteAmount,
|
|
||||||
setQuoteAmount,
|
|
||||||
showInvoiceModal,
|
|
||||||
setShowInvoiceModal,
|
|
||||||
currentInvoice,
|
|
||||||
handleLightningReceive,
|
|
||||||
lightningInvoice,
|
|
||||||
setLightningInvoice,
|
|
||||||
isProcessingPayment,
|
|
||||||
handleLightningSend,
|
|
||||||
} = useLightningOperations({ coco, activeMintUrl, generateQRCode });
|
|
||||||
|
|
||||||
const {
|
|
||||||
tokenInput,
|
|
||||||
setTokenInput,
|
|
||||||
handleTokenReceive,
|
|
||||||
sendAmount,
|
|
||||||
setSendAmount,
|
|
||||||
handleTokenSend,
|
|
||||||
sentToken,
|
|
||||||
setSentToken,
|
|
||||||
sentTokenQRCode,
|
|
||||||
} = useTokenOperations({ coco, activeMintUrl, generateQRCode, getEncodedToken });
|
|
||||||
|
|
||||||
const { history, isLoading: historyLoading } = useHistoryManager({
|
|
||||||
coco,
|
|
||||||
repositories,
|
|
||||||
mints,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tracks the browser-only case where the user has mints but none are
|
|
||||||
// reachable (all CORS-blocked). Native shells never hit this.
|
|
||||||
const [noReachableMint, setNoReachableMint] = useState(false);
|
|
||||||
|
|
||||||
// Pick an active mint when none is selected — prefer a REACHABLE mint so a
|
|
||||||
// user whose first saved mint is CORS-blocked (e.g. legacy minibits) doesn't
|
|
||||||
// get stuck with a mint that fails every invoice.
|
|
||||||
useEffect(() => {
|
|
||||||
if (mints.length === 0 || activeMintUrl) return;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const { probeMintReachability } = await import('@/lib/mintReachability');
|
|
||||||
// Probe mints in order; select the first reachable one.
|
|
||||||
for (const m of mints) {
|
|
||||||
const probe = await probeMintReachability(m.url);
|
|
||||||
if (cancelled) return;
|
|
||||||
if (probe.reachability === 'ok') {
|
|
||||||
setActiveMintUrl(m.url);
|
|
||||||
setNoReachableMint(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing reachable — select the first so the UI still renders, and flag
|
|
||||||
// the state so we can warn the user (browser CORS case).
|
|
||||||
if (!cancelled) {
|
|
||||||
setActiveMintUrl(mints[0].url);
|
|
||||||
setNoReachableMint(true);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [mints, activeMintUrl, setActiveMintUrl]);
|
|
||||||
|
|
||||||
// Show wallet loading state
|
|
||||||
if (walletLoading) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-black/90 border-2 border-orange-500/30">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-orange-400">
|
|
||||||
<Wallet className="h-5 w-5" />
|
|
||||||
Cashu Wallet
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Initializing wallet...</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Skeleton className="h-20 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show wallet error state
|
|
||||||
if (walletError) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-black/90 border-2 border-red-500/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-red-400">
|
|
||||||
<AlertCircle className="h-5 w-5" />
|
|
||||||
Wallet Error
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="bg-red-950/30 border-red-500/50">
|
|
||||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
|
||||||
<AlertDescription className="text-gray-300">
|
|
||||||
{walletError.message}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy to clipboard helper
|
|
||||||
const copyToClipboard = async (text: string, label: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
// Could add toast notification here
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="bg-gradient-to-br from-orange-950/30 to-yellow-950/30 border-2 border-orange-500/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl font-black uppercase text-orange-400 flex items-center gap-2">
|
|
||||||
<Wallet className="h-5 w-5" />
|
|
||||||
Cashu Wallet
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Built-in wallet for easy payments - no external setup required
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* No reachable mint (browser CORS) warning */}
|
|
||||||
{noReachableMint && (
|
|
||||||
<Alert className="bg-amber-950/30 border-amber-500/50">
|
|
||||||
<AlertCircle className="h-4 w-4 text-amber-400" />
|
|
||||||
<AlertDescription className="text-gray-300 space-y-1">
|
|
||||||
<p className="font-bold text-amber-300">This mint can't be reached from your browser</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Your saved mint doesn't allow web requests (no CORS). Add a web-friendly
|
|
||||||
mint below (e.g. <span className="font-mono">mint.coinos.io</span>) and set it active — or open
|
|
||||||
SkateSpots in the native app, where any mint (including Minibits) works.
|
|
||||||
</p>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Balance Display */}
|
|
||||||
<div className="text-center py-6 space-y-2">
|
|
||||||
<div className="text-sm text-gray-400 uppercase tracking-wide">Total Balance</div>
|
|
||||||
<div className="text-4xl font-black text-white">
|
|
||||||
{totalBalance.toLocaleString()} <span className="text-2xl text-gray-400">sats</span>
|
|
||||||
</div>
|
|
||||||
{usdValue !== null && (
|
|
||||||
<div className="text-lg text-gray-400">
|
|
||||||
≈ ${usdValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Mint Selector */}
|
|
||||||
{mints.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-bold text-gray-300">Active Mint</Label>
|
|
||||||
<Select value={activeMintUrl || undefined} onValueChange={setActiveMintUrl}>
|
|
||||||
<SelectTrigger className="bg-gray-800 border-gray-700">
|
|
||||||
<SelectValue placeholder="Select a mint" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{mints.map((mint) => (
|
|
||||||
<SelectItem key={mint.url} value={mint.url}>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span>{getCleanMintLabel(mint)}</span>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{balances[mint.url] || 0} sats
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Actions */}
|
|
||||||
<Tabs defaultValue="receive" className="w-full">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="receive" className="gap-2">
|
|
||||||
<ArrowDownCircle className="h-4 w-4" />
|
|
||||||
Receive
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="send" className="gap-2">
|
|
||||||
<ArrowUpCircle className="h-4 w-4" />
|
|
||||||
Send
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Receive Tab */}
|
|
||||||
<TabsContent value="receive" className="space-y-4 mt-4">
|
|
||||||
{/* Lightning Invoice */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-sm font-bold text-gray-300">Receive Lightning Payment</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Amount in sats"
|
|
||||||
value={quoteAmount}
|
|
||||||
onChange={(e) => setQuoteAmount(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleLightningReceive}
|
|
||||||
disabled={!activeMintUrl || !quoteAmount}
|
|
||||||
className="bg-orange-500 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
Generate Invoice
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-gray-500">Or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cashu Token */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-sm font-bold text-gray-300">Receive Cashu Token</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Paste Cashu token..."
|
|
||||||
value={tokenInput}
|
|
||||||
onChange={(e) => setTokenInput(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleTokenReceive}
|
|
||||||
disabled={!tokenInput}
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700"
|
|
||||||
>
|
|
||||||
Receive Token
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Send Tab */}
|
|
||||||
<TabsContent value="send" className="space-y-4 mt-4">
|
|
||||||
{/* Send Token */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-sm font-bold text-gray-300">Send Cashu Token</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Amount in sats"
|
|
||||||
value={sendAmount}
|
|
||||||
onChange={(e) => setSendAmount(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleTokenSend}
|
|
||||||
disabled={!activeMintUrl || !sendAmount}
|
|
||||||
className="bg-orange-500 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
Create Token
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-gray-500">Or</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pay Lightning Invoice */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-sm font-bold text-gray-300">Pay Lightning Invoice</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Paste Lightning invoice (lnbc...)"
|
|
||||||
value={lightningInvoice}
|
|
||||||
onChange={(e) => setLightningInvoice(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleLightningSend}
|
|
||||||
disabled={!lightningInvoice || isProcessingPayment}
|
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
{isProcessingPayment ? 'Processing...' : 'Pay Invoice'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* History */}
|
|
||||||
{history.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm font-bold text-gray-300 flex items-center gap-2">
|
|
||||||
<History className="h-4 w-4" />
|
|
||||||
Recent Activity
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
||||||
{history.slice(0, 5).map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="flex items-center justify-between p-2 rounded-lg bg-black/50 border border-gray-700"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{entry.type === 'mint' || entry.type === 'receive' ? (
|
|
||||||
<ArrowDownCircle className="h-4 w-4 text-green-400" />
|
|
||||||
) : (
|
|
||||||
<ArrowUpCircle className="h-4 w-4 text-red-400" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-white">
|
|
||||||
{entry.type === 'mint' && 'Received Lightning'}
|
|
||||||
{entry.type === 'melt' && 'Sent Lightning'}
|
|
||||||
{entry.type === 'send' && 'Sent Token'}
|
|
||||||
{entry.type === 'receive' && 'Received Token'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{new Date(entry.timestamp).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-sm font-mono text-white">
|
|
||||||
{entry.type === 'mint' || entry.type === 'receive' ? '+' : '-'}
|
|
||||||
{entry.amount.toLocaleString()} sats
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={entry.status === 'completed' ? 'default' : entry.status === 'pending' ? 'secondary' : 'destructive'}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{entry.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* What is Cashu Info Card */}
|
|
||||||
<Card className="bg-blue-950/30 border-2 border-blue-500/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-blue-400">What is Cashu?</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-gray-300 text-sm">
|
|
||||||
Cashu is digital cash backed by Bitcoin Lightning. Think of it as Bitcoin tokens you hold in your browser.
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-2 text-sm">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-green-400">✅</span>
|
|
||||||
<span className="text-gray-300">Works offline at skate spots</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-green-400">✅</span>
|
|
||||||
<span className="text-gray-300">Instant peer-to-peer payments</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-green-400">✅</span>
|
|
||||||
<span className="text-gray-300">Privacy-focused (mints don't track you)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-green-400">✅</span>
|
|
||||||
<span className="text-gray-300">Always redeemable for Lightning sats</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="https://mintradar.pedani.eu/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-[#ccff00] hover:text-[#ccff00]/80 font-bold"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
Find mints at mintradar.pedani.eu
|
|
||||||
</a>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Mint Management */}
|
|
||||||
<Card className="bg-purple-950/30 border-2 border-purple-500/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-purple-400 flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
Trusted Mints ({mints.length})
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-gray-400">
|
|
||||||
Manage which mints you trust. Multiple mints = redundancy (like multiple relays)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Mint List */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{mints.map((mint) => (
|
|
||||||
<div
|
|
||||||
key={mint.url}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg bg-black/50 border border-purple-500/30"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-mono text-sm text-white truncate">
|
|
||||||
{getCleanMintLabel(mint)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{balances[mint.url] || 0} sats
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRemoveMintClick(mint)}
|
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Mint */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-bold text-gray-300">Add New Mint</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="https://mint.example.com"
|
|
||||||
value={mintUrl}
|
|
||||||
onChange={(e) => setMintUrl(e.target.value)}
|
|
||||||
className="bg-gray-800 border-gray-700 font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleAddMint()}
|
|
||||||
disabled={!mintUrl}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-purple-500/50 text-purple-400 hover:bg-purple-500/10"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://mintradar.pedani.eu/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-sm text-purple-400 hover:text-purple-300"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
Find more mints at mintradar.pedani.eu
|
|
||||||
</a>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Backup Warning */}
|
|
||||||
{mnemonic && (
|
|
||||||
<Card className="bg-yellow-950/30 border-2 border-yellow-500/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-yellow-400 flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" />
|
|
||||||
Backup Your Wallet
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-gray-300 text-sm">
|
|
||||||
Your wallet is secured with a 12-word recovery phrase. Save it safely - you'll need it to restore your balance if you clear browser data.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
// Show backup modal (will implement separately)
|
|
||||||
alert(`Your backup phrase:\n\n${mnemonic}\n\nWrite these words down and store them safely!`);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10"
|
|
||||||
>
|
|
||||||
View Recovery Phrase
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-gray-400 italic">
|
|
||||||
💡 This is like your Nostr nsec - keep it secret and backed up!
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Invoice Modal */}
|
|
||||||
<Dialog open={showInvoiceModal} onOpenChange={setShowInvoiceModal}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Receive {currentInvoice?.amount} sats</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Pay this invoice from any Lightning wallet
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{currentInvoice && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
|
||||||
<img src={currentInvoice.qrCodeUrl} alt="Invoice QR Code" className="max-w-full h-auto" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-gray-400">Lightning Invoice</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={currentInvoice.invoice}
|
|
||||||
readOnly
|
|
||||||
className="font-mono text-xs bg-gray-800 border-gray-700"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => copyToClipboard(currentInvoice.invoice, 'Invoice')}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-yellow-400">
|
|
||||||
⏳ Waiting for payment...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Send Token Modal */}
|
|
||||||
<Dialog open={!!sentToken} onOpenChange={(open) => !open && setSentToken(null)}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Token Ready to Send</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Share this token with the recipient
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{sentToken && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{sentTokenQRCode && (
|
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
|
||||||
<img src={sentTokenQRCode} alt="Token QR Code" className="max-w-full h-auto" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs text-gray-400">Cashu Token</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
value={sentToken}
|
|
||||||
readOnly
|
|
||||||
className="font-mono text-xs bg-gray-800 border-gray-700"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => copyToClipboard(sentToken, 'Token')}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
Copy Token
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Alert className="bg-yellow-950/30 border-yellow-500/30">
|
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-400" />
|
|
||||||
<AlertDescription className="text-sm text-gray-300">
|
|
||||||
Anyone with this token can claim it - treat it like cash!
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Remove Mint Confirmation */}
|
|
||||||
<Dialog open={showRemoveMintModal} onOpenChange={setShowRemoveMintModal}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Remove Mint?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to remove {mintToRemove && getCleanMintLabel(mintToRemove)}?
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{mintToRemove && (balances[mintToRemove.url] || 0) > 0 ? (
|
|
||||||
// Real balance at risk — removing deletes the local proofs and
|
|
||||||
// strands these sats. Make the amount explicit and loud.
|
|
||||||
<Alert className="bg-red-950/40 border-red-500/50">
|
|
||||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
|
||||||
<AlertDescription className="text-sm text-gray-200">
|
|
||||||
<span className="font-bold text-red-400">Warning:</span> This mint still holds{' '}
|
|
||||||
<span className="font-bold">{(balances[mintToRemove.url] || 0).toLocaleString()} sats</span>.
|
|
||||||
Removing it deletes those funds from this device — withdraw or send them first,
|
|
||||||
or they'll be lost.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Alert className="bg-yellow-950/30 border-yellow-500/30">
|
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-400" />
|
|
||||||
<AlertDescription className="text-sm text-gray-300">
|
|
||||||
Make sure you have withdrawn all funds from this mint before removing it.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button variant="outline" onClick={() => setShowRemoveMintModal(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleConfirmRemoveMint}
|
|
||||||
>
|
|
||||||
{mintToRemove && (balances[mintToRemove.url] || 0) > 0
|
|
||||||
? 'Remove anyway'
|
|
||||||
: 'Remove Mint'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -146,6 +146,8 @@ function CashuWalletCardNative() {
|
||||||
isLoading: walletLoading,
|
isLoading: walletLoading,
|
||||||
error: walletError,
|
error: walletError,
|
||||||
mnemonic,
|
mnemonic,
|
||||||
|
needsBackup,
|
||||||
|
confirmBackup,
|
||||||
getEncodedToken,
|
getEncodedToken,
|
||||||
refreshMints,
|
refreshMints,
|
||||||
} = useCashu();
|
} = useCashu();
|
||||||
|
|
@ -807,12 +809,17 @@ function CashuWalletCardNative() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Recovery Phrase Dialog — replaces the old raw alert() with an elegant,
|
{/* Recovery Phrase Dialog — replaces the old raw alert() with an elegant,
|
||||||
hidden-by-default reveal */}
|
hidden-by-default reveal.
|
||||||
|
When needsBackup is set (a freshly auto-generated seed), it becomes a
|
||||||
|
MANDATORY, non-dismissable backup step: the user must reveal the words
|
||||||
|
and confirm before they can start using the wallet. */}
|
||||||
{mnemonic && (
|
{mnemonic && (
|
||||||
<RecoveryPhraseDialog
|
<RecoveryPhraseDialog
|
||||||
open={showRecoveryPhrase}
|
open={showRecoveryPhrase || needsBackup}
|
||||||
onOpenChange={setShowRecoveryPhrase}
|
onOpenChange={setShowRecoveryPhrase}
|
||||||
mnemonic={mnemonic}
|
mnemonic={mnemonic}
|
||||||
|
requireConfirm={needsBackup}
|
||||||
|
onConfirm={confirmBackup}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ interface RecoveryPhraseDialogProps {
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Space-separated BIP-39 mnemonic. */
|
/** Space-separated BIP-39 mnemonic. */
|
||||||
mnemonic: string;
|
mnemonic: string;
|
||||||
|
/**
|
||||||
|
* When true, the dialog is a MANDATORY first-time backup step for a freshly
|
||||||
|
* generated wallet seed: it cannot be dismissed by clicking outside, and the
|
||||||
|
* user must reveal the words and explicitly confirm they saved them. Used to
|
||||||
|
* ensure a silently auto-generated seed is never left un-backed-up (clearing
|
||||||
|
* app storage without a backup = permanent loss of funds).
|
||||||
|
*/
|
||||||
|
requireConfirm?: boolean;
|
||||||
|
/** Called when the user confirms they've backed up (only in requireConfirm mode). */
|
||||||
|
onConfirm?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,7 +40,13 @@ interface RecoveryPhraseDialogProps {
|
||||||
* leak); paper is the safe path.
|
* leak); paper is the safe path.
|
||||||
* • The seed is cleared from the reveal state whenever the dialog closes.
|
* • The seed is cleared from the reveal state whenever the dialog closes.
|
||||||
*/
|
*/
|
||||||
export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryPhraseDialogProps) {
|
export function RecoveryPhraseDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
mnemonic,
|
||||||
|
requireConfirm = false,
|
||||||
|
onConfirm,
|
||||||
|
}: RecoveryPhraseDialogProps) {
|
||||||
const [revealed, setRevealed] = useState(false);
|
const [revealed, setRevealed] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -38,6 +54,9 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP
|
||||||
const words = mnemonic.trim().split(/\s+/);
|
const words = mnemonic.trim().split(/\s+/);
|
||||||
|
|
||||||
const handleOpenChange = (next: boolean) => {
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
// In mandatory-backup mode the dialog can't be dismissed without confirming
|
||||||
|
// — swallow close attempts (outside-click / Esc) until the seed is saved.
|
||||||
|
if (requireConfirm && !next) return;
|
||||||
// Re-hide on every close so re-opening always starts concealed.
|
// Re-hide on every close so re-opening always starts concealed.
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setRevealed(false);
|
setRevealed(false);
|
||||||
|
|
@ -46,6 +65,13 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP
|
||||||
onOpenChange(next);
|
onOpenChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm?.();
|
||||||
|
setRevealed(false);
|
||||||
|
setCopied(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(mnemonic);
|
await navigator.clipboard.writeText(mnemonic);
|
||||||
|
|
@ -59,7 +85,11 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-5 sm:p-6">
|
<DialogContent
|
||||||
|
className="w-[calc(100%-1rem)] sm:max-w-md p-5 sm:p-6"
|
||||||
|
onEscapeKeyDown={(e) => { if (requireConfirm) e.preventDefault(); }}
|
||||||
|
onInteractOutside={(e) => { if (requireConfirm) e.preventDefault(); }}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/15">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500/15">
|
||||||
<Shield className="h-6 w-6 text-amber-500" />
|
<Shield className="h-6 w-6 text-amber-500" />
|
||||||
|
|
@ -135,6 +165,19 @@ export function RecoveryPhraseDialog({ open, onOpenChange, mnemonic }: RecoveryP
|
||||||
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
<p className="text-center text-[11px] leading-relaxed text-muted-foreground">
|
||||||
Writing it on paper is safest — copying to your clipboard can leak to other apps.
|
Writing it on paper is safest — copying to your clipboard can leak to other apps.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Mandatory first-time backup confirmation. Disabled until the user has
|
||||||
|
actually revealed the words, so they can't blindly dismiss it. */}
|
||||||
|
{requireConfirm && (
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2 bg-amber-500 text-black hover:bg-amber-600 font-black uppercase"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!revealed}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
{revealed ? "I've written it down" : 'Reveal the words first'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { useCurrentUser } from './useCurrentUser';
|
import { useCurrentUser } from './useCurrentUser';
|
||||||
|
import { onlyVerified } from '@/lib/verifyEvents';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch trust scores for multiple pubkeys in batch.
|
* Hook to fetch trust scores for multiple pubkeys in batch.
|
||||||
|
|
@ -23,10 +24,10 @@ export function useBatchTrustScores(pubkeys: string[]) {
|
||||||
// Get user's follow list (kind 3)
|
// Get user's follow list (kind 3)
|
||||||
const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
||||||
|
|
||||||
const followListEvents = await nostr.query(
|
const followListEvents = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [3], authors: [sourcePubkey], limit: 1 }],
|
[{ kinds: [3], authors: [sourcePubkey], limit: 1 }],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
const followList = followListEvents[0];
|
const followList = followListEvents[0];
|
||||||
if (!followList) {
|
if (!followList) {
|
||||||
|
|
@ -64,10 +65,10 @@ export function useBatchTrustScores(pubkeys: string[]) {
|
||||||
const uncheckedPubkeys = pubkeys.filter((pk) => scores.get(pk) === 0);
|
const uncheckedPubkeys = pubkeys.filter((pk) => scores.get(pk) === 0);
|
||||||
|
|
||||||
if (uncheckedPubkeys.length > 0) {
|
if (uncheckedPubkeys.length > 0) {
|
||||||
const friendsFollowLists = await nostr.query(
|
const friendsFollowLists = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [3], authors: Array.from(following), limit: 100 }],
|
[{ kinds: [3], authors: Array.from(following), limit: 100 }],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
// Build a set of all friends-of-friends
|
// Build a set of all friends-of-friends
|
||||||
const friendsOfFriends = new Set<string>();
|
const friendsOfFriends = new Set<string>();
|
||||||
|
|
@ -89,10 +90,10 @@ export function useBatchTrustScores(pubkeys: string[]) {
|
||||||
const unverifiedPubkeys = pubkeys.filter((pk) => scores.get(pk) === 0);
|
const unverifiedPubkeys = pubkeys.filter((pk) => scores.get(pk) === 0);
|
||||||
|
|
||||||
if (unverifiedPubkeys.length > 0) {
|
if (unverifiedPubkeys.length > 0) {
|
||||||
const profileEvents = await nostr.query(
|
const profileEvents = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [0], authors: unverifiedPubkeys }],
|
[{ kinds: [0], authors: unverifiedPubkeys }],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
for (const profileEvent of profileEvents) {
|
for (const profileEvent of profileEvents) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { onlyVerified } from '@/lib/verifyEvents';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relatr NIP-85 Trust Score Hook
|
* Relatr NIP-85 Trust Score Hook
|
||||||
|
|
@ -81,7 +82,9 @@ export function useRelatrScore(targetPubkey: string | undefined, sourcePubkey?:
|
||||||
// Query NIP-85 Trusted Assertion for this user
|
// Query NIP-85 Trusted Assertion for this user
|
||||||
// Kind 30382 = User trust assertions
|
// Kind 30382 = User trust assertions
|
||||||
// d tag = target pubkey
|
// d tag = target pubkey
|
||||||
const events = await nostr.relay(RELATR_CONFIG.relay).query(
|
// Signature-verify assertions: a forged kind-30382 (spoofed author,
|
||||||
|
// invalid sig) from a rogue relay must not grant fake trust.
|
||||||
|
const events = onlyVerified(await nostr.relay(RELATR_CONFIG.relay).query(
|
||||||
[{
|
[{
|
||||||
kinds: [30382],
|
kinds: [30382],
|
||||||
authors: [RELATR_CONFIG.servicePubkey],
|
authors: [RELATR_CONFIG.servicePubkey],
|
||||||
|
|
@ -89,7 +92,7 @@ export function useRelatrScore(targetPubkey: string | undefined, sourcePubkey?:
|
||||||
limit: 1,
|
limit: 1,
|
||||||
}],
|
}],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
console.log(`[Relatr] No trust assertion found for ${targetPubkey.slice(0, 8)}`);
|
console.log(`[Relatr] No trust assertion found for ${targetPubkey.slice(0, 8)}`);
|
||||||
|
|
@ -161,7 +164,7 @@ export function useBatchRelatrScores(targetPubkeys: string[]) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Query all trust assertions for these pubkeys
|
// Query all trust assertions for these pubkeys
|
||||||
const events = await nostr.relay(RELATR_CONFIG.relay).query(
|
const events = onlyVerified(await nostr.relay(RELATR_CONFIG.relay).query(
|
||||||
[{
|
[{
|
||||||
kinds: [30382],
|
kinds: [30382],
|
||||||
authors: [RELATR_CONFIG.servicePubkey],
|
authors: [RELATR_CONFIG.servicePubkey],
|
||||||
|
|
@ -169,7 +172,7 @@ export function useBatchRelatrScores(targetPubkeys: string[]) {
|
||||||
limit: targetPubkeys.length,
|
limit: targetPubkeys.length,
|
||||||
}],
|
}],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
console.log(`[Relatr] Found ${events.length} trust assertions for ${targetPubkeys.length} pubkeys`);
|
console.log(`[Relatr] Found ${events.length} trust assertions for ${targetPubkeys.length} pubkeys`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { useCurrentUser } from './useCurrentUser';
|
import { useCurrentUser } from './useCurrentUser';
|
||||||
|
import { onlyVerified } from '@/lib/verifyEvents';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to calculate trust score for a Nostr pubkey using Web of Trust (WoT).
|
* Hook to calculate trust score for a Nostr pubkey using Web of Trust (WoT).
|
||||||
|
|
@ -35,10 +36,12 @@ export function useTrustScore(targetPubkey: string | undefined) {
|
||||||
// Get user's follow list (kind 3)
|
// Get user's follow list (kind 3)
|
||||||
const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
const sourcePubkey = user?.pubkey || '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
||||||
|
|
||||||
const followListEvents = await nostr.query(
|
// Signature-verify follow lists before deriving any trust from them —
|
||||||
|
// an unverified kind-3 from a malicious relay could inflate a score.
|
||||||
|
const followListEvents = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [3], authors: [sourcePubkey], limit: 1 }],
|
[{ kinds: [3], authors: [sourcePubkey], limit: 1 }],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
const followList = followListEvents[0];
|
const followList = followListEvents[0];
|
||||||
if (!followList) {
|
if (!followList) {
|
||||||
|
|
@ -58,10 +61,10 @@ export function useTrustScore(targetPubkey: string | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check friend-of-friend (2 hops)
|
// Check friend-of-friend (2 hops)
|
||||||
const friendsFollowLists = await nostr.query(
|
const friendsFollowLists = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [3], authors: Array.from(following), limit: 100 }],
|
[{ kinds: [3], authors: Array.from(following), limit: 100 }],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
for (const friendList of friendsFollowLists) {
|
for (const friendList of friendsFollowLists) {
|
||||||
const friendFollowing = friendList.tags
|
const friendFollowing = friendList.tags
|
||||||
|
|
@ -74,10 +77,10 @@ export function useTrustScore(targetPubkey: string | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if they have NIP-05 verification (basic credibility)
|
// Check if they have NIP-05 verification (basic credibility)
|
||||||
const profileEvents = await nostr.query(
|
const profileEvents = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [0], authors: [targetPubkey], limit: 1 }],
|
[{ kinds: [0], authors: [targetPubkey], limit: 1 }],
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
));
|
||||||
|
|
||||||
if (profileEvents[0]) {
|
if (profileEvents[0]) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useLocalStorage } from '../useLocalStorage';
|
|
||||||
import * as bip39 from '@scure/bip39';
|
import * as bip39 from '@scure/bip39';
|
||||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||||
import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } from 'coco-cashu-core';
|
import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } from 'coco-cashu-core';
|
||||||
|
|
@ -7,6 +6,37 @@ import { IndexedDbRepositories } from 'coco-cashu-indexeddb';
|
||||||
import type { Mint } from 'coco-cashu-core';
|
import type { Mint } from 'coco-cashu-core';
|
||||||
import { probeMintReachability } from '@/lib/mintReachability';
|
import { probeMintReachability } from '@/lib/mintReachability';
|
||||||
import { isNativePlatform } from '@/lib/platform';
|
import { isNativePlatform } from '@/lib/platform';
|
||||||
|
import { secureStorage } from '@/lib/secureStorage';
|
||||||
|
|
||||||
|
// Storage key for the wallet's BIP-39 mnemonic. On native it lives in the OS
|
||||||
|
// Keychain/KeyStore (via secureStorage); on web it falls back to localStorage.
|
||||||
|
const MNEMONIC_KEY = 'cashu-mnemonic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the mnemonic from secure storage. Tolerates the legacy format where the
|
||||||
|
* old `useLocalStorage` hook JSON-stringified the value (i.e. a quoted string),
|
||||||
|
* so users upgrading from a plaintext-localStorage build are read correctly and
|
||||||
|
* transparently migrated to the Keychain/KeyStore on native.
|
||||||
|
*/
|
||||||
|
async function loadMnemonic(): Promise<string | null> {
|
||||||
|
const raw = await secureStorage.getItem(MNEMONIC_KEY);
|
||||||
|
if (raw == null) return null;
|
||||||
|
// Legacy values were JSON.stringify'd ("\"word word…\""). Detect and unwrap.
|
||||||
|
if (raw.startsWith('"')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed === 'string') return parsed;
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — fall through and treat as a bare mnemonic.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the mnemonic as a bare string (no JSON wrapping) to secure storage. */
|
||||||
|
async function saveMnemonic(value: string): Promise<void> {
|
||||||
|
await secureStorage.setItem(MNEMONIC_KEY, value);
|
||||||
|
}
|
||||||
|
|
||||||
// Types from coco-cashu-core
|
// Types from coco-cashu-core
|
||||||
type CocoManager = Awaited<ReturnType<typeof initializeCoco>>;
|
type CocoManager = Awaited<ReturnType<typeof initializeCoco>>;
|
||||||
|
|
@ -37,7 +67,17 @@ export interface UseCashuReturn {
|
||||||
mnemonic: string | null;
|
mnemonic: string | null;
|
||||||
setMnemonic: (mnemonic: string) => void;
|
setMnemonic: (mnemonic: string) => void;
|
||||||
generateMnemonic: () => string;
|
generateMnemonic: () => string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a mnemonic was freshly auto-generated for the user this session
|
||||||
|
* and they have not yet confirmed they backed it up. The wallet UI should
|
||||||
|
* force a backup step (reveal + "I wrote it down") before letting funds
|
||||||
|
* accumulate — clearing app storage without a backup means permanent loss.
|
||||||
|
*/
|
||||||
|
needsBackup: boolean;
|
||||||
|
/** Mark the freshly-generated mnemonic as backed up (dismisses needsBackup). */
|
||||||
|
confirmBackup: () => void;
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
getEncodedToken: typeof getEncodedToken;
|
getEncodedToken: typeof getEncodedToken;
|
||||||
|
|
@ -49,8 +89,26 @@ export function useCashu(): UseCashuReturn {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
// Mnemonic management with localStorage
|
// Mnemonic management, backed by native secure storage (Keychain / KeyStore)
|
||||||
const [mnemonic, setMnemonicState] = useLocalStorage<string | null>('cashu-mnemonic', null);
|
// with a localStorage fallback on web. Loaded asynchronously on mount.
|
||||||
|
const [mnemonic, setMnemonicState] = useState<string | null>(null);
|
||||||
|
// True only for a mnemonic auto-generated this session that hasn't been
|
||||||
|
// acknowledged as backed up yet. Never set for a mnemonic loaded from storage
|
||||||
|
// (that one was created in a prior session and is assumed already handled).
|
||||||
|
const [needsBackup, setNeedsBackup] = useState(false);
|
||||||
|
|
||||||
|
const confirmBackup = useCallback(() => setNeedsBackup(false), []);
|
||||||
|
|
||||||
|
// Load any existing mnemonic from secure storage on mount (async).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadMnemonic().then((m) => {
|
||||||
|
if (!cancelled && m) setMnemonicState(m);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.warn('[Cashu] Failed to load mnemonic from secure storage:', err);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Manager instance ref
|
// Manager instance ref
|
||||||
const cocoRef = useRef<CocoManager | null>(null);
|
const cocoRef = useRef<CocoManager | null>(null);
|
||||||
|
|
@ -90,9 +148,15 @@ export function useCashu(): UseCashuReturn {
|
||||||
throw new Error('Invalid mnemonic');
|
throw new Error('Invalid mnemonic');
|
||||||
}
|
}
|
||||||
setMnemonicState(newMnemonic);
|
setMnemonicState(newMnemonic);
|
||||||
|
// A user-supplied mnemonic (import/restore) is one they already possess, so
|
||||||
|
// no forced-backup prompt is needed.
|
||||||
|
setNeedsBackup(false);
|
||||||
|
saveMnemonic(newMnemonic).catch((err) => {
|
||||||
|
console.warn('[Cashu] Failed to persist mnemonic to secure storage:', err);
|
||||||
|
});
|
||||||
// Reinitialize manager with new mnemonic
|
// Reinitialize manager with new mnemonic
|
||||||
setIsInitialized(false);
|
setIsInitialized(false);
|
||||||
}, [setMnemonicState]);
|
}, []);
|
||||||
|
|
||||||
// Initialize the coco manager
|
// Initialize the coco manager
|
||||||
const initializeCocoManager = useCallback(async () => {
|
const initializeCocoManager = useCallback(async () => {
|
||||||
|
|
@ -127,11 +191,20 @@ export function useCashu(): UseCashuReturn {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate or retrieve mnemonic
|
// Generate or retrieve mnemonic. Prefer in-memory state, but fall back to
|
||||||
let currentMnemonic = mnemonic;
|
// a fresh async read of secure storage in case the mount-effect load
|
||||||
|
// hasn't resolved yet — this avoids generating a SECOND mnemonic and
|
||||||
|
// orphaning the user's existing funds.
|
||||||
|
let currentMnemonic = mnemonic ?? (await loadMnemonic());
|
||||||
if (!currentMnemonic) {
|
if (!currentMnemonic) {
|
||||||
currentMnemonic = bip39.generateMnemonic(wordlist);
|
currentMnemonic = bip39.generateMnemonic(wordlist);
|
||||||
setMnemonicState(currentMnemonic);
|
setMnemonicState(currentMnemonic);
|
||||||
|
// Freshly created for the user → require a backup acknowledgement.
|
||||||
|
setNeedsBackup(true);
|
||||||
|
await saveMnemonic(currentMnemonic);
|
||||||
|
} else if (currentMnemonic !== mnemonic) {
|
||||||
|
// Loaded from storage on this pass — sync it into state.
|
||||||
|
setMnemonicState(currentMnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create seed from mnemonic
|
// Create seed from mnemonic
|
||||||
|
|
@ -241,7 +314,7 @@ export function useCashu(): UseCashuReturn {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [isInitialized, isLoading, mnemonic, setMnemonicState]);
|
}, [isInitialized, isLoading, mnemonic]);
|
||||||
|
|
||||||
// Initialize on mount and when mnemonic changes
|
// Initialize on mount and when mnemonic changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -294,6 +367,8 @@ export function useCashu(): UseCashuReturn {
|
||||||
mnemonic,
|
mnemonic,
|
||||||
setMnemonic,
|
setMnemonic,
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
|
needsBackup,
|
||||||
|
confirmBackup,
|
||||||
clearError,
|
clearError,
|
||||||
getEncodedToken,
|
getEncodedToken,
|
||||||
refreshMints,
|
refreshMints,
|
||||||
|
|
|
||||||
24
src/lib/safeJson.ts
Normal file
24
src/lib/safeJson.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Parse JSON without throwing.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* `JSON.parse` on untrusted input (decrypted DM/gift-wrap payloads, relay event
|
||||||
|
* content, persisted localStorage blobs) throws on any malformed byte. A single
|
||||||
|
* bad value can crash an async message-processing loop (local DoS — messages
|
||||||
|
* stop rendering until reload) or crash-loop the app on boot before a Zod schema
|
||||||
|
* gets a chance to validate.
|
||||||
|
*
|
||||||
|
* `safeJson` returns the provided fallback instead of throwing, so callers can
|
||||||
|
* skip a bad item and keep going. Pair with schema validation (e.g. Zod) for the
|
||||||
|
* happy path — this only guarantees you never throw.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = safeJson<MyShape>(raw, {} as MyShape);
|
||||||
|
* const parsed = MySchema.safeParse(safeJson(stored, {}));
|
||||||
|
*/
|
||||||
|
export function safeJson<T>(raw: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/lib/secureStorage.ts
Normal file
70
src/lib/secureStorage.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage adapter that uses native secure storage (iOS Keychain / Android
|
||||||
|
* KeyStore) on Capacitor builds and falls back to localStorage on web.
|
||||||
|
*
|
||||||
|
* API is async-compatible with the Web Storage interface (`getItem`,
|
||||||
|
* `setItem`, `removeItem`) — each method returns a Promise.
|
||||||
|
*
|
||||||
|
* On the first native read, if a key is not found in secure storage but
|
||||||
|
* exists in localStorage, it is automatically migrated to secure storage
|
||||||
|
* and the plaintext localStorage copy is removed. This means shipping a
|
||||||
|
* Capacitor build of an app that previously wrote to localStorage will
|
||||||
|
* transparently upgrade existing users' secrets to the Keychain/KeyStore
|
||||||
|
* the first time the app reads them.
|
||||||
|
*
|
||||||
|
* Use this for any sensitive state you don't want in plaintext on device:
|
||||||
|
* - The Cashu wallet BIP-39 mnemonic (master key to all ecash funds)
|
||||||
|
* - NWC (Nostr Wallet Connect) connection strings
|
||||||
|
* - Encryption passphrases / derived keys
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await secureStorage.setItem('cashu-mnemonic', 'word word word …');
|
||||||
|
* const seed = await secureStorage.getItem('cashu-mnemonic');
|
||||||
|
*/
|
||||||
|
export const secureStorage = {
|
||||||
|
async getItem(key: string): Promise<string | null> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { value } = await SecureStoragePlugin.get({ key });
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
// Key not found in secure storage — check localStorage for migration.
|
||||||
|
const legacy = localStorage.getItem(key);
|
||||||
|
if (legacy !== null) {
|
||||||
|
// Migrate to secure storage and remove the plaintext copy.
|
||||||
|
await SecureStoragePlugin.set({ key, value: legacy });
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SecureStoragePlugin.set({ key, value });
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
if (!Capacitor.isNativePlatform()) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await SecureStoragePlugin.remove({ key });
|
||||||
|
} catch {
|
||||||
|
// Key didn't exist — ignore.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
38
src/lib/verifyEvents.ts
Normal file
38
src/lib/verifyEvents.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { verifyEvent } from 'nostr-tools';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature-verify a batch of events, dropping any that fail.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Relays are untrusted transport. Rendering event content is already XSS-safe
|
||||||
|
* throughout the app, but *trust decisions* (trust scores, moderation flags,
|
||||||
|
* listing authorship) must not be made on unverified data — a malicious or
|
||||||
|
* buggy relay can otherwise inject forged events that were never signed by the
|
||||||
|
* claimed author.
|
||||||
|
*
|
||||||
|
* `verifyEvent` checks both the event id (hash of the serialized event) and the
|
||||||
|
* Schnorr signature against the pubkey, so a passing event is provably authored
|
||||||
|
* by `event.pubkey`. Verification is ~1ms/event; memoize upstream if a hot path
|
||||||
|
* verifies the same events repeatedly.
|
||||||
|
*
|
||||||
|
* Never throws — a malformed event that makes `verifyEvent` throw is simply
|
||||||
|
* treated as invalid and filtered out.
|
||||||
|
*/
|
||||||
|
export function onlyVerified(events: NostrEvent[]): NostrEvent[] {
|
||||||
|
return events.filter((e) => {
|
||||||
|
try {
|
||||||
|
return verifyEvent(e);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-event convenience wrapper. Returns false on any error. */
|
||||||
|
export function isVerified(event: NostrEvent): boolean {
|
||||||
|
try {
|
||||||
|
return verifyEvent(event);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue