SkateSpots/src/hooks/useCrewGoal.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

112 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { ZAP_GOAL_KIND } from '@/lib/types';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
export interface CrewGoal {
id: string;
pubkey: string;
/** Human-readable goal description (content). */
description: string;
/** Target amount in sats. */
targetSats: number;
/** Total raised so far in sats (tallied from zap receipts). */
raisedSats: number;
/** Number of contributions. */
contributions: number;
/** Optional deadline (unix seconds). */
closedAt?: number;
/** Percentage complete (0–100+, may exceed 100). */
percent: number;
image?: string;
summary?: string;
event: NostrEvent;
}
/**
* Sum sats from a set of zap receipts (kind 9735).
*
* SECURITY: we tally ONLY the amount encoded in each receipt's bolt11 invoice.
* The free-form `amount` tag is trivially forgeable — anyone can publish a fake
* 9735 with `["amount","1000000000"]` `#e`-tagging the goal to inflate "raised"
* for fake funding/social proof. The bolt11 amount reflects a real invoice, so
* a receipt with no (or unparseable) bolt11 contributes 0 and is not counted.
*/
function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; count: number } {
let sats = 0;
let count = 0;
for (const z of receipts) {
if (closedAt && z.created_at > closedAt) continue; // NIP-75: ignore late zaps
const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1];
if (!bolt11) continue; // no invoice → unverifiable → ignore
try {
const invoiceSats = getSatoshisFromBolt11(bolt11);
if (invoiceSats > 0) {
sats += invoiceSats;
count++;
}
} catch {
// Unparseable invoice → ignore (do NOT fall back to the amount tag).
}
}
return { sats, count };
}
/**
* Load a crew's NIP-75 funding goal (kind 9041) and tally its progress from
* zap receipts (kind 9735) that reference the goal event.
*
* The goal is fully transparent & non-custodial: contributors zap the goal, and
* funds settle to the goal author's own Lightning address. SkateSpots never
* holds funds — it only reads the public tally.
*/
export function useCrewGoal(goalId?: string): UseQueryResult<CrewGoal | null> {
const { nostr } = useNostr();
return useQuery({
queryKey: ['crew-goal', goalId],
enabled: !!goalId,
staleTime: 60 * 1000,
refetchInterval: (q) => (q.getObserversCount() > 0 ? 90 * 1000 : false),
queryFn: async (c): Promise<CrewGoal | null> => {
if (!goalId) return null;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
// Fetch the goal event and its zap receipts in one round-trip.
const [goalEvents, receipts] = await Promise.all([
nostr.query([{ kinds: [ZAP_GOAL_KIND], ids: [goalId] }], { signal }),
nostr.query([{ kinds: [9735], '#e': [goalId], limit: 500 }], { signal }),
]);
const goal = goalEvents[0];
if (!goal) return null;
const amountTag = goal.tags.find(([n]) => n === 'amount')?.[1];
const targetMs = amountTag ? parseInt(amountTag, 10) : 0;
const targetSats = Number.isNaN(targetMs) ? 0 : Math.floor(targetMs / 1000);
const closedAtTag = goal.tags.find(([n]) => n === 'closed_at')?.[1];
const closedAt = closedAtTag ? parseInt(closedAtTag, 10) : undefined;
const { sats: raisedSats, count } = tallyZaps(receipts, closedAt);
const percent = targetSats > 0 ? Math.round((raisedSats / targetSats) * 100) : 0;
return {
id: goal.id,
pubkey: goal.pubkey,
description: goal.content,
targetSats,
raisedSats,
contributions: count,
closedAt,
percent,
image: goal.tags.find(([n]) => n === 'image')?.[1],
summary: goal.tags.find(([n]) => n === 'summary')?.[1],
event: goal,
};
},
});
}