SkateSpots/src/hooks/useCrewZap.ts
shakespeare.diy be05a603be security: verify invoice amounts + require provider-issued zap receipts (1.2.14)
Fixes the audit findings:

HIGH-1 (NWC/WebLN auto-pay of unverified invoices): add assertInvoiceAmount and
call it before every programmatic payment in useZaps, useCrewZap, and
SpamFeePayment. A malicious LNURL/Lightning-address server can no longer return
an inflated invoice and drain the wallet — amount is decoded from bolt11 and must
match the intended sats (±1 sat), amountless invoices are rejected.

HIGH-2 (forgeable trust signals):
- useSasquatchPro: Pro is granted ONLY by a zap receipt AUTHORED by the
  platform's Lightning provider, embedding a user-signed zap request that tags
  the platform, with a real paid amount >= smallest tier. The self-signed
  kind:32671 receipt is now advisory only and never grants access (closed the
  "self-grant permanent Pro" hole).
- useFeaturedVerification: a listing is 'verified' only when the matching 9735 is
  authored by the provider pubkey (not any self-published receipt).
- useCrewGoal: tally only invoice-backed (bolt11) zaps; the forgeable amount tag
  is no longer summed, so goal progress can't be inflated with fake receipts.

Bump to 1.2.14.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 11:45:21 +07:00

166 lines
5.8 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useNostr } from '@nostrify/react';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
import type { NostrMetadata } from '@nostrify/nostrify';
export type CrewZapStatus = 'pending' | 'resolving' | 'paying' | 'success' | 'skipped' | 'failed';
export interface CrewZapResult {
pubkey: string;
amount: number;
status: CrewZapStatus;
/** Lightning address used (if resolved). */
address?: string;
/** Invoice to pay manually if auto-pay unavailable. */
invoice?: string;
error?: string;
}
/** Split `total` sats as evenly as possible across `count` recipients. */
export function splitAmount(total: number, count: number): number[] {
if (count <= 0) return [];
const base = Math.floor(total / count);
let remainder = total - base * count;
return Array.from({ length: count }, () => {
const extra = remainder > 0 ? 1 : 0;
if (remainder > 0) remainder--;
return base + extra;
});
}
/**
* Zap a whole crew: split an amount across the given recipient pubkeys and pay
* each recipient's Lightning address. Uses the connected wallet (NWC → WebLN).
* Recipients without a Lightning address are skipped. Recipients that can't be
* auto-paid surface a manual invoice.
*/
export function useCrewZap() {
const { nostr } = useNostr();
const { sendPayment, getActiveConnection } = useNWC();
const { webln } = useWallet();
const [results, setResults] = useState<CrewZapResult[]>([]);
const [isRunning, setIsRunning] = useState(false);
const reset = useCallback(() => setResults([]), []);
const zapCrew = useCallback(
async (recipients: string[], totalAmount: number, comment: string) => {
if (recipients.length === 0 || totalAmount <= 0) return;
setIsRunning(true);
const shares = splitAmount(totalAmount, recipients.length);
// Seed initial state
const initial: CrewZapResult[] = recipients.map((pubkey, i) => ({
pubkey,
amount: shares[i],
status: 'pending',
}));
setResults(initial);
const update = (pubkey: string, patch: Partial<CrewZapResult>) =>
setResults((prev) => prev.map((r) => (r.pubkey === pubkey ? { ...r, ...patch } : r)));
// Fetch metadata (kind 0) for all recipients in one query
let metaByPubkey = new Map<string, NostrMetadata>();
try {
const signal = AbortSignal.timeout(6000);
const events = await nostr.query([{ kinds: [0], authors: recipients }], { signal });
// Latest kind-0 per author
const latest = new Map<string, { created_at: number; content: string }>();
for (const ev of events) {
const existing = latest.get(ev.pubkey);
if (!existing || ev.created_at > existing.created_at) {
latest.set(ev.pubkey, { created_at: ev.created_at, content: ev.content });
}
}
metaByPubkey = new Map(
[...latest.entries()].map(([pk, { content }]) => {
try {
return [pk, JSON.parse(content) as NostrMetadata];
} catch {
return [pk, {} as NostrMetadata];
}
})
);
} catch {
// Continue — recipients without resolvable metadata will be skipped
}
const activeNWC = getActiveConnection();
// Pay each recipient sequentially (keeps NWC/WebLN happy, clear progress)
for (let i = 0; i < recipients.length; i++) {
const pubkey = recipients[i];
const amount = shares[i];
if (amount <= 0) {
update(pubkey, { status: 'skipped', error: 'Share rounds to 0 sats' });
continue;
}
const meta = metaByPubkey.get(pubkey);
const address = meta?.lud16;
if (!address) {
update(pubkey, { status: 'skipped', error: 'No Lightning address' });
continue;
}
update(pubkey, { status: 'resolving', address });
let invoice: string;
try {
invoice = await getInvoiceFromLightningAddress({
lightningAddress: address,
amountSats: amount,
comment,
});
// SECURITY: never auto-pay an invoice whose amount doesn't match this
// recipient's share — a hostile address server could inflate it.
assertInvoiceAmount(invoice, amount);
} catch (err) {
update(pubkey, { status: 'failed', error: err instanceof Error ? err.message : 'Invoice failed' });
continue;
}
// Try to auto-pay
update(pubkey, { status: 'paying', invoice });
let paid = false;
if (activeNWC && activeNWC.connectionString && activeNWC.isConnected) {
try {
await sendPayment(activeNWC, invoice);
paid = true;
} catch { /* fall through to WebLN / manual */ }
}
if (!paid && webln) {
try {
let provider = webln;
if (webln.enable && typeof webln.enable === 'function') {
const enabled = (await webln.enable()) as typeof webln | undefined;
if (enabled) provider = enabled;
}
await provider.sendPayment(invoice);
paid = true;
} catch { /* fall through to manual */ }
}
if (paid) {
update(pubkey, { status: 'success' });
} else {
// Leave invoice attached for manual payment
update(pubkey, { status: 'failed', error: 'Auto-pay unavailable — pay manually', invoice });
}
}
setIsRunning(false);
},
[nostr, sendPayment, getActiveConnection, webln]
);
return { zapCrew, results, isRunning, reset };
}