SkateSpots/.agents/skills/onchain-bitcoin/SKILL.md
shakespeare.diy 06fae294b8 New project created with Shakespeare
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 19:49:07 +07:00

19 KiB
Raw Blame History

name description
onchain-bitcoin 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

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.

// 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(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>
);

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:

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

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

import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
import { satsToUSD, formatBTC } from '@/lib/bitcoin';

function WalletWidget() {
  const { bitcoinAddress, addressData, btcPrice, transactions, isLoading } = useBitcoinWallet();

  if (isLoading) return <Skeleton className="h-10 w-40" />;
  if (!addressData) return null;

  return (
    <div>
      <div>{btcPrice ? satsToUSD(addressData.totalBalance, btcPrice) : '—'}</div>
      <div>{formatBTC(addressData.totalBalance)} BTC</div>
      <code>{bitcoinAddress}</code>
    </div>
  );
}

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:

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:

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

{
  "kind": 8333,
  "pubkey": "<sender-pubkey>",
  "content": "Great post!",
  "tags": [
    ["i", "bitcoin:tx:<txid>"],
    ["p", "<recipient-pubkey>"],
    ["amount", "<sats>"],
    ["e", "<target-event-id>"],
    ["alt", "Bitcoin zap: 25000 sats"]
  ]
}
Tag Required Notes
i Yes NIP-73 identifier bitcoin:tx:<txid> (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) <kind>:<pubkey>:<d-tag>.
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:

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 (
    <button
      disabled={isZapping}
      onClick={() => zap({ amountSats: 5000, comment: 'Great post!', feeSpeed: 'halfHour' })}
    >
      {isZapping ? `${progress}…` : 'Zap 5k sats'}
    </button>
  );
}

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:

import { useOnchainZaps } from '@/hooks/useOnchainZaps';

function OnchainZapList({ target }: { target: NostrEvent }) {
  const { zaps, totalSats, isLoading } = useOnchainZaps(target);
  if (isLoading) return <Skeleton className="h-6 w-24" />;
  return <div>{totalSats.toLocaleString()} sats from {zaps.length} zappers</div>;
}

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/<txid> 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:<address>) + 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:<address>?amount=<btc>) when capability is 'unsupported'.

References