Files
eranos/WALLET.md
T
Alex Gleason 72989349e8 Document on-chain zaps (kind 8333) in WALLET.md
The wallet doc covered sending Bitcoin and NIP-73 tx/address pages but
never mentioned that the zap-dialog flow also publishes a kind 8333
attestation pairing the tx with the zapped Nostr event. Add a short
section describing the tags and cross-link the full spec in NIP.md.
2026-05-07 15:09:23 -07:00

11 KiB

Nostr-to-Bitcoin Wallet

This document explains how the application derives a Bitcoin Taproot address from a Nostr public key, enabling every Nostr identity to function as a Bitcoin wallet.

Why This Works

Nostr and Bitcoin Taproot (BIP-341) share the exact same cryptographic primitives:

Property Nostr Bitcoin Taproot
Curve secp256k1 secp256k1
Signature scheme Schnorr (BIP-340) Schnorr (BIP-340)
Public key format 32-byte x-only 32-byte x-only

Because the key formats are byte-for-byte identical, a Nostr public key can be used directly as a Taproot internal key with no mathematical conversion, hashing, or derivation.

Derivation Algorithm

Step 1 -- Parse the Public Key

A Nostr pubkey is a 64-character hex string representing 32 bytes. Convert it to a byte buffer:

pubkey (hex): e7a2e3b5f1c8d4a6...  (64 hex chars = 32 bytes)
              ↓
pubkeyBuffer: <Buffer e7 a2 e3 b5 f1 c8 d4 a6 ...>

Step 2 -- Compute the Taproot Output Key

Bitcoin Taproot (BIP-341) defines a "tweaking" process for the internal key:

t       = taggedHash("TapTweak", internalPubkey)
Q       = P + t*G          (where P = internal key, G = generator point)

When there is no script tree (key-path-only spend), only the internal key participates in the tweak. The result Q is the output key that appears on-chain.

This step is handled internally by bitcoinjs-lib's payments.p2tr().

Step 3 -- Encode as a bech32m Address

The 32-byte output key Q is encoded with:

  • Witness version: 1 (Taproot)
  • Encoding: bech32m (BIP-350)
  • Human-readable prefix: bc (mainnet)

The resulting address always starts with bc1p.

Implementation

import * as bitcoin from 'bitcoinjs-lib';

function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
  const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex');

  const { address } = bitcoin.payments.p2tr({
    internalPubkey: pubkeyBuffer,
    network: bitcoin.networks.bitcoin,
  });

  return address; // "bc1p..."
}

Example

Nostr pubkey (hex):  0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd
Bitcoin address:     bc1pvdaezs0mdhxgrfhw2zjsw4r02wlzrafd7stxcvpzzscsh8hv6vhqdc9ets

Dependencies

Package Role
bitcoinjs-lib P2TR address generation, PSBT construction
@bitcoinerlab/secp256k1 secp256k1 ECC operations (Schnorr, key tweaking)
buffer Node.js Buffer polyfill for the browser

The ECC library must be initialized once at startup:

import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
bitcoin.initEccLib(ecc);

Balance & Transaction APIs

All Bitcoin data is fetched from the public mempool.space Esplora-compatible API:

Endpoint Purpose
GET https://mempool.space/api/address/{address} Balance stats (funded/spent sums, tx counts)
GET https://mempool.space/api/address/{address}/txs Transaction history for an address
GET https://mempool.space/api/tx/{txid} Full transaction detail (inputs, outputs, fee, block)

The wallet page polls balance and transaction data every 30 seconds. BTC/USD price is fetched from CoinGecko every 60 seconds.

NIP-73 Integration

Transaction and address detail pages use NIP-73 external content identifiers, enabling Nostr comments and reactions on Bitcoin transactions and addresses:

  • Transaction pages: /i/bitcoin:tx:{txid} -- renders a mempool.space-style transaction view with inputs, outputs, fee, block info, and USD values
  • Address pages: /i/bitcoin:address:{address} -- renders balance, recent transactions, and total received/sent

These pages are part of the existing /i/* external content system, which also supports URLs, ISBNs, country codes, and other NIP-73 identifier types.

Sending Bitcoin

The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr key can sign Bitcoin transactions without any key conversion.

Supported Signer Types

Sending works with all three login types:

Login type Signing method How it works
nsec (secret key) Local signing The app applies the BIP-341 TapTweak and signs the PSBT directly using the private key.
NIP-07 extension window.nostr.signPsbt(hex) The unsigned PSBT hex is passed to the extension, which handles tweaking and signing internally.
NIP-46 bunker sign_psbt RPC The unsigned PSBT hex is sent to the remote signer over the NIP-46 relay channel.

If the signer does not support PSBT signing (e.g. an extension without signPsbt), the send dialog displays an explanation and the user cannot proceed.

Architecture

The send flow uses a three-step PSBT pipeline:

  1. buildUnsignedPsbt() -- Constructs the PSBT with all inputs and outputs but no signatures. Only needs the sender's public key.
  2. signer.signPsbt() -- Signs the PSBT. Dispatched through the signer interface (local, extension, or bunker).
  3. finalizePsbt() -- Finalizes all inputs and extracts the raw transaction hex for broadcast.

The signer classes (NSecSignerBtc, NBrowserSignerBtc, NConnectSignerBtc) extend Nostrify's base signer classes with a signPsbt method. The signerWithNudge wrapper forwards signPsbt to the underlying signer, providing the same nudge toast UX for remote signers.

Transaction Construction

The send flow constructs a standard Taproot (P2TR) key-path spend:

  1. Fetch UTXOs -- All unspent outputs for the sender's address are retrieved from mempool.space/api/address/{address}/utxo.

  2. Fetch fee rates -- Recommended fee rates (sat/vB) for four confirmation targets are retrieved from mempool.space/api/fee-estimates:

    Speed Block target Typical wait
    Fastest 1 block ~10 minutes
    Half hour 3 blocks ~30 minutes
    One hour 6 blocks ~1 hour
    Economy 144 blocks ~1 day
  3. Resolve recipient -- The recipient can be an npub1... (converted to its Taproot address via npubToBitcoinAddress) or a raw Bitcoin address (validated via bitcoin.address.toOutputScript).

  4. Build unsigned PSBT -- All UTXOs are consumed as inputs (no coin selection). Each input includes:

    • witnessUtxo: the P2TR output script and value
    • tapInternalKey: the 32-byte x-only public key
  5. Estimate fee -- The formula is ceil((numInputs * 57.5 + numOutputs * 43 + 10.5) * feeRate). The output count is determined dynamically: if the change would be below the 546-sat dust limit, it is dropped (donated as extra miner fee) and the estimate uses 1 output instead of 2.

  6. Add outputs -- The recipient output is always added. A change output (back to the sender's own Taproot address) is added only if the change exceeds the dust limit.

  7. Sign -- The unsigned PSBT is passed to the signer's signPsbt method. For local nsec signing, the private key is tweaked for Taproot key-path spending (BIP-341):

    tweak = taggedHash("TapTweak", internalPubkey)
    tweakedKey = privateKey + tweak  (mod n, with y-parity correction)
    

    For extension and bunker signers, the tweaking is handled by the external signer.

  8. Finalize and broadcast -- The signed PSBT is finalized, the raw transaction hex is extracted, and POSTed to mempool.space/api/tx, which returns the txid on success.

User Flow

The send dialog has three steps:

  1. Form -- Enter recipient, amount, and fee speed. Shows available balance, USD conversion, and a "Send Max" button that correctly subtracts the estimated fee.
  2. Confirm -- Review recipient address, amount (BTC + USD), fee breakdown, and total debit before committing.
  3. Success -- Shows the transaction ID with a link to the in-app NIP-73 detail page (/i/bitcoin:tx:{txid}).

Additional API Endpoints

Endpoint Purpose
GET https://mempool.space/api/address/{address}/utxo Unspent transaction outputs
GET https://mempool.space/api/fee-estimates Recommended fee rates by block target
POST https://mempool.space/api/tx Broadcast signed transaction (raw hex body)

Dependencies (sending-specific)

Package Role
ecpair Key pair creation and Taproot key tweaking
tiny-secp256k1 Low-level ECC operations (peer dep of ecpair)

These are in addition to the base dependencies listed above.

On-chain Zaps (kind 8333)

Sending Bitcoin through the in-app zap dialog is more than a plain transfer -- it publishes a matching Nostr event so the payment is attributed to the sender on Nostr, just like a NIP-57 Lightning zap.

Whenever the Bitcoin tab of the zap dialog succeeds, the client:

  1. Broadcasts the Taproot-funded Bitcoin transaction to the recipient's derived address.
  2. Publishes a kind 8333 "Onchain Zap" event containing:
    • i tag referencing the Bitcoin transaction (bitcoin:tx:<txid>, per NIP-73)
    • p tag for the recipient's Nostr pubkey
    • e tag (and a tag for addressable events) pointing at the zapped post
    • amount tag with the amount in satoshis

Clients treat kind 8333 the same way they treat NIP-57 kind 9735 receipts -- as an attestation that a payment happened. The kind number mirrors the semantic pairing of 9735 (Lightning's P2P port) and 8333 (Bitcoin's mainnet P2P port).

Unlike NIP-57, no LNURL or Lightning address is required. Because every Nostr keypair deterministically maps to a Taproot address (see above), on-chain zaps work for any Nostr user.

The full specification -- including verification rules, spoofing defenses, and the kind-9735 vs kind-8333 comparison table -- lives in NIP.md.

Security Considerations

  • The same private key (nsec in Nostr) controls both the Nostr identity and the Bitcoin funds at the derived address.
  • Extension and bunker signers handle the private key internally -- the app never sees the raw key for those login types. Only the unsigned PSBT (which contains no secret material) is sent to the signer.
  • This is a single-key Taproot address with no HD derivation (no BIP-32/BIP-44 path). Every Nostr keypair maps to exactly one Bitcoin address.
  • Users should ensure they have secure backups of their Nostr private key before receiving Bitcoin at the derived address.
  • Extensions and bunkers that support signPsbt / sign_psbt should display a confirmation dialog showing transaction outputs and amounts before signing.

References