Use when implementing Bitcoin wallet features - provides complete architecture for on-chain Bitcoin transactions, UTXO management, address derivation from Nostr keys, and transaction handling with proper security patterns
This skill guides you through implementing a complete Bitcoin wallet system with on-chain transaction support, UTXO management, and integration with Nostr key infrastructure.
Core Capabilities:
Key Architecture Pattern: The wallet derives Bitcoin addresses from Nostr public keys, enabling users to access Bitcoin using their Nostr identity without managing separate keys.
Before implementing a Bitcoin wallet, ensure:
package.json{
"bitcoinjs-lib": "^7.0.0",
"@bitcoinerlab/secp256k1": "^1.2.0",
"ecpair": "^3.0.0"
}
qr-code-generator skill. OPTIONAL: For scanning recipient addresses, use the qr-code-scanner skill. These skills provide QR code generation, camera-based scanning, and content classification for Bitcoin addresses, Lightning invoices, and other formats.exchange-rates skill. This skill provides Coinbase API integration, caching strategies, and React hooks for displaying exchange rates in wallet UIs.Create TodoWrite todos for each section:
bitcoin.initEccLib(ecc)) at module loadqr-code-generator skill)qr-code-scanner skill)exchange-rates skill)CRITICAL: ECC Library Initialization
bitcoinjs-lib v7+ requires explicit ECC library initialization before any operations. Initialize once at the top of your bitcoin utilities file:
// lib/bitcoin.ts
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
// Initialize ECC library - MUST be called before any bitcoinjs-lib operations
bitcoin.initEccLib(ecc);
// Now you can use bitcoinjs-lib functions
Common Error: Missing this causes "No ECC Library provided" errors. Initialize at module load time, not inside functions.
Key Pattern: Use Taproot (P2TR) addresses derived from Nostr public keys.
CRITICAL: Key Format Differences
// lib/bitcoin.ts
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
import { nip19 } from 'nostr-tools';
// Initialize ECC library - MUST be called before any bitcoinjs-lib operations
bitcoin.initEccLib(ecc);
/**
* Validate and normalize Nostr public key
* Handles 0x prefix, validates length and format
*/
function validateAndConvertKey(pubkeyHex: string): Buffer {
if (!pubkeyHex || typeof pubkeyHex !== 'string') {
throw new Error('Invalid input: pubkey must be a non-empty string');
}
// Remove 0x prefix if present
let cleanHex = pubkeyHex.trim();
if (cleanHex.startsWith('0x')) {
cleanHex = cleanHex.slice(2);
}
// Validate length (Nostr keys are always 64 hex chars = 32 bytes)
if (cleanHex.length !== 64) {
throw new Error(`Invalid pubkey length: expected 64 hex chars, got ${cleanHex.length}`);
}
// Validate hex format
if (!/^[0-9a-fA-F]{64}$/.test(cleanHex)) {
throw new Error('Invalid hex characters in pubkey');
}
// Convert to buffer
const buffer = Buffer.from(cleanHex, 'hex');
if (buffer.length !== 32) {
throw new Error(`Buffer conversion failed: expected 32 bytes, got ${buffer.length}`);
}
return buffer;
}
/**
* Convert Nostr public key (hex) to Bitcoin Taproot address
* CRITICAL: Nostr keys are 32 bytes - use directly, don't remove bytes
*/
export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string {
const pubkeyBuffer = validateAndConvertKey(pubkeyHex);
const { address } = bitcoin.payments.p2tr({
internalPubkey: pubkeyBuffer, // Use full 32-byte key directly
network: bitcoin.networks.bitcoin,
});
if (!address) {
throw new Error('Failed to generate Bitcoin address');
}
return address;
}
/**
* Convert npub to Bitcoin address
*/
export function npubToBitcoinAddress(npub: string): string {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
// npub.data is Uint8Array, convert to hex string
const pubkeyHex = Buffer.from(decoded.data as Uint8Array).toString('hex');
return nostrPubkeyToBitcoinAddress(pubkeyHex);
}
/**
* Validate Bitcoin address format
*/
export function isValidBitcoinAddress(address: string): boolean {
try {
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
return true;
} catch {
return false;
}
}
Why Taproot?
UTXO (Unspent Transaction Output) represents spendable Bitcoin.
// lib/bitcoin.ts
export interface UTXO {
txid: string;
vout: number;
value: number; // in satoshis
status: {
confirmed: boolean;
block_height?: number;
};
}
/**
* Fetch UTXOs from Blockstream API
*/
export async function fetchUTXOs(address: string): Promise<UTXO[]> {
const response = await fetch(
`https://blockstream.info/api/address/${address}/utxo`
);
if (!response.ok) {
throw new Error('Failed to fetch UTXOs');
}
return await response.json();
}
/**
* Calculate total spendable balance from UTXOs
*/
export function calculateBalance(utxos: UTXO[]): number {
return utxos.reduce((sum, utxo) => sum + utxo.value, 0);
}
/**
* Calculate maximum sendable amount (balance minus estimated fee)
*/
export function calculateMaxSendAmount(
utxos: UTXO[],
feeRate: number
): number {
if (utxos.length === 0) {
return 0;
}
const totalBalance = calculateBalance(utxos);
// Estimate transaction size for Send Max (single output, no change)
// P2TR input: ~57.5 vBytes, P2TR output: ~43 vBytes
const estimatedSize = utxos.length * 57.5 + 43 + 10.5;
const estimatedFee = Math.ceil(estimatedSize * feeRate);
const maxAmount = totalBalance - estimatedFee;
return Math.max(0, maxAmount);
}
// lib/bitcoin.ts
export interface FeeRates {
fastestFee: number;
halfHourFee: number;
hourFee: number;
economyFee: number;
minimumFee: number;
}
/**
* Fetch current fee rates from Blockstream API
*/
export async function getFeeRates(): Promise<FeeRates> {
const response = await fetch(
'https://blockstream.info/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),
};
}
Critical Security: Never expose or log private keys.
// lib/bitcoin.ts
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
import { ECPairFactory, type ECPairAPI } from 'ecpair';
// Initialize ECC library - MUST be called before any bitcoinjs-lib operations
bitcoin.initEccLib(ecc);
// Lazy initialization for ECPair (avoids issues in test environments)
let ECPair: ECPairAPI | null = null;
function getECPair(): ECPairAPI {
if (!ECPair) {
ECPair = ECPairFactory(ecc);
}
return ECPair;
}
/**
* Create and sign a Bitcoin transaction
* @param privateKeyHex - Private key in hex format (from nsec)
* @param recipientAddress - Recipient Bitcoin address
* @param amountSats - Amount to send in satoshis
* @param utxos - Available UTXOs to spend
* @param feeRate - Fee rate in sat/vB
* @param sendMax - If true, send all available funds
*/
export async function createBitcoinTransaction(
privateKeyHex: string,
recipientAddress: string,
amountSats: number,
utxos: UTXO[],
feeRate: number,
sendMax: boolean = false
): Promise<{ txHex: string; fee: number }> {
const privateKeyBuffer = Buffer.from(privateKeyHex, 'hex');
const keyPair = getECPair().fromPrivateKey(privateKeyBuffer);
// Get x-only public key (32 bytes) for Taproot
// Remove the first byte (compression flag) from the 33-byte compressed pubkey
const internalPubkey = keyPair.publicKey.slice(1, 33);
// Get sender's address for change output
const { address: changeAddress } = bitcoin.payments.p2tr({
internalPubkey,
network: bitcoin.networks.bitcoin,
});
if (!changeAddress) {
throw new Error('Failed to generate change address');
}
// Create transaction builder
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });
// Add inputs (UTXOs)
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;
}
// Estimate transaction size
// P2TR input: ~57.5 vBytes, P2TR output: ~43 vBytes
const outputCount = sendMax ? 1 : 2; // Send Max = 1 output, Regular = 2 outputs
const estimatedSize = utxos.length * 57.5 + outputCount * 43 + 10.5;
const estimatedFee = Math.ceil(estimatedSize * feeRate);
if (sendMax) {
// Send Max: send all UTXOs to recipient, no change
const maxAmount = totalInput - estimatedFee;
if (maxAmount <= 0) {
throw new Error(
`Insufficient funds for Send Max. Total: ${totalInput} sats, Fee: ${estimatedFee} sats`
);
}
psbt.addOutput({
address: recipientAddress,
value: BigInt(maxAmount),
});
} else {
// Regular transaction: calculate change
const change = totalInput - amountSats - estimatedFee;
if (change < 0) {
throw new Error(
`Insufficient funds. Need ${amountSats + estimatedFee} sats, have ${totalInput} sats`
);
}
// Add output for recipient
psbt.addOutput({
address: recipientAddress,
value: BigInt(amountSats),
});
// Add change output if significant (> dust limit)
const dustLimit = 546; // Standard dust limit for Bitcoin
if (change > dustLimit) {
psbt.addOutput({
address: changeAddress,
value: BigInt(change),
});
}
}
// Create a Taproot signer (tweaked for key-path spending)
const tweakedSigner = keyPair.tweak(
bitcoin.crypto.taggedHash('TapTweak', internalPubkey)
);
// Sign all inputs
for (let i = 0; i < utxos.length; i++) {
psbt.signInput(i, tweakedSigner);
}
// Finalize and extract transaction
psbt.finalizeAllInputs();
const tx = psbt.extractTransaction();
return {
txHex: tx.toHex(),
fee: estimatedFee,
};
}
/**
* Broadcast transaction to network
*/
export async function broadcastTransaction(txHex: string): Promise<string> {
const response = await fetch('https://blockstream.info/api/tx', {
method: 'POST',
body: txHex,
});
if (!response.ok) {
throw new Error('Failed to broadcast transaction');
}
return await response.text(); // Returns txid
}
Critical: Only works with nsec login (not browser extensions or bunkers).
// hooks/useNsecAccess.ts
import { useNostrLogin } from '@nostrify/react/login';
import { nip19 } from 'nostr-tools';
/**
* Hook to check if user logged in with nsec and get private key
* SECURITY: Private key never leaves this hook except when explicitly requested
*/
export function useNsecAccess() {
const { logins } = useNostrLogin();
const currentLogin = logins[0];
// Check if user logged in with nsec
const hasNsecAccess = currentLogin?.type === 'nsec';
// Get private key in hex format
const getPrivateKey = (): string | null => {
if (!hasNsecAccess || !currentLogin) return null;
try {
const loginData = currentLogin as { data?: { nsec: string } };
const nsec = loginData.data?.nsec;
if (!nsec) return null;
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') return null;
return Buffer.from(decoded.data as Uint8Array).toString('hex');
} catch (error) {
console.error('Failed to decode nsec:', error);
return null;
}
};
return {
hasNsecAccess,
getPrivateKey,
loginType: currentLogin?.type,
};
}
// hooks/wallet/useBitcoin.ts
import { useQuery } from '@tanstack/react-query';
export function useBitcoinBalance(address: string | null) {
return useQuery({
queryKey: ['bitcoin-balance', address],
queryFn: async (): Promise<{ balance: number; utxos: UTXO[] }> => {
if (!address) {
return { balance: 0, utxos: [] };
}
const utxos = await fetchUTXOs(address);
const balance = utxos.reduce((sum, utxo) => sum + utxo.value, 0);
return { balance, utxos };
},
enabled: !!address,
refetchInterval: 30000, // Refetch every 30 seconds
staleTime: 10000, // Consider stale after 10 seconds
});
}
// hooks/wallet/useBitcoin.ts
export interface BitcoinTransaction {
id: string;
txid: string;
type: 'sent' | 'received' | 'consolidate';
amount: number; // satoshis
fee?: number; // satoshis
address: string;
confirmations: number;
blockHeight?: number;
timestamp: number;
status: 'confirmed' | 'unconfirmed' | 'pending';
}
export function useBitcoinTransactionHistory(address: string | null) {
return useQuery({
queryKey: ['bitcoin-transactions', address],
queryFn: async (): Promise<BitcoinTransaction[]> => {
if (!address) return [];
// Fetch from Blockstream API
const response = await fetch(
`https://blockstream.info/api/address/${address}/txs`
);
if (!response.ok) {
throw new Error('Failed to fetch transactions');
}
const txList = await response.json();
// Transform to BitcoinTransaction format
return txList.map((tx: any) => {
// Determine if sent or received
const isReceived = tx.vout.some(
(output: any) => output.scriptpubkey_address === address
);
const isSent = tx.vin.some(
(input: any) => input.prevout?.scriptpubkey_address === address
);
// Calculate amount for this address
let amount = 0;
let type: 'sent' | 'received' | 'consolidate';
if (isReceived && !isSent) {
type = 'received';
amount = tx.vout
.filter((output: any) => output.scriptpubkey_address === address)
.reduce((sum: number, output: any) => sum + output.value, 0);
} else if (isSent) {
const inputsFromAddress = tx.vin
.filter((input: any) => input.prevout?.scriptpubkey_address === address)
.reduce((sum: number, input: any) => sum + (input.prevout?.value || 0), 0);
const outputsToAddress = tx.vout
.filter((output: any) => output.scriptpubkey_address === address)
.reduce((sum: number, output: any) => sum + output.value, 0);
const amountSentToOthers = inputsFromAddress - outputsToAddress - (tx.fee || 0);
// Check if consolidation (self-send)
if (amountSentToOthers <= 546 && outputsToAddress > 0) {
type = 'consolidate';
amount = outputsToAddress;
} else {
type = 'sent';
amount = amountSentToOthers;
}
} else {
type = 'received';
amount = 0;
}
return {
id: `${tx.txid}-${tx.vout}`,
txid: tx.txid,
type,
amount: Math.abs(amount),
fee: tx.fee,
address,
confirmations: tx.status.confirmed ? 1 : 0,
blockHeight: tx.status.block_height,
timestamp: (tx.status.block_time || 0) * 1000,
status: tx.status.confirmed ? 'confirmed' : 'unconfirmed',
} as BitcoinTransaction;
});
},
enabled: !!address,
refetchInterval: 30000,
staleTime: 10000,
});
}
// hooks/wallet/useBitcoin.ts
import { useToast } from '@/hooks/useToast';
export function useBitcoinOperations(bitcoinAddress: string | null) {
// Default: Toast notifications
const { toast } = useToast();
// Alternative options (commented):
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: No notification handler
const { hasNsecAccess, getPrivateKey } = useNsecAccess();
const { data: bitcoinBalanceData, refetch: refetchBalance } = useBitcoinBalance(bitcoinAddress);
const utxos = bitcoinBalanceData?.utxos || [];
const [feeRate, setFeeRate] = useState<number>(1);
const [isConsolidating, setIsConsolidating] = useState(false);
// Fetch fee rates on mount
useEffect(() => {
getFeeRates()
.then(rates => setFeeRate(rates.fastestFee))
.catch(() => {
// Fallback to 1 sat/vB
});
}, []);
const bitcoinMaxAmount = utxos.length > 0
? calculateMaxSendAmount(utxos, feeRate)
: 0;
// Send Bitcoin transaction
const handleBitcoinSend = async (
amount: string,
recipientAddress: string,
onSuccess: () => void
) => {
if (!hasNsecAccess || !bitcoinAddress) {
// Default: Toast notification
toast({ variant: "destructive", title: "Error", description: "Nsec login required for Bitcoin transactions" });
// Alternative options (commented):
// Option 1: Console logging
// console.error('Error: Nsec login required for Bitcoin transactions');
// Option 2: No notification (silent failure)
return;
}
try {
const privateKey = getPrivateKey();
if (!privateKey) {
throw new Error('Unable to access private key');
}
const amountSats = parseInt(amount);
if (isNaN(amountSats) || amountSats <= 0) {
throw new Error('Invalid amount');
}
// Fetch fresh UTXOs and fee rates
const [utxos, feeRates] = await Promise.all([
fetchUTXOs(bitcoinAddress),
getFeeRates(),
]);
const isSendMax = amountSats === bitcoinMaxAmount;
// Create and sign transaction
const { txHex, fee } = await createBitcoinTransaction(
privateKey,
recipientAddress,
amountSats,
utxos,
feeRates.fastestFee,
isSendMax
);
// Broadcast
const txId = await broadcastTransaction(txHex);
// Default: Toast notification
toast({ title: "Bitcoin Sent!", description: `Transaction ${txId.slice(0, 8)}... (Fee: ${fee} sats)` });
// Alternative options (commented):
// Option 1: Console logging
// console.log(`Bitcoin Sent! Transaction ${txId.slice(0, 8)}... (Fee: ${fee} sats)`);
// Option 2: No notification (silent success)
refetchBalance();
onSuccess();
} catch (error) {
// Default: Toast notification
toast({ variant: "destructive", title: "Send Failed", description: error instanceof Error ? error.message : 'Unknown error' });
// Alternative options (commented):
// Option 1: Console logging
// console.error('Send Failed:', error instanceof Error ? error.message : 'Unknown error');
// Option 2: No notification (silent failure)
}
};
// Consolidate UTXOs (combine multiple UTXOs into one)
const handleConsolidateUtxos = async () => {
if (!hasNsecAccess || !bitcoinAddress || utxos.length <= 1) {
// Default: Toast notification
toast({ variant: "destructive", title: "Error", description: "Cannot consolidate UTXOs" });
// Alternative options (commented):
// Option 1: Console logging
// console.error('Error: Cannot consolidate UTXOs');
// Option 2: No notification (silent failure)
return;
}
setIsConsolidating(true);
try {
const privateKey = getPrivateKey();
if (!privateKey) {
throw new Error('Unable to access private key');
}
const consolidationAmount = calculateMaxSendAmount(utxos, feeRate);
if (consolidationAmount <= 0) {
throw new Error('Insufficient funds for consolidation');
}
// Self-send with all UTXOs
const { txHex, fee } = await createBitcoinTransaction(
privateKey,
bitcoinAddress, // Send to self
consolidationAmount,
utxos,
feeRate,
true // sendMax = true
);
const txId = await broadcastTransaction(txHex);
// Default: Toast notification
toast({ title: "UTXOs Consolidated!", description: `Transaction ${txId.slice(0, 8)}... (Fee: ${fee} sats)` });
// Alternative options (commented):
// Option 1: Console logging
// console.log(`UTXOs Consolidated! Transaction ${txId.slice(0, 8)}... (Fee: ${fee} sats)`);
// Option 2: No notification (silent success)
refetchBalance();
} catch (error) {
// Default: Toast notification
toast({ variant: "destructive", title: "Consolidation Failed", description: error instanceof Error ? error.message : 'Unknown error' });
// Alternative options (commented):
// Option 1: Console logging
// console.error('Consolidation Failed:', error instanceof Error ? error.message : 'Unknown error');
// Option 2: No notification (silent failure)
} finally {
setIsConsolidating(false);
}
};
return {
utxos,
feeRate,
isConsolidating,
bitcoinMaxAmount,
handleBitcoinSend,
handleConsolidateUtxos,
};
}
Essential for Bitcoin wallets: Users need to copy Bitcoin addresses easily. This hook provides clipboard functionality with visual feedback.
// hooks/useCopyToClipboard.ts
import { useState, useCallback } from 'react';
// optional import { useToast } from '@/hooks/useToast';
interface UseCopyToClipboardReturn {
copy: (text: string) => Promise<void>;
copied: boolean;
copyError: string | null;
}
/**
* Hook for copying text to clipboard with feedback
*
* @example
* ```tsx
* const { copy, copied, copyError } = useCopyToClipboard();
*
* await copy('bc1p...');
* // copied will be true for 2 seconds
* ```
*/
export function useCopyToClipboard(): UseCopyToClipboardReturn {
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState<string | null>(null);
// Optional: User feedback notifications
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: Toast notifications (if useToast hook is available)
// const { toast } = useToast();
// Option 3: No notification handler
const copy = useCallback(async (text: string): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setCopyError(null);
// Auto-reset copied state after 2 seconds
setTimeout(() => setCopied(false), 2000);
} catch (err) {
const errorMessage = 'Failed to copy to clipboard';
console.error(errorMessage, err);
setCopyError(errorMessage);
// Optional: User feedback - choose one:
// Option 1: Console logging
// console.error('Failed to copy to clipboard:', err);
// Option 2: Toast notification (if toast is available)
// toast({ variant: "destructive", title: "Copy Failed", description: errorMessage });
// Option 3: No notification (silent failure)
}
}, []);
return {
copy,
copied,
copyError,
};
}
Usage in wallet components:
// components/wallet/BitcoinWallet.tsx
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
import { Button } from '@/components/ui/button';
import { Copy, Check } from 'lucide-react';
function BitcoinAddressDisplay({ address }: { address: string }) {
const { copy, copied } = useCopyToClipboard();
return (
<div className="flex items-center gap-2">
<code className="text-sm font-mono">{address}</code>
<Button
variant="ghost"
size="icon"
onClick={() => copy(address)}
className="h-8 w-8"
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
);
}
Key Features:
// components/wallet/BitcoinWallet.tsx
import { BitcoinAddressDisplay } from './BitcoinAddressDisplay';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
export function BitcoinWallet() {
const { user } = useCurrentUser();
const { hasNsecAccess } = useNsecAccess();
// Optional: User feedback notifications
// Option 1: Console logging
// const logMessage = (message: string) => console.log(message);
// Option 2: Toast notifications (if useToast hook is available)
// const { toast } = useToast();
// Option 3: No notification handler
const bitcoinAddress = user?.pubkey
? nostrPubkeyToBitcoinAddress(user.pubkey)
: null;
const { data: balanceData } = useBitcoinBalance(bitcoinAddress);
const { data: transactions } = useBitcoinTransactionHistory(bitcoinAddress);
const {
utxos,
bitcoinMaxAmount,
handleBitcoinSend,
handleConsolidateUtxos,
} = useBitcoinOperations(bitcoinAddress);
// State
const [amount, setAmount] = useState('');
const [recipient, setRecipient] = useState('');
const [showSendModal, setShowSendModal] = useState(false);
// Exchange rate for USD display
const balanceSats = balanceData?.balance || 0;
const usdValue = useSatsToUsd(balanceSats);
if (!hasNsecAccess) {
return (
<div className="p-4">
<p>Bitcoin wallet requires nsec login</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Balance Display with USD equivalent */}
<div className="p-4 border rounded-lg">
<h2 className="text-2xl font-bold">
{balanceSats.toLocaleString()} sats
</h2>
{usdValue !== null && (
<p className="text-sm text-muted-foreground">
≈ ${usdValue.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
{bitcoinAddress && (
<BitcoinAddressDisplay address={bitcoinAddress} />
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<Button onClick={() => setShowSendModal(true)}>
Send
</Button>
<Button onClick={handleConsolidateUtxos}>
Consolidate UTXOs
</Button>
</div>
{/* Transaction History */}
<div className="space-y-2">
{transactions?.map((tx) => (
<div key={tx.id} className="p-2 border rounded">
<p>{tx.type}: {tx.amount} sats</p>
<p className="text-sm text-muted-foreground">
{tx.status}
</p>
</div>
))}
</div>
{/* Send Modal */}
{showSendModal && (
<SendModal
amount={amount}
setAmount={setAmount}
recipient={recipient}
setRecipient={setRecipient}
maxAmount={bitcoinMaxAmount}
onSend={() => {
handleBitcoinSend(amount, recipient, () => {
setShowSendModal(false);
setAmount('');
setRecipient('');
});
}}
onClose={() => setShowSendModal(false)}
/>
)}
</div>
);
}
REQUIRED: Use the qr-code-generator skill for displaying Bitcoin addresses as QR codes. OPTIONAL: Use the qr-code-scanner skill for scanning recipient addresses.
Displaying Bitcoin Address as QR Code:
// components/wallet/BitcoinWallet.tsx
import { useQRCodeGenerator } from '@/hooks/useQRCodeGenerator';
import { QRModal } from '@/components/QRModal';
function BitcoinWallet() {
const { generateQRCode } = useQRCodeGenerator();
const [showQRModal, setShowQRModal] = useState(false);
// Generate QR code for Bitcoin address
const handleShowQR = async () => {
if (bitcoinAddress) {
const qrDataUrl = await generateQRCode(bitcoinAddress);
setShowQRModal(true);
}
};
return (
<>
<Button onClick={handleShowQR}>Show QR Code</Button>
{showQRModal && bitcoinAddress && (
<QRModal
isOpen={showQRModal}
onClose={() => setShowQRModal(false)}
qrDataUrl={await generateQRCode(bitcoinAddress)}
address={bitcoinAddress}
/>
)}
</>
);
}
Scanning Recipient Address:
// components/wallet/SendModal.tsx
import { QRScanner } from '@/components/QRScanner';
import { useQRCodeScanner } from '@/hooks/useQRCodeScanner';
import { useToast } from '@/hooks/useToast';
function SendModal({ onSend, onClose }: SendModalProps) {
const { toast } = useToast();
const { classifyQRCode, startScanning, stopScanning, error: qrError } = useQRCodeScanner();
const [showScanner, setShowScanner] = useState(false);
const [recipient, setRecipient] = useState('');
const handleQRResult = (result: QRScanResult) => {
if (result.type === 'bitcoin_address') {
setRecipient(result.value);
setShowScanner(false);
} else {
// Default: Toast notification
toast({ variant: "destructive", title: "Invalid QR Code", description: "Please scan a Bitcoin address" });
// Alternative options (commented):
// Option 1: Console logging
// console.error('Invalid QR Code: Please scan a Bitcoin address');
// Option 2: No notification (silent failure)
}
};
return (
<>
<Button onClick={() => setShowScanner(true)}>Scan QR Code</Button>
{showScanner && (
<QRScanner
isOpen={showScanner}
onClose={async () => {
await stopScanning();
setShowScanner(false);
}}
onResult={handleQRResult}
classifyQRCode={classifyQRCode}
startScanning={startScanning}
stopScanning={stopScanning}
qrError={qrError}
/>
)}
</>
);
}
Key Integration Points:
generateQRCode() from qr-code-generator skill to create QR codes for Bitcoin addressesQRScanner component from qr-code-scanner skill to scan recipient addressesqr-code-scanner skill automatically classifies Bitcoin addresses, Lightning invoices, and other formatsSee the qr-code-generator skill (required) and qr-code-scanner skill (optional) for complete implementation details.
Use the exchange-rates skill for displaying USD equivalents and converting between BTC/sats and fiat currencies.
Displaying Balance with USD Equivalent:
// components/wallet/BitcoinWallet.tsx
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { BalanceDisplay } from '@/components/wallet/BalanceDisplay';
function BitcoinWallet() {
const { data: balanceData } = useBitcoinBalance(bitcoinAddress);
const balanceSats = balanceData?.balance || 0;
return (
<div className="space-y-4">
{/* Balance Display with USD equivalent */}
<BalanceDisplay sats={balanceSats} />
{/* Or inline display */}
<div className="p-4 border rounded-lg">
<h2 className="text-2xl font-bold">
{balanceSats.toLocaleString()} sats
</h2>
<UsdEquivalent sats={balanceSats} />
</div>
</div>
);
}
// Helper component for USD equivalent
function UsdEquivalent({ sats }: { sats: number }) {
const usdValue = useSatsToUsd(sats);
if (usdValue === null) {
return (
<p className="text-sm text-muted-foreground">
Loading exchange rate...
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
≈ ${usdValue.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
);
}
Displaying Exchange Rate:
// components/wallet/BitcoinWallet.tsx
import { ExchangeRateDisplay } from '@/components/ExchangeRateDisplay';
function BitcoinWallet() {
return (
<div className="space-y-4">
{/* Show current BTC/USD rate */}
<ExchangeRateDisplay />
{/* Rest of wallet UI */}
</div>
);
}
Converting Transaction Amounts:
// components/wallet/TransactionHistory.tsx
import { useSatsToUsd } from '@/hooks/useExchangeRate';
function TransactionItem({ transaction }: { transaction: BitcoinTransaction }) {
const usdValue = useSatsToUsd(transaction.amount);
return (
<div className="flex justify-between items-center">
<div>
<p>{transaction.type}: {transaction.amount.toLocaleString()} sats</p>
{usdValue !== null && (
<p className="text-sm text-muted-foreground">
≈ ${usdValue.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
</div>
<span className="text-sm">{transaction.status}</span>
</div>
);
}
Key Integration Points:
useSatsToUsd() hook to display USD values alongside satoshi amountsExchangeRateDisplay component to show current BTC/USD rateexchange-rates skill handles caching automaticallySee the exchange-rates skill for complete implementation details.
Follow TDD principles: Write tests before implementation.
// lib/bitcoin.test.ts
describe('Bitcoin Utilities', () => {
describe('nostrPubkeyToBitcoinAddress', () => {
test('converts valid 32-byte Nostr pubkey to Taproot address', () => {
const pubkey = 'a'.repeat(64); // Valid 64-char hex = 32 bytes
const address = nostrPubkeyToBitcoinAddress(pubkey);
expect(address).toMatch(/^bc1p/);
expect(address.length).toBeGreaterThan(50);
});
test('handles 0x prefix correctly', () => {
const pubkey = '0x' + 'a'.repeat(64);
expect(() => nostrPubkeyToBitcoinAddress(pubkey)).not.toThrow();
const address = nostrPubkeyToBitcoinAddress(pubkey);
expect(address).toMatch(/^bc1p/);
});
test('rejects invalid key lengths', () => {
expect(() => {
nostrPubkeyToBitcoinAddress('abc'); // Too short
}).toThrow('Invalid pubkey length');
expect(() => {
nostrPubkeyToBitcoinAddress('a'.repeat(66)); // Too long
}).toThrow('Invalid pubkey length');
});
test('rejects non-hex characters', () => {
expect(() => {
nostrPubkeyToBitcoinAddress('g'.repeat(64)); // Invalid hex
}).toThrow('Invalid hex characters');
});
test('rejects empty or null input', () => {
expect(() => nostrPubkeyToBitcoinAddress('')).toThrow();
expect(() => nostrPubkeyToBitcoinAddress(null as any)).toThrow();
});
test('generates deterministic addresses', () => {
const pubkey = 'b'.repeat(64);
const addr1 = nostrPubkeyToBitcoinAddress(pubkey);
const addr2 = nostrPubkeyToBitcoinAddress(pubkey);
expect(addr1).toBe(addr2);
});
});
describe('npubToBitcoinAddress', () => {
test('converts valid npub to address', () => {
// Use a real npub format (simplified test)
const npub = 'npub1test...'; // Replace with valid npub in real test
// This would require mocking nip19.decode
});
test('rejects invalid npub format', () => {
expect(() => {
npubToBitcoinAddress('invalid');
}).toThrow();
});
});
describe('calculateMaxSendAmount', () => {
test('returns balance minus fee', () => {
const utxos = [
{ txid: '123', vout: 0, value: 10000, status: { confirmed: true } }
];
const feeRate = 1;
const maxAmount = calculateMaxSendAmount(utxos, feeRate);
expect(maxAmount).toBeLessThan(10000);
expect(maxAmount).toBeGreaterThan(9000);
});
test('returns 0 if fee exceeds balance', () => {
const utxos = [
{ txid: '123', vout: 0, value: 100, status: { confirmed: true } }
];
const feeRate = 10;
const maxAmount = calculateMaxSendAmount(utxos, feeRate);
expect(maxAmount).toBe(0);
});
});
});
// hooks/useBitcoin.test.tsx
describe('useBitcoinBalance', () => {
test('fetches balance and UTXOs', async () => {
const { result, waitFor } = renderHook(() =>
useBitcoinBalance('bc1p...')
);
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toHaveProperty('balance');
expect(result.current.data).toHaveProperty('utxos');
});
});
Never log private keys
Validate all inputs
Use HTTPS for all API calls
Implement rate limiting
Verify transaction before broadcast
Handle errors without exposing details
import { useToast } from '@/hooks/useToast';
// In your component or hook:
const { toast } = useToast();
catch (error) {
console.error('Transaction error:', error); // For developers
// Default: Toast notification
toast({ variant: "destructive", title: "Transaction Failed", description: "Please try again" });
// Alternative options (commented):
// Option 1: Console logging
// console.error('Transaction Failed: Please try again');
// Option 2: No notification (silent failure)
}
Problem: bitcoinjs-lib v7+ requires bitcoin.initEccLib(ecc) before any operations. Missing this causes runtime errors when calling bitcoinjs-lib functions.
Solution: Initialize at module load time (top level of bitcoin utilities file):
import * as bitcoin from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
// Initialize ECC library immediately - MUST be at top level
bitcoin.initEccLib(ecc);
Where: Top level of your bitcoin utilities module, not inside functions. Call once when the module loads.
Problem: Nostr pubkeys are 32 bytes (64 hex chars) with no prefix. Bitcoin compressed keys are 33 bytes with 02/03 prefix. Using .subarray(1, 33) removes actual key data.
Wrong:
internalPubkey: pubkeyBuffer.subarray(1, 33) // Removes first byte - WRONG for Nostr!
Right:
internalPubkey: pubkeyBuffer // Use full 32-byte Nostr key directly
Solution: Always validate key length before conversion. Nostr keys = 64 hex chars exactly.
Problem: Buffer.from(pubkey, 'hex') can fail silently or produce wrong-sized buffers.
Wrong:
const buffer = Buffer.from(pubkeyHex, 'hex'); // No validation
Right:
function validateAndConvertKey(pubkeyHex: string): Buffer {
// Remove 0x prefix, validate length (64 chars), validate hex format
// Then convert to buffer and verify 32 bytes
}
Solution: Always validate length, format, and buffer size before using keys.
Problem: Keys may come with 0x prefix, whitespace, or mixed case.
Solution: Normalize input first:
let cleanHex = pubkeyHex.trim();
if (cleanHex.startsWith('0x')) cleanHex = cleanHex.slice(2);
// Then validate and convert
Problem: Generic errors like "Invalid key" don't help debugging.
Wrong:
throw new Error('Invalid key');
Right:
throw new Error(`Invalid pubkey length: expected 64 hex chars, got ${cleanHex.length}`);
Solution: Include specific details: expected vs actual values, what failed, why.
Problem: Creating outputs below 546 sats (dust threshold) makes transactions invalid.
Solution:
if (changeAmount >= 546) {
psbt.addOutput({ address: senderAddress, value: changeAmount });
}
Problem: Using stale UTXO data leads to double-spend attempts.
Solution: Always refetch UTXOs after broadcasting transactions.
Problem: Underestimating fees causes transactions to be rejected or delayed.
Solution: Use current fee rates and add buffer for safety.
Problem: Spending unconfirmed UTXOs can cause transaction chains to fail.
Solution: Filter UTXOs by confirmation status:
const confirmedUtxos = utxos.filter(utxo => utxo.status.confirmed);
Replace-By-Fee (RBF):
Coin Control:
Multi-sig Support:
Primary data source for Bitcoin operations:
https://blockstream.info/api/address/:address/utxo - Fetch UTXOs/address/:address/txs - Transaction history/tx/:txid - Transaction details/fee-estimates - Current fee rates/tx (POST) - Broadcast transactionsAlternative APIs:
Before marking implementation complete:
bc1p (Taproot format)0x prefix (handled correctly)Bitcoin Improvement Proposals (BIPs):
Documentation:
To implement a Bitcoin wallet:
Key principle: The wallet enables Bitcoin functionality using Nostr identity, eliminating separate key management while maintaining Bitcoin's security model.