From cf6fcc353cd109500523ed290a771c7d13778406 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:04:17 -0500 Subject: [PATCH 01/25] Add Bitcoin wallet page deriving Taproot address from Nostr pubkey Derive a bc1p... Taproot address directly from the user's Nostr public key (both use secp256k1 x-only keys) and display balance via Blockstream API. Includes QR code, copy-to-clipboard, balance with pending detection, and a WALLET.md documenting the derivation algorithm. Sending is not yet implemented. --- WALLET.md | 114 ++++++++++++++++ package-lock.json | 200 ++++++++++++++++++++++++++- package.json | 4 + src/AppRouter.tsx | 2 + src/hooks/useBitcoinWallet.ts | 47 +++++++ src/lib/bitcoin.ts | 79 +++++++++++ src/lib/polyfills.ts | 9 ++ src/main.tsx | 7 +- src/pages/WalletPage.tsx | 248 ++++++++++++++++++++++++++++++++++ 9 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 WALLET.md create mode 100644 src/hooks/useBitcoinWallet.ts create mode 100644 src/lib/bitcoin.ts create mode 100644 src/pages/WalletPage.tsx diff --git a/WALLET.md b/WALLET.md new file mode 100644 index 00000000..a50641f2 --- /dev/null +++ b/WALLET.md @@ -0,0 +1,114 @@ +# 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: +``` + +### 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 + +```typescript +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: + +```typescript +import * as bitcoin from 'bitcoinjs-lib'; +import * as ecc from '@bitcoinerlab/secp256k1'; +bitcoin.initEccLib(ecc); +``` + +## Balance API + +Balance data is fetched from the public Blockstream Esplora API: + +``` +GET https://blockstream.info/api/address/{address} +``` + +Returns confirmed and mempool stats (funded/spent sums, transaction counts). The wallet page polls this endpoint every 30 seconds. + +## 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 logins do not expose the raw private key, so spending Bitcoin from those login types requires exporting the key or using a compatible wallet application. +- 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. + +## References + +- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- [BIP-341: Taproot (SegWit v1)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) +- [BIP-350: Bech32m](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) (defines secp256k1 x-only keys for Nostr) diff --git a/package-lock.json b/package-lock.json index b36c6bdf..e99ca79e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "ditto", - "version": "2.6.0", + "version": "2.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.6.0", + "version": "2.6.1", "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", "@capacitor/filesystem": "^8.1.2", @@ -93,6 +94,7 @@ "@tanstack/react-query": "^5.56.2", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", + "bitcoinjs-lib": "^7.0.1", "blurhash": "^2.0.5", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -100,6 +102,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "dompurify": "^3.3.3", + "ecpair": "^3.0.1", "embla-carousel-react": "^8.3.0", "emoji-mart": "^5.6.0", "fflate": "^0.8.2", @@ -126,6 +129,7 @@ "smol-toml": "^1.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "tiny-secp256k1": "^2.2.4", "uri-templates": "^0.2.0", "vaul": "^1.1.2", "zod": "^4.3.6" @@ -286,6 +290,42 @@ "node": ">=6.9.0" } }, + "node_modules/@bitcoinerlab/secp256k1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz", + "integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^1.7.0" + } + }, + "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@capacitor/android": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.1.0.tgz", @@ -7419,6 +7459,12 @@ "dev": true, "license": "MIT" }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -7439,6 +7485,12 @@ ], "license": "MIT" }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "license": "MIT" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -7461,6 +7513,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bip174": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz", + "integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.9", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz", + "integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^3.0.0", + "bs58check": "^4.0.0", + "uint8array-tools": "^0.0.9", + "valibot": "^1.2.0", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/blurhash": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz", @@ -7536,6 +7619,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -8314,6 +8416,29 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/ecpair": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz", + "integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.8", + "valibot": "^1.2.0", + "wif": "^5.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ecpair/node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.149", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz", @@ -13400,6 +13525,27 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tiny-secp256k1": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.4.tgz", + "integrity": "sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "0.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tiny-secp256k1/node_modules/uint8array-tools": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", + "integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -13659,6 +13805,15 @@ "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "license": "MIT" }, + "node_modules/uint8array-tools": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz", + "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -13948,6 +14103,38 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/valibot": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", + "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/varuint-bitcoin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz", + "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==", + "license": "MIT", + "dependencies": { + "uint8array-tools": "^0.0.8" + } + }, + "node_modules/varuint-bitcoin/node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vaul": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", @@ -15541,6 +15728,15 @@ "node": ">=8" } }, + "node_modules/wif": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz", + "integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==", + "license": "MIT", + "dependencies": { + "bs58check": "^4.0.0" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 007f383f..f0f36e80 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "node": ">=22" }, "dependencies": { + "@bitcoinerlab/secp256k1": "^1.2.0", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", "@capacitor/filesystem": "^8.1.2", @@ -99,6 +100,7 @@ "@tanstack/react-query": "^5.56.2", "@unhead/addons": "^2.0.10", "@unhead/react": "^2.0.10", + "bitcoinjs-lib": "^7.0.1", "blurhash": "^2.0.5", "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", @@ -106,6 +108,7 @@ "cmdk": "^1.0.0", "date-fns": "^3.6.0", "dompurify": "^3.3.3", + "ecpair": "^3.0.1", "embla-carousel-react": "^8.3.0", "emoji-mart": "^5.6.0", "fflate": "^0.8.2", @@ -132,6 +135,7 @@ "smol-toml": "^1.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "tiny-secp256k1": "^2.2.4", "uri-templates": "^0.2.0", "vaul": "^1.1.2", "zod": "^4.3.6" diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 2f9590e0..8075bd86 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -73,6 +73,7 @@ const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage }))); const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage }))); const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage }))); +const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage }))); const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage }))); const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage }))); const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage }))); @@ -255,6 +256,7 @@ export function AppRouter() { } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts new file mode 100644 index 00000000..dca35eca --- /dev/null +++ b/src/hooks/useBitcoinWallet.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { nostrPubkeyToBitcoinAddress, fetchAddressData } from '@/lib/bitcoin'; + +/** + * Hook that derives a Bitcoin Taproot address from the current user's Nostr + * pubkey and fetches the on-chain balance from the Blockstream API. + * + * Balance auto-refreshes every 30 seconds while the component is mounted. + */ +export function useBitcoinWallet() { + const { user } = useCurrentUser(); + + const bitcoinAddress = useMemo(() => { + if (!user) return ''; + return nostrPubkeyToBitcoinAddress(user.pubkey); + }, [user]); + + const { + data: addressData, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['bitcoin-balance', bitcoinAddress], + queryFn: () => fetchAddressData(bitcoinAddress), + enabled: !!bitcoinAddress, + refetchInterval: 30_000, + }); + + return { + /** The derived bc1p... Taproot address. */ + bitcoinAddress, + /** Balance and transaction data (undefined while loading). */ + addressData, + /** Whether the initial balance fetch is in progress. */ + isLoading, + /** Error from the balance query, if any. */ + error, + /** Manually trigger a balance refresh. */ + refetch, + /** The current user's hex pubkey (convenience). */ + pubkey: user?.pubkey ?? '', + }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts new file mode 100644 index 00000000..14eca44b --- /dev/null +++ b/src/lib/bitcoin.ts @@ -0,0 +1,79 @@ +import * as bitcoin from 'bitcoinjs-lib'; + +/** + * Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address. + * + * Both Nostr and Bitcoin Taproot use secp256k1 with 32-byte x-only public keys + * (Schnorr / BIP-340), so the key can be used directly as a Taproot internal + * public key with no mathematical conversion. + */ +export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { + try { + const pubkeyBuffer = Buffer.from(pubkeyHex, 'hex'); + + const { address } = bitcoin.payments.p2tr({ + internalPubkey: pubkeyBuffer, + network: bitcoin.networks.bitcoin, + }); + + return address || ''; + } catch (error) { + console.error('Error generating Bitcoin address:', error); + return ''; + } +} + +/** Balance data returned by the Blockstream API. */ +export interface AddressData { + /** Confirmed on-chain balance in satoshis. */ + balance: number; + /** Unconfirmed mempool balance in satoshis. */ + pendingBalance: number; + /** Sum of confirmed + pending balance. */ + totalBalance: number; + /** Total satoshis ever received (confirmed). */ + totalReceived: number; + /** Total satoshis ever sent (confirmed). */ + totalSent: number; + /** Confirmed transaction count. */ + txCount: number; + /** Pending (mempool) transaction count. */ + pendingTxCount: number; +} + +/** + * Fetch balance and transaction stats for a Bitcoin address from the + * Blockstream Esplora API. + */ +export async function fetchAddressData(address: string): Promise { + const response = await fetch(`https://blockstream.info/api/address/${address}`); + + if (!response.ok) { + throw new Error('Failed to fetch balance'); + } + + const data = await response.json(); + + const confirmedBalance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; + const pendingBalance = data.mempool_stats.funded_txo_sum - data.mempool_stats.spent_txo_sum; + + return { + balance: confirmedBalance, + pendingBalance, + totalBalance: confirmedBalance + pendingBalance, + totalReceived: data.chain_stats.funded_txo_sum, + totalSent: data.chain_stats.spent_txo_sum, + txCount: data.chain_stats.tx_count, + pendingTxCount: data.mempool_stats.tx_count, + }; +} + +/** Convert satoshis to a BTC string with up to 8 decimal places. */ +export function satsToBTC(sats: number): string { + return (sats / 100_000_000).toFixed(8); +} + +/** Format a satoshi amount with locale-aware thousand separators. */ +export function formatSats(sats: number): string { + return sats.toLocaleString(); +} diff --git a/src/lib/polyfills.ts b/src/lib/polyfills.ts index edb726ee..76572d63 100644 --- a/src/lib/polyfills.ts +++ b/src/lib/polyfills.ts @@ -1,3 +1,12 @@ +/** + * Polyfill for Buffer in browser environment. + * + * Many Node.js libraries like bitcoinjs-lib expect Buffer to be globally available. + * This polyfill makes the buffer package's Buffer available globally. + */ +import { Buffer } from 'buffer'; +globalThis.Buffer = Buffer; + /** * Polyfill for AbortSignal.any() * diff --git a/src/main.tsx b/src/main.tsx index 9859c771..488cdbf7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,13 @@ import { createRoot } from 'react-dom/client'; -// Import polyfills first +// Import polyfills first (Buffer must be globally available before bitcoinjs-lib) import './lib/polyfills.ts'; +// Initialize ECC library for bitcoinjs-lib (Taproot / Schnorr support) +import * as bitcoin from 'bitcoinjs-lib'; +import * as ecc from '@bitcoinerlab/secp256k1'; +bitcoin.initEccLib(ecc); + // Kick off cache hydration early so data is ready before components render. import { hydrateNip05Cache } from '@/lib/nip05Cache'; import { hydrateProfileCache } from '@/lib/profileCache'; diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx new file mode 100644 index 00000000..9e0284b4 --- /dev/null +++ b/src/pages/WalletPage.tsx @@ -0,0 +1,248 @@ +import { useState } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ArrowDownLeft, ArrowUpRight, Hash, ExternalLink } from 'lucide-react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeader } from '@/components/PageHeader'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { satsToBTC, formatSats } from '@/lib/bitcoin'; + +export function WalletPage() { + const { config } = useAppContext(); + const { user } = useCurrentUser(); + const { bitcoinAddress, addressData, isLoading, error, refetch } = useBitcoinWallet(); + + const [copiedAddress, setCopiedAddress] = useState(false); + + useSeoMeta({ + title: `Bitcoin Wallet | ${config.appName}`, + description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.', + }); + + const copyAddress = async () => { + if (!bitcoinAddress) return; + try { + await navigator.clipboard.writeText(bitcoinAddress); + setCopiedAddress(true); + setTimeout(() => setCopiedAddress(false), 2000); + } catch { + // clipboard API not available + } + }; + + return ( +
+ } /> + + {!user ? ( +
+
+ +
+
+

Your Bitcoin Wallet

+

+ Log in to see your Bitcoin Taproot address derived from your Nostr identity. +

+
+ +
+ ) : ( +
+ {/* Balance Card */} + + +
+ + + Balance + + +
+
+ + {isLoading ? ( +
+ +
+ + + +
+
+ ) : error ? ( +
+

Failed to fetch balance. Please try again.

+ +
+ ) : addressData ? ( + <> + {/* Main balance display */} +
+
+ {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} BTC +
+
+ {formatSats(addressData.totalBalance)} sats +
+ + {addressData.pendingBalance !== 0 && ( +
+ + Confirmed: {satsToBTC(addressData.balance).replace(/\.?0+$/, '')} BTC + + + + Pending: {satsToBTC(addressData.pendingBalance).replace(/\.?0+$/, '')} BTC + +
+ )} +
+ + {/* Stats grid */} +
+
+
+ + Received +
+
{formatSats(addressData.totalReceived)}
+
+
+
+ + Sent +
+
{formatSats(addressData.totalSent)}
+
+
+
+ + Txns +
+
+ {addressData.txCount} + {addressData.pendingTxCount > 0 && ( + (+{addressData.pendingTxCount}) + )} +
+
+
+ + ) : null} +
+
+ + {/* Address Card with QR */} + + + + + Your Bitcoin Address + + + + {/* QR Code */} +
+
+ +
+
+ + {/* Address text */} +
+

+ {bitcoinAddress} +

+
+ + {/* Copy button */} + + + {/* View on explorer */} + + + {/* Warning */} +

+ This is a Taproot (P2TR) address derived from your Nostr key. You need + access to your Nostr private key to spend funds sent here. +

+
+
+ + {/* How it works */} + + + How It Works + + +

+ Same cryptography.{' '} + Both Nostr and Bitcoin Taproot use secp256k1 with Schnorr signatures (BIP-340). + Your 32-byte Nostr x-only public key is byte-for-byte identical to a Taproot + internal key. +

+

+ Direct derivation.{' '} + The bc1p address + above is generated by passing your Nostr pubkey to{' '} + bitcoin.payments.p2tr(){' '} + as the internal key. No seed phrases or HD derivation paths involved. +

+

+ Caution: This is an experimental feature. + Always test with small amounts first and ensure you have secure backups of your + Nostr private key. +

+
+
+
+ )} +
+ ); +} From a75fef039d6c745ec72c15c00a94cb361059cecf Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:06:39 -0500 Subject: [PATCH 02/25] Add Bitcoin Wallet to the left sidebar --- src/lib/sidebarItems.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 29d83766..00ed5aa9 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -31,6 +31,7 @@ import { Settings, Smile, SmilePlus, + Wallet, Sparkles, TrendingUp, User, @@ -134,6 +135,13 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ requiresAuth: true, }, { id: "settings", label: "Settings", path: "/settings", icon: Settings }, + { + id: "wallet", + label: "Bitcoin Wallet", + path: "/wallet", + icon: Wallet, + requiresAuth: true, + }, { id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText }, { id: "letters", From c8d46b361148e30b80e59823b53d4f1b6747aeee Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:10:49 -0500 Subject: [PATCH 03/25] Rename sidebar label from 'Bitcoin Wallet' to 'Wallet' --- src/lib/sidebarItems.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 00ed5aa9..1e1d36fe 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -137,7 +137,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [ { id: "settings", label: "Settings", path: "/settings", icon: Settings }, { id: "wallet", - label: "Bitcoin Wallet", + label: "Wallet", path: "/wallet", icon: Wallet, requiresAuth: true, From 2c853ff02a4cfe5b26c08cd66b4dfdfa35ddafa5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:13:02 -0500 Subject: [PATCH 04/25] Rename page title and header from 'Bitcoin Wallet' to 'Wallet' --- src/pages/WalletPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 9e0284b4..e8b42e63 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -21,7 +21,7 @@ export function WalletPage() { const [copiedAddress, setCopiedAddress] = useState(false); useSeoMeta({ - title: `Bitcoin Wallet | ${config.appName}`, + title: `Wallet | ${config.appName}`, description: 'Your Bitcoin Taproot wallet derived from your Nostr identity.', }); @@ -38,7 +38,7 @@ export function WalletPage() { return (
- } /> + } /> {!user ? (
From a145f92bcba835780e7441c5dcbb8c3f663f46a0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:20:05 -0500 Subject: [PATCH 05/25] Simplify wallet page to modern crypto wallet UX Remove outer Balance card wrapper, stats grid, and How It Works section. Balance is now the hero element, centered with QR code below and a compact pill-shaped address with inline copy. Clean, minimal layout. --- src/pages/WalletPage.tsx | 254 +++++++++++---------------------------- 1 file changed, 69 insertions(+), 185 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index e8b42e63..d0c528a5 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,8 +1,7 @@ import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ArrowDownLeft, ArrowUpRight, Hash, ExternalLink } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; @@ -36,6 +35,10 @@ export function WalletPage() { } }; + const truncatedAddress = bitcoinAddress + ? `${bitcoinAddress.slice(0, 12)}...${bitcoinAddress.slice(-8)}` + : ''; + return (
} /> @@ -54,193 +57,74 @@ export function WalletPage() {
) : ( -
- {/* Balance Card */} - - -
- - - Balance - - -
-
- - {isLoading ? ( -
- -
- - - -
-
- ) : error ? ( -
-

Failed to fetch balance. Please try again.

- -
- ) : addressData ? ( - <> - {/* Main balance display */} -
-
- {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} BTC -
-
- {formatSats(addressData.totalBalance)} sats -
- - {addressData.pendingBalance !== 0 && ( -
- - Confirmed: {satsToBTC(addressData.balance).replace(/\.?0+$/, '')} BTC - - - - Pending: {satsToBTC(addressData.pendingBalance).replace(/\.?0+$/, '')} BTC - -
- )} -
- - {/* Stats grid */} -
-
-
- - Received -
-
{formatSats(addressData.totalReceived)}
-
-
-
- - Sent -
-
{formatSats(addressData.totalSent)}
-
-
-
- - Txns -
-
- {addressData.txCount} - {addressData.pendingTxCount > 0 && ( - (+{addressData.pendingTxCount}) - )} -
-
-
- - ) : null} -
-
- - {/* Address Card with QR */} - - - - - Your Bitcoin Address - - - - {/* QR Code */} -
-
- -
-
- - {/* Address text */} -
-

- {bitcoinAddress} -

-
- - {/* Copy button */} - - - {/* View on explorer */} - + + {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} + + BTC + + + {formatSats(addressData.totalBalance)} sats + - {/* Warning */} -

- This is a Taproot (P2TR) address derived from your Nostr key. You need - access to your Nostr private key to spend funds sent here. -

-
-
+ {addressData.pendingBalance !== 0 && ( + + + {formatSats(addressData.pendingBalance)} sats pending + + )} +
+ ) : null} - {/* How it works */} - - - How It Works - - -

- Same cryptography.{' '} - Both Nostr and Bitcoin Taproot use secp256k1 with Schnorr signatures (BIP-340). - Your 32-byte Nostr x-only public key is byte-for-byte identical to a Taproot - internal key. -

-

- Direct derivation.{' '} - The bc1p address - above is generated by passing your Nostr pubkey to{' '} - bitcoin.payments.p2tr(){' '} - as the internal key. No seed phrases or HD derivation paths involved. -

-

- Caution: This is an experimental feature. - Always test with small amounts first and ensure you have secure backups of your - Nostr private key. -

-
-
+ {/* QR Code */} +
+ +
+ + {/* Address + copy */} + + + {/* Explorer link */} + + + View on explorer + )}
From 43917436954ffca1ed4f883ade1f4179c365ba40 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:28:21 -0500 Subject: [PATCH 06/25] Show wallet balance in USD with BTC underneath Fetch BTC/USD price from CoinGecko (refreshes every 60s). Display USD as the hero balance, BTC amount as the secondary line. Remove sats display entirely. Pending amounts also shown in USD. --- src/hooks/useBitcoinWallet.ts | 12 +++++++++++- src/lib/bitcoin.ts | 25 +++++++++++++++++++++++++ src/pages/WalletPage.tsx | 25 +++++++++++-------------- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts index dca35eca..a4bf1562 100644 --- a/src/hooks/useBitcoinWallet.ts +++ b/src/hooks/useBitcoinWallet.ts @@ -2,13 +2,14 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { nostrPubkeyToBitcoinAddress, fetchAddressData } from '@/lib/bitcoin'; +import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice } from '@/lib/bitcoin'; /** * Hook that derives a Bitcoin Taproot address from the current user's Nostr * pubkey and fetches the on-chain balance from the Blockstream API. * * Balance auto-refreshes every 30 seconds while the component is mounted. + * BTC/USD price refreshes every 60 seconds. */ export function useBitcoinWallet() { const { user } = useCurrentUser(); @@ -30,11 +31,20 @@ export function useBitcoinWallet() { refetchInterval: 30_000, }); + const { data: btcPrice } = useQuery({ + queryKey: ['btc-price'], + queryFn: fetchBtcPrice, + refetchInterval: 60_000, + staleTime: 30_000, + }); + return { /** The derived bc1p... Taproot address. */ bitcoinAddress, /** Balance and transaction data (undefined while loading). */ addressData, + /** Current BTC price in USD. */ + btcPrice, /** Whether the initial balance fetch is in progress. */ isLoading, /** Error from the balance query, if any. */ diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 14eca44b..7c45b679 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -77,3 +77,28 @@ export function satsToBTC(sats: number): string { export function formatSats(sats: number): string { return sats.toLocaleString(); } + +/** Fetch the current BTC price in USD from the CoinGecko API. */ +export async function fetchBtcPrice(): Promise { + const response = await fetch( + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd', + ); + + if (!response.ok) { + throw new Error('Failed to fetch BTC price'); + } + + const data = await response.json(); + return data.bitcoin.usd; +} + +/** Convert satoshis to USD given a BTC price. */ +export function satsToUSD(sats: number, btcPrice: number): string { + const btc = sats / 100_000_000; + return (btc * btcPrice).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index d0c528a5..ea2deb41 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -10,12 +10,12 @@ import { QRCodeCanvas } from '@/components/ui/qrcode'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; -import { satsToBTC, formatSats } from '@/lib/bitcoin'; +import { satsToBTC, satsToUSD } from '@/lib/bitcoin'; export function WalletPage() { const { config } = useAppContext(); const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, isLoading, error, refetch } = useBitcoinWallet(); + const { bitcoinAddress, addressData, btcPrice, isLoading, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); @@ -74,24 +74,21 @@ export function WalletPage() { ) : addressData ? (
- + + {btcPrice + ? satsToUSD(addressData.totalBalance, btcPrice) + : '---'} + - {formatSats(addressData.totalBalance)} sats + {satsToBTC(addressData.totalBalance).replace(/\.?0+$/, '')} BTC {addressData.pendingBalance !== 0 && ( - {formatSats(addressData.pendingBalance)} sats pending + {btcPrice + ? `${satsToUSD(addressData.pendingBalance, btcPrice)} pending` + : 'pending'} )}
From 5ce2d3d8b4ef6284f830d8c0442fe06b5329ce47 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:34:16 -0500 Subject: [PATCH 07/25] Add transaction history to wallet page Fetch transactions from Blockstream Esplora API, compute net amount per tx relative to the user's address, and display as a list below the QR code. Each row shows receive/send direction, relative date, USD amount (with BTC underneath), and links to the block explorer. Includes loading skeletons and empty state. --- src/hooks/useBitcoinWallet.ts | 16 +++++- src/lib/bitcoin.ts | 60 ++++++++++++++++++++ src/pages/WalletPage.tsx | 100 +++++++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/hooks/useBitcoinWallet.ts b/src/hooks/useBitcoinWallet.ts index a4bf1562..2d7bf0b1 100644 --- a/src/hooks/useBitcoinWallet.ts +++ b/src/hooks/useBitcoinWallet.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice } from '@/lib/bitcoin'; +import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin'; /** * Hook that derives a Bitcoin Taproot address from the current user's Nostr @@ -38,6 +38,16 @@ export function useBitcoinWallet() { staleTime: 30_000, }); + const { + data: transactions, + isLoading: isLoadingTxs, + } = useQuery({ + queryKey: ['bitcoin-txs', bitcoinAddress], + queryFn: () => fetchTransactions(bitcoinAddress), + enabled: !!bitcoinAddress, + refetchInterval: 30_000, + }); + return { /** The derived bc1p... Taproot address. */ bitcoinAddress, @@ -45,8 +55,12 @@ export function useBitcoinWallet() { addressData, /** Current BTC price in USD. */ btcPrice, + /** Transaction history for the address. */ + transactions, /** Whether the initial balance fetch is in progress. */ isLoading, + /** Whether transactions are still loading. */ + isLoadingTxs, /** Error from the balance query, if any. */ error, /** Manually trigger a balance refresh. */ diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 7c45b679..1f2d70cf 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -102,3 +102,63 @@ export function satsToUSD(sats: number, btcPrice: number): string { maximumFractionDigits: 2, }); } + +/** A simplified transaction relevant to a specific address. */ +export interface Transaction { + /** Transaction ID (hex). */ + txid: string; + /** Net satoshi change for the address (positive = received, negative = sent). */ + amount: number; + /** Whether this is a receive or send relative to the address. */ + type: 'receive' | 'send'; + /** Whether the transaction is confirmed. */ + confirmed: boolean; + /** Unix timestamp of the block (undefined if unconfirmed). */ + timestamp?: number; +} + +/** + * Fetch transactions for a Bitcoin address from the Blockstream Esplora API. + * Returns simplified transactions with net amount relative to the address. + */ +export async function fetchTransactions(address: string): Promise { + const response = await fetch(`https://blockstream.info/api/address/${address}/txs`); + + if (!response.ok) { + throw new Error('Failed to fetch transactions'); + } + + const txs = await response.json(); + + return txs.map((tx: Record) => { + const vin = tx.vin as Array<{ prevout: { scriptpubkey_address?: string; value: number } | null }>; + const vout = tx.vout as Array<{ scriptpubkey_address?: string; value: number }>; + const status = tx.status as { confirmed: boolean; block_time?: number }; + + // Sum sats flowing out of this address (inputs we owned) + const totalIn = vin.reduce((sum, input) => { + if (input.prevout?.scriptpubkey_address === address) { + return sum + input.prevout.value; + } + return sum; + }, 0); + + // Sum sats flowing into this address (outputs we own) + const totalOut = vout.reduce((sum, output) => { + if (output.scriptpubkey_address === address) { + return sum + output.value; + } + return sum; + }, 0); + + const net = totalOut - totalIn; + + return { + txid: tx.txid as string, + amount: Math.abs(net), + type: net >= 0 ? 'receive' : 'send', + confirmed: status.confirmed, + timestamp: status.block_time, + } satisfies Transaction; + }); +} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index ea2deb41..c1cbbcf6 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -11,11 +12,12 @@ import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; import { satsToBTC, satsToUSD } from '@/lib/bitcoin'; +import type { Transaction } from '@/lib/bitcoin'; export function WalletPage() { const { config } = useAppContext(); const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, btcPrice, isLoading, error, refetch } = useBitcoinWallet(); + const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, isLoadingTxs, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); @@ -122,8 +124,102 @@ export function WalletPage() { View on explorer + + {/* Transactions */} + {isLoadingTxs ? ( +
+ + {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
+ +
+ ))} +
+ ) : transactions && transactions.length > 0 ? ( +
+ +
+ {transactions.map((tx) => ( + + ))} +
+
+ ) : transactions && transactions.length === 0 ? ( +
+ +

+ No transactions yet +

+
+ ) : null} )} ); } + +/** Format a unix timestamp as a relative or absolute date. */ +function formatTxDate(timestamp?: number): string { + if (!timestamp) return 'Pending'; + + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** Single transaction row. */ +function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { + const isReceive = tx.type === 'receive'; + + return ( + +
+
+ {isReceive + ? + : } +
+
+

{isReceive ? 'Received' : 'Sent'}

+

{formatTxDate(tx.timestamp)}

+
+
+
+

+ {isReceive ? '+' : '-'} + {btcPrice + ? satsToUSD(tx.amount, btcPrice) + : `${satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC`} +

+

+ {satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC +

+
+
+ ); +} From 4abc45a8491123f15ad014f97fe20031636bfa7c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:43:56 -0500 Subject: [PATCH 08/25] Replace explorer link with collapsible Transactions toggle Transactions button with chevron replaces the 'View on explorer' link. Clicking toggles the tx list open/closed with a smooth accordion slide using CSS grid-template-rows animation. Chevron rotates on open. --- src/pages/WalletPage.tsx | 83 ++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index c1cbbcf6..65199b6e 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,9 +1,8 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ExternalLink, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; @@ -20,6 +19,7 @@ export function WalletPage() { const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, isLoadingTxs, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); + const [txOpen, setTxOpen] = useState(false); useSeoMeta({ title: `Wallet | ${config.appName}`, @@ -114,57 +114,66 @@ export function WalletPage() { )} - {/* Explorer link */} - setTxOpen((o) => !o)} + className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer" > - - View on explorer - + Transactions + + - {/* Transactions */} - {isLoadingTxs ? ( -
- - {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
- - + {/* Transactions accordion */} + + {isLoadingTxs ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
- -
- ))} -
- ) : transactions && transactions.length > 0 ? ( -
- -
+ ))} +
+ ) : transactions && transactions.length > 0 ? ( +
{transactions.map((tx) => ( ))}
-
- ) : transactions && transactions.length === 0 ? ( -
- + ) : (

No transactions yet

-
- ) : null} + )} +
)} ); } +/** Accordion wrapper using grid-template-rows for smooth height animation. */ +function TxAccordion({ open, children }: { open: boolean; children: React.ReactNode }) { + const contentRef = useRef(null); + + return ( +
+
+ {children} +
+
+ ); +} + /** Format a unix timestamp as a relative or absolute date. */ function formatTxDate(timestamp?: number): string { if (!timestamp) return 'Pending'; From 995088842af3d30a8d18ab7eb96f0e3428913651 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:49:52 -0500 Subject: [PATCH 09/25] Hide transactions button and list when there are no transactions --- src/pages/WalletPage.tsx | 58 ++++++++++++++-------------------------- 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index 65199b6e..bd5e89a6 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -16,7 +16,7 @@ import type { Transaction } from '@/lib/bitcoin'; export function WalletPage() { const { config } = useAppContext(); const { user } = useCurrentUser(); - const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, isLoadingTxs, error, refetch } = useBitcoinWallet(); + const { bitcoinAddress, addressData, btcPrice, transactions, isLoading, error, refetch } = useBitcoinWallet(); const [copiedAddress, setCopiedAddress] = useState(false); const [txOpen, setTxOpen] = useState(false); @@ -114,44 +114,26 @@ export function WalletPage() { )} - {/* Transactions toggle */} - + {/* Transactions */} + {transactions && transactions.length > 0 && ( + <> + - {/* Transactions accordion */} - - {isLoadingTxs ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- -
- - -
-
- -
- ))} -
- ) : transactions && transactions.length > 0 ? ( -
- {transactions.map((tx) => ( - - ))} -
- ) : ( -

- No transactions yet -

- )} -
+ +
+ {transactions.map((tx) => ( + + ))} +
+
+ + )}
)} From 773592f9ddb179fc408644fea68bb5807f492cbb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 19:57:28 -0500 Subject: [PATCH 10/25] Make block explorer URLs configurable via AppConfig URI templates Add blockExplorerAddress and blockExplorerTx fields as RFC 6570 URI templates with {address} and {txid} variables respectively. Default to mempool.space instead of blockstream.info. Wallet page uses UriTemplate to fill the configured templates. --- src/App.tsx | 2 ++ src/contexts/AppContext.ts | 4 ++++ src/lib/schemas.ts | 2 ++ src/pages/WalletPage.tsx | 8 +++++--- src/test/TestApp.tsx | 2 ++ 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1ef943aa..0299ec05 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,6 +149,8 @@ const hardcodedConfig: AppConfig = { plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "", savedFeeds: [], imageQuality: 'compressed', + blockExplorerAddress: 'https://mempool.space/address/{address}', + blockExplorerTx: 'https://mempool.space/tx/{txid}', }; /** diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 492244ae..a7eca7dd 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -241,6 +241,10 @@ export interface AppConfig { savedFeeds: SavedFeed[]; /** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */ imageQuality: 'compressed' | 'original'; + /** Block explorer URI template for address pages. Supports RFC 6570 variable: {address}. */ + blockExplorerAddress: string; + /** Block explorer URI template for transaction pages. Supports RFC 6570 variable: {txid}. */ + blockExplorerTx: string; } export interface AppContextType { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index ee034e12..39a2ad5f 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -245,6 +245,8 @@ export const AppConfigSchema = z.object({ }) ).optional().default([]), imageQuality: z.enum(['compressed', 'original']), + blockExplorerAddress: z.string(), + blockExplorerTx: z.string(), }); // ─── DittoConfigSchema (build-time ditto.json) ─────────────────────── diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index bd5e89a6..a60d5930 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; +import UriTemplate from 'uri-templates'; import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -128,7 +129,7 @@ export function WalletPage() {
{transactions.map((tx) => ( - + ))}
@@ -173,12 +174,13 @@ function formatTxDate(timestamp?: number): string { } /** Single transaction row. */ -function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { +function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: number; txUrlTemplate: string }) { const isReceive = tx.type === 'receive'; + const txUrl = UriTemplate(txUrlTemplate).fill({ txid: tx.txid }); return ( Date: Mon, 6 Apr 2026 20:58:17 -0500 Subject: [PATCH 11/25] Add NIP-73 Bitcoin transaction and address detail pages Integrate Bitcoin content into the /i/* external content system using NIP-73 identifiers (bitcoin:tx:{txid} and bitcoin:address:{address}). - Add bitcoin-tx and bitcoin-address types to ExternalContent parser - Create BitcoinTxHeader with mempool.space-style inputs/outputs flow view - Create BitcoinAddressHeader with balance, stats, and recent transactions - Add useBitcoinTx and useBitcoinAddress hooks (mempool.space Esplora API) - Switch all Bitcoin API calls from blockstream.info to mempool.space - Update WalletPage to link transactions to /i/bitcoin:tx:{txid} pages - Remove unused blockExplorerAddress/blockExplorerTx config fields - Add compact Bitcoin previews for embedded note contexts --- WALLET.md | 23 +- src/App.tsx | 2 - src/components/BitcoinContentHeader.tsx | 567 +++++++++++++++++++++++ src/components/ExternalContentHeader.tsx | 5 + src/contexts/AppContext.ts | 4 - src/hooks/useBitcoinAddress.ts | 25 + src/hooks/useBitcoinTx.ts | 25 + src/lib/bitcoin.ts | 159 ++++++- src/lib/externalContent.ts | 24 + src/lib/schemas.ts | 2 - src/pages/ExternalContentPage.tsx | 3 + src/pages/WalletPage.tsx | 15 +- src/test/TestApp.tsx | 2 - 13 files changed, 826 insertions(+), 30 deletions(-) create mode 100644 src/components/BitcoinContentHeader.tsx create mode 100644 src/hooks/useBitcoinAddress.ts create mode 100644 src/hooks/useBitcoinTx.ts diff --git a/WALLET.md b/WALLET.md index a50641f2..0113ee7b 100644 --- a/WALLET.md +++ b/WALLET.md @@ -89,15 +89,26 @@ import * as ecc from '@bitcoinerlab/secp256k1'; bitcoin.initEccLib(ecc); ``` -## Balance API +## Balance & Transaction APIs -Balance data is fetched from the public Blockstream Esplora API: +All Bitcoin data is fetched from the public [mempool.space](https://mempool.space) Esplora-compatible API: -``` -GET https://blockstream.info/api/address/{address} -``` +| 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) | -Returns confirmed and mempool stats (funded/spent sums, transaction counts). The wallet page polls this endpoint every 30 seconds. +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](https://github.com/nostr-protocol/nips/blob/master/73.md) 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. ## Security Considerations diff --git a/src/App.tsx b/src/App.tsx index 0299ec05..1ef943aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,8 +149,6 @@ const hardcodedConfig: AppConfig = { plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "", savedFeeds: [], imageQuality: 'compressed', - blockExplorerAddress: 'https://mempool.space/address/{address}', - blockExplorerTx: 'https://mempool.space/tx/{txid}', }; /** diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx new file mode 100644 index 00000000..790b54b5 --- /dev/null +++ b/src/components/BitcoinContentHeader.tsx @@ -0,0 +1,567 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + ArrowDownLeft, + ArrowRight, + ArrowUpRight, + Bitcoin, + Check, + Clock, + Copy, + ExternalLink, + Hash, + Layers, + RefreshCw, + Weight, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useBitcoinTx } from '@/hooks/useBitcoinTx'; +import { useBitcoinAddress } from '@/hooks/useBitcoinAddress'; +import { satsToBTC, satsToUSD, formatSats } from '@/lib/bitcoin'; +import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function truncateMiddle(str: string, startLen = 8, endLen = 8): string { + if (str.length <= startLen + endLen + 3) return str; + return `${str.slice(0, startLen)}...${str.slice(-endLen)}`; +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard not available + } + }; + + return ( + + ); +} + +/** Format a unix timestamp as a readable date string. */ +function formatBlockTime(timestamp: number): string { + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); +} + +/** Format BTC amount, stripping trailing zeros. */ +function formatBTC(sats: number): string { + return satsToBTC(sats).replace(/\.?0+$/, ''); +} + +/** Format a large number with locale separators. */ +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +// --------------------------------------------------------------------------- +// Bitcoin Transaction Header +// --------------------------------------------------------------------------- + +export function BitcoinTxHeader({ txid }: { txid: string }) { + const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid); + + if (isLoading) return ; + + if (error || !tx) { + return ( +
+ +

Failed to load transaction

+

{txid}

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ {tx.confirmed ? : } +
+
+

+ {tx.confirmed ? 'Confirmed' : 'Unconfirmed'} +

+ {tx.blockTime && ( +

{formatBlockTime(tx.blockTime)}

+ )} +
+
+ + {/* Transaction ID */} +
+

Transaction ID

+
+

{tx.txid}

+ +
+
+ + {/* Stats grid */} +
+ {tx.confirmed && tx.blockHeight !== undefined && ( + } label="Block" value={formatNumber(tx.blockHeight)} /> + )} + } label="Size" value={`${formatNumber(tx.weight / 4)} vB`} /> + } + label="Fee" + value={`${formatSats(tx.fee)} sat`} + subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`} + /> + } + label="Amount" + value={`${formatBTC(tx.totalOutput)} BTC`} + subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined} + /> +
+
+ + {/* Inputs → Outputs flow */} +
+ +
+ + {/* Footer: link to mempool.space */} +
+
+ ); +} + +function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) { + return ( +
+
+ {icon} + {label} +
+

{value}

+ {subtitle &&

{subtitle}

} +
+ ); +} + +/** Inputs → Outputs visualization, mempool.space-style. */ +function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) { + return ( +
+
+ {tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''} + + {tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''} +
+ +
+ {/* Inputs */} +
+ {tx.inputs.slice(0, 10).map((input, i) => ( + + ))} + {tx.inputs.length > 10 && ( +

+ +{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''} +

+ )} +
+ + {/* Outputs */} +
+ {tx.outputs.slice(0, 10).map((output, i) => ( + + ))} + {tx.outputs.length > 10 && ( +

+ +{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''} +

+ )} +
+
+
+ ); +} + +function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) { + if (input.isCoinbase) { + return ( +
+
+ Coinbase + {formatBTC(input.value)} BTC +
+
+ ); + } + + return ( +
+
+ {input.address ? ( + + {truncateMiddle(input.address, 10, 6)} + + ) : ( + Unknown + )} + {formatBTC(input.value)} BTC +
+ {btcPrice !== undefined && ( +

{satsToUSD(input.value, btcPrice)}

+ )} +
+ ); +} + +function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) { + const isOpReturn = output.scriptpubkeyType === 'op_return'; + + if (isOpReturn) { + return ( +
+ OP_RETURN +
+ ); + } + + return ( +
+
+ {output.address ? ( + + {truncateMiddle(output.address, 10, 6)} + + ) : ( + Unknown + )} + {formatBTC(output.value)} BTC +
+ {btcPrice !== undefined && ( +

{satsToUSD(output.value, btcPrice)}

+ )} +
+ ); +} + +function TxSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Bitcoin Address Header +// --------------------------------------------------------------------------- + +export function BitcoinAddressHeader({ address }: { address: string }) { + const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address); + + if (isLoading) return ; + + if (error || !addressDetail) { + return ( +
+ +

Failed to load address

+

{address}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Bitcoin Address

+

+ {addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''} +

+
+
+ + {/* Address */} +
+

Address

+
+

{address}

+ +
+
+ + {/* Balance hero */} +
+

Balance

+

+ {btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`} +

+

+ {formatBTC(addressDetail.totalBalance)} BTC +

+ {addressDetail.pendingBalance !== 0 && ( +

+ + {btcPrice + ? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending` + : `${formatBTC(addressDetail.pendingBalance)} BTC pending`} +

+ )} +
+ + {/* Stats grid */} +
+ } + label="Total Received" + value={`${formatBTC(addressDetail.totalReceived)} BTC`} + subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined} + /> + } + label="Total Sent" + value={`${formatBTC(addressDetail.totalSent)} BTC`} + subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined} + /> +
+
+ + {/* Recent Transactions */} + {addressDetail.recentTxs.length > 0 && ( +
+
+

+ Recent Transactions +

+
+
+ {addressDetail.recentTxs.slice(0, 10).map((tx) => ( + + ))} +
+ {addressDetail.recentTxs.length > 10 && ( +
+

+ {addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''} +

+
+ )} +
+ )} + + {/* Footer: link to mempool.space */} + +
+ ); +} + +function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) { + const isReceive = tx.type === 'receive'; + + return ( + +
+
+ {isReceive ? : } +
+
+

{isReceive ? 'Received' : 'Sent'}

+

{truncateMiddle(tx.txid, 8, 8)}

+
+
+
+

+ {isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC +

+ {btcPrice && ( +

+ {satsToUSD(tx.amount, btcPrice)} +

+ )} +
+ + ); +} + +function AddressSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+ + + +
+
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Compact previews (used in NoteCard embeds, etc.) +// --------------------------------------------------------------------------- + +/** Compact preview for a Bitcoin transaction (used in ExternalContentPreview). */ +export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) { + return ( + +
+ +
+
+
+ + Bitcoin Transaction +
+

{truncateMiddle(txid, 12, 8)}

+
+ + + ); +} + +/** Compact preview for a Bitcoin address (used in ExternalContentPreview). */ +export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) { + return ( + +
+ +
+
+
+ + Bitcoin Address +
+

{truncateMiddle(address, 12, 8)}

+
+ + + ); +} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index a22a70c6..386bd814 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -11,6 +11,7 @@ import { LinkEmbed } from '@/components/LinkEmbed'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; +import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader'; import { extractYouTubeId, extractWikipediaTitle, extractBlueskyPost } from '@/lib/linkEmbed'; import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; import { shareOrCopy } from '@/lib/share'; @@ -746,6 +747,10 @@ export function ExternalContentPreview({ identifier }: { identifier: string }) { return ; case 'iso3166': return ; + case 'bitcoin-tx': + return ; + case 'bitcoin-address': + return ; default: return ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index a7eca7dd..492244ae 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -241,10 +241,6 @@ export interface AppConfig { savedFeeds: SavedFeed[]; /** Image upload quality: "compressed" resizes/optimizes, "original" uploads as-is. Default: "compressed". */ imageQuality: 'compressed' | 'original'; - /** Block explorer URI template for address pages. Supports RFC 6570 variable: {address}. */ - blockExplorerAddress: string; - /** Block explorer URI template for transaction pages. Supports RFC 6570 variable: {txid}. */ - blockExplorerTx: string; } export interface AppContextType { diff --git a/src/hooks/useBitcoinAddress.ts b/src/hooks/useBitcoinAddress.ts new file mode 100644 index 00000000..56830f40 --- /dev/null +++ b/src/hooks/useBitcoinAddress.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchAddressDetail, fetchBtcPrice } from '@/lib/bitcoin'; + +/** + * Fetch full address details (balance + recent txs) via the mempool.space API. + * Also fetches the current BTC/USD price for display. + */ +export function useBitcoinAddress(address: string) { + const { data: addressDetail, isLoading, error, refetch } = useQuery({ + queryKey: ['bitcoin-address-detail', address], + queryFn: () => fetchAddressDetail(address), + enabled: !!address, + refetchInterval: 30_000, + }); + + const { data: btcPrice } = useQuery({ + queryKey: ['btc-price'], + queryFn: fetchBtcPrice, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + return { addressDetail, btcPrice, isLoading, error, refetch }; +} diff --git a/src/hooks/useBitcoinTx.ts b/src/hooks/useBitcoinTx.ts new file mode 100644 index 00000000..6063e423 --- /dev/null +++ b/src/hooks/useBitcoinTx.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchTxDetail, fetchBtcPrice } from '@/lib/bitcoin'; + +/** + * Fetch full transaction details for a Bitcoin txid via the mempool.space API. + * Also fetches the current BTC/USD price for display. + */ +export function useBitcoinTx(txid: string) { + const { data: tx, isLoading, error } = useQuery({ + queryKey: ['bitcoin-tx-detail', txid], + queryFn: () => fetchTxDetail(txid), + enabled: !!txid, + staleTime: 60_000, + }); + + const { data: btcPrice } = useQuery({ + queryKey: ['btc-price'], + queryFn: fetchBtcPrice, + refetchInterval: 60_000, + staleTime: 30_000, + }); + + return { tx, btcPrice, isLoading, error }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 1f2d70cf..c24aa2a6 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -1,5 +1,12 @@ import * as bitcoin from 'bitcoinjs-lib'; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Base URL for the mempool.space Esplora-compatible REST API. */ +const MEMPOOL_API = 'https://mempool.space/api'; + /** * Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address. * @@ -23,7 +30,11 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { } } -/** Balance data returned by the Blockstream API. */ +// --------------------------------------------------------------------------- +// Balance / Address data (wallet page) +// --------------------------------------------------------------------------- + +/** Balance data returned by the Esplora API. */ export interface AddressData { /** Confirmed on-chain balance in satoshis. */ balance: number; @@ -43,10 +54,10 @@ export interface AddressData { /** * Fetch balance and transaction stats for a Bitcoin address from the - * Blockstream Esplora API. + * mempool.space Esplora API. */ export async function fetchAddressData(address: string): Promise { - const response = await fetch(`https://blockstream.info/api/address/${address}`); + const response = await fetch(`${MEMPOOL_API}/address/${address}`); if (!response.ok) { throw new Error('Failed to fetch balance'); @@ -68,6 +79,10 @@ export async function fetchAddressData(address: string): Promise { }; } +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + /** Convert satoshis to a BTC string with up to 8 decimal places. */ export function satsToBTC(sats: number): string { return (sats / 100_000_000).toFixed(8); @@ -103,6 +118,10 @@ export function satsToUSD(sats: number, btcPrice: number): string { }); } +// --------------------------------------------------------------------------- +// Wallet-page transaction list (simplified per-address view) +// --------------------------------------------------------------------------- + /** A simplified transaction relevant to a specific address. */ export interface Transaction { /** Transaction ID (hex). */ @@ -118,11 +137,11 @@ export interface Transaction { } /** - * Fetch transactions for a Bitcoin address from the Blockstream Esplora API. + * Fetch transactions for a Bitcoin address from the mempool.space Esplora API. * Returns simplified transactions with net amount relative to the address. */ export async function fetchTransactions(address: string): Promise { - const response = await fetch(`https://blockstream.info/api/address/${address}/txs`); + const response = await fetch(`${MEMPOOL_API}/address/${address}/txs`); if (!response.ok) { throw new Error('Failed to fetch transactions'); @@ -162,3 +181,133 @@ export async function fetchTransactions(address: string): Promise } satisfies Transaction; }); } + +// --------------------------------------------------------------------------- +// Full transaction detail (NIP-73 /i/bitcoin:tx:... page) +// --------------------------------------------------------------------------- + +/** A single input in a full transaction. */ +export interface TxInput { + txid: string; + vout: number; + address?: string; + value: number; + isCoinbase: boolean; +} + +/** A single output in a full transaction. */ +export interface TxOutput { + address?: string; + value: number; + scriptpubkeyType: string; + /** True if the output has been spent. */ + spent: boolean; +} + +/** Full transaction detail returned by the Esplora API. */ +export interface TxDetail { + txid: string; + version: number; + locktime: number; + size: number; + weight: number; + fee: number; + confirmed: boolean; + blockHeight?: number; + blockHash?: string; + blockTime?: number; + inputs: TxInput[]; + outputs: TxOutput[]; + /** Total value of all inputs (sats). */ + totalInput: number; + /** Total value of all outputs (sats). */ + totalOutput: number; +} + +/** Fetch full transaction details from mempool.space. */ +export async function fetchTxDetail(txid: string): Promise { + const response = await fetch(`${MEMPOOL_API}/tx/${txid}`); + if (!response.ok) throw new Error('Failed to fetch transaction'); + + const tx = await response.json(); + + const vin = tx.vin as Array<{ + txid: string; + vout: number; + prevout: { scriptpubkey_address?: string; value: number } | null; + is_coinbase: boolean; + }>; + const vout = tx.vout as Array<{ + scriptpubkey_address?: string; + value: number; + scriptpubkey_type: string; + }>; + const status = tx.status as { confirmed: boolean; block_height?: number; block_hash?: string; block_time?: number }; + + const inputs: TxInput[] = vin.map((input) => ({ + txid: input.txid, + vout: input.vout, + address: input.prevout?.scriptpubkey_address, + value: input.prevout?.value ?? 0, + isCoinbase: input.is_coinbase, + })); + + const outputs: TxOutput[] = vout.map((output) => ({ + address: output.scriptpubkey_address, + value: output.value, + scriptpubkeyType: output.scriptpubkey_type, + spent: false, // Esplora /tx endpoint doesn't include spending info + })); + + const totalInput = inputs.reduce((sum, i) => sum + i.value, 0); + const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0); + + return { + txid: tx.txid as string, + version: tx.version as number, + locktime: tx.locktime as number, + size: tx.size as number, + weight: tx.weight as number, + fee: tx.fee as number, + confirmed: status.confirmed, + blockHeight: status.block_height, + blockHash: status.block_hash, + blockTime: status.block_time, + inputs, + outputs, + totalInput, + totalOutput, + }; +} + +// --------------------------------------------------------------------------- +// Full address detail (NIP-73 /i/bitcoin:address:... page) +// --------------------------------------------------------------------------- + +/** Full address detail combining balance stats + recent transactions. */ +export interface AddressDetail { + address: string; + balance: number; + pendingBalance: number; + totalBalance: number; + totalReceived: number; + totalSent: number; + txCount: number; + pendingTxCount: number; + /** Most recent transactions (up to 25). */ + recentTxs: Transaction[]; +} + +/** Fetch full address details (balance + recent txs) from mempool.space. */ +export async function fetchAddressDetail(address: string): Promise { + const [addrData, txs] = await Promise.all([ + fetchAddressData(address), + fetchTransactions(address), + ]); + + return { + address, + ...addrData, + recentTxs: txs.slice(0, 25), + }; +} diff --git a/src/lib/externalContent.ts b/src/lib/externalContent.ts index b01db600..17594494 100644 --- a/src/lib/externalContent.ts +++ b/src/lib/externalContent.ts @@ -10,6 +10,8 @@ export type ExternalContent = | { type: 'url'; value: string } | { type: 'isbn'; value: string } | { type: 'iso3166'; value: string; code: string } + | { type: 'bitcoin-tx'; value: string; txid: string } + | { type: 'bitcoin-address'; value: string; address: string } | { type: 'unknown'; value: string }; /** Parse a URI string into a typed external content object. */ @@ -21,6 +23,16 @@ export function parseExternalUri(uri: string): ExternalContent { const code = uri.slice('iso3166:'.length); return { type: 'iso3166', value: uri, code }; } + // NIP-73 Bitcoin transaction: bitcoin:tx: + const btcTxMatch = uri.match(/^bitcoin:tx:([0-9a-f]{64})$/i); + if (btcTxMatch) { + return { type: 'bitcoin-tx', value: uri, txid: btcTxMatch[1].toLowerCase() }; + } + // NIP-73 Bitcoin address: bitcoin:address:
+ const btcAddrMatch = uri.match(/^bitcoin:address:(.+)$/); + if (btcAddrMatch) { + return { type: 'bitcoin-address', value: uri, address: btcAddrMatch[1] }; + } if (uri.startsWith('http://') || uri.startsWith('https://')) { return { type: 'url', value: uri }; } @@ -89,6 +101,10 @@ export function headerLabel(content: ExternalContent): string { const info = getCountryInfo(content.code); return info?.subdivisionName ?? info?.name ?? 'Country'; } + case 'bitcoin-tx': + return 'Bitcoin Transaction'; + case 'bitcoin-address': + return 'Bitcoin Address'; default: return 'External Content'; } @@ -112,6 +128,14 @@ export function seoTitle(content: ExternalContent, appName: string): string { const seoName = seoInfo?.subdivisionName ?? seoInfo?.name; return seoName ? `${seoName} | ${appName}` : `Country | ${appName}`; } + case 'bitcoin-tx': { + const shortTxid = `${content.txid.slice(0, 8)}...${content.txid.slice(-8)}`; + return `Bitcoin TX ${shortTxid} | ${appName}`; + } + case 'bitcoin-address': { + const shortAddr = `${content.address.slice(0, 8)}...${content.address.slice(-6)}`; + return `Bitcoin Address ${shortAddr} | ${appName}`; + } default: return `External Content | ${appName}`; } diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 39a2ad5f..ee034e12 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -245,8 +245,6 @@ export const AppConfigSchema = z.object({ }) ).optional().default([]), imageQuality: z.enum(['compressed', 'original']), - blockExplorerAddress: z.string(), - blockExplorerTx: z.string(), }); // ─── DittoConfigSchema (build-time ditto.json) ─────────────────────── diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index 1f1c53c9..4bea4f71 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -22,6 +22,7 @@ import { BookContentHeader, CountryContentHeader, } from '@/components/ExternalContentHeader'; +import { BitcoinTxHeader, BitcoinAddressHeader } from '@/components/BitcoinContentHeader'; import { PrecipitationEffect } from '@/components/PrecipitationEffect'; import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent'; import { ratingToStars } from '@/lib/bookstr'; @@ -259,6 +260,8 @@ export function ExternalContentPage() { {content.type === 'url' && } {content.type === 'isbn' && } {content.type === 'iso3166' && } + {content.type === 'bitcoin-tx' && } + {content.type === 'bitcoin-address' && } {content.type === 'unknown' && (
diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index a60d5930..c880f93d 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; -import UriTemplate from 'uri-templates'; import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -129,7 +129,7 @@ export function WalletPage() {
{transactions.map((tx) => ( - + ))}
@@ -174,15 +174,12 @@ function formatTxDate(timestamp?: number): string { } /** Single transaction row. */ -function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: number; txUrlTemplate: string }) { +function TxRow({ tx, btcPrice }: { tx: Transaction; btcPrice?: number }) { const isReceive = tx.type === 'receive'; - const txUrl = UriTemplate(txUrlTemplate).fill({ txid: tx.txid }); return ( -
@@ -213,6 +210,6 @@ function TxRow({ tx, btcPrice, txUrlTemplate }: { tx: Transaction; btcPrice?: nu {satsToBTC(tx.amount).replace(/\.?0+$/, '')} BTC

-
+ ); } diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 46edafcf..2a3f6076 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -111,8 +111,6 @@ export function TestApp({ children }: TestAppProps) { plausibleEndpoint: "", savedFeeds: [], imageQuality: 'compressed', - blockExplorerAddress: 'https://mempool.space/address/{address}', - blockExplorerTx: 'https://mempool.space/tx/{txid}', }; return ( From 64bac107585e8ef7801bd116b023e7615f8f7f18 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 21:14:37 -0500 Subject: [PATCH 12/25] Fix baseline alignment of Bitcoin txid/address in comment context rows The link was using inline-flex items-center with child spans at different font sizes (text-sm 'transaction' + text-xs monospace hash). Flexbox center-aligns by box center, not text baseline, causing the smaller text to appear shifted up. Changed to plain inline text flow so the browser's natural baseline alignment handles mixed font sizes correctly. --- src/components/BitcoinContentHeader.tsx | 79 +++++++++++++++++++++++-- src/components/CommentContext.tsx | 79 ++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/src/components/BitcoinContentHeader.tsx b/src/components/BitcoinContentHeader.tsx index 790b54b5..bba2e54d 100644 --- a/src/components/BitcoinContentHeader.tsx +++ b/src/components/BitcoinContentHeader.tsx @@ -519,11 +519,30 @@ function AddressSkeleton() { } // --------------------------------------------------------------------------- -// Compact previews (used in NoteCard embeds, etc.) +// Compact previews (used in NoteCard embeds, hover cards, etc.) // --------------------------------------------------------------------------- -/** Compact preview for a Bitcoin transaction (used in ExternalContentPreview). */ +/** Compact preview for a Bitcoin transaction — fetches real data. */ export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) { + const { tx, btcPrice, isLoading } = useBitcoinTx(txid); + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + const amount = tx ? tx.totalOutput : 0; + const fee = tx?.fee ?? 0; + return ( Bitcoin Transaction + {tx && ( + + {tx.confirmed ? 'Confirmed' : 'Unconfirmed'} + + )}
-

{truncateMiddle(txid, 12, 8)}

+

+ {tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)} + {tx && btcPrice ? ( + ({satsToUSD(amount, btcPrice)}) + ) : null} +

+ {tx && ( +

+ Fee {formatSats(fee)} sats + {tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''} +

+ )}
); } -/** Compact preview for a Bitcoin address (used in ExternalContentPreview). */ +/** Compact preview for a Bitcoin address — fetches real data. */ export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) { + const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address); + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + const balance = addressDetail?.totalBalance ?? 0; + const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0; + return ( Bitcoin Address -

{truncateMiddle(address, 12, 8)}

+

+ {addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)} + {addressDetail && btcPrice ? ( + ({satsToUSD(balance, btcPrice)}) + ) : null} +

+ {addressDetail && ( +

+ {txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''} + {' · '} + {truncateMiddle(address, 8, 6)} +

+ )} diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 1285f587..74be613e 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -3,13 +3,14 @@ import { type ReactNode, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { - Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film, + Award, BarChart3, Bitcoin, BookOpen, Camera, Clapperboard, Egg, FileText, Film, GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music, Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, Users, Vote, Zap, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; +import { BitcoinTxPreview, BitcoinAddressPreview } from '@/components/BitcoinContentHeader'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { ChestIcon } from '@/components/icons/ChestIcon'; import { RepostIcon } from '@/components/icons/RepostIcon'; @@ -672,6 +673,16 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN return ; } + // Bitcoin transaction identifiers — show icon + truncated txid with hover preview + if (identifier.startsWith('bitcoin:tx:')) { + return ; + } + + // Bitcoin address identifiers — show icon + truncated address with hover preview + if (identifier.startsWith('bitcoin:address:')) { + return ; + } + // Generic fallback for other external identifiers const link = `/i/${encodeURIComponent(identifier)}`; @@ -847,3 +858,69 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla ); } + +/** Comment context for Bitcoin transaction identifiers — shows icon, truncated txid, and hover preview. */ +function BitcoinTxCommentContext({ identifier, className }: { identifier: string; className?: string }) { + const txid = identifier.slice('bitcoin:tx:'.length); + const link = `/i/${encodeURIComponent(identifier)}`; + const truncated = txid.length > 19 ? `${txid.slice(0, 8)}…${txid.slice(-8)}` : txid; + + return ( + + + + + e.stopPropagation()} + > + transaction {truncated} + + + e.stopPropagation()} + > + + + + + ); +} + +/** Comment context for Bitcoin address identifiers — shows icon, truncated address, and hover preview. */ +function BitcoinAddressCommentContext({ identifier, className }: { identifier: string; className?: string }) { + const address = identifier.slice('bitcoin:address:'.length); + const link = `/i/${encodeURIComponent(identifier)}`; + const truncated = address.length > 19 ? `${address.slice(0, 8)}…${address.slice(-8)}` : address; + + return ( + + + + + e.stopPropagation()} + > + address {truncated} + + + e.stopPropagation()} + > + + + + + ); +} From c49afc7add6f98eeddc3d890d771e54894d5998d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 21:35:13 -0500 Subject: [PATCH 13/25] Add Bitcoin send functionality with 3-step confirmation flow Implement sending Bitcoin transactions from the wallet page. The send flow uses a 3-step dialog: form entry, confirmation review, and success result. Only available for nsec logins since extension/bunker signers don't expose the raw private key needed for Taproot signing. Fixes over the reference implementation: - Send Max correctly subtracts estimated fees - Address validation via bitcoinjs-lib (checksum + format) - Fee estimation accounts for actual output count (1 vs 2) - Confirmation step before broadcast (irreversible action) - All API calls use mempool.space (consistent with existing code) - Success links to in-app NIP-73 tx detail page New files: - src/hooks/useNsecAccess.ts: extract private key from nsec login - src/components/SendBitcoinDialog.tsx: 3-step send dialog New functions in src/lib/bitcoin.ts: - fetchUTXOs, getFeeRates, broadcastTransaction - validateBitcoinAddress, estimateFee, maxSendable - createBitcoinTransaction (PSBT construction + Taproot signing) - npubToBitcoinAddress, btcToSats --- src/components/SendBitcoinDialog.tsx | 612 +++++++++++++++++++++++++++ src/hooks/useNsecAccess.ts | 43 ++ src/lib/bitcoin.ts | 241 +++++++++++ src/pages/WalletPage.tsx | 23 +- 4 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 src/components/SendBitcoinDialog.tsx create mode 100644 src/hooks/useNsecAccess.ts diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx new file mode 100644 index 00000000..59d7db7c --- /dev/null +++ b/src/components/SendBitcoinDialog.tsx @@ -0,0 +1,612 @@ +import { useState, useCallback, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { + ArrowUpRight, + AlertTriangle, + Check, + ChevronLeft, + Loader2, + Send, +} from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Skeleton } from '@/components/ui/skeleton'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNsecAccess } from '@/hooks/useNsecAccess'; +import { useToast } from '@/hooks/useToast'; +import { + nostrPubkeyToBitcoinAddress, + npubToBitcoinAddress, + validateBitcoinAddress, + fetchUTXOs, + getFeeRates, + createBitcoinTransaction, + broadcastTransaction, + estimateFee, + maxSendable, + satsToBTC, + btcToSats, + satsToUSD, + formatSats, +} from '@/lib/bitcoin'; +import type { FeeRates, UTXO } from '@/lib/bitcoin'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy'; + +type Step = 'form' | 'confirm' | 'success'; + +interface SendBitcoinDialogProps { + isOpen: boolean; + onClose: () => void; + /** BTC/USD price — passed from the parent to avoid a duplicate fetch. */ + btcPrice?: number; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const FEE_SPEED_LABELS: Record = { + fastest: 'Fastest (~10 min)', + halfHour: 'Half hour', + hour: 'One hour', + economy: 'Economy (~1 day)', +}; + +function feeRateForSpeed(rates: FeeRates, speed: FeeSpeed): number { + const map: Record = { + fastest: rates.fastestFee, + halfHour: rates.halfHourFee, + hour: rates.hourFee, + economy: rates.economyFee, + }; + return map[speed]; +} + +/** Resolve a recipient string to a Bitcoin address, or throw. */ +function resolveRecipient(input: string): string { + const trimmed = input.trim(); + if (trimmed.startsWith('npub1')) { + return npubToBitcoinAddress(trimmed); + } + if (validateBitcoinAddress(trimmed)) { + return trimmed; + } + throw new Error('Invalid recipient. Enter an npub or a Bitcoin address (bc1...).'); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) { + const { user } = useCurrentUser(); + const { hasNsecAccess, getPrivateKeyHex } = useNsecAccess(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + // Form state + const [recipient, setRecipient] = useState(''); + const [amount, setAmount] = useState(''); + const [feeSpeed, setFeeSpeed] = useState('halfHour'); + const [error, setError] = useState(''); + + // Multi-step state + const [step, setStep] = useState('form'); + const [txId, setTxId] = useState(''); + const [confirmedFee, setConfirmedFee] = useState(0); + + const senderAddress = user ? nostrPubkeyToBitcoinAddress(user.pubkey) : ''; + + // ── Data fetching ────────────────────────────────────────────── + + const { data: utxos, isLoading: isLoadingUtxos } = useQuery({ + queryKey: ['bitcoin-utxos', senderAddress], + queryFn: () => fetchUTXOs(senderAddress), + enabled: !!senderAddress && isOpen, + staleTime: 30_000, + }); + + const { data: feeRates, isLoading: isLoadingFees } = useQuery({ + queryKey: ['bitcoin-fee-rates'], + queryFn: getFeeRates, + enabled: isOpen, + staleTime: 30_000, + }); + + const totalBalance = useMemo(() => utxos?.reduce((s, u) => s + u.value, 0) ?? 0, [utxos]); + + const currentFeeRate = feeRates ? feeRateForSpeed(feeRates, feeSpeed) : 0; + + // ── Derived values for the confirm screen ────────────────────── + + const parsedAmountSats = useMemo(() => { + const n = parseFloat(amount); + return isNaN(n) || n <= 0 ? 0 : btcToSats(n); + }, [amount]); + + const resolvedRecipient = useMemo(() => { + try { return resolveRecipient(recipient); } catch { return ''; } + }, [recipient]); + + const previewFee = useMemo(() => { + if (!utxos?.length || !currentFeeRate || !parsedAmountSats) return 0; + // Estimate with 2 outputs first, then check if change would be below dust + const fee2 = estimateFee(utxos.length, 2, currentFeeRate); + const change = totalBalance - parsedAmountSats - fee2; + const numOutputs = change > 546 ? 2 : 1; + return estimateFee(utxos.length, numOutputs, currentFeeRate); + }, [utxos, currentFeeRate, parsedAmountSats, totalBalance]); + + // ── Send Max ─────────────────────────────────────────────────── + + const handleSendMax = useCallback(() => { + if (!utxos?.length || !currentFeeRate) return; + const max = maxSendable(totalBalance, utxos.length, currentFeeRate); + if (max <= 0) return; + setAmount(satsToBTC(max).replace(/\.?0+$/, '')); + setError(''); + }, [utxos, currentFeeRate, totalBalance]); + + // ── Send mutation ────────────────────────────────────────────── + + const sendMutation = useMutation({ + mutationFn: async () => { + if (!user || !hasNsecAccess) throw new Error('Nsec login required.'); + const privateKey = getPrivateKeyHex(); + if (!privateKey) throw new Error('Failed to access private key.'); + if (!utxos?.length) throw new Error('No UTXOs available.'); + if (!feeRates) throw new Error('Fee rates not loaded.'); + + const recipientAddress = resolveRecipient(recipient); + const amountSats = btcToSats(parseFloat(amount)); + if (isNaN(amountSats) || amountSats <= 0) throw new Error('Invalid amount.'); + + const feeRate = feeRateForSpeed(feeRates, feeSpeed); + + const { txHex, fee } = createBitcoinTransaction( + privateKey, + recipientAddress, + amountSats, + utxos, + feeRate, + ); + + const id = await broadcastTransaction(txHex); + return { txId: id, fee }; + }, + onSuccess: ({ txId: id, fee }) => { + setTxId(id); + setConfirmedFee(fee); + setStep('success'); + toast({ title: 'Transaction sent', description: `Fee: ${formatSats(fee)} sats` }); + + // Invalidate wallet data so balance updates + queryClient.invalidateQueries({ queryKey: ['bitcoin-wallet'] }); + queryClient.invalidateQueries({ queryKey: ['bitcoin-utxos'] }); + }, + onError: (err: Error) => { + setError(err.message); + setStep('form'); + toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' }); + }, + }); + + // ── Navigation ───────────────────────────────────────────────── + + const goToConfirm = () => { + setError(''); + try { + resolveRecipient(recipient); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid recipient'); + return; + } + const sats = btcToSats(parseFloat(amount)); + if (isNaN(sats) || sats <= 0) { setError('Enter a valid amount.'); return; } + if (sats + previewFee > totalBalance) { setError('Insufficient funds.'); return; } + setStep('confirm'); + }; + + const handleClose = () => { + setRecipient(''); + setAmount(''); + setError(''); + setTxId(''); + setConfirmedFee(0); + setStep('form'); + setFeeSpeed('halfHour'); + onClose(); + }; + + // ── Render ───────────────────────────────────────────────────── + + // Not logged in with nsec + if (isOpen && !hasNsecAccess) { + return ( + + + + + + Nsec Required + + + Sending Bitcoin requires access to your private key. + + + + + + This feature is only available when logged in with your nsec (secret key). + Browser extensions and remote signers don't expose the raw private key + needed to sign Bitcoin transactions. + + + + + + ); + } + + return ( + + + {step === 'success' ? ( + + ) : step === 'confirm' ? ( + setStep('form')} + onConfirm={() => sendMutation.mutate()} + /> + ) : ( + { setRecipient(v); setError(''); }} + onAmountChange={(v) => { setAmount(v); setError(''); }} + onFeeSpeedChange={setFeeSpeed} + onSendMax={handleSendMax} + onNext={goToConfirm} + onCancel={handleClose} + /> + )} + + + ); +} + +// ═══════════════════════════════════════════════════════════════════ +// Sub-views +// ═══════════════════════════════════════════════════════════════════ + +// ── Form ───────────────────────────────────────────────────────── + +interface FormViewProps { + recipient: string; + amount: string; + feeSpeed: FeeSpeed; + error: string; + totalBalance: number; + btcPrice?: number; + utxos?: UTXO[]; + feeRates?: FeeRates; + isLoadingUtxos: boolean; + isLoadingFees: boolean; + currentFeeRate: number; + onRecipientChange: (v: string) => void; + onAmountChange: (v: string) => void; + onFeeSpeedChange: (v: FeeSpeed) => void; + onSendMax: () => void; + onNext: () => void; + onCancel: () => void; +} + +function FormView({ + recipient, amount, feeSpeed, error, totalBalance, btcPrice, + feeRates, isLoadingUtxos, isLoadingFees, currentFeeRate, + onRecipientChange, onAmountChange, onFeeSpeedChange, onSendMax, onNext, onCancel, +}: FormViewProps) { + const parsedBtc = parseFloat(amount) || 0; + + return ( + <> + + + + Send Bitcoin + + + Send Bitcoin to a Nostr user or Bitcoin address + + + +
+ {/* Balance */} +
+ + {isLoadingUtxos ? ( + + ) : ( +

+ {btcPrice + ? satsToUSD(totalBalance, btcPrice) + : `${satsToBTC(totalBalance).replace(/\.?0+$/, '')} BTC`} +

+ )} +
+ + {/* Recipient */} +
+ + onRecipientChange(e.target.value)} + /> +

Nostr npub or Bitcoin address

+
+ + {/* Amount */} +
+ + onAmountChange(e.target.value)} + /> +
+ + {parsedBtc > 0 + ? btcPrice + ? satsToUSD(btcToSats(parsedBtc), btcPrice) + : `${formatSats(btcToSats(parsedBtc))} sats` + : ''} + + +
+
+ + {/* Fee speed */} +
+ + + {currentFeeRate > 0 && parsedBtc > 0 && ( +

+ Estimated fee: ~{formatSats(estimateFee(1, 2, currentFeeRate))} sats +

+ )} +
+ + {/* Error */} + {error && ( + + + {error} + + )} + + {/* Warning */} + + + + Warning: This is an experimental feature. Test with small amounts first. + Transactions cannot be reversed. + + + + {/* Actions */} +
+ + +
+
+ + ); +} + +// ── Confirm ────────────────────────────────────────────────────── + +interface ConfirmViewProps { + recipient: string; + amountSats: number; + fee: number; + feeSpeed: FeeSpeed; + btcPrice?: number; + isPending: boolean; + onBack: () => void; + onConfirm: () => void; +} + +function ConfirmView({ recipient, amountSats, fee, feeSpeed, btcPrice, isPending, onBack, onConfirm }: ConfirmViewProps) { + const totalSats = amountSats + fee; + + const row = (label: string, sats: number) => ( +
+ {label} +
+ + {satsToBTC(sats).replace(/\.?0+$/, '')} BTC + + {btcPrice && ( + + ({satsToUSD(sats, btcPrice)}) + + )} +
+
+ ); + + const truncatedRecipient = recipient.length > 24 + ? `${recipient.slice(0, 12)}...${recipient.slice(-8)}` + : recipient; + + return ( + <> + + + + Confirm Transaction + + + Review the details before sending + + + +
+ {/* Recipient */} +
+ +

{truncatedRecipient}

+
+ + {/* Breakdown */} +
+ {row('Amount', amountSats)} + {row(`Fee (${FEE_SPEED_LABELS[feeSpeed].toLowerCase()})`, fee)} +
+ {row('Total', totalSats)} +
+
+ + {/* Actions */} +
+ + +
+
+ + ); +} + +// ── Success ────────────────────────────────────────────────────── + +interface SuccessViewProps { + txId: string; + fee: number; + btcPrice?: number; + onClose: () => void; +} + +function SuccessView({ txId, fee, btcPrice, onClose }: SuccessViewProps) { + return ( + <> + + + + Transaction Sent + + + Your transaction has been broadcast to the Bitcoin network. + + + +
+
+ +

{txId}

+
+ +

+ Fee: {formatSats(fee)} sats + {btcPrice ? ` (${satsToUSD(fee, btcPrice)})` : ''} +

+ +
+ + +
+
+ + ); +} diff --git a/src/hooks/useNsecAccess.ts b/src/hooks/useNsecAccess.ts new file mode 100644 index 00000000..88fa65bd --- /dev/null +++ b/src/hooks/useNsecAccess.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import { useNostrLogin } from '@nostrify/react/login'; +import { nip19 } from 'nostr-tools'; + +/** + * Hook that checks whether the current login is an nsec and, if so, provides + * a function to retrieve the raw 32-byte private key as a hex string. + * + * Only nsec logins expose the raw secret key — extension and bunker logins + * do not, so Bitcoin transaction signing is only possible with nsec. + */ +export function useNsecAccess() { + const { logins } = useNostrLogin(); + const currentLogin = logins[0]; + + const hasNsecAccess = currentLogin?.type === 'nsec'; + + const getPrivateKeyHex = useMemo(() => { + return (): string | null => { + if (currentLogin?.type !== 'nsec') return null; + + try { + const decoded = nip19.decode(currentLogin.data.nsec); + if (decoded.type !== 'nsec') return null; + + // decoded.data is a Uint8Array for nsec + return Buffer.from(decoded.data).toString('hex'); + } catch (error) { + console.error('Failed to decode nsec:', error); + return null; + } + }; + }, [currentLogin]); + + return { + /** Whether the current login exposes the raw private key. */ + hasNsecAccess, + /** Retrieve the 32-byte private key as hex. Returns null if not an nsec login. */ + getPrivateKeyHex, + /** The login type of the current user (nsec, bunker, extension, etc.). */ + loginType: currentLogin?.type, + }; +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index c24aa2a6..323289e6 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -1,4 +1,8 @@ import * as bitcoin from 'bitcoinjs-lib'; +import { toXOnly } from 'bitcoinjs-lib'; +import { nip19 } from 'nostr-tools'; +import * as ecc from '@bitcoinerlab/secp256k1'; +import { ECPairFactory, type ECPairAPI } from 'ecpair'; // --------------------------------------------------------------------------- // Constants @@ -7,6 +11,32 @@ import * as bitcoin from 'bitcoinjs-lib'; /** Base URL for the mempool.space Esplora-compatible REST API. */ const MEMPOOL_API = 'https://mempool.space/api'; +/** Standard Bitcoin dust limit in satoshis. */ +const DUST_LIMIT = 546; + +/** Estimated vBytes per P2TR input. */ +const VBYTES_PER_INPUT = 57.5; + +/** Estimated vBytes per P2TR output. */ +const VBYTES_PER_OUTPUT = 43; + +/** Estimated vBytes for transaction overhead (version, locktime, etc.). */ +const VBYTES_OVERHEAD = 10.5; + +// --------------------------------------------------------------------------- +// ECC initialisation (lazy) +// --------------------------------------------------------------------------- + +let _ECPair: ECPairAPI | null = null; + +function getECPair(): ECPairAPI { + if (!_ECPair) { + bitcoin.initEccLib(ecc); + _ECPair = ECPairFactory(ecc); + } + return _ECPair; +} + /** * Convert a Nostr public key (32-byte hex) to a Bitcoin Taproot (P2TR) address. * @@ -30,6 +60,18 @@ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { } } +/** + * Convert a bech32 `npub1...` identifier to a Bitcoin Taproot (P2TR) address. + * Decodes the npub to a hex pubkey, then delegates to {@link nostrPubkeyToBitcoinAddress}. + */ +export function npubToBitcoinAddress(npub: string): string { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('Invalid npub format'); + } + return nostrPubkeyToBitcoinAddress(decoded.data); +} + // --------------------------------------------------------------------------- // Balance / Address data (wallet page) // --------------------------------------------------------------------------- @@ -107,6 +149,11 @@ export async function fetchBtcPrice(): Promise { return data.bitcoin.usd; } +/** Convert a BTC amount to satoshis (rounded to nearest integer). */ +export function btcToSats(btc: number): number { + return Math.round(btc * 100_000_000); +} + /** Convert satoshis to USD given a BTC price. */ export function satsToUSD(sats: number, btcPrice: number): string { const btc = sats / 100_000_000; @@ -311,3 +358,197 @@ export async function fetchAddressDetail(address: string): Promise { + const response = await fetch(`${MEMPOOL_API}/address/${address}/utxo`); + if (!response.ok) throw new Error('Failed to fetch UTXOs'); + return response.json(); +} + +/** Fee rate estimates keyed by confirmation speed. */ +export interface FeeRates { + /** ~10 min / next block (target 1). */ + fastestFee: number; + /** ~30 min (target 3). */ + halfHourFee: number; + /** ~1 hour (target 6). */ + hourFee: number; + /** ~1 day (target 144). */ + economyFee: number; + /** Minimum relay fee (target 504). */ + minimumFee: number; +} + +/** Fetch recommended fee rates (sat/vB) from mempool.space. */ +export async function getFeeRates(): Promise { + const response = await fetch(`${MEMPOOL_API}/fee-estimates`); + if (!response.ok) throw new Error('Failed to fetch fee estimates'); + + const data = await response.json(); + + return { + fastestFee: Math.ceil(data['1'] || 1), + halfHourFee: Math.ceil(data['3'] || 1), + hourFee: Math.ceil(data['6'] || 1), + economyFee: Math.ceil(data['144'] || 1), + minimumFee: Math.ceil(data['504'] || 1), + }; +} + +/** + * Estimate the fee for a P2TR transaction in satoshis. + * + * @param numInputs Number of Taproot inputs. + * @param numOutputs Number of outputs (recipient + optional change). + * @param feeRate Fee rate in sat/vB. + */ +export function estimateFee(numInputs: number, numOutputs: number, feeRate: number): number { + const vBytes = numInputs * VBYTES_PER_INPUT + numOutputs * VBYTES_PER_OUTPUT + VBYTES_OVERHEAD; + return Math.ceil(vBytes * feeRate); +} + +/** + * Validate a Bitcoin address (mainnet). Returns `true` if the address has a + * valid format and checksum, `false` otherwise. + */ +export function validateBitcoinAddress(address: string): boolean { + try { + bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin); + return true; + } catch { + return false; + } +} + +/** Broadcast a signed transaction hex to the Bitcoin network via mempool.space. Returns the txid. */ +export async function broadcastTransaction(txHex: string): Promise { + const response = await fetch(`${MEMPOOL_API}/tx`, { + method: 'POST', + body: txHex, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Broadcast failed: ${body}`); + } + + return response.text(); +} + +/** + * Compute the maximum sendable amount (in sats) after fees. + * + * @param totalBalance Total spendable sats across all UTXOs. + * @param numInputs Number of UTXOs that will be consumed. + * @param feeRate Fee rate in sat/vB. + * @returns The max amount in sats, or 0 if the balance cannot cover fees. + */ +export function maxSendable(totalBalance: number, numInputs: number, feeRate: number): number { + // When sending max there is no change output, so only 1 output. + const fee = estimateFee(numInputs, 1, feeRate); + return Math.max(0, totalBalance - fee); +} + +/** + * Create, sign, and return a raw Bitcoin Taproot transaction. + * + * @param privateKeyHex 32-byte hex private key (from Nostr nsec). + * @param toAddress Recipient Bitcoin address. + * @param amountSats Amount to send in satoshis. + * @param utxos Available UTXOs (all will be consumed). + * @param feeRate Fee rate in sat/vB. + * @returns The signed transaction hex and the fee paid. + */ +export function createBitcoinTransaction( + privateKeyHex: string, + toAddress: string, + amountSats: number, + utxos: UTXO[], + feeRate: number, +): { txHex: string; fee: number } { + // 1. Key pair from raw private key + const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const internalPubkey = toXOnly(keyPair.publicKey); + + // 2. Derive change address (same Taproot address as sender) + const { address: changeAddress } = bitcoin.payments.p2tr({ + internalPubkey, + network: bitcoin.networks.bitcoin, + }); + if (!changeAddress) throw new Error('Failed to derive change address'); + + // 3. Build PSBT, add all UTXOs as inputs + const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin }); + let totalInput = 0; + + for (const utxo of utxos) { + psbt.addInput({ + hash: utxo.txid, + index: utxo.vout, + witnessUtxo: { + script: bitcoin.payments.p2tr({ + internalPubkey, + network: bitcoin.networks.bitcoin, + }).output!, + value: BigInt(utxo.value), + }, + tapInternalKey: internalPubkey, + }); + totalInput += utxo.value; + } + + // 4. Estimate fee — first assume 2 outputs (recipient + change) + const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate); + const hasChange = change2Out > DUST_LIMIT; + const numOutputs = hasChange ? 2 : 1; + const fee = estimateFee(utxos.length, numOutputs, feeRate); + const change = totalInput - amountSats - fee; + + if (change < 0) { + throw new Error( + `Insufficient funds. Need ${(amountSats + fee).toLocaleString()} sats, have ${totalInput.toLocaleString()} sats.`, + ); + } + + // 5. Add outputs + psbt.addOutput({ address: toAddress, value: BigInt(amountSats) }); + + if (hasChange) { + psbt.addOutput({ address: changeAddress, value: BigInt(change) }); + } + + // 6. Tweak private key for Taproot key-path spending (BIP-341) + const tweakedSigner = keyPair.tweak( + bitcoin.crypto.taggedHash('TapTweak', internalPubkey), + ); + + // 7. Sign all inputs + for (let i = 0; i < utxos.length; i++) { + psbt.signInput(i, tweakedSigner); + } + + // 8. Finalize and extract + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + + return { txHex: tx.toHex(), fee }; +} diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx index c880f93d..212cb571 100644 --- a/src/pages/WalletPage.tsx +++ b/src/pages/WalletPage.tsx @@ -1,13 +1,14 @@ import { useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; -import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight } from 'lucide-react'; +import { Bitcoin, Copy, Check, RefreshCw, Wallet, ChevronDown, ArrowDownLeft, ArrowUpRight, Send } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { SendBitcoinDialog } from '@/components/SendBitcoinDialog'; import { useAppContext } from '@/hooks/useAppContext'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; @@ -21,6 +22,7 @@ export function WalletPage() { const [copiedAddress, setCopiedAddress] = useState(false); const [txOpen, setTxOpen] = useState(false); + const [sendOpen, setSendOpen] = useState(false); useSeoMeta({ title: `Wallet | ${config.appName}`, @@ -97,6 +99,25 @@ export function WalletPage() { ) : null} + {/* Send button */} + {addressData && ( + + )} + + setSendOpen(false)} + btcPrice={btcPrice} + /> + {/* QR Code */}
From aa618edc43a5b45bae2c0929feb756b31cf7d709 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 6 Apr 2026 23:06:34 -0500 Subject: [PATCH 14/25] Document Bitcoin sending flow in WALLET.md --- WALLET.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/WALLET.md b/WALLET.md index 0113ee7b..f1f4ea49 100644 --- a/WALLET.md +++ b/WALLET.md @@ -110,6 +110,75 @@ Transaction and address detail pages use [NIP-73](https://github.com/nostr-proto 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 nsec can sign Bitcoin transactions without any key conversion. + +### Requirements + +Sending is only available when logged in with an **nsec**. Extension (NIP-07) and bunker (NIP-46) logins do not expose the raw private key, which is required for Taproot key-path signing. Users on those login types see an explanation in the send dialog. + +### 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 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. **Tweak and sign** -- For Taproot key-path spending (BIP-341), the private key is tweaked: + + ``` + tweak = taggedHash("TapTweak", internalPubkey) + tweakedKey = privateKey + tweak (mod n, with y-parity correction) + ``` + + The `ecpair` library's `.tweak()` method handles parity normalization automatically (negating the private key if the public key has an odd y-coordinate). Each input is signed with the tweaked signer using Schnorr signatures. + +8. **Broadcast** -- The finalized transaction hex is 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. From 5660a1cb1becf8b004d31f95d7a3e2547adc45ee Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 7 Apr 2026 00:16:36 -0500 Subject: [PATCH 15/25] 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. --- BITCOIN-SIGNING.md | 85 +++++++++++++++++++ WALLET.md | 35 ++++++-- src/components/SendBitcoinDialog.tsx | 36 ++++---- src/hooks/useBitcoinSigner.ts | 38 +++++++++ src/hooks/useCurrentUser.ts | 33 ++++++-- src/lib/bitcoin-signers.ts | 113 +++++++++++++++++++++++++ src/lib/bitcoin.ts | 120 +++++++++++++++++++++------ src/lib/signerWithNudge.ts | 9 ++ 8 files changed, 413 insertions(+), 56 deletions(-) create mode 100644 BITCOIN-SIGNING.md create mode 100644 src/hooks/useBitcoinSigner.ts create mode 100644 src/lib/bitcoin-signers.ts diff --git a/BITCOIN-SIGNING.md b/BITCOIN-SIGNING.md new file mode 100644 index 00000000..c1eba4d5 --- /dev/null +++ b/BITCOIN-SIGNING.md @@ -0,0 +1,85 @@ +# Bitcoin PSBT Signing for Nostr Signers + +This document specifies how Nostr signers (NIP-07 browser extensions and NIP-46 remote signers) can support signing Bitcoin Partially Signed Bitcoin Transactions (PSBTs). + +## Motivation + +Nostr and Bitcoin Taproot (BIP-341) share identical cryptographic primitives: secp256k1 with 32-byte x-only public keys and BIP-340 Schnorr signatures. This means a Nostr private key can directly sign Bitcoin Taproot transactions without any key conversion. To enable this, Nostr signers need a method to sign PSBTs. + +## `signPsbt` Method + +### NIP-07 (Browser Extensions) + +Extensions that support Bitcoin signing MUST expose a `signPsbt` method on the `window.nostr` object: + +```typescript +window.nostr.signPsbt(psbtHex: string): Promise +``` + +**Parameters:** + +- `psbtHex` — Hex-encoded PSBT (BIP-174/BIP-370). + +**Returns:** + +- A hex-encoded PSBT with Taproot key-path signatures (`tapKeySig`) added to matching inputs. + +### NIP-46 (Remote Signers) + +Remote signers that support Bitcoin signing MUST handle the `sign_psbt` RPC method: + +``` +method: "sign_psbt" +params: [""] +result: "" +``` + +The method follows the same NIP-46 request/response pattern as `sign_event`. If the signer does not support this method, it MUST return an error. + +## Signer Behavior + +When a signer receives a PSBT to sign, it MUST: + +1. Decode the PSBT from hex. +2. For each input, check if `tapInternalKey` is present. +3. Compare the input's `tapInternalKey` against the signer's own 32-byte x-only public key. +4. For each matching input: + a. Compute the BIP-341 tweak: `t = taggedHash("TapTweak", tapInternalKey)`. + b. Tweak the private key: apply `t` to the secret key with y-parity correction (negate the key if the corresponding public key has an odd y-coordinate, then add the tweak scalar modulo the curve order). + c. Compute the BIP-341 sighash for the input. + d. Produce a BIP-340 Schnorr signature over the sighash using the tweaked key. + e. Set `tapKeySig` on the input. +5. Return the PSBT with signatures added. The signer MUST NOT finalize or extract the transaction. + +Inputs whose `tapInternalKey` does not match the signer's key MUST be left unchanged. + +## Security Considerations + +- Signers SHOULD display a confirmation dialog showing the transaction outputs, amounts, and fees before signing. +- Signers SHOULD reject PSBTs that do not contain any inputs matching the signer's key. +- The PSBT format (BIP-174) carries all information needed for the signer to verify what is being signed, including input amounts (`witnessUtxo`) and output destinations. + +## Capability Detection + +### NIP-07 + +Clients SHOULD check for the presence of `signPsbt` before calling it: + +```typescript +if (typeof window.nostr?.signPsbt === 'function') { + const signedHex = await window.nostr.signPsbt(unsignedPsbtHex); +} +``` + +### NIP-46 + +Clients SHOULD handle errors gracefully when the remote signer does not support `sign_psbt`. If the signer returns an error for an unknown method, the client should inform the user that their signer does not support Bitcoin signing. + +## References + +- [BIP-174: Partially Signed Bitcoin Transaction Format](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) +- [BIP-340: Schnorr Signatures for secp256k1](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) +- [BIP-341: Taproot (SegWit v1 Spending Rules)](https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki) +- [BIP-370: PSBT Version 2](https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki) +- [NIP-07: `window.nostr` capability for web browsers](https://github.com/nostr-protocol/nips/blob/master/07.md) +- [NIP-46: Nostr Remote Signing](https://github.com/nostr-protocol/nips/blob/master/46.md) diff --git a/WALLET.md b/WALLET.md index f1f4ea49..8e1be2f3 100644 --- a/WALLET.md +++ b/WALLET.md @@ -112,11 +112,29 @@ These pages are part of the existing `/i/*` external content system, which also ## Sending Bitcoin -The wallet supports sending Bitcoin transactions directly from the app. Because Nostr and Bitcoin Taproot share the same private key, the Nostr nsec can sign Bitcoin transactions without any key conversion. +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. -### Requirements +### Supported Signer Types -Sending is only available when logged in with an **nsec**. Extension (NIP-07) and bunker (NIP-46) logins do not expose the raw private key, which is required for Taproot key-path signing. Users on those login types see an explanation in the send dialog. +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 @@ -135,7 +153,7 @@ The send flow constructs a standard Taproot (P2TR) key-path spend: 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 PSBT** -- All UTXOs are consumed as inputs (no coin selection). Each input includes: +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 @@ -143,16 +161,16 @@ The send flow constructs a standard Taproot (P2TR) key-path spend: 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. **Tweak and sign** -- For Taproot key-path spending (BIP-341), the private key is tweaked: +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) ``` - The `ecpair` library's `.tweak()` method handles parity normalization automatically (negating the private key if the public key has an odd y-coordinate). Each input is signed with the tweaked signer using Schnorr signatures. + For extension and bunker signers, the tweaking is handled by the external signer. -8. **Broadcast** -- The finalized transaction hex is POSTed to `mempool.space/api/tx`, which returns the txid on success. +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 @@ -182,9 +200,10 @@ 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 logins do not expose the raw private key, so spending Bitcoin from those login types requires exporting the key or using a compatible wallet application. +- 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 diff --git a/src/components/SendBitcoinDialog.tsx b/src/components/SendBitcoinDialog.tsx index 59d7db7c..90d6855a 100644 --- a/src/components/SendBitcoinDialog.tsx +++ b/src/components/SendBitcoinDialog.tsx @@ -31,7 +31,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNsecAccess } from '@/hooks/useNsecAccess'; +import { useBitcoinSigner } from '@/hooks/useBitcoinSigner'; import { useToast } from '@/hooks/useToast'; import { nostrPubkeyToBitcoinAddress, @@ -39,7 +39,8 @@ import { validateBitcoinAddress, fetchUTXOs, getFeeRates, - createBitcoinTransaction, + buildUnsignedPsbt, + finalizePsbt, broadcastTransaction, estimateFee, maxSendable, @@ -104,7 +105,7 @@ function resolveRecipient(input: string): string { export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDialogProps) { const { user } = useCurrentUser(); - const { hasNsecAccess, getPrivateKeyHex } = useNsecAccess(); + const { canSignPsbt, signPsbt } = useBitcoinSigner(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -175,9 +176,7 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const sendMutation = useMutation({ mutationFn: async () => { - if (!user || !hasNsecAccess) throw new Error('Nsec login required.'); - const privateKey = getPrivateKeyHex(); - if (!privateKey) throw new Error('Failed to access private key.'); + if (!user || !canSignPsbt || !signPsbt) throw new Error('Bitcoin signing not available.'); if (!utxos?.length) throw new Error('No UTXOs available.'); if (!feeRates) throw new Error('Fee rates not loaded.'); @@ -187,14 +186,21 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial const feeRate = feeRateForSpeed(feeRates, feeSpeed); - const { txHex, fee } = createBitcoinTransaction( - privateKey, + // 1. Build unsigned PSBT + const { psbtHex, fee } = buildUnsignedPsbt( + user.pubkey, recipientAddress, amountSats, utxos, feeRate, ); + // 2. Sign via the signer (local nsec, NIP-07 extension, or NIP-46 bunker) + const signedHex = await signPsbt(psbtHex); + + // 3. Finalize and extract raw tx + const txHex = finalizePsbt(signedHex); + const id = await broadcastTransaction(txHex); return { txId: id, fee }; }, @@ -244,26 +250,26 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial // ── Render ───────────────────────────────────────────────────── - // Not logged in with nsec - if (isOpen && !hasNsecAccess) { + // Signer doesn't support Bitcoin signing + if (isOpen && !canSignPsbt) { return ( - Nsec Required + Signing Not Available - Sending Bitcoin requires access to your private key. + Sending Bitcoin requires a signer that supports PSBT signing. - This feature is only available when logged in with your nsec (secret key). - Browser extensions and remote signers don't expose the raw private key - needed to sign Bitcoin transactions. + Your current signer does not support Bitcoin transaction signing. + Log in with your nsec, a NIP-07 extension that supports signPsbt, + or a NIP-46 remote signer that supports the sign_psbt method. diff --git a/src/hooks/useBitcoinSigner.ts b/src/hooks/useBitcoinSigner.ts new file mode 100644 index 00000000..a89d9188 --- /dev/null +++ b/src/hooks/useBitcoinSigner.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; + +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers'; + +/** + * Hook that exposes Bitcoin PSBT signing capability from the current user's signer. + * + * Works with all login types: + * - **nsec**: Signs locally using the Taproot-tweaked private key. + * - **extension (NIP-07)**: Delegates to `window.nostr.signPsbt()`. + * - **bunker (NIP-46)**: Sends `sign_psbt` RPC to the remote signer. + * + * Returns `canSignPsbt: false` if the user is not logged in or their signer + * doesn't support `signPsbt` (shouldn't happen with the Btc-extended signers, + * but is a safety guard for unexpected signer types). + */ +export function useBitcoinSigner() { + const { user } = useCurrentUser(); + + const btcSigner = useMemo((): BtcSigner | null => { + if (!user) return null; + if (hasBtcSigning(user.signer)) return user.signer; + return null; + }, [user]); + + return { + /** Whether the current user's signer supports Bitcoin PSBT signing. */ + canSignPsbt: btcSigner !== null, + /** + * Sign a hex-encoded PSBT. Throws if the signer doesn't support it. + * The returned hex is a signed (but not finalized) PSBT. + */ + signPsbt: btcSigner + ? (psbtHex: string) => btcSigner.signPsbt(psbtHex) + : null, + }; +} diff --git a/src/hooks/useCurrentUser.ts b/src/hooks/useCurrentUser.ts index 9e41530e..f2406472 100644 --- a/src/hooks/useCurrentUser.ts +++ b/src/hooks/useCurrentUser.ts @@ -1,10 +1,12 @@ import { useNostr } from '@nostrify/react'; import { type NLoginType, NUser, useNostrLogin } from '@nostrify/react/login'; -import { NRelay1 } from '@nostrify/nostrify'; +import { NRelay1, NSecSigner } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; import { useCallback, useMemo } from 'react'; import { useAuthor } from './useAuthor.ts'; import { signerWithNudge } from '@/lib/signerWithNudge'; +import { NSecSignerBtc, NBrowserSignerBtc, NConnectSignerBtc } from '@/lib/bitcoin-signers'; export function useCurrentUser() { const { nostr } = useNostr(); @@ -15,23 +17,38 @@ export function useCurrentUser() { let isBunkerConnected: (() => boolean) | undefined; switch (login.type) { - case 'nsec': // Nostr login with secret key - user = NUser.fromNsecLogin(login); + case 'nsec': { // Nostr login with secret key — use BTC-extended signer + const sk = nip19.decode(login.data.nsec) as { type: 'nsec'; data: Uint8Array }; + user = new NUser(login.type, login.pubkey, new NSecSignerBtc(sk.data)); break; - case 'bunker': { // Nostr login with NIP-46 "bunker://" URI - user = NUser.fromBunkerLogin(login, nostr); + } + case 'bunker': { // Nostr login with NIP-46 "bunker://" URI — use BTC-extended signer + const clientSk = nip19.decode(login.data.clientNsec) as { type: 'nsec'; data: Uint8Array }; + const clientSigner = new NSecSigner(clientSk.data); + const bunkerRelays = login.data.relays; + + user = new NUser( + login.type, + login.pubkey, + new NConnectSignerBtc({ + relay: nostr.group(bunkerRelays), + pubkey: login.data.bunkerPubkey, + signer: clientSigner, + timeout: 60_000, + }), + ); + // Called at nudge time to check whether any of the bunker's relay // WebSockets are OPEN. Relay instances are shared with the main pool // so pool.relays will contain them once they have been opened. - const bunkerRelays = (login as Extract).data.relays; isBunkerConnected = () => bunkerRelays.some((url) => { const relay = nostr.relay(url); return relay instanceof NRelay1 && relay.socket.readyState === WebSocket.OPEN; }); break; } - case 'extension': // Nostr login with NIP-07 browser extension - user = NUser.fromExtensionLogin(login); + case 'extension': // Nostr login with NIP-07 browser extension — use BTC-extended signer + user = new NUser(login.type, login.pubkey, new NBrowserSignerBtc()); break; // Other login types can be defined here default: diff --git a/src/lib/bitcoin-signers.ts b/src/lib/bitcoin-signers.ts new file mode 100644 index 00000000..6b1cca29 --- /dev/null +++ b/src/lib/bitcoin-signers.ts @@ -0,0 +1,113 @@ +import type { NostrSigner } from '@nostrify/types'; +import { NSecSigner, NBrowserSigner, NConnectSigner } from '@nostrify/nostrify'; +import type { NConnectSignerOpts } from '@nostrify/nostrify'; + +import { signPsbtLocal } from '@/lib/bitcoin'; + +// --------------------------------------------------------------------------- +// BtcSigner interface +// --------------------------------------------------------------------------- + +/** + * A Nostr signer extended with Bitcoin PSBT signing capability. + * + * Implementations receive a hex-encoded unsigned PSBT, sign all Taproot + * inputs whose `tapInternalKey` matches the signer's key, and return the + * hex-encoded signed (but not finalized) PSBT. + */ +export interface BtcSigner extends NostrSigner { + signPsbt(psbtHex: string): Promise; +} + +/** Runtime check for whether a signer supports `signPsbt`. */ +export function hasBtcSigning(signer: NostrSigner): signer is BtcSigner { + return typeof (signer as BtcSigner).signPsbt === 'function'; +} + +// --------------------------------------------------------------------------- +// NSecSignerBtc — local nsec signing +// --------------------------------------------------------------------------- + +/** + * Extends `NSecSigner` with local Taproot PSBT signing. + * + * `NSecSigner` stores the secret key in a JS `#private` field that subclasses + * cannot access. To work around this, the constructor accepts the raw secret + * key bytes, passes them to `super()`, and keeps its own copy for Bitcoin use. + */ +export class NSecSignerBtc extends NSecSigner implements BtcSigner { + private readonly secretKeyBytes: Uint8Array; + + constructor(secretKey: Uint8Array) { + super(secretKey); + this.secretKeyBytes = new Uint8Array(secretKey); + } + + async signPsbt(psbtHex: string): Promise { + const privateKeyHex = Buffer.from(this.secretKeyBytes).toString('hex'); + return signPsbtLocal(psbtHex, privateKeyHex); + } +} + +// --------------------------------------------------------------------------- +// NBrowserSignerBtc — NIP-07 extension signing +// --------------------------------------------------------------------------- + +/** + * Extends `NBrowserSigner` with NIP-07 `window.nostr.signPsbt()` support. + * + * Calls the extension's `signPsbt` method if available. If the extension does + * not expose `signPsbt`, an error is thrown with a user-friendly message. + */ +export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner { + constructor(opts?: { timeout?: number }) { + super(opts); + } + + async signPsbt(psbtHex: string): Promise { + // `awaitNostr` is TypeScript-private but JavaScript-public at runtime. + const nostr = await (this as unknown as { awaitNostr(): Promise> }).awaitNostr(); + + if (typeof nostr.signPsbt !== 'function') { + throw new Error( + 'Your browser extension does not support Bitcoin signing (signPsbt). ' + + 'Please use an extension that supports this feature, or log in with your nsec.', + ); + } + + const signPsbt = nostr.signPsbt as (hex: string) => Promise; + return signPsbt(psbtHex); + } +} + +// --------------------------------------------------------------------------- +// NConnectSignerBtc — NIP-46 remote signer +// --------------------------------------------------------------------------- + +/** + * Extends `NConnectSigner` with NIP-46 `sign_psbt` RPC support. + * + * Sends a `sign_psbt` command over the NIP-46 relay channel. The remote + * signer handles the TapTweak and Schnorr signing internally. If the remote + * signer does not support `sign_psbt`, it returns an error which is propagated + * with a user-friendly message. + */ +export class NConnectSignerBtc extends NConnectSigner implements BtcSigner { + constructor(opts: NConnectSignerOpts) { + super(opts); + } + + async signPsbt(psbtHex: string): Promise { + try { + // `cmd` is TypeScript-private but JavaScript-public at runtime. + const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise }).cmd; + return await cmd.call(this, 'sign_psbt', [psbtHex]); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error( + `Remote signer does not support Bitcoin signing (sign_psbt): ${msg}. ` + + 'Please update your signer or log in with your nsec.', + ); + } + } +} diff --git a/src/lib/bitcoin.ts b/src/lib/bitcoin.ts index 323289e6..7f537806 100644 --- a/src/lib/bitcoin.ts +++ b/src/lib/bitcoin.ts @@ -468,35 +468,44 @@ export function maxSendable(totalBalance: number, numInputs: number, feeRate: nu return Math.max(0, totalBalance - fee); } +/** Result of building an unsigned PSBT. */ +export interface UnsignedPsbt { + /** Hex-encoded unsigned PSBT. */ + psbtHex: string; + /** Fee in satoshis. */ + fee: number; +} + /** - * Create, sign, and return a raw Bitcoin Taproot transaction. + * Build an unsigned Taproot PSBT ready for signing. * - * @param privateKeyHex 32-byte hex private key (from Nostr nsec). - * @param toAddress Recipient Bitcoin address. - * @param amountSats Amount to send in satoshis. - * @param utxos Available UTXOs (all will be consumed). - * @param feeRate Fee rate in sat/vB. - * @returns The signed transaction hex and the fee paid. + * This function constructs the PSBT with all inputs and outputs but does NOT + * sign it. The returned hex can be passed to any signer (local nsec, NIP-07 + * extension, or NIP-46 remote signer). + * + * @param senderPubkeyHex 32-byte hex x-only public key of the sender. + * @param toAddress Recipient Bitcoin address. + * @param amountSats Amount to send in satoshis. + * @param utxos Available UTXOs (all will be consumed). + * @param feeRate Fee rate in sat/vB. */ -export function createBitcoinTransaction( - privateKeyHex: string, +export function buildUnsignedPsbt( + senderPubkeyHex: string, toAddress: string, amountSats: number, utxos: UTXO[], feeRate: number, -): { txHex: string; fee: number } { - // 1. Key pair from raw private key - const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); - const internalPubkey = toXOnly(keyPair.publicKey); +): UnsignedPsbt { + const internalPubkey = Buffer.from(senderPubkeyHex, 'hex'); - // 2. Derive change address (same Taproot address as sender) + // Derive change address (same Taproot address as sender) const { address: changeAddress } = bitcoin.payments.p2tr({ internalPubkey, network: bitcoin.networks.bitcoin, }); if (!changeAddress) throw new Error('Failed to derive change address'); - // 3. Build PSBT, add all UTXOs as inputs + // Build PSBT, add all UTXOs as inputs const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin }); let totalInput = 0; @@ -516,7 +525,7 @@ export function createBitcoinTransaction( totalInput += utxo.value; } - // 4. Estimate fee — first assume 2 outputs (recipient + change) + // Estimate fee — first assume 2 outputs (recipient + change) const change2Out = totalInput - amountSats - estimateFee(utxos.length, 2, feeRate); const hasChange = change2Out > DUST_LIMIT; const numOutputs = hasChange ? 2 : 1; @@ -529,26 +538,87 @@ export function createBitcoinTransaction( ); } - // 5. Add outputs + // Add outputs psbt.addOutput({ address: toAddress, value: BigInt(amountSats) }); if (hasChange) { psbt.addOutput({ address: changeAddress, value: BigInt(change) }); } - // 6. Tweak private key for Taproot key-path spending (BIP-341) + return { psbtHex: psbt.toHex(), fee }; +} + +/** + * Sign a PSBT locally using a raw private key (nsec). + * + * Applies the BIP-341 TapTweak to the private key, signs all inputs whose + * `tapInternalKey` matches, and returns the signed (but not finalized) PSBT hex. + * + * @param psbtHex Hex-encoded unsigned PSBT. + * @param privateKeyHex 32-byte hex private key. + * @returns Hex-encoded signed PSBT (not finalized). + */ +export function signPsbtLocal(psbtHex: string, privateKeyHex: string): string { + bitcoin.initEccLib(ecc); + const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin }); + + const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const internalPubkey = toXOnly(keyPair.publicKey); + + // Tweak private key for Taproot key-path spending (BIP-341) const tweakedSigner = keyPair.tweak( bitcoin.crypto.taggedHash('TapTweak', internalPubkey), ); - // 7. Sign all inputs - for (let i = 0; i < utxos.length; i++) { + // Sign all inputs + for (let i = 0; i < psbt.inputCount; i++) { psbt.signInput(i, tweakedSigner); } - // 8. Finalize and extract - psbt.finalizeAllInputs(); - const tx = psbt.extractTransaction(); - - return { txHex: tx.toHex(), fee }; + return psbt.toHex(); +} + +/** + * Finalize a signed PSBT and extract the raw transaction hex. + * + * @param psbtHex Hex-encoded signed PSBT. + * @returns Raw transaction hex ready for broadcast. + */ +export function finalizePsbt(psbtHex: string): string { + bitcoin.initEccLib(ecc); + const psbt = bitcoin.Psbt.fromHex(psbtHex, { network: bitcoin.networks.bitcoin }); + psbt.finalizeAllInputs(); + return psbt.extractTransaction().toHex(); +} + +/** + * Create, sign, and return a raw Bitcoin Taproot transaction. + * + * Convenience wrapper that calls {@link buildUnsignedPsbt}, + * {@link signPsbtLocal}, and {@link finalizePsbt} in sequence. + * + * @param privateKeyHex 32-byte hex private key (from Nostr nsec). + * @param toAddress Recipient Bitcoin address. + * @param amountSats Amount to send in satoshis. + * @param utxos Available UTXOs (all will be consumed). + * @param feeRate Fee rate in sat/vB. + * @returns The signed transaction hex and the fee paid. + */ +export function createBitcoinTransaction( + privateKeyHex: string, + toAddress: string, + amountSats: number, + utxos: UTXO[], + feeRate: number, +): { txHex: string; fee: number } { + // Derive the x-only pubkey from the private key for buildUnsignedPsbt + const keyPair = getECPair().fromPrivateKey(Buffer.from(privateKeyHex, 'hex')); + const internalPubkey = toXOnly(keyPair.publicKey); + const senderPubkeyHex = Buffer.from(internalPubkey).toString('hex'); + + const { psbtHex, fee } = buildUnsignedPsbt(senderPubkeyHex, toAddress, amountSats, utxos, feeRate); + const signedHex = signPsbtLocal(psbtHex, privateKeyHex); + const txHex = finalizePsbt(signedHex); + + return { txHex, fee }; } diff --git a/src/lib/signerWithNudge.ts b/src/lib/signerWithNudge.ts index 297fd409..6858a75b 100644 --- a/src/lib/signerWithNudge.ts +++ b/src/lib/signerWithNudge.ts @@ -3,6 +3,7 @@ import { createElement } from 'react'; import { toast } from '@/hooks/useToast'; import { androidResume } from '@/lib/androidResume'; import { NudgeToastContent } from '@/components/SignerToastContent'; +import { type BtcSigner, hasBtcSigning } from '@/lib/bitcoin-signers'; // --------------------------------------------------------------------------- // Constants @@ -355,5 +356,13 @@ export function signerWithNudge( wrapped.nip44 = wrapCrypto(signer.nip44); } + // Forward signPsbt if the underlying signer supports Bitcoin signing. + if (hasBtcSigning(signer)) { + const btcWrapped = wrapped as BtcSigner; + const btcSigner = signer; + btcWrapped.signPsbt = (psbtHex: string) => + run(() => btcSigner.signPsbt(psbtHex), undefined, 'sign'); + } + return wrapped; } From bddfe4b838692bea813dd6975f0ce6467f8d4656 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 22 Apr 2026 15:17:09 -0500 Subject: [PATCH 16/25] Add on-chain Bitcoin zaps as the default zap method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce kind 3043, a new Nostr event that attests an on-chain Bitcoin payment against a target event or profile. Because every Nostr pubkey deterministically maps to a Taproot address, any user can receive an on-chain zap without configuring lud06/lud16 — the zap button now appears on every post whose author is not the current user. Publishing flow: sender builds and broadcasts a Bitcoin transaction paying the recipient's derived Taproot address, then publishes a kind 3043 event with an `i` tag (`bitcoin:tx:`), the recipient's `p`, the target's `e` / `a`, and a self-reported `amount` in sats. Before displaying or counting a kind 3043 event clients verify the referenced transaction on-chain and use the sum of outputs paying the recipient's address as the authoritative amount, capping the sender's claim at the verified value to prevent spoofing. Lightning zaps remain available as an opt-in tab inside the zap dialog whenever the author has a Lightning address configured; otherwise the dialog is purely on-chain. Defaults favour on-chain: USD amount presets ($1 / $5 / $10 / $25 / $100), fee-speed selection, and a 3-step form → confirm → success flow mirroring SendBitcoinDialog. --- NIP.md | 89 +++++ src/components/BookFeedItem.tsx | 3 +- src/components/LiveStreamPage.tsx | 9 +- src/components/MusicDetailContent.tsx | 3 +- src/components/NoteCard.tsx | 7 +- src/components/OnchainZapContent.tsx | 447 ++++++++++++++++++++++++ src/components/PhotoBottomBar.tsx | 3 +- src/components/PodcastDetailContent.tsx | 3 +- src/components/PostActionBar.tsx | 8 +- src/components/ZapDialog.tsx | 56 ++- src/hooks/useOnchainZap.ts | 201 +++++++++++ src/hooks/useOnchainZaps.ts | 176 ++++++++++ src/pages/ProfilePage.tsx | 7 +- src/pages/VinesFeedPage.tsx | 3 +- 14 files changed, 978 insertions(+), 37 deletions(-) create mode 100644 src/components/OnchainZapContent.tsx create mode 100644 src/hooks/useOnchainZap.ts create mode 100644 src/hooks/useOnchainZaps.ts diff --git a/NIP.md b/NIP.md index 7bc9d995..e75ec2a8 100644 --- a/NIP.md +++ b/NIP.md @@ -6,6 +6,7 @@ | Kind | Name | Description | |-------|----------------------|-------------------------------------------------------| +| 3043 | Onchain Zap | Attestation that an on-chain BTC tx paid a target | | 36767 | Theme Definition | Shareable, named custom UI theme | | 16767 | Active Profile Theme | The user's currently active theme (one per user) | | 16769 | Profile Tabs | The user's custom profile page tabs (one per user) | @@ -32,6 +33,94 @@ These event kinds were created by community contributors and are supported by Di --- +## Kind 3043: Onchain Zap + +### Summary + +Regular event kind that records a **Bitcoin on-chain payment** ("onchain zap") sent in appreciation of a Nostr event or profile. Functions as the on-chain analogue of NIP-57 zap receipts (kind 9735), but without the LNURL round-trip: the event is self-attested by the sender and references a real Bitcoin transaction that clients can verify directly on-chain. + +Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) address (both use 32-byte x-only secp256k1 keys, per BIP-340/BIP-341), an on-chain zap is simply a Bitcoin transaction whose output pays the recipient's derived Taproot address. The kind 3043 event links that transaction to the Nostr event or profile being zapped. + +### Event Structure + +```json +{ + "kind": 3043, + "pubkey": "", + "content": "Great post!", + "tags": [ + ["e", "", ""], + ["p", ""], + ["i", "bitcoin:tx:"], + ["amount", ""], + ["alt", "On-chain zap: 25000 sats"] + ] +} +``` + +### Content + +The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735). + +### Tags + +| Tag | Required | Description | +|----------|----------|----------------------------------------------------------------------------------------------| +| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:` where `` is a 64-char lowercase hex Bitcoin transaction ID. | +| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). | +| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. | +| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. | +| `a` | If zapping an addressable event | Addressable event coordinate `::`. Used instead of (or alongside) `e` for kinds 30000–39999. | +| `alt` | Yes | NIP-31 human-readable fallback. | + +If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event). + +### Publishing Flow + +1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`). +2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`. +3. Sender signs and publishes a kind 3043 event referencing that `txid` with the appropriate `e`/`a`/`p` tags. +4. The event is published **after** broadcast; the txid is already final at that point. + +### Client Behavior + +**Querying onchain zaps for an event:** + +```json +{ "kinds": [3043], "#e": [""], "limit": 100 } +``` + +For addressable events, use `"#a": ["::"]` instead. For profile-level zaps, use `"#p": [""]`. + +**Verification (REQUIRED before trusting amounts):** + +Clients MUST verify a kind 3043 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify: + +1. Extract the txid from the `i` tag. +2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API). +3. Derive the recipient's expected Taproot address from the `p` tag pubkey. +4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. +5. If the verified amount is 0, or the sender's `amount` tag exceeds the verified amount, the event SHOULD be discarded. +6. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. + +Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 3043 event per (txid, target) pair is canonical. + +### Comparison with NIP-57 (Lightning Zaps) + +| Aspect | NIP-57 (kind 9735) | This spec (kind 3043) | +|--------|---------------------|------------------------| +| Settlement | Lightning Network | Bitcoin L1 | +| Invoice / payment | LNURL + BOLT-11 invoice | Raw Bitcoin tx | +| Event issuer | Recipient's LNURL provider | Sender | +| Availability | Requires `lud06`/`lud16` on recipient profile | Always available (every Nostr pubkey has a derived Taproot addr) | +| Verification | Recipient zap-provider pubkey + bolt11 amount | On-chain tx verified against derived recipient address | +| Finality | Instant | Confirms in ~10 min (mempool first) | +| Fees | Sub-satoshi typical | Significant at low amounts | + +The two zap kinds are complementary. Clients SHOULD sum verified amounts from both kinds when displaying total zap stats for a post or profile. + +--- + ## Kind 36767: Theme Definition ### Summary diff --git a/src/components/BookFeedItem.tsx b/src/components/BookFeedItem.tsx index 21b3b37b..600c4e73 100644 --- a/src/components/BookFeedItem.tsx +++ b/src/components/BookFeedItem.tsx @@ -24,7 +24,6 @@ import { useOpenPost } from '@/hooks/useOpenPost'; import { useBookSummary } from '@/hooks/useBookSummary'; import { getDisplayName } from '@/lib/getDisplayName'; import { timeAgo } from '@/lib/timeAgo'; -import { canZap } from '@/lib/canZap'; import { formatNumber } from '@/lib/formatNumber'; import { cn } from '@/lib/utils'; import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr'; @@ -60,7 +59,7 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) { const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); - const canZapAuthor = user && canZap(metadata); + const canZapAuthor = !!user && user.pubkey !== event.pubkey; const isbn = useMemo(() => extractISBNFromEvent(event), [event]); const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW; diff --git a/src/components/LiveStreamPage.tsx b/src/components/LiveStreamPage.tsx index 7b0e7a63..e9b30fce 100644 --- a/src/components/LiveStreamPage.tsx +++ b/src/components/LiveStreamPage.tsx @@ -22,7 +22,6 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { getDisplayName } from '@/lib/getDisplayName'; import { useProfileUrl } from '@/hooks/useProfileUrl'; -import { canZap } from '@/lib/canZap'; import { getEffectiveStreamStatus } from '@/lib/streamStatus'; import { cn } from '@/lib/utils'; @@ -351,11 +350,9 @@ function StreamAuthorRow({ event, participants }: { event: NostrEvent; participa } function ZapButton({ event }: { event: NostrEvent }) { - const author = useAuthor(event.pubkey); - const metadata = author.data?.metadata; - - if (!canZap(metadata)) return null; - + // ZapDialog handles the self-zap guard internally, so we only need to + // render the trigger. On-chain zaps are always available for any author; + // Lightning is an opt-in tab inside the dialog. return ( + +
+ + ); + } + + // ── Confirm view ────────────────────────────────────────────── + + if (step === 'confirm') { + const totalSats = amountSats + estimatedFeeSats; + const recipientAddress = nostrPubkeyToBitcoinAddress(target.pubkey); + + return ( +
+
+ +

{recipientAddress}

+
+ +
+ + + + +
+ + {comment && ( +
+ +

{comment}

+
+ )} + + + + + On-chain transactions are final. Funds settle after ~10 minutes. + + + +
+ + +
+
+ ); + } + + // ── Form view ───────────────────────────────────────────────── + + const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount; + + return ( +
+ {/* Balance */} +
+ + {isLoadingUtxos ? ( + + ) : ( +

+ {btcPrice + ? satsToUSD(totalBalance, btcPrice) + : `${satsToBTC(totalBalance).replace(/\.?0+$/, '')} BTC`} +

+ )} +
+ + {/* Amount presets (USD) */} +
+ + { if (v) { setUsdAmount(Number(v)); setError(''); } }} + className="grid grid-cols-5 gap-1 w-full" + > + {USD_PRESETS.map((v) => ( + + ${v} + + ))} + + +
+
+ OR +
+
+ +
+ $ + { setUsdAmount(e.target.value); setError(''); }} + className="pl-6" + /> +
+ + {currentUsd > 0 && amountSats > 0 && ( +

+ ≈ {formatSats(amountSats)} sats +

+ )} +
+ + {/* Comment */} +
+ +