SkateSpots/.agents/skills/onchain-bitcoin/files/hooks/useOnchainZaps.ts
shakespeare.diy 06fae294b8 New project created with Shakespeare
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 19:49:07 +07:00

205 lines
7.3 KiB
TypeScript

import { useQueries, useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
/** A single verified on-chain zap, with the amount that actually paid the recipient on-chain. */
export interface OnchainZapEntry {
/** The kind 8333 event. */
event: NostrEvent;
/** Bitcoin transaction id (lowercase hex). */
txid: string;
/** Pubkey of the sender (the 8333 event author). */
senderPubkey: string;
/** Pubkey of the recipient (from `p` tag). */
recipientPubkey: string;
/** Verified amount in sats — sum of tx outputs that pay the recipient's derived Taproot address. */
amountSats: number;
/** Sender's self-reported amount (may differ from verified). */
claimedAmountSats: number;
/** Comment from the 8333 event content. */
comment: string;
/** Unix timestamp of the 8333 event. */
createdAt: number;
/** Whether the Bitcoin tx is confirmed on-chain. */
confirmed: boolean;
}
/** Parse the txid from a kind 8333 event's `i` tag. Returns null if missing or malformed. */
export function extractOnchainZapTxid(event: NostrEvent): string | null {
const iTag = event.tags.find(([n, v]) => n === 'i' && typeof v === 'string' && v.startsWith('bitcoin:tx:'));
if (!iTag?.[1]) return null;
const txid = iTag[1].slice('bitcoin:tx:'.length).toLowerCase();
if (!/^[0-9a-f]{64}$/.test(txid)) return null;
return txid;
}
/** Parse the claimed amount (sats) from a kind 8333 event. */
export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
const tag = event.tags.find(([n]) => n === 'amount');
if (!tag?.[1]) return 0;
const n = parseInt(tag[1], 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
/** Parse the recipient pubkey from a kind 8333 event (first `p` tag). */
export function extractOnchainZapRecipient(event: NostrEvent): string {
const tag = event.tags.find(([n]) => n === 'p');
return tag?.[1] ?? '';
}
/**
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
*
* Returns the verified amount (sum of tx outputs paying the recipient's
* derived Taproot address) and confirmation status. Returns `null` if the
* event is malformed or the transaction cannot be verified.
*
* A verified amount of 0 means the transaction exists but does not pay
* the claimed recipient — callers should discard such events.
*/
export async function verifyOnchainZap(event: NostrEvent): Promise<OnchainZapEntry | null> {
const txid = extractOnchainZapTxid(event);
const recipientPubkey = extractOnchainZapRecipient(event);
if (!txid || !recipientPubkey) return null;
// Reject self-zaps (sender == recipient). The sender already controls the
// destination address, so self-zaps are trivial to fabricate and contribute
// nothing meaningful to zap totals.
if (event.pubkey === recipientPubkey) return null;
const recipientAddress = nostrPubkeyToBitcoinAddress(recipientPubkey);
if (!recipientAddress) return null;
let detail;
try {
detail = await fetchTxDetail(txid);
} catch {
return null;
}
const amountSats = detail.outputs
.filter((o) => o.address === recipientAddress)
.reduce((sum, o) => sum + o.value, 0);
if (amountSats === 0) return null;
const claimed = extractOnchainZapClaimedAmount(event);
// If the sender is claiming more than the tx actually paid, cap it at the verified amount.
const effectiveClaim = Math.min(claimed || amountSats, amountSats);
return {
event,
txid,
senderPubkey: event.pubkey,
recipientPubkey,
amountSats: effectiveClaim,
claimedAmountSats: claimed,
comment: event.content,
createdAt: event.created_at,
confirmed: detail.confirmed,
};
}
/**
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
* each one on-chain. Returns only verified entries (deduped by txid).
*/
export function useOnchainZaps(target: NostrEvent | undefined) {
const { nostr } = useNostr();
const isAddressable = target && target.kind >= 30000 && target.kind < 40000;
const dTag = isAddressable
? target.tags.find(([n]) => n === 'd')?.[1] ?? ''
: '';
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
// Step 1: fetch the raw kind 8333 events for this target
const eventsQuery = useQuery({
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
queryFn: async ({ signal }) => {
if (!target) return [] as NostrEvent[];
const timeout = AbortSignal.timeout(5000);
const combined = AbortSignal.any([signal, timeout]);
const filters: Parameters<typeof nostr.query>[0] = [
{ kinds: [8333], '#e': [target.id], limit: 100 },
];
if (aCoord) {
filters.push({ kinds: [8333], '#a': [aCoord], limit: 100 });
}
const events = await nostr.query(filters, { signal: combined });
// Dedupe by event id, then by txid (one canonical zap per tx per target).
const byId = new Map<string, NostrEvent>();
for (const e of events) byId.set(e.id, e);
const byTxid = new Map<string, NostrEvent>();
for (const e of byId.values()) {
const txid = extractOnchainZapTxid(e);
if (!txid) continue;
const existing = byTxid.get(txid);
// Prefer the earliest event for each txid (first to claim this tx).
if (!existing || e.created_at < existing.created_at) {
byTxid.set(txid, e);
}
}
return Array.from(byTxid.values());
},
enabled: !!target,
staleTime: 30_000,
});
// Step 2: verify each event on-chain (parallel, cached per txid)
const events = eventsQuery.data ?? [];
const verifications = useQueries({
queries: events.map((event) => ({
queryKey: ['onchain-zaps', 'verify', extractOnchainZapTxid(event), extractOnchainZapRecipient(event)],
queryFn: () => verifyOnchainZap(event),
staleTime: 60_000,
})),
});
const verified: OnchainZapEntry[] = verifications
.map((v) => v.data)
.filter((v): v is OnchainZapEntry => !!v);
// Sort by verified amount (largest first)
verified.sort((a, b) => b.amountSats - a.amountSats);
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
return {
zaps: verified,
totalSats,
count: verified.length,
isLoading,
};
}
/**
* Verify a single kind 8333 event against the Bitcoin blockchain and return
* the resulting `OnchainZapEntry`. Used by standalone surfaces (embedded
* cards, detail page) that need to display a verified amount without doing
* a full `#e`/`#a` fan-out.
*
* Returns `undefined` while loading, `null` if the event fails verification
* (invalid tx, wrong recipient, self-zap, etc.), or the entry.
*/
export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined {
const txid = event ? extractOnchainZapTxid(event) : null;
const recipient = event ? extractOnchainZapRecipient(event) : '';
const { data } = useQuery({
queryKey: ['onchain-zaps', 'verify', txid, recipient],
queryFn: () => verifyOnchainZap(event!),
enabled: !!event && !!txid && !!recipient,
staleTime: 60_000,
});
if (!event) return null;
return data;
}