B++ Logo

Transaction Construction

Building Bitcoin transactions from scratch requires understanding inputs, outputs, fees, and signing. This guide covers the complete process from UTXO selection to broadcasting.

Transaction Lifecycle

Understanding the complete lifecycle of a transaction helps contextualize the construction process:

  1. Creation: User constructs a transaction specifying inputs (UTXOs to spend) and outputs (recipient addresses and amounts)
  2. Signing: User signs the transaction with their private key, proving ownership of the inputs
  3. Broadcasting: Signed transaction is sent to the network
  4. Mempool: Transaction waits in the mempool (memory pool) of unconfirmed transactions
  5. Selection: A miner selects the transaction (typically prioritizing higher fees)
  6. Inclusion: Transaction is included in a candidate block
  7. Mining: Miner finds valid proof-of-work for the block
  8. Propagation: New block spreads across the network
  9. Confirmation: Each subsequent block adds another confirmation, increasing security

A transaction with 6 confirmations is generally considered irreversible.


Transaction Structure

Components

Transaction
├── Version (4 bytes)
├── Marker & Flag (SegWit only, 2 bytes)
├── Input Count (varint)
├── Inputs
│   ├── Previous TXID (32 bytes)
│   ├── Output Index (4 bytes)
│   ├── Script Length (varint)
│   ├── ScriptSig (variable)
│   └── Sequence (4 bytes)
├── Output Count (varint)
├── Outputs
│   ├── Value (8 bytes)
│   ├── Script Length (varint)
│   └── ScriptPubKey (variable)
├── Witness (SegWit only)
│   └── Witness data per input
└── Locktime (4 bytes)

Byte Order: Most numeric fields (version, value, locktime, sequence, output index) are encoded in little endian. However, transaction IDs (TXIDs) and block hashes are typically displayed in big endian (reversed) for readability, even though they're stored internally in little endian. When working with raw transaction data, the [::-1] reversal in Python (or equivalent) converts between these formats.

Size Calculations

Virtual size (vbytes) = (base_size × 3 + total_size) / 4


Building Transactions


Input Selection

Coin Selection Algorithms

Largest First

function largestFirst(utxos: UTXO[], target: number): UTXO[] {
  // Sort by value descending
  const sorted = [...utxos].sort((a, b) => b.value - a.value);
  
  const selected: UTXO[] = [];
  let total = 0;
  
  for (const utxo of sorted) {
    selected.push(utxo);
    total += utxo.value;
    if (total >= target) break;
  }
  
  return total >= target ? selected : [];
}

Branch and Bound (Exact Match)

function branchAndBound(utxos: UTXO[], target: number, maxTries = 100000): UTXO[] | null {
  let tries = 0;
  let bestSelection: UTXO[] | null = null;
  let bestWaste = Infinity;
  
  function search(index: number, selected: UTXO[], total: number): void {
    if (tries++ > maxTries) return;
    
    // Found exact match
    if (total === target) {
      bestSelection = [...selected];
      bestWaste = 0;
      return;
    }
    
    // Over target, calculate waste
    if (total > target) {
      const waste = total - target;
      if (waste < bestWaste) {
        bestSelection = [...selected];
        bestWaste = waste;
      }
      return;
    }
    
    // Try including next UTXO
    if (index < utxos.length) {
      // Include
      selected.push(utxos[index]);
      search(index + 1, selected, total + utxos[index].value);
      selected.pop();
      
      // Exclude
      search(index + 1, selected, total);
    }
  }
  
  search(0, [], 0);
  return bestSelection;
}

Knapsack

function knapsack(utxos: UTXO[], target: number): UTXO[] {
  const n = utxos.length;
  const dp: boolean[][] = Array(n + 1).fill(null)
    .map(() => Array(target + 1).fill(false));
  
  // Base case: sum of 0 is always achievable
  for (let i = 0; i <= n; i++) dp[i][0] = true;
  
  // Fill the table
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= target; j++) {
      dp[i][j] = dp[i - 1][j]; // Don't include
      if (j >= utxos[i - 1].value) {
        dp[i][j] = dp[i][j] || dp[i - 1][j - utxos[i - 1].value];
      }
    }
  }
  
  // Backtrack to find solution
  if (!dp[n][target]) return largestFirst(utxos, target); // Fallback
  
  const selected: UTXO[] = [];
  let remaining = target;
  for (let i = n; i > 0 && remaining > 0; i--) {
    if (!dp[i - 1][remaining]) {
      selected.push(utxos[i - 1]);
      remaining -= utxos[i - 1].value;
    }
  }
  
  return selected;
}

Fee Estimation

Fee Rate Sources

async function getFeeRate(): Promise<number> {
  // Option 1: mempool.space API
  const response = await fetch('https://mempool.space/api/v1/fees/recommended');
  const fees = await response.json();
  
  return {
    fast: fees.fastestFee,      // Next block
    medium: fees.halfHourFee,   // ~30 min
    slow: fees.hourFee,         // ~1 hour
    economy: fees.economyFee,   // Low priority
  };
}

// Option 2: Bitcoin Core RPC
async function getFeeRateFromNode(rpc: BitcoinRPC, blocks: number): Promise<number> {
  const result = await rpc.call('estimatesmartfee', [blocks]);
  if (result.feerate) {
    // Convert BTC/kB to sat/vB
    return Math.ceil(result.feerate * 100000);
  }
  throw new Error('Fee estimation failed');
}

Calculating Transaction Fee

interface TransactionSizes {
  P2PKH_INPUT: 148,
  P2WPKH_INPUT: 68,
  P2TR_INPUT: 57.5,
  P2PKH_OUTPUT: 34,
  P2WPKH_OUTPUT: 31,
  P2TR_OUTPUT: 43,
  OVERHEAD: 10.5,
}

function estimateFee(
  inputs: { type: string }[],
  outputs: { type: string }[],
  feeRate: number
): number {
  let vSize = TransactionSizes.OVERHEAD;
  
  for (const input of inputs) {
    switch (input.type) {
      case 'P2PKH': vSize += TransactionSizes.P2PKH_INPUT; break;
      case 'P2WPKH': vSize += TransactionSizes.P2WPKH_INPUT; break;
      case 'P2TR': vSize += TransactionSizes.P2TR_INPUT; break;
    }
  }
  
  for (const output of outputs) {
    switch (output.type) {
      case 'P2PKH': vSize += TransactionSizes.P2PKH_OUTPUT; break;
      case 'P2WPKH': vSize += TransactionSizes.P2WPKH_OUTPUT; break;
      case 'P2TR': vSize += TransactionSizes.P2TR_OUTPUT; break;
    }
  }
  
  return Math.ceil(vSize * feeRate);
}

Replace-By-Fee (RBF)

Enabling RBF

function createRBFTransaction(psbt: Psbt) {
  // Set sequence to enable RBF (< 0xFFFFFFFE)
  psbt.setInputSequence(0, 0xFFFFFFFD);
  
  // Alternative: Use the constant
  psbt.setInputSequence(0, bitcoin.Transaction.DEFAULT_SEQUENCE - 2);
}

Creating Replacement Transaction

function bumpFee(originalTx: Transaction, newFeeRate: number): Psbt {
  const psbt = new bitcoin.Psbt();
  
  // Copy inputs from original transaction
  for (const input of originalTx.ins) {
    psbt.addInput({
      hash: input.hash,
      index: input.index,
      sequence: 0xFFFFFFFD, // RBF enabled
      // Add witness UTXO data...
    });
  }
  
  // Recalculate outputs with higher fee
  const originalFee = calculateFee(originalTx);
  const newVSize = originalTx.virtualSize();
  const newFee = newVSize * newFeeRate;
  const additionalFee = newFee - originalFee;
  
  // Reduce change output by additional fee
  for (let i = 0; i < originalTx.outs.length; i++) {
    const output = originalTx.outs[i];
    if (isChangeOutput(output)) {
      psbt.addOutput({
        script: output.script,
        value: output.value - additionalFee,
      });
    } else {
      psbt.addOutput({
        script: output.script,
        value: output.value,
      });
    }
  }
  
  return psbt;
}

Child-Pays-For-Parent (CPFP)

Creating CPFP Transaction

function createCPFPTransaction(
  stuckTx: Transaction,
  stuckTxFee: number,
  targetFeeRate: number
): Psbt {
  // Find our output in the stuck transaction
  const ourOutput = findOurOutput(stuckTx);
  
  // Calculate required fee for both transactions
  const stuckTxVSize = stuckTx.virtualSize();
  const childVSize = 110; // Estimate for simple spend
  const totalVSize = stuckTxVSize + childVSize;
  const totalFeeNeeded = totalVSize * targetFeeRate;
  const childFee = totalFeeNeeded - stuckTxFee;
  
  // Create child transaction
  const psbt = new bitcoin.Psbt();
  
  psbt.addInput({
    hash: stuckTx.getId(),
    index: ourOutput.index,
    witnessUtxo: {
      script: ourOutput.script,
      value: ourOutput.value,
    },
  });
  
  psbt.addOutput({
    address: newAddress,
    value: ourOutput.value - childFee,
  });
  
  return psbt;
}

Transaction Batching

Batch Multiple Payments

interface Payment {
  address: string;
  amount: number;
}

function createBatchTransaction(
  utxos: UTXO[],
  payments: Payment[],
  feeRate: number,
  changeAddress: string
): Psbt {
  const psbt = new bitcoin.Psbt();
  
  // Calculate total needed
  const totalPayments = payments.reduce((sum, p) => sum + p.amount, 0);
  
  // Select inputs
  const estimatedFee = estimateBatchFee(utxos.length, payments.length + 1, feeRate);
  const target = totalPayments + estimatedFee;
  const selectedUtxos = selectCoins(utxos, target);
  
  // Add inputs
  let totalInput = 0;
  for (const utxo of selectedUtxos) {
    psbt.addInput({
      hash: utxo.txid,
      index: utxo.vout,
      witnessUtxo: {
        script: utxo.script,
        value: utxo.value,
      },
    });
    totalInput += utxo.value;
  }
  
  // Add payment outputs
  for (const payment of payments) {
    psbt.addOutput({
      address: payment.address,
      value: payment.amount,
    });
  }
  
  // Add change output
  const actualFee = estimateBatchFee(selectedUtxos.length, payments.length + 1, feeRate);
  const change = totalInput - totalPayments - actualFee;
  
  if (change > 546) {
    psbt.addOutput({
      address: changeAddress,
      value: change,
    });
  }
  
  return psbt;
}

Benefits of Batching

Single transaction to 10 recipients:
- 1 input, 11 outputs (including change)
- ~380 vbytes
- 1 fee payment

10 separate transactions:
- 10 inputs, 20 outputs total
- ~1,100 vbytes total
- 10 fee payments

Time Locks

Absolute Time Lock (nLockTime)

function createTimeLocked(lockTime: number): Psbt {
  const psbt = new bitcoin.Psbt();
  
  // Set locktime (block height or Unix timestamp)
  psbt.setLocktime(lockTime);
  
  // Must set sequence < 0xFFFFFFFF to enable locktime
  psbt.addInput({
    hash: utxo.txid,
    index: utxo.vout,
    sequence: 0xFFFFFFFE,
    witnessUtxo: {
      script: utxo.script,
      value: utxo.value,
    },
  });
  
  psbt.addOutput({
    address: recipient,
    value: amount,
  });
  
  return psbt;
}

// Lock until specific block
createTimeLocked(850000); // Block 850,000

// Lock until specific time (Unix timestamp > 500,000,000)
createTimeLocked(1735689600); // Jan 1, 2025

Relative Time Lock (CSV)

function createCSVLocked(blocks: number): Psbt {
  const psbt = new bitcoin.Psbt();
  
  // Set sequence for relative timelock
  // Blocks: blocks (up to 65535)
  // Time: blocks | 0x00400000 (in 512-second units)
  psbt.addInput({
    hash: utxo.txid,
    index: utxo.vout,
    sequence: blocks, // e.g., 144 for ~1 day
    witnessUtxo: {
      script: utxo.script,
      value: utxo.value,
    },
  });
  
  return psbt;
}

Broadcasting Transactions


Error Handling

Common Errors

async function safebroadcast(txHex: string): Promise<string> {
  try {
    return await broadcastTransaction(txHex);
  } catch (error) {
    const message = error.message.toLowerCase();
    
    if (message.includes('insufficient fee')) {
      throw new Error('Fee too low. Increase fee rate and retry.');
    }
    if (message.includes('dust')) {
      throw new Error('Output amount too small (below dust limit).');
    }
    if (message.includes('missing inputs') || message.includes('bad-txns-inputs-missingorspent')) {
      throw new Error('Input already spent or does not exist.');
    }
    if (message.includes('txn-mempool-conflict')) {
      throw new Error('Conflicting transaction in mempool. May need RBF.');
    }
    if (message.includes('non-final')) {
      throw new Error('Transaction timelock not yet satisfied.');
    }
    
    throw error;
  }
}

Best Practices

Transaction Construction

  1. Validate All Inputs: Verify UTXOs exist and are unspent
  2. Calculate Fees Carefully: Use appropriate fee rate for urgency
  3. Handle Dust: Don't create outputs below dust limit (~546 sats)
  4. Use RBF: Enable RBF for flexibility (unless specific reason not to)
  5. Verify Before Signing: Double-check amounts and addresses

Security

  1. Test on Testnet: Always test transaction logic on testnet first
  2. Validate Addresses: Verify recipient addresses are valid
  3. Check Change Amounts: Ensure change is calculated correctly
  4. Review Before Broadcast: Final review of all transaction details

Optimization

  1. Batch When Possible: Combine multiple payments into one transaction
  2. Use SegWit/Taproot: Lower fees for SegWit and Taproot inputs/outputs
  3. Consolidate UTXOs: During low-fee periods, consolidate small UTXOs
  4. Avoid Unnecessary Outputs: Minimize output count when possible

Summary

Transaction construction involves:

  • Building: Creating inputs, outputs, and metadata
  • Coin Selection: Choosing optimal UTXOs to spend
  • Fee Estimation: Calculating appropriate fees
  • Signing: Adding valid signatures
  • Broadcasting: Submitting to the network

Understanding these fundamentals enables building robust Bitcoin applications that handle funds safely and efficiently.