Implementing Bitcoin Wallet
Overview
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:
- Send/receive on-chain Bitcoin transactions
- UTXO management and consolidation
- Bitcoin address derivation from Nostr public keys
- Transaction history tracking
- QR code support for addresses
- Fee estimation and transaction building
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.
Prerequisites
Before implementing a Bitcoin wallet, ensure:
- User authentication exists - Requires user pubkey and private key access
- IMPORTANT: Before adding dependencies, review your project's
package.jsonto check if any of these packages already exist. If they do, verify the versions are compatible with the requirements below. Only add packages that are missing or need version updates. - Required packages installed:
{ "bitcoinjs-lib": "^7.0.0", "@bitcoinerlab/secp256k1": "^1.2.0", "ecpair": "^3.0.0" } - QR Code functionality - REQUIRED: For displaying Bitcoin addresses as QR codes, use the
qr-code-generatorskill. OPTIONAL: For scanning recipient addresses, use theqr-code-scannerskill. These skills provide QR code generation, camera-based scanning, and content classification for Bitcoin addresses, Lightning invoices, and other formats. - Exchange Rate functionality - For displaying USD equivalents and converting between BTC/sats and fiat currencies, use the
exchange-ratesskill. This skill provides Coinbase API integration, caching strategies, and React hooks for displaying exchange rates in wallet UIs.
Implementation Checklist
Create TodoWrite todos for each section:
- Initialize ECC library (
bitcoin.initEccLib(ecc)) at module load - Set up Bitcoin utility library
- Implement address derivation from Nostr keys
- Create UTXO fetching and management
- Build transaction creation and signing
- Implement balance tracking hooks
- Create transaction history hooks
- Build wallet UI components
- Add copy to clipboard functionality for addresses
- Add UTXO consolidation feature
- REQUIRED: Implement QR code generation for addresses (use
qr-code-generatorskill) - OPTIONAL: Implement QR code scanning for recipient addresses (use
qr-code-scannerskill) - Add exchange rate display (use
exchange-ratesskill) - Add comprehensive error handling
Part 1: Core Bitcoin Utilities
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.
Address Derivation from Nostr Keys
Key Pattern: Use Taproot (P2TR) addresses derived from Nostr public keys.
CRITICAL: Key Format Differences
- Nostr pubkeys: Always 32 bytes (64 hex chars), no prefix
- Bitcoin compressed pubkeys: 33 bytes with 02/03 prefix (66 hex chars)
- For Taproot: Always use 32-byte internal pubkey directly
// 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?
- Modern address format (bc1p...)
- Better privacy through key-path spending
- Lower transaction fees
- Future-proof for advanced features
UTXO Management
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);
}
Fee Estimation
// 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),
};
}
Transaction Building and Signing
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
}
Part 2: React Hooks
Private Key Access Hook
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,
};
}
Balance Hook
// 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
});
}
Transaction History Hook
// 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,
});
}
Operations Hook
// 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,
};
}
Part 3: UI Components
Copy to Clipboard Hook
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"
=> 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:
- Visual feedback - Shows checkmark when copied
- Auto-reset - Returns to copy icon after 2 seconds
- Error handling - Optional user feedback (console.log, toast notifications, or silent)
- Accessible - Works with screen readers
Main Wallet Component Structure
// 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 => setShowSendModal(true)}>
Send
</Button>
<Button
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}
=> {
handleBitcoinSend(amount, recipient, () => {
setShowSendModal(false);
setAmount('');
setRecipient('');
});
}}
=> setShowSendModal(false)}
/>
)}
</div>
);
}
QR Code Integration
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 QR Code</Button>
{showQRModal && bitcoinAddress && (
<QRModal
isOpen={showQRModal}
=> 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 => setShowScanner(true)}>Scan QR Code</Button>
{showScanner && (
<QRScanner
isOpen={showScanner}
() => {
await stopScanning();
setShowScanner(false);
}}
classifyQRCode={classifyQRCode}
startScanning={startScanning}
stopScanning={stopScanning}
qrError={qrError}
/>
)}
</>
);
}
Key Integration Points:
- QR Generation (REQUIRED) - Use
generateQRCode()fromqr-code-generatorskill to create QR codes for Bitcoin addresses - QR Scanning (OPTIONAL) - Use
QRScannercomponent fromqr-code-scannerskill to scan recipient addresses - Content Classification - The
qr-code-scannerskill automatically classifies Bitcoin addresses, Lightning invoices, and other formats - Error Handling - Both generation and scanning include proper error handling
See the qr-code-generator skill (required) and qr-code-scanner skill (optional) for complete implementation details.
Exchange Rate Integration
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:
- USD Equivalents - Use
useSatsToUsd()hook to display USD values alongside satoshi amounts - Exchange Rate Display - Use
ExchangeRateDisplaycomponent to show current BTC/USD rate - Automatic Caching - The
exchange-ratesskill handles caching automatically - Error Handling - Exchange rate hooks handle API failures gracefully with fallbacks
See the exchange-rates skill for complete implementation details.
Part 4: Testing Strategy
Follow TDD principles: Write tests before implementation.
Unit Tests
// 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);
});
});
});
Integration Tests
// 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');
});
});
Security Considerations
Critical Security Rules
Never log private keys
- No console.log, no error messages, no debug output
- Use secure memory handling
Validate all inputs
- Bitcoin addresses
- Transaction amounts
…(truncated)