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:
parent
48b127f7b0
commit
39ac517610
3 changed files with 172 additions and 114 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "skate-spots",
|
||||
"private": true,
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"homepage": "https://skate-spots.com",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import { useZaps } from '@/hooks/useZaps';
|
|||
import { useWallet } from '@/hooks/useWallet';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||
import { useLightningOperations } from '@/hooks/wallet/useLightningOperations';
|
||||
import { WalletModal } from '@/components/WalletModal';
|
||||
import type { Event } from 'nostr-tools';
|
||||
// qrcode imported lazily in async function to avoid canvas crash at module load
|
||||
|
|
@ -363,27 +362,40 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
|||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{/* Show insufficient balance warning for Cashu */}
|
||||
{paymentMethod === 'cashu' && cashuBalance !== undefined && (
|
||||
typeof amount === 'number' && amount > cashuBalance ? (
|
||||
{/* Show insufficient balance warning for Cashu. Coerce amount to a
|
||||
number first — the custom-amount input stores it as a string, so a
|
||||
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">
|
||||
<CardContent className="p-3">
|
||||
<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.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null
|
||||
)}
|
||||
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<Button
|
||||
onClick={handleZap}
|
||||
className="w-full"
|
||||
disabled={
|
||||
isZapping ||
|
||||
(paymentMethod === 'cashu' && typeof amount === 'number' && cashuBalance !== undefined && amount > cashuBalance)
|
||||
}
|
||||
disabled={(() => {
|
||||
if (isZapping) return true;
|
||||
if (paymentMethod !== 'cashu' || cashuBalance === undefined) return false;
|
||||
const numericAmount =
|
||||
typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||
return Number.isFinite(numericAmount) && numericAmount > cashuBalance;
|
||||
})()}
|
||||
size="default"
|
||||
>
|
||||
{isZapping ? (
|
||||
|
|
@ -408,7 +420,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
const { data: author } = useAuthor(target.pubkey);
|
||||
const { toast } = useToast();
|
||||
const { webln, activeNWC } = useWallet();
|
||||
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
|
||||
const { zap, generateZapInvoice, isZapping, setIsZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
|
||||
const [amount, setAmount] = useState<number | string>(100);
|
||||
const [comment, setComment] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -432,6 +444,19 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
|
@ -496,8 +521,10 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
setCopied(false);
|
||||
setQrCodeUrl('');
|
||||
|
||||
// Show wallet setup screen if no wallet connected and haven't shown it yet
|
||||
if (!hasWallet && !hasShownSetup) {
|
||||
// Show wallet setup screen only if the user has NO way to pay yet — no
|
||||
// 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);
|
||||
} else {
|
||||
setShowWalletSetup(false);
|
||||
|
|
@ -510,42 +537,74 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
|||
setQrCodeUrl('');
|
||||
setShowWalletSetup(false);
|
||||
}
|
||||
}, [open, setInvoice, hasWallet, hasShownSetup]);
|
||||
}, [open, setInvoice, hasWallet, hasAnyCashuBalance, hasShownSetup]);
|
||||
|
||||
const handleZap = async () => {
|
||||
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||
|
||||
// If Cashu payment method is selected and user has invoice to pay
|
||||
if (paymentMethod === 'cashu' && hasCashu && coco && invoice) {
|
||||
try {
|
||||
// Get first active mint
|
||||
const activeMint = mints.find(m => m.url);
|
||||
if (!activeMint) {
|
||||
throw new Error('No active mint found');
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Pay the Lightning invoice using Cashu (create and pay melt quote)
|
||||
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, invoice);
|
||||
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
|
||||
|
||||
// ── Cashu (built-in wallet) path ──
|
||||
// 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({
|
||||
title: 'Zap sent!',
|
||||
description: `Successfully zapped ${finalAmount} sats from your Cashu wallet`,
|
||||
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 {
|
||||
const activeMint = mints.find((m) => m.url);
|
||||
if (!activeMint) throw new Error('No active Cashu mint found.');
|
||||
|
||||
// Same verified invoice the Lightning rail would use (amount-checked).
|
||||
const zapInvoice = await generateZapInvoice(finalAmount, comment);
|
||||
|
||||
const meltQuote = await coco.quotes.createMeltQuote(activeMint.url, zapInvoice);
|
||||
await coco.quotes.payMeltQuote(activeMint.url, meltQuote.quote);
|
||||
|
||||
toast({
|
||||
title: 'Zap sent! 🥜',
|
||||
description: `Zapped ${finalAmount.toLocaleString()} sats from your Cashu wallet.`,
|
||||
});
|
||||
setOpen(false);
|
||||
setInvoice(null);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Cashu payment error:', error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Payment failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to pay with Cashu wallet',
|
||||
title: 'Cashu payment failed',
|
||||
description: error instanceof Error ? error.message : 'Failed to pay with your Cashu wallet.',
|
||||
});
|
||||
} finally {
|
||||
setIsZapping(false);
|
||||
}
|
||||
} else {
|
||||
// Use regular Lightning/NWC zap flow
|
||||
zap(finalAmount, comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Lightning / NWC / WebLN path ──
|
||||
zap(finalAmount, comment);
|
||||
};
|
||||
|
||||
const handleContinueWithoutWallet = () => {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,78 @@ export function useZaps(
|
|||
return { zapCount: count, totalSats: sats, zaps: zapEvents };
|
||||
}, [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) => {
|
||||
if (amount <= 0) {
|
||||
return;
|
||||
|
|
@ -171,83 +243,8 @@ export function useZaps(
|
|||
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 {
|
||||
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 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);
|
||||
const newInvoice = await generateZapInvoice(amount, comment);
|
||||
|
||||
// Get the current active NWC connection dynamically
|
||||
const currentNWCConnection = getActiveConnection();
|
||||
|
|
@ -363,7 +360,9 @@ export function useZaps(
|
|||
totalSats,
|
||||
...query,
|
||||
zap,
|
||||
generateZapInvoice,
|
||||
isZapping,
|
||||
setIsZapping,
|
||||
invoice,
|
||||
setInvoice,
|
||||
resetInvoice,
|
||||
|
|
|
|||
Loading…
Reference in a new issue