342 lines
9.2 KiB
TypeScript
342 lines
9.2 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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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: t('toastsUi.nutzapEnabled'),
|
|
description: t('toastsUi.nutzapEnabledDesc'),
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
title: t('toastsUi.nutzapSetupFailed'),
|
|
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 { t } = useTranslation();
|
|
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: t('toastsUi.nutzapUnavailable'),
|
|
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),
|
|
};
|
|
}
|