---
name: onchain-bitcoin
description: Add an onchain Bitcoin wallet derived from the user's Nostr identity. Every Nostr keypair maps deterministically to a Taproot (P2TR) address via shared secp256k1 + BIP-340 cryptography, so the same nsec receives, signs PSBTs, and broadcasts transactions through mempool.space — with no LNURL, custodian, or HD derivation. Includes balance/tx queries, send pipeline (build → sign → broadcast), capability detection across nsec / NIP-07 / NIP-46 signers, and kind 8333 onchain zaps with on-chain verification.
---
# Onchain Bitcoin Wallet
Nostr public keys (`npub` / 32-byte hex) are byte-for-byte identical to Bitcoin Taproot internal keys (BIP-340 / BIP-341): both are 32-byte x-only secp256k1 keys. This means **every Nostr identity already owns a Bitcoin address** — no key conversion, no HD derivation, no LNURL provider needed.
This skill ships a complete in-app wallet:
- Derive the user's `bc1p…` Taproot address from their pubkey
- Display balance, USD value, and transaction history
- Send Bitcoin via a PSBT pipeline that works with **all three** Nostrify signer types
- Publish and verify NIP-73 / kind 8333 "onchain zaps"
- Render NIP-73 `bitcoin:tx:` and `bitcoin:address:` identifiers in the existing `/:nip19` router
## Why It Works
| 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 formats match, a Nostr pubkey **is** a Taproot internal key (`P`). The on-chain output key is the standard BIP-341 tweak `Q = P + taggedHash("TapTweak", P) · G` with no script tree (key-path-only spend), encoded as bech32m with HRP `bc` and witness version 1 — always producing a `bc1p…` address.
`bitcoinjs-lib`'s `payments.p2tr({ internalPubkey })` performs the tweak + bech32m encoding internally.
## Files Provided by This Skill
| Skill file | Copy to |
|---|---|
| `files/lib/bitcoin.ts` | `src/lib/bitcoin.ts` |
| `files/lib/bitcoin-signers.ts` | `src/lib/bitcoin-signers.ts` |
| `files/hooks/useBitcoinWallet.ts` | `src/hooks/useBitcoinWallet.ts` |
| `files/hooks/useBitcoinAddress.ts` | `src/hooks/useBitcoinAddress.ts` |
| `files/hooks/useBitcoinTx.ts` | `src/hooks/useBitcoinTx.ts` |
| `files/hooks/useBitcoinSigner.ts` | `src/hooks/useBitcoinSigner.ts` |
| `files/hooks/useOnchainZap.ts` | `src/hooks/useOnchainZap.ts` |
| `files/hooks/useOnchainZaps.ts` | `src/hooks/useOnchainZaps.ts` |
These files are framework-only. The wallet page, send dialog, and on-chain zap UI are built on top of them.
## Setup Instructions
### 1. Install Dependencies
```bash
npm install bitcoinjs-lib @bitcoinerlab/secp256k1 ecpair tiny-secp256k1
```
`buffer` is already a dependency of the template; if you removed it, also `npm install buffer`.
| Package | Role |
|---|---|
| `bitcoinjs-lib` | P2TR address derivation, PSBT construction & finalization |
| `@bitcoinerlab/secp256k1` | secp256k1 ECC backend (Schnorr, key tweaking) |
| `ecpair` | Key pair creation and BIP-341 tweaking for local nsec signing |
| `tiny-secp256k1` | Peer dep of `ecpair` (low-level ECC) |
### 2. Initialize ECC in `main.tsx`
The Buffer polyfill is already imported in mkstack's `main.tsx`. Add the ECC initialization right after it. **`bitcoin.initEccLib(ecc)` must run before any wallet code** — otherwise `payments.p2tr()` and Schnorr operations throw.
```tsx
// src/main.tsx
import { createRoot } from 'react-dom/client';
// Import polyfills first (Buffer must exist before bitcoinjs-lib is loaded)
import './lib/polyfills.ts';
// Initialize ECC for bitcoinjs-lib (Taproot / Schnorr support)
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
bitcoin.initEccLib(ecc);
import { ErrorBoundary } from '@/components/ErrorBoundary';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
);
```
`src/lib/bitcoin.ts` calls `initEccLib` again lazily inside signing/finalizing functions as a defensive measure, so test files and ad-hoc imports also work — but the early init in `main.tsx` is what the live app relies on.
### 3. Copy the Skill Files
Copy every file from `files/` into the matching directory under `src/`. The hook files import each other and `@/lib/bitcoin` / `@/lib/bitcoin-signers`, so paths must match exactly.
### 4. Wire the BTC-Capable Signers into `useCurrentUser`
The default `useCurrentUser` uses Nostrify's `NUser.from*Login` factories, which return signers without a `signPsbt` method. Replace them with the BTC-extended subclasses from this skill so the same `user.signer` instance can also sign PSBTs.
Edit `src/hooks/useCurrentUser.ts`:
```tsx
import { useNostr } from '@nostrify/react';
import { type NLoginType, NUser, useNostrLogin } from '@nostrify/react/login';
import { NSecSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { useCallback, useMemo } from 'react';
import { useAuthor } from './useAuthor.ts';
import { NSecSignerBtc, NBrowserSignerBtc, NConnectSignerBtc } from '@/lib/bitcoin-signers';
export function useCurrentUser() {
const { nostr } = useNostr();
const { logins } = useNostrLogin();
const loginToUser = useCallback((login: NLoginType): NUser => {
switch (login.type) {
case 'nsec': {
const sk = nip19.decode(login.data.nsec) as { type: 'nsec'; data: Uint8Array };
return new NUser(login.type, login.pubkey, new NSecSignerBtc(sk.data));
}
case 'bunker': {
const clientSk = nip19.decode(login.data.clientNsec) as { type: 'nsec'; data: Uint8Array };
const clientSigner = new NSecSigner(clientSk.data);
return new NUser(
login.type,
login.pubkey,
new NConnectSignerBtc({
relay: nostr.group(login.data.relays),
pubkey: login.data.bunkerPubkey,
signer: clientSigner,
timeout: 60_000,
}),
);
}
case 'extension':
return new NUser(login.type, login.pubkey, new NBrowserSignerBtc());
default:
throw new Error(`Unsupported login type: ${login.type}`);
}
}, [nostr]);
const users = useMemo(() => {
const users: NUser[] = [];
for (const login of logins) {
try { users.push(loginToUser(login)); }
catch (error) { console.warn('Skipped invalid login', login.id, error); }
}
return users;
}, [logins, loginToUser]);
const user = users[0] as NUser | undefined;
const author = useAuthor(user?.pubkey);
return { user, users, ...author.data };
}
```
This is the **only** change required for the wallet to function across all three login types. If you skip it, the hooks still compile but `useBitcoinSigner` will report `'unsupported'` for every user.
## Core APIs
### Address derivation
```ts
import { nostrPubkeyToBitcoinAddress, npubToBitcoinAddress } from '@/lib/bitcoin';
nostrPubkeyToBitcoinAddress('d6889cb0…300a961d'); // 'bc1p2wsldez…'
npubToBitcoinAddress('npub1…'); // 'bc1p…'
```
Both return `''` for malformed input or off-curve pubkeys (so UIs can render an empty state without try/catch).
### `useBitcoinWallet()` — current user's wallet
```tsx
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { satsToUSD, formatBTC } from '@/lib/bitcoin';
function WalletWidget() {
const { bitcoinAddress, addressData, btcPrice, transactions, isLoading } = useBitcoinWallet();
if (isLoading) return ;
if (!addressData) return null;
return (
);
}
```
Auto-refreshes every 30 s (balance + transactions) and 60 s (BTC price).
### `useBitcoinAddress(address)` / `useBitcoinTx(txid)` — NIP-73 detail pages
Use these from the `/:nip19` router (`src/pages/NIP19Page.tsx`) when an `i` tag is `bitcoin:address:bc1p…` or `bitcoin:tx:abc123…`. Both also return the BTC price for USD display.
Parse NIP-73 Bitcoin identifiers with this regex:
```ts
const txMatch = uri.match(/^bitcoin:tx:([0-9a-f]{64})$/i);
const addrMatch = uri.match(/^bitcoin:address:(.+)$/);
```
### Sending Bitcoin: the PSBT pipeline
The send flow is intentionally split into three pure functions so any signer (local key, NIP-07, NIP-46) can plug in at step 2:
```ts
import {
buildUnsignedPsbt,
finalizePsbt,
broadcastTransaction,
fetchUTXOs,
getFeeRates,
} from '@/lib/bitcoin';
import { useBitcoinSigner } from '@/hooks/useBitcoinSigner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
const { user } = useCurrentUser();
const { signPsbt, canSignPsbt } = useBitcoinSigner();
if (!canSignPsbt || !signPsbt || !user) throw new Error("Login can't sign PSBTs.");
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress), getFeeRates()]);
// 1. Build (no key material required — pubkey only)
const { psbtHex, fee } = buildUnsignedPsbt(
user.pubkey,
recipientAddress,
amountSats,
utxos,
rates.halfHourFee, // sat/vB
);
// 2. Sign — dispatched through the user's signer
const signedHex = await signPsbt(psbtHex);
// 3. Finalize + broadcast
const txHex = finalizePsbt(signedHex);
const txid = await broadcastTransaction(txHex);
```
Key facts about `buildUnsignedPsbt`:
- All available UTXOs are consumed as inputs (no coin selection — keep it predictable).
- Fee uses `ceil((numInputs · 57.5 + numOutputs · 43 + 10.5) · feeRate)`.
- Change is added back to the sender's own Taproot address **only** if it's ≥ 546 sats (dust limit). Below dust, change is donated to fees and the tx has 1 output instead of 2.
- Throws `Insufficient funds` if `amount + fee > total UTXO value`.
For send-max, use `maxSendable(totalBalance, numInputs, feeRate)` — it correctly subtracts a 1-output fee.
### Signer capability detection
`useBitcoinSigner()` returns one of three states for the active login:
| Login | Capability |
|---|---|
| **nsec** | Always `'supported'` — local signing with the private key. |
| **NIP-07 extension** | Probes `window.nostr.signPsbt` (re-tries every 250 ms for up to 3 s). `'supported'`, `'unsupported'`, or `'unknown'` while the extension is still injecting. |
| **NIP-46 bunker** | `'unknown'` initially (NIP-46 has no capability-discovery RPC). Flips to `'unsupported'` for the rest of the session once a `sign_psbt` call returns a "method not found"-style error — see `reportSignerUnsupported()` and `isSignerCapabilityError()`. |
The send dialog should render an "unsupported" panel (with QR-code fallback for receiving funds) whenever `capability === 'unsupported'`, and let `'unknown'` users attempt the send. `useOnchainZap`'s `onError` handler already calls `reportSignerUnsupported(user.pubkey)` automatically, so the second send attempt with the same bunker won't re-attempt.
## Onchain Zaps (kind 8333)
A Bitcoin equivalent of NIP-57 Lightning zaps. Defined in this skill — the kind number mirrors the convention `9735 = Lightning P2P port`, `8333 = Bitcoin mainnet P2P port`.
> If the project has a `NIP.md` file, **document this kind there**. If it doesn't exist, create one with the kind 8333 schema below.
### Event Structure
```json
{
"kind": 8333,
"pubkey": "",
"content": "Great post!",
"tags": [
["i", "bitcoin:tx:"],
["p", ""],
["amount", ""],
["e", ""],
["alt", "Bitcoin zap: 25000 sats"]
]
}
```
| Tag | Required | Notes |
|---|---|---|
| `i` | Yes | NIP-73 identifier `bitcoin:tx:` (64-char lowercase hex). |
| `p` | Yes | Recipient pubkey. |
| `amount` | Yes | Sats paid to the recipient (may be capped by verifier). |
| `e` | If zapping an event | Event being zapped. |
| `a` | If zapping an addressable event (kind 30000–39999) | `::`. |
| `alt` | Yes | NIP-31 fallback. |
If neither `e` nor `a` is present, the zap targets the recipient's profile.
### Publishing — `useOnchainZap(target)`
Builds + signs + broadcasts the Bitcoin transaction, then publishes the kind 8333 event:
```tsx
import { useOnchainZap } from '@/hooks/useOnchainZap';
function ZapButton({ target }: { target: NostrEvent }) {
const { zap, isZapping, progress, canZap } = useOnchainZap(target);
if (!canZap) return null; // not logged in, self-zap, or PSBT-incapable signer
return (
);
}
```
`progress` cycles through `'building' → 'signing' → 'broadcasting' → 'publishing' → 'idle'`.
### Verification — never trust the `amount` tag
The `amount` tag is self-reported. **Always verify on-chain before counting it toward zap totals.** The skill's verifier:
1. Extracts the txid from the `i` tag.
2. Fetches the transaction from mempool.space.
3. Derives the recipient's expected Taproot address from the `p` pubkey.
4. Sums tx outputs paying that address. **Change outputs to the sender are excluded automatically** — they go to a different address.
5. Caps the displayed amount at the verified amount.
6. Rejects events where verified amount = 0, where sender = recipient (self-zap), or where the txid is malformed.
Use `useOnchainZaps(target)` for a list of verified zaps targeting an event, or `useVerifiedOnchainZap(event)` for a single event:
```tsx
import { useOnchainZaps } from '@/hooks/useOnchainZaps';
function OnchainZapList({ target }: { target: NostrEvent }) {
const { zaps, totalSats, isLoading } = useOnchainZaps(target);
if (isLoading) return ;
return
{totalSats.toLocaleString()} sats from {zaps.length} zappers
;
}
```
`useOnchainZaps` queries `{ kinds: [8333], '#e': [target.id] }` (and `#a` for addressables), dedupes by txid (preferring the earliest event for any given tx), and verifies each one via mempool.space. Cached for 60 s per (txid, recipient).
### Combining with NIP-57 totals
When showing total zaps for a post, sum verified amounts from **both** kind 9735 (Lightning) and kind 8333 (onchain) — they're complementary settlement layers, not alternatives.
## Mempool.space API Cheat Sheet
All endpoints are public and unauthenticated:
| Endpoint | Used by |
|---|---|
| `GET /address/{addr}` | `fetchAddressData` — balance + tx counts |
| `GET /address/{addr}/txs` | `fetchTransactions` — recent tx history |
| `GET /address/{addr}/utxo` | `fetchUTXOs` — for building send PSBTs |
| `GET /tx/{txid}` | `fetchTxDetail` + zap verifier — full inputs/outputs |
| `GET /fee-estimates` | `getFeeRates` — sat/vB per block target |
| `POST /tx` | `broadcastTransaction` — body is raw tx hex, returns txid |
You can swap to a self-hosted Esplora instance by changing the `MEMPOOL_API` constant in `src/lib/bitcoin.ts`.
## Security Considerations
> **The same private key controls both the user's Nostr identity and the Bitcoin funds at the derived address.** This is by design, but increases the blast radius of every common Nostr-client risk.
- **localStorage nsec is now spendable Bitcoin.** The `nostr-security` skill's XSS warnings apply doubly here. Treat `dangerouslySetInnerHTML`, unsanitized URLs, and CSS injection as on-par with stealing money. Load the `nostr-security` skill before merging anything that touches user input.
- **Extensions and bunkers never see the raw key.** Only the unsigned PSBT (no secret material) is passed to the signer. The PSBT format (BIP-174) carries `witnessUtxo` values and outputs, so a properly-implemented signer can show users what they're signing before approving.
- **Mainnet only.** This skill ships mainnet derivation and uses the public mempool.space API. Testnet/signet support would require a different `network`, a different Esplora endpoint, and is out of scope for kind 8333 verification.
- **Single-key, no HD.** Every Nostr keypair maps to exactly one Bitcoin address. There's no BIP-32 path. This is fine for a wallet tied to identity, but means the user has no way to rotate the address without losing their identity.
- **Users need a backup of their nsec before receiving funds.** Surfacing this to the user (e.g. on the wallet page) is the implementing app's responsibility.
- **Self-zap rejection.** `verifyOnchainZap` already drops events where `event.pubkey === recipient pubkey`. Don't relax this check — self-zaps are trivial to fabricate.
## Testing the Wallet
For end-to-end manual testing without spending real BTC:
1. Generate two Nostr keys (`nak generate` or any client).
2. Send a small amount of mainnet BTC (e.g. 5,000 sats) to key A's derived address.
3. Log in as key A, open the wallet page, confirm balance appears within ~30 s.
4. Use the send flow to forward sats to key B's derived address.
5. Verify the transaction shows up in B's wallet and the kind 8333 event (if zapping a post) is queryable from a relay.
Mempool.space confirms unconfirmed transactions within ~10 s of broadcast and provides a `/tx/` web view for verification.
## Building UI On Top
This skill provides only the framework. To build the user-facing surfaces:
- **Wallet page** (`/wallet`) — balance + QR code (`bitcoin:`) + tx list. Use `useBitcoinWallet`. Render the QR with the `qrcode` package (`npm install qrcode`).
- **Send dialog** — recipient input (accepts both raw `bc1…` addresses and `npub1…`), amount input with USD conversion, fee-speed selector, confirm/success steps. Use `useBitcoinSigner` to gate the form, `validateBitcoinAddress` for validation, `npubToBitcoinAddress` for npub recipients, and the PSBT pipeline shown above.
- **NIP-73 detail pages** — extend `NIP19Page.tsx` to dispatch `bitcoin:tx:` and `bitcoin:address:` identifiers to detail components powered by `useBitcoinTx` / `useBitcoinAddress`.
- **Onchain zap button** — drop `useOnchainZap` into the existing zap dialog as a Bitcoin tab, alongside Lightning. Show the QR-code BIP-21 fallback (`bitcoin:?amount=`) when capability is `'unsupported'`.
## References
- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) — shared with NIP-01 keys
- [BIP-341: Taproot](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) — defines the TapTweak and key-path spend
- [BIP-350: Bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) — `bc1p…` encoding
- [BIP-174: PSBT format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) — what `signPsbt` operates on
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — defines secp256k1 x-only keys for Nostr
- [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) — `window.nostr` extension API; `signPsbt` is the optional Bitcoin extension
- [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) — remote signers; `sign_psbt` is the optional Bitcoin command
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Lightning zaps (the kind 8333 design mirrors this)
- [NIP-73](https://github.com/nostr-protocol/nips/blob/master/73.md) — external content identifiers (`bitcoin:tx:` / `bitcoin:address:`)
- [mempool.space API docs](https://mempool.space/docs/api/rest)