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 // --------------------------------------------------------------------------- /** 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; } /** * Strict 32-byte hex validator. Rejects anything that isn't exactly 64 * lowercase-or-uppercase hex characters. */ function isValidPubkeyHex(hex: string): boolean { return typeof hex === 'string' && /^[0-9a-fA-F]{64}$/.test(hex); } /** * 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. * * Returns an empty string if the input is malformed or not a valid x-only key * on the secp256k1 curve. */ export function nostrPubkeyToBitcoinAddress(pubkeyHex: string): string { if (!isValidPubkeyHex(pubkeyHex)) return ''; 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 ''; } } /** * 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) // --------------------------------------------------------------------------- /** Balance data returned by the Esplora 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 * mempool.space Esplora API. */ export async function fetchAddressData(address: string): Promise { const response = await fetch(`${MEMPOOL_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, }; } // --------------------------------------------------------------------------- // 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); } /** * Convert satoshis to a BTC string with trailing zeros stripped. * E.g. `formatBTC(100_000_000)` → `"1"`, `formatBTC(1_234_560)` → `"0.0123456"`. */ export function formatBTC(sats: number): string { return satsToBTC(sats).replace(/\.?0+$/, ''); } /** Format a satoshi amount with locale-aware thousand separators. */ 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 a BTC amount to satoshis (rounded to nearest integer). */ export function btcToSats(btc: number): number { return Math.round(btc * 100_000_000); } /** * USD threshold above which Bitcoin send/zap flows should require explicit * confirmation (two-tap). Chosen to catch meaningful dollar amounts without * nagging on everyday $5–$25 zaps. */ export const LARGE_AMOUNT_USD_THRESHOLD = 100; /** * Whether a given satoshi amount crosses the "large amount" threshold at the * current BTC/USD price. Returns false when `btcPrice` is unavailable, so the * UI does not arm confirmation without a known USD value. */ export function isLargeAmount(sats: number, btcPrice: number | undefined): boolean { if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return false; if (!Number.isFinite(sats) || sats <= 0) return false; const usd = (sats / 100_000_000) * btcPrice; return usd >= LARGE_AMOUNT_USD_THRESHOLD; } /** 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, }); } // --------------------------------------------------------------------------- // Wallet-page transaction list (simplified per-address view) // --------------------------------------------------------------------------- /** A simplified transaction relevant to a specific address. */ export interface Transaction { /** Transaction ID (hex). */ txid: string; /** Net satoshi change for the address (always positive — see `type`). */ 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 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(`${MEMPOOL_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; }); } // --------------------------------------------------------------------------- // Full transaction detail (NIP-73 /:nip19 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 /:nip19 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), }; } // --------------------------------------------------------------------------- // Sending: UTXOs, fee estimation, transaction construction, broadcast // --------------------------------------------------------------------------- /** An unspent transaction output. */ export interface UTXO { txid: string; vout: number; /** Value in satoshis. */ value: number; status: { confirmed: boolean; block_height?: number; block_hash?: string; block_time?: number; }; } /** Fetch UTXOs for a Bitcoin address from mempool.space. */ export async function fetchUTXOs(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); } /** Result of building an unsigned PSBT. */ export interface UnsignedPsbt { /** Hex-encoded unsigned PSBT. */ psbtHex: string; /** Fee in satoshis. */ fee: number; } /** * Build an unsigned Taproot PSBT ready for signing. * * 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 buildUnsignedPsbt( senderPubkeyHex: string, toAddress: string, amountSats: number, utxos: UTXO[], feeRate: number, ): UnsignedPsbt { const internalPubkey = Buffer.from(senderPubkeyHex, 'hex'); // 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'); // 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; } // Estimate fee — first assume 2 outputs (recipient + change). Change at the // dust limit exactly is still standard, so use >= (not >) per BIP-141/P2TR // relay policy (minimum non-dust output is 546 sats). 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.`, ); } // Add outputs psbt.addOutput({ address: toAddress, value: BigInt(amountSats) }); if (hasChange) { psbt.addOutput({ address: changeAddress, value: BigInt(change) }); } 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), ); // Per BITCOIN-SIGNING.md: inputs whose `tapInternalKey` does not match the // signer's x-only pubkey MUST be left unchanged. This matters for future // multi-signer PSBTs; today `buildUnsignedPsbt` only ever adds the user's // own UTXOs, so in practice every input matches. let signedAny = false; for (let i = 0; i < psbt.inputCount; i++) { const input = psbt.data.inputs[i]; const inputInternalKey = input.tapInternalKey; if (!inputInternalKey || !Buffer.from(inputInternalKey).equals(Buffer.from(internalPubkey))) { continue; } psbt.signInput(i, tweakedSigner); signedAny = true; } if (!signedAny) { throw new Error('No inputs in this PSBT are owned by the signer.'); } 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(); }