Files
eranos/src/hooks/useOnchainZap.ts
T
Alex Gleason 93c22dec2e Merge remote-tracking branch 'origin/main' into hdwallet
# Conflicts:
#	src/components/DonateDialog.tsx
#	src/hooks/useDonateCampaign.ts
#	src/hooks/useOnchainZaps.ts
2026-05-22 00:16:29 -05:00

227 lines
7.8 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBitcoinSigner, isSignerCapabilityError, reportSignerUnsupported } from '@/hooks/useBitcoinSigner';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { notificationSuccess } from '@/lib/haptics';
import { withAgoraTag } from '@/lib/agoraNoteTags';
import {
nostrPubkeyToBitcoinAddress,
fetchUTXOs,
getFeeRates,
buildUnsignedPsbt,
finalizePsbt,
broadcastTransaction,
estimateFee,
} from '@/lib/bitcoin';
import type { FeeRates } from '@/lib/bitcoin';
export type OnchainFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
/**
* Resolves the fee rate for a given speed preset from a FeeRates bundle.
*/
function feeRateForSpeed(rates: FeeRates, speed: OnchainFeeSpeed): number {
switch (speed) {
case 'fastest': return rates.fastestFee;
case 'halfHour': return rates.halfHourFee;
case 'hour': return rates.hourFee;
case 'economy': return rates.economyFee;
}
}
interface OnchainZapArgs {
/** Amount to zap in satoshis. */
amountSats: number;
/** Optional comment to include in the kind 8333 event content. */
comment?: string;
/** Fee speed preset. Defaults to "halfHour". */
feeSpeed?: OnchainFeeSpeed;
}
interface OnchainZapResult {
/** The broadcast Bitcoin transaction ID. */
txid: string;
/** Amount sent in satoshis. */
amountSats: number;
/** Fee paid in satoshis. */
fee: number;
/** The published kind 8333 event. */
event: NostrEvent;
}
/**
* Hook for sending on-chain (Bitcoin L1) zaps to a Nostr event or profile.
*
* Flow:
* 1. Build, sign, and broadcast a Bitcoin transaction paying the target
* author's derived Taproot address.
* 2. Publish a kind 8333 "onchain zap" event referencing the txid, the
* target event (`e` or `a` tag), and the recipient's pubkey.
*
* Unlike NIP-57 Lightning zaps, this works for *any* Nostr user — there is
* no LNURL dependency because every pubkey has a derived Taproot address.
*/
export function useOnchainZap(
target: NostrEvent,
onSuccess?: (result: OnchainZapResult) => void,
) {
const { user } = useCurrentUser();
const { canSignPsbt, signPsbt } = useBitcoinSigner();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const { config } = useAppContext();
const { esploraApis } = config;
const queryClient = useQueryClient();
const [isZapping, setIsZapping] = useState(false);
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'>('idle');
const mutation = useMutation<OnchainZapResult, Error, OnchainZapArgs>({
mutationFn: async ({ amountSats, comment = '', feeSpeed = 'halfHour' }) => {
if (!user) throw new Error('You must be logged in to zap.');
if (user.pubkey === target.pubkey) throw new Error("You can't zap yourself.");
if (!canSignPsbt || !signPsbt) {
throw new Error(
"Your login doesn't support sending Bitcoin. Log in with your secret key to send Bitcoin zaps.",
);
}
if (!Number.isFinite(amountSats) || amountSats <= 0) {
throw new Error('Invalid amount.');
}
setIsZapping(true);
setProgress('building');
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey);
if (!senderAddress || !recipientAddress) {
throw new Error('Failed to derive Bitcoin address.');
}
// Fetch UTXOs and fee rates
const [utxos, rates] = await Promise.all([
fetchUTXOs(senderAddress, esploraApis),
getFeeRates(esploraApis),
]);
if (utxos.length === 0) {
throw new Error('Your Bitcoin wallet has no spendable funds.');
}
const feeRate = feeRateForSpeed(rates, feeSpeed);
const totalBalance = utxos.reduce((s, u) => s + u.value, 0);
const estFee = estimateFee(utxos.length, 2, feeRate);
if (amountSats + estFee > totalBalance) {
throw new Error(
`Insufficient funds. Need ~${(amountSats + estFee).toLocaleString()} sats, have ${totalBalance.toLocaleString()}.`,
);
}
// Build unsigned PSBT
const { psbtHex, fee } = buildUnsignedPsbt(
user.pubkey,
recipientAddress,
amountSats,
utxos,
feeRate,
);
// Sign
setProgress('signing');
const signedHex = await signPsbt(psbtHex);
const txHex = finalizePsbt(signedHex);
// Broadcast
setProgress('broadcasting');
const txid = await broadcastTransaction(txHex, esploraApis);
// Publish kind 8333 event
setProgress('publishing');
const isAddressable = target.kind >= 30000 && target.kind < 40000;
const tags: string[][] = [
['i', `bitcoin:tx:${txid}`],
['p', target.pubkey],
['amount', String(amountSats)],
];
if (isAddressable) {
const dTag = target.tags.find(([n]) => n === 'd')?.[1] ?? '';
tags.push(['a', `${target.kind}:${target.pubkey}:${dTag}`]);
}
// Always include `e` for a concrete event reference (even for addressable events)
tags.push(['e', target.id]);
tags.push(['alt', `Bitcoin zap: ${amountSats.toLocaleString()} sats`]);
const event = await publishEvent({
kind: 8333,
content: comment,
tags: withAgoraTag(tags),
});
return { txid, amountSats, fee, event };
},
onSuccess: (result) => {
notificationSuccess();
// Optimistically mark the target as zapped-by-me so the action-bar
// bolt icon fills immediately, without waiting for the relay to echo
// the kind 8333 back through useUserZap's REQ.
queryClient.setQueryData(['user-zap', target.id], true);
// Invalidate caches that track zaps / balances
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
queryClient.invalidateQueries({ queryKey: ['event-interactions'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
// If the caller opted into handling success themselves (e.g. the
// ZapDialog shows a grand confirmation screen and owns the dismiss),
// skip the built-in toast — the screen is the feedback.
if (onSuccess) {
onSuccess(result);
} else {
toast({
title: 'Bitcoin zap sent!',
description: `Broadcast txid ${result.txid.slice(0, 12)}… (fee ${result.fee.toLocaleString()} sats)`,
});
}
},
onError: (err) => {
// If the signer turned out to not support PSBT signing (common for
// NIP-46 bunkers where capability can't be probed up front), mark the
// signer as unsupported for the rest of the session. The dialog UI
// watches this state and replaces itself with an "unsupported" panel
// instead of relying on this toast.
if (isSignerCapabilityError(err) && user) {
reportSignerUnsupported(user.pubkey);
return;
}
toast({
title: 'Bitcoin zap failed',
description: err.message,
variant: 'destructive',
});
},
onSettled: () => {
setIsZapping(false);
setProgress('idle');
},
});
return {
zap: mutation.mutate,
zapAsync: mutation.mutateAsync,
isZapping,
progress,
canZap: !!user && user.pubkey !== target.pubkey && canSignPsbt,
/** Whether the logged-in user has a PSBT-capable signer. */
canSignPsbt,
};
}