SkateSpots/src/hooks/useCrewGoal.ts
shakespeare.diy 36845b122d Add dependency-free BOLT11 amount decoder; drop removed nip57 helper
Newer nostr-tools removed nip57.getSatoshisAmountFromBolt11, breaking
useZaps and useCrewGoal. Add src/lib/bolt11.ts (getSatoshisFromBolt11)
that parses the sat amount straight from the invoice's human-readable
prefix — no external dependency, immune to nostr-tools API churn — and
use it in both hooks.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 22:20:27 +07:00

111 lines
3.7 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), reusing the bolt11/amount logic. */
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 amountTag = z.tags.find(([n]) => n === 'amount')?.[1];
if (amountTag) {
const ms = parseInt(amountTag, 10);
if (!Number.isNaN(ms)) {
sats += Math.floor(ms / 1000);
count++;
continue;
}
}
const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1];
if (bolt11) {
try {
sats += getSatoshisFromBolt11(bolt11);
count++;
continue;
} catch {
// ignore unparseable invoice
}
}
}
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,
};
},
});
}