Files
eranos/WALLET.md
T
Alex Gleason 5660a1cb1b Add multi-signer PSBT signing for Bitcoin transactions
Split createBitcoinTransaction into buildUnsignedPsbt, signPsbtLocal,
and finalizePsbt so the signing step can be delegated to any signer.

Introduce BtcSigner interface and three extended signer classes:
- NSecSignerBtc: local Taproot signing with the raw private key
- NBrowserSignerBtc: delegates to window.nostr.signPsbt() (NIP-07)
- NConnectSignerBtc: sends sign_psbt RPC over NIP-46 relay channel

useCurrentUser now constructs Btc-extended signers instead of base
ones. signerWithNudge forwards signPsbt when present on the wrapped
signer. SendBitcoinDialog uses the new useBitcoinSigner hook instead
of useNsecAccess, enabling sending from all login types.
2026-04-07 00:16:36 -05:00

9.6 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):  82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2
Bitcoin address:     bc1pw0qkazw9twl4snwxal6v90djv3c8cph4s0w7rvtyp3k95rll3cqqhv4cn8

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.

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