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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue