Add on-chain Bitcoin zaps as the default zap method
Introduce kind 3043, a new Nostr event that attests an on-chain Bitcoin payment against a target event or profile. Because every Nostr pubkey deterministically maps to a Taproot address, any user can receive an on-chain zap without configuring lud06/lud16 — the zap button now appears on every post whose author is not the current user. Publishing flow: sender builds and broadcasts a Bitcoin transaction paying the recipient's derived Taproot address, then publishes a kind 3043 event with an `i` tag (`bitcoin:tx:<txid>`), the recipient's `p`, the target's `e` / `a`, and a self-reported `amount` in sats. Before displaying or counting a kind 3043 event clients verify the referenced transaction on-chain and use the sum of outputs paying the recipient's address as the authoritative amount, capping the sender's claim at the verified value to prevent spoofing. Lightning zaps remain available as an opt-in tab inside the zap dialog whenever the author has a Lightning address configured; otherwise the dialog is purely on-chain. Defaults favour on-chain: USD amount presets ($1 / $5 / $10 / $25 / $100), fee-speed selection, and a 3-step form → confirm → success flow mirroring SendBitcoinDialog.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------|-------------------------------------------------------|
|
||||
| 3043 | Onchain Zap | Attestation that an on-chain BTC tx paid a target |
|
||||
| 36767 | Theme Definition | Shareable, named custom UI theme |
|
||||
| 16767 | Active Profile Theme | The user's currently active theme (one per user) |
|
||||
| 16769 | Profile Tabs | The user's custom profile page tabs (one per user) |
|
||||
@@ -32,6 +33,94 @@ These event kinds were created by community contributors and are supported by Di
|
||||
|
||||
---
|
||||
|
||||
## Kind 3043: Onchain Zap
|
||||
|
||||
### Summary
|
||||
|
||||
Regular event kind that records a **Bitcoin on-chain payment** ("onchain zap") sent in appreciation of a Nostr event or profile. Functions as the on-chain analogue of NIP-57 zap receipts (kind 9735), but without the LNURL round-trip: the event is self-attested by the sender and references a real Bitcoin transaction that clients can verify directly on-chain.
|
||||
|
||||
Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) address (both use 32-byte x-only secp256k1 keys, per BIP-340/BIP-341), an on-chain zap is simply a Bitcoin transaction whose output pays the recipient's derived Taproot address. The kind 3043 event links that transaction to the Nostr event or profile being zapped.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 3043,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great post!",
|
||||
"tags": [
|
||||
["e", "<target-event-id>", "<relay-hint>"],
|
||||
["p", "<target-pubkey>"],
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats>"],
|
||||
["alt", "On-chain zap: 25000 sats"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|----------|----------|----------------------------------------------------------------------------------------------|
|
||||
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
|
||||
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
|
||||
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
|
||||
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
|
||||
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 30000–39999. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. |
|
||||
|
||||
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
|
||||
|
||||
### Publishing Flow
|
||||
|
||||
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
|
||||
3. Sender signs and publishes a kind 3043 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
|
||||
4. The event is published **after** broadcast; the txid is already final at that point.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
**Querying onchain zaps for an event:**
|
||||
|
||||
```json
|
||||
{ "kinds": [3043], "#e": ["<target-event-id>"], "limit": 100 }
|
||||
```
|
||||
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
|
||||
|
||||
**Verification (REQUIRED before trusting amounts):**
|
||||
|
||||
Clients MUST verify a kind 3043 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
|
||||
|
||||
1. Extract the txid from the `i` tag.
|
||||
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
|
||||
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
|
||||
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**.
|
||||
5. If the verified amount is 0, or the sender's `amount` tag exceeds the verified amount, the event SHOULD be discarded.
|
||||
6. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals.
|
||||
|
||||
Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 3043 event per (txid, target) pair is canonical.
|
||||
|
||||
### Comparison with NIP-57 (Lightning Zaps)
|
||||
|
||||
| Aspect | NIP-57 (kind 9735) | This spec (kind 3043) |
|
||||
|--------|---------------------|------------------------|
|
||||
| Settlement | Lightning Network | Bitcoin L1 |
|
||||
| Invoice / payment | LNURL + BOLT-11 invoice | Raw Bitcoin tx |
|
||||
| Event issuer | Recipient's LNURL provider | Sender |
|
||||
| Availability | Requires `lud06`/`lud16` on recipient profile | Always available (every Nostr pubkey has a derived Taproot addr) |
|
||||
| Verification | Recipient zap-provider pubkey + bolt11 amount | On-chain tx verified against derived recipient address |
|
||||
| Finality | Instant | Confirms in ~10 min (mempool first) |
|
||||
| Fees | Sub-satoshi typical | Significant at low amounts |
|
||||
|
||||
The two zap kinds are complementary. Clients SHOULD sum verified amounts from both kinds when displaying total zap stats for a post or profile.
|
||||
|
||||
---
|
||||
|
||||
## Kind 36767: Theme Definition
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -24,7 +24,6 @@ import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useBookSummary } from '@/hooks/useBookSummary';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
|
||||
@@ -60,7 +59,7 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
|
||||
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
|
||||
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
|
||||
|
||||
@@ -22,7 +22,6 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -351,11 +350,9 @@ function StreamAuthorRow({ event, participants }: { event: NostrEvent; participa
|
||||
}
|
||||
|
||||
function ZapButton({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
if (!canZap(metadata)) return null;
|
||||
|
||||
// ZapDialog handles the self-zap guard internally, so we only need to
|
||||
// render the trigger. On-chain zaps are always available for any author;
|
||||
// Lightning is an opt-in tab inside the dialog.
|
||||
return (
|
||||
<ZapDialog target={event}>
|
||||
<Button variant="outline" size="icon" className="shrink-0 size-9 rounded-full text-amber-500 hover:text-amber-400 hover:bg-amber-500/10">
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -188,7 +187,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
{user && canZap(metadata) && (
|
||||
{user && user.pubkey !== event.pubkey && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
|
||||
|
||||
@@ -96,7 +96,6 @@ import { useProfileUrl } from "@/hooks/useProfileUrl";
|
||||
import { useShareOrigin } from "@/hooks/useShareOrigin";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useEventStats } from "@/hooks/useTrending";
|
||||
import { canZap } from "@/lib/canZap";
|
||||
import { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/useEventInteractions";
|
||||
import { getContentWarning } from "@/lib/contentWarning";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
@@ -349,8 +348,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
// Check if the current user can zap this event's author
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
// Zap button shows for any logged-in user except on their own posts.
|
||||
// On-chain zaps are always available; Lightning is offered inside the dialog
|
||||
// when the author has lud06/lud16.
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
|
||||
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(
|
||||
`/${encodedId}`,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
Check,
|
||||
Loader2,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useOnchainZap, type OnchainFeeSpeed } from '@/hooks/useOnchainZap';
|
||||
import {
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
fetchUTXOs,
|
||||
fetchBtcPrice,
|
||||
getFeeRates,
|
||||
estimateFee,
|
||||
satsToBTC,
|
||||
satsToUSD,
|
||||
formatSats,
|
||||
} from '@/lib/bitcoin';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
|
||||
const FEE_SPEED_LABELS: Record<OnchainFeeSpeed, string> = {
|
||||
fastest: 'Fastest (~10 min)',
|
||||
halfHour: 'Half hour',
|
||||
hour: 'One hour',
|
||||
economy: 'Economy (~1 day)',
|
||||
};
|
||||
|
||||
type Step = 'form' | 'confirm' | 'success';
|
||||
|
||||
interface OnchainZapContentProps {
|
||||
target: NostrEvent;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-chain Bitcoin zap flow. Publishes a BTC transaction paying the target
|
||||
* author's derived Taproot address, then publishes a kind 3043 event
|
||||
* linking the tx to the target event.
|
||||
*/
|
||||
export function OnchainZapContent({ target, onSuccess }: OnchainZapContentProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { canSignPsbt } = useBitcoinSigner();
|
||||
|
||||
const [step, setStep] = useState<Step>('form');
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [comment, setComment] = useState('');
|
||||
const [feeSpeed, setFeeSpeed] = useState<OnchainFeeSpeed>('halfHour');
|
||||
const [error, setError] = useState('');
|
||||
const [successTxid, setSuccessTxid] = useState('');
|
||||
const [successFee, setSuccessFee] = useState(0);
|
||||
|
||||
const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : '';
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price'],
|
||||
queryFn: fetchBtcPrice,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: utxos, isLoading: isLoadingUtxos } = useQuery({
|
||||
queryKey: ['bitcoin-utxos', senderAddress],
|
||||
queryFn: () => fetchUTXOs(senderAddress),
|
||||
enabled: !!senderAddress,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: feeRates, isLoading: isLoadingFees } = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates'],
|
||||
queryFn: getFeeRates,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]);
|
||||
|
||||
const currentFeeRate = useMemo(() => {
|
||||
if (!feeRates) return 0;
|
||||
switch (feeSpeed) {
|
||||
case 'fastest': return feeRates.fastestFee;
|
||||
case 'halfHour': return feeRates.halfHourFee;
|
||||
case 'hour': return feeRates.hourFee;
|
||||
case 'economy': return feeRates.economyFee;
|
||||
}
|
||||
}, [feeRates, feeSpeed]);
|
||||
|
||||
// Convert the USD amount to sats
|
||||
const amountSats = useMemo(() => {
|
||||
if (!btcPrice) return 0;
|
||||
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
||||
const btc = usd / btcPrice;
|
||||
return Math.round(btc * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
const estimatedFeeSats = useMemo(() => {
|
||||
if (!utxos?.length || !currentFeeRate || !amountSats) return 0;
|
||||
const fee2 = estimateFee(utxos.length, 2, currentFeeRate);
|
||||
const change = totalBalance - amountSats - fee2;
|
||||
const numOutputs = change > 546 ? 2 : 1;
|
||||
return estimateFee(utxos.length, numOutputs, currentFeeRate);
|
||||
}, [utxos, currentFeeRate, amountSats, totalBalance]);
|
||||
|
||||
const feePct = estimatedFeeSats && amountSats ? (estimatedFeeSats / amountSats) * 100 : 0;
|
||||
const feeWarning = feePct > 25; // warn if fee is over 25% of the zap
|
||||
|
||||
const { zapAsync, isZapping, progress } = useOnchainZap(target, onSuccess);
|
||||
|
||||
const goToConfirm = useCallback(() => {
|
||||
setError('');
|
||||
if (!user) { setError('You must be logged in.'); return; }
|
||||
if (user.pubkey === target.pubkey) { setError("You can't zap yourself."); return; }
|
||||
if (!canSignPsbt) {
|
||||
setError("Your signer doesn't support Bitcoin signing. Log in with your nsec, or an extension/bunker that supports signPsbt.");
|
||||
return;
|
||||
}
|
||||
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
||||
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
||||
if (!utxos?.length) { setError('Your on-chain wallet has no spendable funds. Receive some Bitcoin first.'); return; }
|
||||
if (amountSats + estimatedFeeSats > totalBalance) { setError('Insufficient funds for this amount + fee.'); return; }
|
||||
setStep('confirm');
|
||||
}, [user, target.pubkey, canSignPsbt, btcPrice, amountSats, utxos, estimatedFeeSats, totalBalance]);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
const result = await zapAsync({ amountSats, comment, feeSpeed });
|
||||
setSuccessTxid(result.txid);
|
||||
setSuccessFee(result.fee);
|
||||
setStep('success');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Zap failed');
|
||||
setStep('form');
|
||||
}
|
||||
}, [zapAsync, amountSats, comment, feeSpeed]);
|
||||
|
||||
// ── Signer not supported ──────────────────────────────────────
|
||||
|
||||
if (user && !canSignPsbt) {
|
||||
return (
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<Alert>
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Your signer doesn't support Bitcoin transaction signing. Log in with your nsec, a
|
||||
NIP-07 extension that supports <code>signPsbt</code>, or a NIP-46 remote signer
|
||||
that supports <code>sign_psbt</code> to send on-chain zaps.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Success view ──────────────────────────────────────────────
|
||||
|
||||
if (step === 'success') {
|
||||
return (
|
||||
<div className="px-4 py-4 space-y-4">
|
||||
<div className="flex flex-col items-center text-center gap-2">
|
||||
<div className="size-12 rounded-full bg-green-500/15 flex items-center justify-center">
|
||||
<Check className="size-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Zap broadcast!</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{btcPrice && amountSats > 0
|
||||
? `${satsToUSD(amountSats, btcPrice)} sent on-chain`
|
||||
: `${formatSats(amountSats)} sats sent on-chain`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 p-3 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Transaction ID</Label>
|
||||
<p className="text-[10px] font-mono break-all">{successTxid}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Fee: {formatSats(successFee)} sats
|
||||
{btcPrice ? ` (${satsToUSD(successFee, btcPrice)})` : ''}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" asChild>
|
||||
<Link to={`/i/bitcoin:tx:${successTxid}`}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
View Transaction
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={onSuccess}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Confirm view ──────────────────────────────────────────────
|
||||
|
||||
if (step === 'confirm') {
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 space-y-4">
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Paying to</Label>
|
||||
<p className="text-[11px] font-mono break-all">{recipientAddress}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Row label="Zap" sats={amountSats} btcPrice={btcPrice} primary />
|
||||
<Row label={`Network fee (${FEE_SPEED_LABELS[feeSpeed].toLowerCase()})`} sats={estimatedFeeSats} btcPrice={btcPrice} />
|
||||
<Separator className="my-1" />
|
||||
<Row label="Total" sats={totalSats} btcPrice={btcPrice} bold />
|
||||
</div>
|
||||
|
||||
{comment && (
|
||||
<div className="rounded-lg bg-muted/50 p-3 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Comment</Label>
|
||||
<p className="text-sm break-words">{comment}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert className="py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
On-chain transactions are final. Funds settle after ~10 minutes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setStep('form')} disabled={isZapping} className="flex-1">
|
||||
<ChevronLeft className="size-4 mr-1" /> Back
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isZapping} className="flex-1">
|
||||
{isZapping ? (
|
||||
<><Loader2 className="size-4 mr-1.5 animate-spin" />{progressLabel(progress)}</>
|
||||
) : (
|
||||
<><Zap className="size-4 mr-1.5" />Confirm & Zap</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Form view ─────────────────────────────────────────────────
|
||||
|
||||
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 space-y-4">
|
||||
{/* Balance */}
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<Label className="text-xs text-muted-foreground">Your on-chain balance</Label>
|
||||
{isLoadingUtxos ? (
|
||||
<Skeleton className="mt-1 h-6 w-32" />
|
||||
) : (
|
||||
<p className="text-base font-semibold">
|
||||
{btcPrice
|
||||
? satsToUSD(totalBalance, btcPrice)
|
||||
: `${satsToBTC(totalBalance).replace(/\.?0+$/, '')} BTC`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount presets (USD) */}
|
||||
<div className="space-y-2">
|
||||
<Label>Amount</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={USD_PRESETS.includes(Number(usdAmount)) ? String(usdAmount) : ''}
|
||||
onValueChange={(v) => { if (v) { setUsdAmount(Number(v)); setError(''); } }}
|
||||
className="grid grid-cols-5 gap-1 w-full"
|
||||
>
|
||||
{USD_PRESETS.map((v) => (
|
||||
<ToggleGroupItem
|
||||
key={v}
|
||||
value={String(v)}
|
||||
className="flex flex-col h-auto min-w-0 text-xs px-1 py-2"
|
||||
>
|
||||
<span className="font-semibold">${v}</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
<span className="text-xs text-muted-foreground">OR</span>
|
||||
<div className="h-px flex-1 bg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="Custom amount (USD)"
|
||||
value={usdAmount}
|
||||
onChange={(e) => { setUsdAmount(e.target.value); setError(''); }}
|
||||
className="pl-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentUsd > 0 && amountSats > 0 && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
≈ {formatSats(amountSats)} sats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="onchain-zap-comment">Comment (optional)</Label>
|
||||
<Textarea
|
||||
id="onchain-zap-comment"
|
||||
placeholder="Add a note…"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Transaction speed</Label>
|
||||
<Select value={feeSpeed} onValueChange={(v) => setFeeSpeed(v as OnchainFeeSpeed)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(FEE_SPEED_LABELS) as OnchainFeeSpeed[]).map((speed) => (
|
||||
<SelectItem key={speed} value={speed}>
|
||||
{FEE_SPEED_LABELS[speed]}
|
||||
{' — '}
|
||||
{isLoadingFees
|
||||
? '...'
|
||||
: feeRates
|
||||
? `${speed === 'fastest' ? feeRates.fastestFee
|
||||
: speed === 'halfHour' ? feeRates.halfHourFee
|
||||
: speed === 'hour' ? feeRates.hourFee
|
||||
: feeRates.economyFee} sat/vB`
|
||||
: ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{estimatedFeeSats > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Estimated fee: ~{satsToUSD(estimatedFeeSats, btcPrice)} ({formatSats(estimatedFeeSats)} sats)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fee warning */}
|
||||
{feeWarning && amountSats > 0 && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Network fees are ~{feePct.toFixed(0)}% of your zap. Consider a larger amount or using Lightning for small zaps.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={goToConfirm}
|
||||
disabled={!btcPrice || amountSats <= 0 || isLoadingUtxos || isLoadingFees}
|
||||
className="w-full"
|
||||
>
|
||||
<Zap className="size-4 mr-1.5" />
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function Row({
|
||||
label,
|
||||
sats,
|
||||
btcPrice,
|
||||
primary,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
sats: number;
|
||||
btcPrice?: number;
|
||||
primary?: boolean;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className={`text-sm ${primary ? 'font-medium' : 'text-muted-foreground'}`}>{label}</span>
|
||||
<div className="text-right">
|
||||
<span className={`text-sm ${bold ? 'font-semibold' : ''}`}>
|
||||
{btcPrice ? satsToUSD(sats, btcPrice) : `${formatSats(sats)} sats`}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{formatSats(sats)} sats
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function progressLabel(progress: 'idle' | 'building' | 'signing' | 'broadcasting' | 'publishing'): string {
|
||||
switch (progress) {
|
||||
case 'building': return 'Building…';
|
||||
case 'signing': return 'Signing…';
|
||||
case 'broadcasting': return 'Broadcasting…';
|
||||
case 'publishing': return 'Publishing…';
|
||||
default: return 'Processing…';
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
|
||||
interface PhotoBottomBarProps {
|
||||
@@ -40,7 +39,7 @@ export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -184,7 +183,7 @@ function EpisodeDetail({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
{user && canZap(metadata) && (
|
||||
{user && user.pubkey !== event.pubkey && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
|
||||
|
||||
@@ -7,12 +7,10 @@ import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
import { RepostMenu } from '@/components/RepostMenu';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { shareOrCopy } from '@/lib/share';
|
||||
|
||||
@@ -36,9 +34,9 @@ export function PostActionBar({
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const shareOrigin = useShareOrigin();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
// Zap button shows for any logged-in user except on their own posts.
|
||||
// Both on-chain and Lightning zaps are supported inside the dialog.
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const repostTotal = (stats?.reposts ?? 0) + (stats?.quotes ?? 0);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile } from 'lucide-react';
|
||||
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile, Bitcoin } from 'lucide-react';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
@@ -15,10 +15,12 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { EmojiPicker } from '@/components/EmojiPicker';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
import { OnchainZapContent } from '@/components/OnchainZapContent';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -28,6 +30,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useInsertText } from '@/hooks/useInsertText';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import type { Event } from 'nostr-tools';
|
||||
import QRCode from 'qrcode';
|
||||
import type { WebLNProvider } from "@webbtc/webln-types";
|
||||
@@ -46,7 +49,7 @@ const presetAmounts = [
|
||||
{ amount: 1000, icon: Rocket },
|
||||
];
|
||||
|
||||
interface ZapContentProps {
|
||||
interface LightningZapContentProps {
|
||||
invoice: string | null;
|
||||
amount: number | string;
|
||||
comment: string;
|
||||
@@ -67,8 +70,8 @@ interface ZapContentProps {
|
||||
zap: (amount: number, comment: string) => void;
|
||||
}
|
||||
|
||||
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
|
||||
const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
||||
// Forwarded ref + defined outside ZapDialog to prevent re-render focus loss.
|
||||
const LightningZapContent = forwardRef<HTMLDivElement, LightningZapContentProps>(({
|
||||
invoice,
|
||||
amount,
|
||||
comment,
|
||||
@@ -271,7 +274,7 @@ const ZapContent = forwardRef<HTMLDivElement, ZapContentProps>(({
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
ZapContent.displayName = 'ZapContent';
|
||||
LightningZapContent.displayName = 'LightningZapContent';
|
||||
|
||||
export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -292,6 +295,10 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const customEmojis = feedSettings.showCustomEmojis !== false ? allCustomEmojis : [];
|
||||
const { insertAtCursor, insertEmoji } = useInsertText(commentTextareaRef, comment, setComment);
|
||||
|
||||
// Default tab: onchain. Users can switch to Lightning if available.
|
||||
const [activeTab, setActiveTab] = useState<'onchain' | 'lightning'>('onchain');
|
||||
const hasLightning = canZap(author?.metadata);
|
||||
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
setComment(`Zapped with ${config.appName}!`);
|
||||
@@ -360,6 +367,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
setInvoice(null);
|
||||
setCopied(false);
|
||||
setQrCodeUrl('');
|
||||
setActiveTab('onchain');
|
||||
} else {
|
||||
setAmount(100);
|
||||
setInvoice(null);
|
||||
@@ -374,7 +382,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
zap(finalAmount, comment);
|
||||
};
|
||||
|
||||
const contentProps = {
|
||||
const lightningContentProps = {
|
||||
invoice,
|
||||
amount,
|
||||
comment,
|
||||
@@ -395,9 +403,12 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
zap,
|
||||
};
|
||||
|
||||
const canZap = !!user && user.pubkey !== target.pubkey && !!(author?.metadata?.lud06 || author?.metadata?.lud16);
|
||||
// Zap button shows for any logged-in user except when targeting oneself.
|
||||
// On-chain is always available; Lightning is offered as an in-dialog option
|
||||
// when the author has a Lightning address.
|
||||
const canOpenZap = !!user && user.pubkey !== target.pubkey;
|
||||
|
||||
if (!canZap) {
|
||||
if (!canOpenZap) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -421,10 +432,35 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
</button>
|
||||
</div>
|
||||
<p className="px-4 -mt-1 mb-1 text-sm text-muted-foreground">
|
||||
{invoice ? 'Pay with Bitcoin Lightning Network' : 'Send a small Bitcoin payment to support the creator.'}
|
||||
{invoice
|
||||
? 'Pay with Bitcoin Lightning Network'
|
||||
: activeTab === 'onchain'
|
||||
? 'Send Bitcoin on-chain to support the creator.'
|
||||
: 'Send a small Bitcoin payment to support the creator.'}
|
||||
</p>
|
||||
<div className="overflow-y-auto">
|
||||
<ZapContent {...contentProps} />
|
||||
{hasLightning ? (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'onchain' | 'lightning')} className="w-full">
|
||||
<div className="px-4 pt-2">
|
||||
<TabsList className="grid w-full grid-cols-2 h-9">
|
||||
<TabsTrigger value="onchain" className="gap-1.5 text-xs">
|
||||
<Bitcoin className="size-3.5" /> On-chain
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lightning" className="gap-1.5 text-xs">
|
||||
<Zap className="size-3.5" /> Lightning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="onchain" className="mt-0">
|
||||
<OnchainZapContent target={target} onSuccess={() => setOpen(false)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="lightning" className="mt-0">
|
||||
<LightningZapContent {...lightningContentProps} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OnchainZapContent target={target} onSuccess={() => setOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
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 3043 event content. */
|
||||
comment?: string;
|
||||
/** Fee speed preset. Defaults to "halfHour". */
|
||||
feeSpeed?: OnchainFeeSpeed;
|
||||
}
|
||||
|
||||
interface OnchainZapResult {
|
||||
/** The broadcast Bitcoin transaction ID. */
|
||||
txid: string;
|
||||
/** Fee paid in satoshis. */
|
||||
fee: number;
|
||||
/** The published kind 3043 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 3043 "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?: () => void,
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const { canSignPsbt, signPsbt } = useBitcoinSigner();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
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 signer doesn't support Bitcoin transaction signing. Log in with your nsec, a NIP-07 extension that supports signPsbt, or a NIP-46 remote signer that supports sign_psbt.",
|
||||
);
|
||||
}
|
||||
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),
|
||||
getFeeRates(),
|
||||
]);
|
||||
|
||||
if (utxos.length === 0) {
|
||||
throw new Error('Your on-chain 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);
|
||||
|
||||
// Publish kind 3043 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', `On-chain zap: ${amountSats.toLocaleString()} sats`]);
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: 3043,
|
||||
content: comment,
|
||||
tags,
|
||||
});
|
||||
|
||||
return { txid, fee, event };
|
||||
},
|
||||
onSuccess: ({ txid, fee }) => {
|
||||
notificationSuccess();
|
||||
toast({
|
||||
title: 'On-chain zap sent!',
|
||||
description: `Broadcast txid ${txid.slice(0, 12)}… (fee ${fee.toLocaleString()} sats)`,
|
||||
});
|
||||
// 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'] });
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'On-chain 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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 3043 event. */
|
||||
event: NostrEvent;
|
||||
/** Bitcoin transaction id (lowercase hex). */
|
||||
txid: string;
|
||||
/** Pubkey of the sender (the 3043 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 3043 event content. */
|
||||
comment: string;
|
||||
/** Unix timestamp of the 3043 event. */
|
||||
createdAt: number;
|
||||
/** Whether the Bitcoin tx is confirmed on-chain. */
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** Parse the txid from a kind 3043 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 3043 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 3043 event (first `p` tag). */
|
||||
export function extractOnchainZapRecipient(event: NostrEvent): string {
|
||||
const tag = event.tags.find(([n]) => n === 'p');
|
||||
return tag?.[1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a kind 3043 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;
|
||||
|
||||
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 3043 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 3043 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: [3043], '#e': [target.id], limit: 100 },
|
||||
];
|
||||
if (aCoord) {
|
||||
filters.push({ kinds: [3043], '#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,
|
||||
};
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import { FlatThreadedReplyList } from '@/components/ThreadedReplyList';
|
||||
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
@@ -185,8 +184,10 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile
|
||||
const [giveBadgeOpen, setGiveBadgeOpen] = useState(false);
|
||||
const [followQROpen, setFollowQROpen] = useState(false);
|
||||
const zapTriggerRef = useRef<HTMLSpanElement>(null);
|
||||
const author = useAuthor(pubkey);
|
||||
const showZap = !isOwnProfile && authorEvent && canZap(author.data?.metadata);
|
||||
// Show zap action for any non-self profile. Both on-chain and Lightning
|
||||
// zaps are offered inside the dialog (Lightning only when the author has
|
||||
// a lud06/lud16 configured).
|
||||
const showZap = !isOwnProfile && !!authorEvent;
|
||||
|
||||
const close = () => onOpenChange(false);
|
||||
const openAfterClose = (setter: (v: boolean) => void) => {
|
||||
|
||||
@@ -41,7 +41,6 @@ import { type EventStats, useEventStats } from "@/hooks/useTrending";
|
||||
import { useUserReaction } from "@/hooks/useUserReaction";
|
||||
import { DITTO_RELAY } from "@/lib/appRelays";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { canZap } from "@/lib/canZap";
|
||||
import { EXTRA_KINDS } from "@/lib/extraKinds";
|
||||
import { getRepostKind } from "@/lib/feedUtils";
|
||||
import { formatNumber } from "@/lib/formatNumber";
|
||||
@@ -339,7 +338,7 @@ export function VineCard({
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user