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.
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:
buildUnsignedPsbt()-- Constructs the PSBT with all inputs and outputs but no signatures. Only needs the sender's public key.signer.signPsbt()-- Signs the PSBT. Dispatched through the signer interface (local, extension, or bunker).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:
-
Fetch UTXOs -- All unspent outputs for the sender's address are retrieved from
mempool.space/api/address/{address}/utxo. -
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 -
Resolve recipient -- The recipient can be an
npub1...(converted to its Taproot address vianpubToBitcoinAddress) or a raw Bitcoin address (validated viabitcoin.address.toOutputScript). -
Build unsigned PSBT -- All UTXOs are consumed as inputs (no coin selection). Each input includes:
witnessUtxo: the P2TR output script and valuetapInternalKey: the 32-byte x-only public key
-
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. -
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.
-
Sign -- The unsigned PSBT is passed to the signer's
signPsbtmethod. 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.
-
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:
- Form -- Enter recipient, amount, and fee speed. Shows available balance, USD conversion, and a "Send Max" button that correctly subtracts the estimated fee.
- Confirm -- Review recipient address, amount (BTC + USD), fee breakdown, and total debit before committing.
- 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:
- Broadcasts the Taproot-funded Bitcoin transaction to the recipient's derived address.
- Publishes a kind 8333 "Onchain Zap" event containing:
itag referencing the Bitcoin transaction (bitcoin:tx:<txid>, per NIP-73)ptag for the recipient's Nostr pubkeyetag (andatag for addressable events) pointing at the zapped postamounttag 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_psbtshould display a confirmation dialog showing transaction outputs and amounts before signing.
References
- BIP-340: Schnorr Signatures for secp256k1
- BIP-341: Taproot (SegWit v1)
- BIP-350: Bech32m
- NIP-01: Basic Protocol (defines secp256k1 x-only keys for Nostr)