QR scanner (CashuWalletCard.handleQRScanResult):
- The classifier recognizes bitcoin_address, npub, and CLINK offer/debit codes,
but all of them fell through to a misleading "Could not recognize the scanned
content" toast. Now each gets an honest, specific message:
• Bitcoin address → "On-chain not supported here" (this is an ecash/Lightning
wallet with no on-chain send)
• npub → "That's a Nostr profile"
• noffer/ndebit → "Payment code not supported yet"
Only genuinely unclassified input shows the generic "unrecognized" message.
MintPrivacyManager:
- Auto-distribute toggle was a live Switch that flipped on and then admitted
"Coming soon — does nothing." Replaced with a disabled switch + "Soon" badge
so users can't think they enabled a feature that isn't real. Removed the now
dead autoDistribute state.
- Dropped unused imports (Progress, Lock, Eye, EyeOff).
The mint manager itself (distribution viz, privacy score, trust/balance
removal warnings) was already excellent and left untouched.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
494 lines
19 KiB
TypeScript
494 lines
19 KiB
TypeScript
import { useState } from 'react';
|
|
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 { Switch } from '@/components/ui/switch';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import {
|
|
Shield,
|
|
Plus,
|
|
Trash2,
|
|
AlertTriangle,
|
|
Info,
|
|
ExternalLink,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Star,
|
|
Globe,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { isNativePlatform } from '@/lib/platform';
|
|
|
|
interface Mint {
|
|
url: string;
|
|
name?: string;
|
|
info?: unknown;
|
|
}
|
|
|
|
interface MintPrivacyManagerProps {
|
|
mints: Mint[];
|
|
balances: { [mintUrl: string]: number };
|
|
activeMintUrl: string | null;
|
|
onSetActiveMint: (url: string) => void;
|
|
onAddMint: (url: string) => Promise<void>;
|
|
onRemoveMint: (mint: Mint) => void;
|
|
totalBalance: number;
|
|
className?: string;
|
|
}
|
|
|
|
// Recommended mints (no stablecoin mints).
|
|
// Ordered by browser reachability — web wallets can only use mints that send
|
|
// CORS headers. Minibits works great natively but does NOT allow browser
|
|
// requests, so on the web it's flagged and listed last. In a native shell there
|
|
// is no CORS restriction, so Minibits (the SkateSpots brand mint) leads.
|
|
const MINIBITS = {
|
|
url: 'https://mint.minibits.cash/Bitcoin',
|
|
name: 'Minibits',
|
|
};
|
|
const COINOS = {
|
|
url: 'https://mint.coinos.io',
|
|
name: 'Coinos',
|
|
};
|
|
const LNBITS = {
|
|
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
|
|
name: 'LNbits Legend',
|
|
description: 'Community mint on LNbits',
|
|
};
|
|
|
|
const RECOMMENDED_MINTS_WEB = [
|
|
{ ...COINOS, description: 'Web-friendly mint (recommended for browsers)' },
|
|
LNBITS,
|
|
{ ...MINIBITS, description: '⚠ Native app only — not reachable from web browsers' },
|
|
];
|
|
|
|
const RECOMMENDED_MINTS_NATIVE = [
|
|
{ ...MINIBITS, description: 'SkateSpots mint — recommended' },
|
|
{ ...COINOS, description: 'Web-friendly mint' },
|
|
LNBITS,
|
|
];
|
|
|
|
export function MintPrivacyManager({
|
|
mints,
|
|
balances,
|
|
activeMintUrl,
|
|
onSetActiveMint,
|
|
onAddMint,
|
|
onRemoveMint,
|
|
totalBalance,
|
|
className,
|
|
}: MintPrivacyManagerProps) {
|
|
const RECOMMENDED_MINTS = isNativePlatform() ? RECOMMENDED_MINTS_NATIVE : RECOMMENDED_MINTS_WEB;
|
|
const [showPrivacyTips, setShowPrivacyTips] = useState(false);
|
|
const [showAddMint, setShowAddMint] = useState(false);
|
|
const [mintUrl, setMintUrl] = useState('');
|
|
const [isAddingMint, setIsAddingMint] = useState(false);
|
|
const [mintToRemove, setMintToRemove] = useState<Mint | null>(null);
|
|
const { toast } = useToast();
|
|
|
|
// Calculate distribution across mints
|
|
const mintDistribution = mints.map(mint => ({
|
|
mint,
|
|
balance: balances[mint.url] || 0,
|
|
percentage: totalBalance > 0 ? ((balances[mint.url] || 0) / totalBalance) * 100 : 0,
|
|
}));
|
|
|
|
// Privacy score based on distribution
|
|
const getPrivacyScore = () => {
|
|
if (mints.length === 0) return { score: 0, label: 'No Mints', color: 'text-gray-500' };
|
|
if (mints.length === 1) return { score: 30, label: 'Single Mint', color: 'text-red-400' };
|
|
|
|
// Check if funds are well distributed
|
|
const maxPercentage = Math.max(...mintDistribution.map(m => m.percentage));
|
|
if (maxPercentage > 80) return { score: 50, label: 'Concentrated', color: 'text-amber-400' };
|
|
if (maxPercentage > 60) return { score: 70, label: 'Moderate', color: 'text-yellow-400' };
|
|
return { score: 90, label: 'Well Distributed', color: 'text-green-400' };
|
|
};
|
|
|
|
const privacyScore = getPrivacyScore();
|
|
|
|
const handleAddMint = async () => {
|
|
if (!mintUrl.trim()) return;
|
|
|
|
// Basic validation
|
|
if (!mintUrl.startsWith('https://')) {
|
|
toast({ variant: 'destructive', title: 'Invalid URL', description: 'Mint URL must start with https://' });
|
|
return;
|
|
}
|
|
|
|
setIsAddingMint(true);
|
|
try {
|
|
await onAddMint(mintUrl.trim());
|
|
setMintUrl('');
|
|
setShowAddMint(false);
|
|
toast({ title: 'Mint Added', description: 'New mint added successfully' });
|
|
} catch (err) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'Failed to add mint',
|
|
description: err instanceof Error ? err.message : 'Unknown error'
|
|
});
|
|
} finally {
|
|
setIsAddingMint(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmRemove = () => {
|
|
if (mintToRemove) {
|
|
onRemoveMint(mintToRemove);
|
|
setMintToRemove(null);
|
|
}
|
|
};
|
|
|
|
const getCleanMintLabel = (mint: Mint) => {
|
|
if (mint.name) return mint.name;
|
|
try {
|
|
const url = new URL(mint.url);
|
|
return url.hostname;
|
|
} catch {
|
|
return mint.url;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card className={cn("border-purple-500/30", className)}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Shield className="h-5 w-5 text-purple-500" />
|
|
Mint Privacy
|
|
</CardTitle>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn("text-xs", privacyScore.color)}
|
|
>
|
|
{privacyScore.label}
|
|
</Badge>
|
|
</div>
|
|
<CardDescription className="text-xs">
|
|
Using multiple mints improves privacy by preventing any single mint from seeing all your transactions
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{/* Distribution Visualization */}
|
|
{mints.length > 0 && totalBalance > 0 && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground">Fund Distribution</Label>
|
|
<div className="h-3 rounded-full bg-gray-800 overflow-hidden flex">
|
|
{mintDistribution.map((item, index) => (
|
|
<div
|
|
key={item.mint.url}
|
|
className={cn(
|
|
"h-full transition-all",
|
|
index === 0 && "bg-purple-500",
|
|
index === 1 && "bg-blue-500",
|
|
index === 2 && "bg-green-500",
|
|
index === 3 && "bg-amber-500",
|
|
index >= 4 && "bg-gray-500"
|
|
)}
|
|
style={{ width: `${item.percentage}%` }}
|
|
title={`${getCleanMintLabel(item.mint)}: ${item.percentage.toFixed(1)}%`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs">
|
|
{mintDistribution.map((item, index) => (
|
|
<div key={item.mint.url} className="flex items-center gap-1">
|
|
<div className={cn(
|
|
"w-2 h-2 rounded-full",
|
|
index === 0 && "bg-purple-500",
|
|
index === 1 && "bg-blue-500",
|
|
index === 2 && "bg-green-500",
|
|
index === 3 && "bg-amber-500",
|
|
index >= 4 && "bg-gray-500"
|
|
)} />
|
|
<span className="text-gray-400">{getCleanMintLabel(item.mint)}</span>
|
|
<span className="text-gray-500">({item.percentage.toFixed(0)}%)</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Privacy Warning */}
|
|
{mints.length === 1 && (
|
|
<Alert className="border-amber-500/30 bg-amber-500/5">
|
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
<AlertDescription className="text-xs">
|
|
Using a single mint means that mint can see all your transaction timing and amounts.
|
|
Consider adding more mints for better privacy.
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Mint List */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">Your Mints ({mints.length})</Label>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{mints.map((mint) => (
|
|
<div
|
|
key={mint.url}
|
|
className={cn(
|
|
"flex items-center justify-between p-3 rounded-lg border transition-colors",
|
|
activeMintUrl === mint.url
|
|
? "bg-purple-500/10 border-purple-500/30"
|
|
: "bg-muted/50 border-muted"
|
|
)}
|
|
>
|
|
<div
|
|
className="flex-1 min-w-0 cursor-pointer"
|
|
onClick={() => onSetActiveMint(mint.url)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm truncate">
|
|
{getCleanMintLabel(mint)}
|
|
</span>
|
|
{activeMintUrl === mint.url && (
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
<Star className="h-2.5 w-2.5 mr-0.5" />
|
|
Default
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{(balances[mint.url] || 0).toLocaleString()} sats
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setMintToRemove(mint)}
|
|
className="text-destructive hover:text-destructive h-8 w-8 p-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Mint */}
|
|
<Button
|
|
variant="outline"
|
|
className="w-full gap-2"
|
|
onClick={() => setShowAddMint(true)}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Mint
|
|
</Button>
|
|
|
|
{/* Auto Distribution Toggle — not yet implemented, so it's shown as a
|
|
disabled affordance with a "Soon" badge rather than a switch that
|
|
flips on but does nothing. */}
|
|
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50 opacity-70">
|
|
<div className="space-y-0.5">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-sm font-medium">Auto-distribute funds</Label>
|
|
<Badge variant="outline" className="text-[10px] text-amber-400 border-amber-500/30">
|
|
Soon
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Spread received funds across multiple mints
|
|
</p>
|
|
</div>
|
|
<Switch checked={false} disabled aria-label="Auto-distribute funds (coming soon)" />
|
|
</div>
|
|
|
|
{/* Privacy Tips Toggle */}
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-between text-sm"
|
|
onClick={() => setShowPrivacyTips(!showPrivacyTips)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Info className="h-4 w-4 text-blue-500" />
|
|
Privacy Tips
|
|
</span>
|
|
{showPrivacyTips ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
|
|
{showPrivacyTips && (
|
|
<div className="space-y-2 text-xs p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-blue-400 font-bold">•</span>
|
|
<span className="text-gray-300">Use 2-3 different mints minimum for better privacy</span>
|
|
</div>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-blue-400 font-bold">•</span>
|
|
<span className="text-gray-300">Don't keep all funds in one mint - spread them out</span>
|
|
</div>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-blue-400 font-bold">•</span>
|
|
<span className="text-gray-300">Consider mint jurisdiction (different countries = different legal access)</span>
|
|
</div>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-blue-400 font-bold">•</span>
|
|
<span className="text-gray-300">Mints see timing of transactions but NOT who you pay (blind signatures)</span>
|
|
</div>
|
|
<div className="mt-2 pt-2 border-t border-blue-500/20">
|
|
<p className="text-gray-500 italic">
|
|
Future features: Mint rotation and privacy scoring - stay tuned!
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* External Link */}
|
|
<a
|
|
href="https://mintradar.pedani.eu/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
Find more mints at mintradar.pedani.eu
|
|
</a>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Add Mint Dialog */}
|
|
<Dialog open={showAddMint} onOpenChange={setShowAddMint}>
|
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Mint</DialogTitle>
|
|
<DialogDescription>
|
|
Add a new Cashu mint to your wallet
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
{/* Manual Entry */}
|
|
<div className="space-y-2">
|
|
<Label className="text-sm">Mint URL</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="https://mint.example.com"
|
|
value={mintUrl}
|
|
onChange={(e) => setMintUrl(e.target.value)}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<Button
|
|
onClick={handleAddMint}
|
|
disabled={!mintUrl || isAddingMint}
|
|
>
|
|
{isAddingMint ? 'Adding...' : 'Add'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<span className="w-full border-t" />
|
|
</div>
|
|
<div className="relative flex justify-center text-xs uppercase">
|
|
<span className="bg-background px-2 text-muted-foreground">Or choose from list</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recommended Mints */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground">Recommended Mints</Label>
|
|
{RECOMMENDED_MINTS.map((mint) => {
|
|
const isAdded = mints.some(m => m.url.toLowerCase() === mint.url.toLowerCase());
|
|
return (
|
|
<div
|
|
key={mint.url}
|
|
className={cn(
|
|
"flex items-center justify-between p-3 rounded-lg border",
|
|
isAdded ? "bg-green-500/5 border-green-500/20" : "bg-muted/50"
|
|
)}
|
|
>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium text-sm">{mint.name}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground ml-6">{mint.description}</p>
|
|
</div>
|
|
{isAdded ? (
|
|
<Badge variant="secondary" className="text-xs">Added</Badge>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={async () => {
|
|
setIsAddingMint(true);
|
|
try {
|
|
await onAddMint(mint.url);
|
|
toast({ title: 'Mint Added', description: `${mint.name} added successfully` });
|
|
} catch (err) {
|
|
toast({
|
|
variant: 'destructive',
|
|
title: 'Failed',
|
|
description: err instanceof Error ? err.message : 'Unknown error'
|
|
});
|
|
} finally {
|
|
setIsAddingMint(false);
|
|
}
|
|
}}
|
|
disabled={isAddingMint}
|
|
>
|
|
Add
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Trust Warning */}
|
|
<Alert className="border-amber-500/30 bg-amber-500/5">
|
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
<AlertDescription className="text-xs">
|
|
<span className="font-bold text-amber-400">Trust Warning:</span> Mints are custodial - they hold your funds.
|
|
Only use mints you trust. If a mint disappears, your funds may be lost.
|
|
Spreading funds across multiple mints reduces risk.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Remove Mint Confirmation */}
|
|
<Dialog open={!!mintToRemove} onOpenChange={(open) => !open && setMintToRemove(null)}>
|
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md">
|
|
<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 && (
|
|
<Alert className="border-red-500/30 bg-red-500/5">
|
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
|
<AlertDescription className="text-xs">
|
|
<span className="font-bold text-red-400">Warning:</span> This mint has {(balances[mintToRemove.url] || 0).toLocaleString()} sats.
|
|
Withdraw your funds before removing!
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="flex gap-2 justify-end">
|
|
<Button variant="outline" onClick={() => setMintToRemove(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmRemove}>
|
|
Remove Mint
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|