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 */} +
+ +