Fix Cashu zap: pay in one click + honor custom-amount balance guard

Audit of the wallet/zap flow surfaced a real money-path bug in ZapDialog's
Cashu branch:

• One-click Cashu zap never worked. The Cashu payment branch only ran
  `if (... && invoice)`, but no invoice exists on the first click — so
  selecting "Cashu" and tapping Zap silently fell through to the Lightning
  flow (generating an invoice / paying via NWC-WebLN) and the user's Cashu
  balance was never used. Now the Cashu path generates the recipient's
  amount-verified zap invoice and melts it from the Cashu wallet in a single
  click.

  To support this cleanly, useZaps now exposes `generateZapInvoice()` —
  builds + signs the NIP-57 request, fetches the LNURL invoice, and runs the
  same `assertInvoiceAmount` security check the Lightning rail uses — WITHOUT
  auto-paying. The Lightning `zap()` path was refactored to use it too, so
  both rails share one verified-invoice codepath.

• Custom-amount balance guard bypassed. The insufficient-balance warning and
  the disabled Send state only checked `typeof amount === 'number'`, but the
  custom-amount input stores a string — so a typed amount over the Cashu
  balance slipped past both. Both now coerce to a number first.

• Smarter defaults: when the dialog opens and the user has Cashu funds but no
  connected Lightning wallet, Cashu is pre-selected and the "connect a wallet"
  setup nag is skipped (they can already pay).

• Removed a dead `useLightningOperations` import.

Also audited mobile layout (Layout.tsx — mature, safe-area-aware, no changes
needed) and ZapCrewDialog (uses the separate useCrewZap splitter, no Cashu
option, no bug).

Bumps to 1.2.21 (Android versionCode 10221 auto-derived).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 20:15:10 +07:00
parent 48b127f7b0
commit 39ac517610
3 changed files with 172 additions and 114 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "skate-spots", "name": "skate-spots",
"private": true, "private": true,
"version": "1.2.20", "version": "1.2.21",
"type": "module", "type": "module",
"homepage": "https://skate-spots.com", "homepage": "https://skate-spots.com",
"scripts": { "scripts": {

View file

@ -32,7 +32,6 @@ import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet'; import { useWallet } from '@/hooks/useWallet';
import { useIsMobile } from '@/hooks/useIsMobile'; import { useIsMobile } from '@/hooks/useIsMobile';
import { useCashu } from '@/hooks/wallet/useCashu'; import { useCashu } from '@/hooks/wallet/useCashu';
import { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
import { WalletModal } from '@/components/WalletModal'; import { WalletModal } from '@/components/WalletModal';
import type { Event } from 'nostr-tools'; import type { Event } from 'nostr-tools';
// qrcode imported lazily in async function to avoid canvas crash at module load // qrcode imported lazily in async function to avoid canvas crash at module load
@ -363,27 +362,40 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
/> />
</div> </div>
<div className="px-4 pb-4 space-y-2"> <div className="px-4 pb-4 space-y-2">
{/* Show insufficient balance warning for Cashu */} {/* Show insufficient balance warning for Cashu. Coerce amount to a
{paymentMethod === 'cashu' && cashuBalance !== undefined && ( number first — the custom-amount input stores it as a string, so a
typeof amount === 'number' && amount > cashuBalance ? ( plain `typeof === 'number'` check would let an over-balance custom
amount slip past both this warning and the disabled state. */}
{(() => {
const numericAmount =
typeof amount === 'string' ? parseInt(amount, 10) : amount;
const overBalance =
paymentMethod === 'cashu' &&
cashuBalance !== undefined &&
Number.isFinite(numericAmount) &&
numericAmount > cashuBalance;
return overBalance ? (
<Card className="bg-yellow-950/30 border-yellow-500/30"> <Card className="bg-yellow-950/30 border-yellow-500/30">
<CardContent className="p-3"> <CardContent className="p-3">
<p className="text-xs text-yellow-200"> <p className="text-xs text-yellow-200">
⚠️ Insufficient Cashu balance. You need {(amount - cashuBalance).toLocaleString()} more sats. ⚠️ Insufficient Cashu balance. You need {(numericAmount - cashuBalance).toLocaleString()} more sats.
Switch to Lightning or top up your Cashu wallet. Switch to Lightning or top up your Cashu wallet.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) : null ) : null;
)} })()}
<Button <Button
onClick={handleZap} onClick={handleZap}
className="w-full" className="w-full"
disabled={ disabled={(() => {
isZapping || if (isZapping) return true;
(paymentMethod === 'cashu' && typeof amount === 'number' && cashuBalance !== undefined && amount > cashuBalance) if (paymentMethod !== 'cashu' || cashuBalance === undefined) return false;
} const numericAmount =
typeof amount === 'string' ? parseInt(amount, 10) : amount;
return Number.isFinite(numericAmount) && numericAmount > cashuBalance;
})()}
size="default" size="default"
> >
{isZapping ? ( {isZapping ? (
@ -408,7 +420,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { data: author } = useAuthor(target.pubkey); const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast(); const { toast } = useToast();
const { webln, activeNWC } = useWallet(); const { webln, activeNWC } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false)); const { zap, generateZapInvoice, isZapping, setIsZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100); const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>(''); const [comment, setComment] = useState<string>('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -432,6 +444,19 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
} }
}, [target]); }, [target]);
// Pick the most useful default payment method when the dialog opens: if the
// user has Cashu funds but no connected Lightning wallet, default to Cashu so
// the one-click built-in payment is pre-selected instead of the manual-invoice
// Lightning fallback.
useEffect(() => {
if (!open) return;
if (hasAnyCashuBalance && !hasWallet) {
setPaymentMethod('cashu');
} else {
setPaymentMethod('lightning');
}
}, [open, hasAnyCashuBalance, hasWallet]);
// Generate QR code // Generate QR code
useEffect(() => { useEffect(() => {
let isCancelled = false; let isCancelled = false;
@ -496,8 +521,10 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setCopied(false); setCopied(false);
setQrCodeUrl(''); setQrCodeUrl('');
// Show wallet setup screen if no wallet connected and haven't shown it yet // Show wallet setup screen only if the user has NO way to pay yet — no
if (!hasWallet && !hasShownSetup) { // connected Lightning wallet AND no Cashu balance. If they have Cashu
// funds, they can zap immediately, so skip the "connect a wallet" nag.
if (!hasWallet && !hasAnyCashuBalance && !hasShownSetup) {
setShowWalletSetup(true); setShowWalletSetup(true);
} else { } else {
setShowWalletSetup(false); setShowWalletSetup(false);
@ -510,42 +537,74 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
setQrCodeUrl(''); setQrCodeUrl('');
setShowWalletSetup(false); setShowWalletSetup(false);
} }
}, [open, setInvoice, hasWallet, hasShownSetup]); }, [open, setInvoice, hasWallet, hasAnyCashuBalance, hasShownSetup]);
const handleZap = async () => { const handleZap = async () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount; const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
if (!Number.isFinite(finalAmount) || finalAmount <= 0) {
toast({
variant: 'destructive',
title: 'Enter an amount',
description: 'Choose a preset or type a valid number of sats.',
});
return;
}
// If Cashu payment method is selected and user has invoice to pay // ── Cashu (built-in wallet) path ──
if (paymentMethod === 'cashu' && hasCashu && coco && invoice) { // Pay in ONE click: fetch the recipient's zap invoice for exactly this
// amount, then melt it from the Cashu wallet. Previously this branch only
// ran when an `invoice` already existed, so the first click silently fell
// through to the Lightning flow and Cashu funds were never used.
if (paymentMethod === 'cashu') {
if (!hasCashu || !coco) {
toast({
variant: 'destructive',
title: 'Cashu wallet unavailable',
description: 'Your Cashu wallet is not ready. Switch to Lightning or open your wallet first.',
});
return;
}
if (finalAmount > cashuBalance) {
toast({
variant: 'destructive',
title: 'Insufficient Cashu balance',
description: `You need ${(finalAmount - cashuBalance).toLocaleString()} more sats. Switch to Lightning or top up.`,
});
return;
}
setIsZapping(true);
try { try {
// Get first active mint const activeMint = mints.find((m) => m.url);
const activeMint = mints.find(m => m.url); if (!activeMint) throw new Error('No active Cashu mint found.');
if (!activeMint) {
throw new Error('No active mint found');
}
// Pay the Lightning invoice using Cashu (create and pay melt quote) // Same verified invoice the Lightning rail would use (amount-checked).
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, invoice); const zapInvoice = await generateZapInvoice(finalAmount, comment);
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, zapInvoice);
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote); await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
toast({ toast({
title: 'Zap sent!', title: 'Zap sent! 🥜',
description: `Successfully zapped ${finalAmount} sats from your Cashu wallet`, description: `Zapped ${finalAmount.toLocaleString()} sats from your Cashu wallet.`,
}); });
setOpen(false);
setInvoice(null); setInvoice(null);
setOpen(false);
} catch (error) { } catch (error) {
console.error('Cashu payment error:', error); console.error('Cashu payment error:', error);
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Payment failed', title: 'Cashu payment failed',
description: error instanceof Error ? error.message : 'Failed to pay with Cashu wallet', description: error instanceof Error ? error.message : 'Failed to pay with your Cashu wallet.',
}); });
} finally {
setIsZapping(false);
} }
} else { return;
// Use regular Lightning/NWC zap flow
zap(finalAmount, comment);
} }
// ── Lightning / NWC / WebLN path ──
zap(finalAmount, comment);
}; };
const handleContinueWithoutWallet = () => { const handleContinueWithoutWallet = () => {

View file

@ -132,6 +132,78 @@ export function useZaps(
return { zapCount: count, totalSats: sats, zaps: zapEvents }; return { zapCount: count, totalSats: sats, zaps: zapEvents };
}, [zapEvents, actualTarget]); }, [zapEvents, actualTarget]);
/**
* Build + sign a NIP-57 zap request, hit the author's LNURL endpoint, and
* return a verified Lightning invoice for `amount` sats WITHOUT paying it.
*
* Extracted so alternative payment rails (e.g. paying the invoice from the
* built-in Cashu wallet via a melt quote) can obtain the very same invoice
* the Lightning path would. Throws on any failure (caller handles the toast).
*/
const generateZapInvoice = useCallback(async (amount: number, comment: string): Promise<string> => {
if (amount <= 0) throw new Error('Enter an amount greater than zero.');
if (!user) throw new Error('You must be logged in to send a zap.');
if (!actualTarget) throw new Error('Could not find the event to zap.');
if (!author.data?.metadata || !author.data?.event) {
throw new Error('Could not find the author of this item.');
}
const { lud06, lud16 } = author.data.metadata;
if (!lud06 && !lud16) {
throw new Error('The author does not have a lightning address configured.');
}
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
if (!zapEndpoint) {
throw new Error('Could not find a zap endpoint for the author.');
}
const zapAmount = amount * 1000; // millisats
const isAddressable = actualTarget.kind >= 30000 && actualTarget.kind < 40000;
const zapTags: string[][] = [
['relays', ...config.relayMetadata.relays.map((r) => r.url)],
['amount', String(zapAmount)],
['p', actualTarget.pubkey],
];
if (isAddressable) {
const dTag = actualTarget.tags.find(([n]) => n === 'd')?.[1] ?? '';
zapTags.push(['a', `${actualTarget.kind}:${actualTarget.pubkey}:${dTag}`]);
} else {
zapTags.push(['e', actualTarget.id]);
}
const zapRequest = {
kind: 9734,
content: comment ?? '',
tags: zapTags,
created_at: Math.floor(Date.now() / 1000),
};
if (!user.signer) throw new Error('No signer available');
const signedZapRequest = await user.signer.signEvent(zapRequest);
const res = await fetch(
`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`,
);
const responseData = await res.json();
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
}
const newInvoice = responseData.pr;
if (!newInvoice || typeof newInvoice !== 'string') {
throw new Error('Lightning service did not return a valid invoice');
}
// SECURITY: verify the invoice requests exactly the chosen amount before it
// can be auto-paid by ANY rail (NWC, WebLN, or Cashu melt). A malicious LNURL
// server could otherwise return an inflated invoice and drain the wallet.
assertInvoiceAmount(newInvoice, amount);
return newInvoice;
}, [user, actualTarget, author.data, config.relayMetadata.relays]);
const zap = async (amount: number, comment: string) => { const zap = async (amount: number, comment: string) => {
if (amount <= 0) { if (amount <= 0) {
return; return;
@ -171,83 +243,8 @@ export function useZaps(
return; return;
} }
const { lud06, lud16 } = author.data.metadata;
if (!lud06 && !lud16) {
toast({
title: 'Lightning address not found',
description: 'The author does not have a lightning address configured.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
// Get zap endpoint using the old reliable method
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
if (!zapEndpoint) {
toast({
title: 'Zap endpoint not found',
description: 'Could not find a zap endpoint for the author.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
const zapAmount = amount * 1000; // convert to millisats
// Build the NIP-57 zap request event directly. We construct it by hand
// (rather than nip57.makeZapRequest) so we're not coupled to that helper's
// changing type signature across nostr-tools versions, and so we can emit
// the correct target tag:
// - addressable events (30000-39999) → 'a' tag (kind:pubkey:dTag)
// - all other events → 'e' tag (event id)
const isAddressable =
actualTarget.kind >= 30000 && actualTarget.kind < 40000;
const zapTags: string[][] = [
['relays', ...config.relayMetadata.relays.map(r => r.url)],
['amount', String(zapAmount)],
['p', actualTarget.pubkey],
];
if (isAddressable) {
const dTag = actualTarget.tags.find(([n]) => n === 'd')?.[1] ?? '';
zapTags.push(['a', `${actualTarget.kind}:${actualTarget.pubkey}:${dTag}`]);
} else {
zapTags.push(['e', actualTarget.id]);
}
const zapRequest = {
kind: 9734,
content: comment ?? '',
tags: zapTags,
created_at: Math.floor(Date.now() / 1000),
};
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
if (!user.signer) {
throw new Error('No signer available');
}
const signedZapRequest = await user.signer.signEvent(zapRequest);
try { try {
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`); const newInvoice = await generateZapInvoice(amount, comment);
const responseData = await res.json();
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
}
const newInvoice = responseData.pr;
if (!newInvoice || typeof newInvoice !== 'string') {
throw new Error('Lightning service did not return a valid invoice');
}
// SECURITY: verify the invoice actually requests the amount the user
// chose BEFORE any auto-pay. A malicious LNURL server could otherwise
// return an inflated invoice and drain the wallet. If this throws we
// fall into the catch below and surface the error instead of paying.
assertInvoiceAmount(newInvoice, amount);
// Get the current active NWC connection dynamically // Get the current active NWC connection dynamically
const currentNWCConnection = getActiveConnection(); const currentNWCConnection = getActiveConnection();
@ -363,7 +360,9 @@ export function useZaps(
totalSats, totalSats,
...query, ...query,
zap, zap,
generateZapInvoice,
isZapping, isZapping,
setIsZapping,
invoice, invoice,
setInvoice, setInvoice,
resetInvoice, resetInvoice,