NIP-BC: Onchain Zaps
NIP-BC
Onchain Zaps
draft optional
A Nostr pubkey is also a Bitcoin Taproot address: both are 32-byte x-only secp256k1 keys, so any Nostr user can receive Bitcoin directly to their Nostr identity — no LNURL, custodian, or Lightning address required. This NIP defines kind:8333, the onchain zap: a regular event published by the sender that attributes such a payment to a Nostr event or profile, analogous to the NIP-57 zap receipt (kind:9735) but for Bitcoin transactions instead of Lightning invoices.
The kind number mirrors the convention of NIP-57: 9735 is the Lightning Network P2P port, and 8333 is the Bitcoin mainnet P2P port.
Deriving the Recipient's Bitcoin Address
The recipient's Nostr pubkey is used directly as the internal key of a BIP-341 P2TR output, with no mathematical conversion. The output key is produced by the standard BIP-341 key-path-only tweak (no script tree), and encoded as bech32m (BIP-350) with human-readable prefix bc. Every Nostr pubkey therefore has exactly one corresponding mainnet Taproot address, and anyone can compute it from the pubkey alone.
This specification applies to Bitcoin mainnet only. Testnet, signet, and other networks are out of scope; addresses and txids from those networks MUST NOT be used in kind:8333 events.
Event Structure
Single-recipient zap (the common case — tipping a post or profile):
{
"kind": 8333,
"pubkey": "<sender-pubkey>",
"content": "Great post!",
"tags": [
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-pubkey>"],
["amount", "<sats>"],
["e", "<target-event-id>", "<relay-hint>"],
["block", "<block-hash-hex>", "<height>"],
["proof", "<raw-tx-hex>", "<merkle-proof-hex>"],
["alt", "Onchain zap: 25000 sats"]
]
}Multi-recipient zap (one transaction paying multiple recipients — community splits, campaign donations):
{
"kind": 8333,
"pubkey": "<sender-pubkey>",
"content": "Great campaign!",
"tags": [
["i", "bitcoin:tx:<txid>"],
["p", "<recipient-1-pubkey>"],
["p", "<recipient-2-pubkey>"],
["p", "<recipient-3-pubkey>"],
["amount", "<total-sats-paid-to-all-listed-recipients>"],
["a", "<kind>:<pubkey>:<d-tag>"],
["alt", "Onchain zap: 75000 sats across 3 recipients"]
]
}The content field is a human-readable comment from the sender. It MAY be empty. Unlike NIP-57, it is NOT a JSON-encoded zap request.
Tags
| Tag | Required | Description |
|---|---|---|
i | Yes | [NIP-73](73.md) external content identifier. MUST be bitcoin:tx:<txid> where <txid> is a 64-character lowercase hex Bitcoin transaction ID. |
p | Yes (≥1) | Hex-encoded pubkey of a zap recipient (an author being paid). A single event MAY include multiple p tags when the transaction has one output per recipient. Each p tag MUST correspond to at least one tx output paying that recipient's derived Taproot address. |
amount | Yes | Total amount paid in satoshis, as a decimal integer string. This is the sum of outputs in the referenced transaction that pay the derived Taproot addresses of all listed p recipients combined — not the total transaction value. The sender's change output MUST NOT be included. For single-recipient events this is simply the amount paid to that one recipient. |
e | If zapping an event | Hex-encoded id of the event being zapped. A relay hint SHOULD be provided as the third element. |
a | If zapping an addressable event | Addressable event coordinate <kind>:<pubkey>:<d-tag>. Used instead of (or alongside) e for addressable events. |
k | No | The stringified kind of the target event, as in NIP-57. |
block | No | Block containing the transaction, as ["block", "<block-hash-hex>", "<height>"]. Enables SPV-style verification (see [Header-only verification](#header-only-verification)). |
proof | No | SPV proof, as ["proof", "<raw-tx-hex>", "<merkle-proof-hex>"]. The <merkle-proof-hex> is the concatenation of sibling hashes on the path from the txid leaf to the block's merkle root, ordered from leaf to root. |
alt | Yes | [NIP-31](31.md) human-readable fallback. |
If neither e nor a is present, the zap targets the recipients' profiles (i.e. a tip to the pubkey(s), not to a specific event).
Per-recipient amounts are not encoded in the event. Clients that need them (e.g. attributing a multi-recipient donation to one recipient's profile zap history) recompute them from the on-chain transaction by matching each recipient's derived Taproot address against the tx outputs.
Querying
Clients can retrieve onchain zaps for an event using a standard NIP-01 filter:
{ "kinds": [8333], "#e": ["<target-event-id>"] }For addressable events, use "#a": ["<kind>:<pubkey>:<d-tag>"]. For profile-level zaps targeting a specific user, use "#p": ["<pubkey>"] — this matches both single-recipient events tagging that user and multi-recipient events where the user is one of several recipients. Clients MAY also locate zaps for a given transaction with "#i": ["bitcoin:tx:<txid>"].
Verification
Because the amount tag is self-reported by the sender, clients MUST verify a kind:8333 event against the referenced Bitcoin transaction before counting it toward a zap total or displaying its amount.
To verify a kind:8333 event:
- Parse the
txidfrom theitag. - Fetch the transaction from a Bitcoin data source (an Esplora-compatible API, an Electrum server, a local node, etc.).
- For each
ptag, derive the recipient's expected Taproot address from the pubkey as described above. - Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the verified amount. Change outputs paying back to the sender's own derived Taproot address MUST NOT be counted.
- If the verified amount is
0(no listed recipient received anything in the tx), the event SHOULD be discarded. - If the sender's
amounttag is greater than the verified amount, clients MUST NOT display or count the claimed amount. They MAY discard the event, or cap it to the verified amount.
When a client needs to attribute a multi-recipient event to one specific recipient (e.g. rendering a profile zap-history entry), it MAY sum only the tx outputs paying that one recipient's derived Taproot address. Per-recipient amounts are not stored in the event — they are recomputed from the transaction at display time.
Unconfirmed transactions MAY be displayed as pending. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate totals or clearly label them as pending until they confirm.
Header-only verification
Fetching transactions from a remote Bitcoin API introduces a trusted third party and a round-trip per zap. Senders MAY include an SPV proof inline in the event via the optional block and proof tags, allowing a verifier with only the ~75 MB Bitcoin block-header chain to validate the event offline.
With both tags present, a verifier:
- Looks up the header by
block-hash-hex(or byheight) in its local header chain. - Double-SHA256 hashes
raw-tx-hexto obtain the txid, and checks it matches theitag txid. - Walks the merkle proof from the txid up to the header's
merkleRoot. - Parses the outputs from
raw-tx-hex, derives the Taproot script for eachptag, and sums outputs paying any of those scripts — this is the verified amount (applying the same rules as above). - Computes confirmations as
tip_height − height + 1.
When these tags are present, clients SHOULD prefer the inline proof over a remote fetch. When they are absent, clients fall back to the remote verification flow in the previous section. Publishers SHOULD include the tags once the transaction has at least one confirmation, so that the block reference is stable (not subject to reorg-induced invalidation of unconfirmed proofs).
Per-event payload cost is roughly 1 KB. For sessions with very large numbers of zaps (≳1M events), downloading compact block filters (BIP-157/BIP-158, ~1–2 GB) may be more efficient than carrying inline proofs on every event.
Anti-spoofing rules
- Self-zaps. Clients SHOULD reject events where the sender's
pubkeyappears in anyptag. Because the sender already controls the destination address, self-zaps are trivial to fabricate and contribute nothing meaningful to zap totals. Outputs in the underlying transaction that pay the sender's own derived Taproot address are change outputs and MUST NOT be counted toward the verified amount regardless of the tag set. - Duplicate events for the same transaction. Any party can publish a
kind:8333event referencing someone else's transaction. Clients SHOULD deduplicate events bytxidand treat the event whosepubkeymatches an input of the transaction as canonical when such a correlation is available; otherwise the earliest valid event pertxidSHOULD be preferred.
Client Behavior
Clients SHOULD present verified onchain zaps alongside NIP-57 Lightning zap receipts and, where relevant, [NIP-61](61.md) Nutzaps, summing their verified amounts when displaying aggregate zap totals for a post or profile.
When the content is non-empty, it MAY be displayed as the sender's comment, analogous to the comment in a NIP-57 zap request.
Signer Methods
Because the sender's secret key is used to sign both the Nostr event and the underlying Bitcoin transaction, signers that hold the key on the user's behalf need a way to sign Bitcoin transactions in addition to Nostr events. This NIP extends [NIP-07](07.md) and [NIP-46](46.md) with a single new method: signPsbt / sign_psbt.
A Partially Signed Bitcoin Transaction (PSBT) is the standard Bitcoin container for an unsigned or partially signed transaction and contains all information the signer needs to produce a valid signature. The client constructs the unsigned PSBT (inputs, outputs, fee, tapInternalKey) and passes it to the signer; the signer applies the BIP-341 TapTweak to the internal key, signs the relevant inputs, and returns the updated PSBT. The client then finalizes and broadcasts the transaction.
Passing a PSBT (rather than a raw transaction or a private-key tweak request) lets the signer inspect the full set of inputs and outputs and display them to the user for confirmation before signing. Signers SHOULD display the destination addresses, the amount being spent, the change output (if any), and the fee before signing.
NIP-07
The window.nostr object MAY expose a signPsbt method:
async window.nostr.signPsbt(psbt: string): string- The input is a PSBT encoded as a lowercase hex string (BIP-174 binary format hex-encoded).
- The signer signs each input whose
tapInternalKeymatches the user's Nostr pubkey, applying the BIP-341 key-path-only tweak. - The return value is the updated PSBT, also as a lowercase hex string.
- The signer SHOULD NOT finalize the PSBT; finalization is the client's responsibility.
- If the user rejects the request, the signer MUST throw / reject the promise.
NIP-46
The sign_psbt command is added to the method table in [NIP-46](46.md):
| Command | Params | Result |
|---|---|---|
sign_psbt | [<psbt-hex>] | <signed-psbt-hex> |
The semantics are identical to the NIP-07 method above: the signer signs inputs whose tapInternalKey matches the user-pubkey, does not finalize, and returns the resulting PSBT as a lowercase hex string. sign_psbt MAY appear in the perms list of a nostrconnect:// URI or a connect request.