SkateSpots/src/components/wallet/MintPrivacyManager.tsx
shakespeare.diy 2b3ee73292 Wallet QR + mint UX: honest scan feedback, truthful auto-distribute affordance (1.2.27)
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>
2026-09-07 21:54:43 +07:00

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>
</>
);
}