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>
166 lines
5.8 KiB
TypeScript
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 };
|
|
}
|