SkateSpots/src/hooks/useNutzap.ts
SkateSpots d1f8870379 chore(lint): remove all explicit any + sweep dead imports
- Fix all 12 no-explicit-any (NPool, NostrEvent[], LabProfile, structural
  Cluster type, explicit SkateSpot cast) -> 0 remaining
- Remove unused imports/vars across 48 files (manual + a verified codemod for
  unused import specifiers only); tsc + tests + boot smoke all green
- Warning backlog: 182 -> 76 (remaining: ~55 no-unused-vars locals,
  14 react-hooks/exhaustive-deps, 7 react-refresh)
- AGENTS.md baseline updated
2026-09-26 21:40:42 +07:00

339 lines
9.1 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { useToast } from './useToast';
import type { NostrEvent } from '@nostrify/nostrify';
// NIP-61 Kinds
export const NUTZAP_INFO_KIND = 10019; // User's nutzap receiving preferences
export const NUTZAP_KIND = 9321; // Nutzap event
export const NUTZAP_HISTORY_KIND = 7376; // Nutzap redemption history
export interface NutzapInfo {
pubkey: string;
relays: string[];
mints: Array<{ url: string; units: string[] }>;
p2pkPubkey?: string;
}
export interface Nutzap {
id: string;
pubkey: string; // sender
recipientPubkey: string;
eventId?: string;
mintUrl: string;
amount: number;
unit: string;
comment?: string;
proofs: string[];
createdAt: number;
}
// Parse a kind:10019 nutzap info event
function parseNutzapInfo(event: NostrEvent): NutzapInfo | null {
const relays: string[] = [];
const mints: Array<{ url: string; units: string[] }> = [];
let p2pkPubkey: string | undefined;
for (const tag of event.tags) {
if (tag[0] === 'relay' && tag[1]) {
relays.push(tag[1]);
} else if (tag[0] === 'mint' && tag[1]) {
const units = tag.slice(2);
mints.push({ url: tag[1], units: units.length > 0 ? units : ['sat'] });
} else if (tag[0] === 'pubkey' && tag[1]) {
p2pkPubkey = tag[1];
}
}
if (relays.length === 0 && mints.length === 0) {
return null;
}
return {
pubkey: event.pubkey,
relays,
mints,
p2pkPubkey,
};
}
// Parse a kind:9321 nutzap event
function parseNutzap(event: NostrEvent): Nutzap | null {
const proofs: string[] = [];
let mintUrl = '';
let unit = 'sat';
let recipientPubkey = '';
let eventId: string | undefined;
let amount = 0;
for (const tag of event.tags) {
if (tag[0] === 'proof' && tag[1]) {
proofs.push(tag[1]);
// Try to extract amount from proof
try {
const proof = JSON.parse(tag[1]);
if (proof.amount) {
amount += proof.amount;
}
} catch {
// Ignore parse errors
}
} else if (tag[0] === 'u' && tag[1]) {
mintUrl = tag[1];
} else if (tag[0] === 'unit' && tag[1]) {
unit = tag[1];
} else if (tag[0] === 'p' && tag[1]) {
recipientPubkey = tag[1];
} else if (tag[0] === 'e' && tag[1]) {
eventId = tag[1];
}
}
if (!mintUrl || proofs.length === 0 || !recipientPubkey) {
return null;
}
return {
id: event.id,
pubkey: event.pubkey,
recipientPubkey,
eventId,
mintUrl,
amount,
unit,
comment: event.content || undefined,
proofs,
createdAt: event.created_at,
};
}
/**
* Get a user's nutzap info (kind:10019)
*/
export function useNutzapInfo(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['nutzap-info', pubkey],
queryFn: async (c) => {
if (!pubkey) return null;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query([
{
kinds: [NUTZAP_INFO_KIND],
authors: [pubkey],
limit: 1,
},
], { signal });
if (events.length === 0) return null;
return parseNutzapInfo(events[0]);
},
enabled: !!pubkey,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
/**
* Get nutzaps received by a user or for a specific event
*/
export function useNutzaps(params: { pubkey?: string; eventId?: string }) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['nutzaps', params.pubkey, params.eventId],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const filters: Parameters<typeof nostr.query>[0] = [];
if (params.eventId) {
filters.push({
kinds: [NUTZAP_KIND],
'#e': [params.eventId],
limit: 100,
});
} else if (params.pubkey) {
filters.push({
kinds: [NUTZAP_KIND],
'#p': [params.pubkey],
limit: 100,
});
}
if (filters.length === 0) return [];
const events = await nostr.query(filters, { signal });
const nutzaps: Nutzap[] = [];
for (const event of events) {
const nutzap = parseNutzap(event);
if (nutzap) {
nutzaps.push(nutzap);
}
}
return nutzaps.sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!(params.pubkey || params.eventId),
staleTime: 1000 * 30, // 30 seconds
});
}
/**
* Check if the current user can receive nutzaps
*/
export function useCanReceiveNutzaps() {
const { user } = useCurrentUser();
const { data: nutzapInfo } = useNutzapInfo(user?.pubkey);
return {
canReceive: !!(nutzapInfo && nutzapInfo.mints.length > 0 && nutzapInfo.p2pkPubkey),
nutzapInfo,
};
}
/**
* Setup nutzap receiving (publish kind:10019)
*/
export function useSetupNutzapReceiving() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
mints,
relays,
p2pkPubkey,
}: {
mints: Array<{ url: string; units?: string[] }>;
relays: string[];
p2pkPubkey: string;
}) => {
if (!user) throw new Error('Must be logged in');
const tags: string[][] = [];
// Add relays
for (const relay of relays) {
tags.push(['relay', relay]);
}
// Add mints with units
for (const mint of mints) {
const mintTag = ['mint', mint.url, ...(mint.units || ['sat'])];
tags.push(mintTag);
}
// Add P2PK pubkey (MUST be different from main Nostr pubkey)
tags.push(['pubkey', p2pkPubkey]);
await publishEvent({
kind: NUTZAP_INFO_KIND,
content: '',
tags,
});
return { mints, relays, p2pkPubkey };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nutzap-info'] });
toast({
title: 'Nutzap receiving enabled',
description: 'You can now receive ecash zaps!',
});
},
onError: (error) => {
toast({
title: 'Setup failed',
description: error instanceof Error ? error.message : 'Failed to setup nutzap receiving',
variant: 'destructive',
});
},
});
}
/**
* Whether in-app nutzap SENDING is available.
*
* Nutzap sending (NIP-61) requires minting Cashu proofs that are P2PK-locked to
* the recipient's `p2pkPubkey` plus DLEQ proofs. Getting P2PK locking wrong would
* lock funds to the wrong key — i.e. lost sats — so sending is intentionally
* DISABLED until it can be implemented and audited against the wallet's P2PK
* capabilities. Nutzap RECEIVING setup (kind 10019) and reading received nutzaps
* work today.
*
* UI should gate any "send nutzap" affordance behind this flag and fall back to
* regular Lightning zaps (which support paying from the Cashu balance anyway).
*/
export const NUTZAP_SENDING_ENABLED = false;
/**
* Send a nutzap to someone.
*
* NOT YET IMPLEMENTED — see {@link NUTZAP_SENDING_ENABLED}. This mutation always
* rejects with a clear message so nothing silently appears to succeed. Do not
* wire a visible "send" button to this until sending is implemented; use the
* regular Lightning zap flow (ZapDialog), which can pay from the Cashu balance.
*
* Full implementation would require:
* 1. Check for tokens on one of the recipient's mints (swap in if needed)
* 2. Mint/swap proofs P2PK-locked to recipient.p2pkPubkey
* 3. Include DLEQ proofs
* 4. Publish kind:9321 with `proof`, `u` (mint), `p` (recipient), optional `e`
*/
export function useSendNutzap() {
const { user } = useCurrentUser();
const { toast } = useToast();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (_params: {
recipientPubkey: string;
recipientNutzapInfo: NutzapInfo;
eventId?: string;
eventKind?: number;
amount: number;
comment?: string;
}) => {
if (!user) throw new Error('Must be logged in');
// Guarded off to avoid locking funds to the wrong key. See NUTZAP_SENDING_ENABLED.
throw new Error(
'Nutzap sending is not available yet — use a regular Lightning zap (you can pay it straight from your Cashu balance).'
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nutzaps'] });
},
onError: (error) => {
toast({
title: 'Nutzap sending unavailable',
description: error instanceof Error ? error.message : 'Use a Lightning zap instead',
variant: 'destructive',
});
},
});
}
/**
* Get total nutzaps received for an event
*/
export function useNutzapTotal(eventId: string | undefined) {
const { data: nutzaps } = useNutzaps({ eventId });
if (!nutzaps) {
return { count: 0, totalAmount: 0 };
}
return {
count: nutzaps.length,
totalAmount: nutzaps.reduce((sum, nz) => sum + nz.amount, 0),
};
}