From bddfe4b838692bea813dd6975f0ce6467f8d4656 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Apr 2026 15:17:09 -0500 Subject: [PATCH] Add on-chain Bitcoin zaps as the default zap method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:`), 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. --- NIP.md | 89 +++++ src/components/BookFeedItem.tsx | 3 +- src/components/LiveStreamPage.tsx | 9 +- src/components/MusicDetailContent.tsx | 3 +- src/components/NoteCard.tsx | 7 +- src/components/OnchainZapContent.tsx | 447 ++++++++++++++++++++++++ src/components/PhotoBottomBar.tsx | 3 +- src/components/PodcastDetailContent.tsx | 3 +- src/components/PostActionBar.tsx | 8 +- src/components/ZapDialog.tsx | 56 ++- src/hooks/useOnchainZap.ts | 201 +++++++++++ src/hooks/useOnchainZaps.ts | 176 ++++++++++ src/pages/ProfilePage.tsx | 7 +- src/pages/VinesFeedPage.tsx | 3 +- 14 files changed, 978 insertions(+), 37 deletions(-) create mode 100644 src/components/OnchainZapContent.tsx create mode 100644 src/hooks/useOnchainZap.ts create mode 100644 src/hooks/useOnchainZaps.ts diff --git a/NIP.md b/NIP.md index 7bc9d995..e75ec2a8 100644 --- a/NIP.md +++ b/NIP.md @@ -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": "", + "content": "Great post!", + "tags": [ + ["e", "", ""], + ["p", ""], + ["i", "bitcoin:tx:"], + ["amount", ""], + ["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:` where `` 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 `::`. 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": [""], "limit": 100 } +``` + +For addressable events, use `"#a": ["::"]` instead. For profile-level zaps, use `"#p": [""]`. + +**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 diff --git a/src/components/BookFeedItem.tsx b/src/components/BookFeedItem.tsx index 21b3b37b..600c4e73 100644 --- a/src/components/BookFeedItem.tsx +++ b/src/components/BookFeedItem.tsx @@ -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; diff --git a/src/components/LiveStreamPage.tsx b/src/components/LiveStreamPage.tsx index 7b0e7a63..e9b30fce 100644 --- a/src/components/LiveStreamPage.tsx +++ b/src/components/LiveStreamPage.tsx @@ -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 ( + + + + ); + } + + // ── Confirm view ────────────────────────────────────────────── + + if (step === 'confirm') { + const totalSats = amountSats + estimatedFeeSats; + const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey); + + return ( +
+
+ +

{recipientAddress}

+
+ +
+ + + + +
+ + {comment && ( +
+ +

{comment}

+
+ )} + + + + + On-chain transactions are final. Funds settle after ~10 minutes. + + + +
+ + +
+
+ ); + } + + // ── Form view ───────────────────────────────────────────────── + + const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; + + return ( +
+ {/* Balance */} +
+ + {isLoadingUtxos ? ( + + ) : ( +

+ {btcPrice + ? satsToUSD(totalBalance, btcPrice) + : `${satsToBTC(totalBalance).replace(/\.?0+$/, '')} BTC`} +

+ )} +
+ + {/* Amount presets (USD) */} +
+ + { if (v) { setUsdAmount(Number(v)); setError(''); } }} + className="grid grid-cols-5 gap-1 w-full" + > + {USD_PRESETS.map((v) => ( + + ${v} + + ))} + + +
+
+ OR +
+
+ +
+ $ + { setUsdAmount(e.target.value); setError(''); }} + className="pl-6" + /> +
+ + {currentUsd > 0 && amountSats > 0 && ( +

+ ≈ {formatSats(amountSats)} sats +

+ )} +
+ + {/* Comment */} +
+ +