Transaction Processing
One of Bitcoin’s core innovations is the UTXO (Unspent Transaction Output) model. Unlike a bank account balance, there is no concept of an “account balance” in the Bitcoin system — all funds exist in a distributed form across the blockchain as unspent transaction outputs. This chapter provides an in-depth look at SimpleBTC’s transaction structure, UTXO model, and transaction creation and validation mechanisms.
UTXO Model Fundamentals
What is a UTXO
UTXO stands for “Unspent Transaction Output.” Every Bitcoin transaction consumes some existing UTXOs (as inputs) and simultaneously creates new UTXOs (as outputs).
┌──────────────────────────────────────────────────────────────────┐
│ Traditional Account Model (e.g., banking) │
│ Alice balance: 100 Bob balance: 0 │
│ Transfer 30 → Alice: 70, Bob: 30 │
├──────────────────────────────────────────────────────────────────┤
│ UTXO Model (Bitcoin) │
│ On-chain: UTXO_A (owned by Alice, value 100) │
│ Transfer 30: │
│ Consume: UTXO_A (100) ← must be consumed in full │
│ Create: UTXO_B (30, owned by Bob) ← transfer amount │
│ UTXO_C (60, owned by Alice) ← change (100 - 30 - 10 fee)│
└──────────────────────────────────────────────────────────────────┘
Key properties of the UTXO model:
- Each UTXO can only be spent once (removed from the UTXO set after spending)
- A UTXO must be consumed in full when spent; any excess is returned to the sender as a “change” output
- “Balance” = the sum of all UTXO values owned by an address (calculated by
UTXOSet) - Unspent UTXOs form the “UTXO set” (a Bitcoin full node must maintain approximately 5-10 GB of UTXO set data)
Transaction Data Structures
TxInput (Transaction Input)
A transaction input references an existing UTXO and provides proof of authorization to spend it (a digital signature):
#![allow(unused)]
fn main() {
// src/transaction.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxInput {
pub txid: String, // ID of the referenced transaction (32-byte hash, 64 hex characters)
pub vout: usize, // Index of the output within that transaction (0-based)
pub signature: String, // ECDSA signature (DER-encoded, hex string)
pub pub_key: String, // Sender's compressed public key (33 bytes, 66 hex characters)
}
}
The combination of txid + vout uniquely identifies a UTXO on the blockchain. The signature is generated by the sender’s private key, proving ownership of that UTXO.
TxOutput (Transaction Output)
A transaction output defines the amount the recipient can receive; it is the carrier of a new UTXO:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxOutput {
pub value: u64, // Amount (unit: satoshi; 1 BTC = 10⁸ satoshi)
pub pub_key_hash: String, // Recipient address (P2PKH) = locking script
}
impl TxOutput {
pub fn new(value: u64, address: String) -> Self {
TxOutput { value, pub_key_hash: address }
}
/// Check whether this output can be unlocked by a given address
/// (i.e., whether that address is the recipient of this output)
pub fn can_be_unlocked_with(&self, address: &str) -> bool {
self.pub_key_hash == address
}
}
}
pub_key_hash is the locking script (scriptPubKey) in real Bitcoin; here it is simplified to the recipient’s address.
Transaction
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
pub id: String, // Transaction ID = SHA-256(transaction content)
pub inputs: Vec<TxInput>, // Input list (which UTXOs to consume)
pub outputs: Vec<TxOutput>, // Output list (which new UTXOs to create)
pub timestamp: u64, // Unix timestamp (seconds)
pub fee: u64, // Fee = total inputs - total outputs
}
}
Invariant: total inputs = total outputs + fee
Creating a Regular Transaction
Regular transactions are created via Blockchain::create_transaction(), which automatically handles UTXO selection, change calculation, and signing:
#![allow(unused)]
fn main() {
pub fn create_transaction(
&self,
from_wallet: &Wallet, // Sender's wallet (private key required for signing)
to_address: String, // Recipient's address
amount: u64, // Transfer amount (satoshi)
fee: u64, // Transaction fee (satoshi)
) -> Result<Transaction, String>
}
Internal flow:
#![allow(unused)]
fn main() {
// Excerpt from src/blockchain.rs (simplified)
// 1. Calculate total needed
let total_needed = amount + fee;
// 2. Find sufficient UTXOs in the UTXO set (excluding those already spent by pending transactions)
let spendable = self.utxo_set.find_spendable_outputs_excluding(
&from_wallet.address,
total_needed,
&self.pending_spent,
);
let (accumulated, utxos) = spendable.ok_or_else(|| "Insufficient balance (including fee)".to_string())?;
// 3. Create an input for each selected UTXO and sign it
let mut inputs = Vec::new();
for (txid, vout) in utxos {
let signature = from_wallet.sign(&format!("{}{}", txid, vout));
let input = TxInput::new(txid, vout, signature, from_wallet.public_key.clone());
inputs.push(input);
}
// 4. Create outputs (transfer + change)
let mut outputs = Vec::new();
outputs.push(TxOutput::new(amount, to_address));
if accumulated > total_needed {
// Return change to the sender (minus the fee)
outputs.push(TxOutput::new(accumulated - total_needed, from_wallet.address.clone()));
}
// 5. Construct the transaction (ID is computed automatically)
Ok(Transaction::new(inputs, outputs, timestamp, fee))
}
Complete usage example:
#![allow(unused)]
fn main() {
use bitcoin_simulation::{blockchain::Blockchain, wallet::Wallet};
let mut blockchain = Blockchain::new();
let genesis = Blockchain::genesis_wallet(); // Pre-funded with 10M satoshi
let alice = Wallet::new();
// genesis transfers 5000 satoshi to alice, with a fee of 100
let tx = blockchain.create_transaction(
&genesis,
alice.address.clone(),
5000, // Transfer amount
100, // Fee
)?;
println!("Transaction ID: {}", tx.id);
println!("Number of inputs: {}", tx.inputs.len());
println!("Number of outputs: {}", tx.outputs.len()); // Usually 2 (transfer + change)
println!("Fee: {} satoshi", tx.fee);
println!("Fee rate: {:.2} sat/byte", tx.fee_rate());
}
Coinbase Transaction
A Coinbase transaction is the first transaction in every block, dedicated to paying the block reward to the miner. Key differences from a regular transaction:
| Feature | Regular Transaction | Coinbase Transaction |
|---|---|---|
| Inputs | Reference existing UTXOs | Empty txid (created from nothing) |
| Outputs | Transfer + change | Block reward + all fees |
| Signature | ECDSA signature | No signature required (pub_key = "coinbase") |
| Validation | All input signatures verified | Signature verification skipped (is_coinbase() = true) |
#![allow(unused)]
fn main() {
pub fn new_coinbase(to: String, reward: u64, timestamp: u64, total_fees: u64) -> Self {
// Use an atomic counter to ensure each coinbase transaction ID is unique
// (similar to BIP34 block height encoding)
static COINBASE_COUNTER: AtomicU64 = AtomicU64::new(0);
let nonce = COINBASE_COUNTER.fetch_add(1, Ordering::Relaxed);
// Output = block reward + all transaction fees
let tx_out = TxOutput::new(reward + total_fees, to);
let tx_in = TxInput {
txid: String::new(), // Empty txid identifies coinbase
vout: 0,
signature: format!("coinbase:{}", nonce), // Uniqueness field
pub_key: String::from("coinbase"),
};
// ...
}
}
How to identify a Coinbase transaction:
#![allow(unused)]
fn main() {
pub fn is_coinbase(&self) -> bool {
// Only one input, and that input's txid is empty
self.inputs.len() == 1 && self.inputs[0].txid.is_empty()
}
}
Transaction Validation
Transaction::verify() validates the ECDSA signatures of all inputs:
#![allow(unused)]
fn main() {
pub fn verify(&self) -> bool {
// Coinbase transactions do not need signature verification
if self.is_coinbase() {
return true;
}
// Must have at least one input and one output
if self.inputs.is_empty() || self.outputs.is_empty() {
return false;
}
// Verify the ECDSA signature of each input
for input in &self.inputs {
// The signed data is the location identifier of the UTXO being spent
let signed_data = format!("{}{}", input.txid, input.vout);
if !Wallet::verify_signature(&input.pub_key, &signed_data, &input.signature) {
return false;
}
}
true
}
}
Validation logic explanation:
- The signed data is
"{txid}{vout}", binding the signature to a specific UTXO and preventing signature replay attacks - The public key carried in the input (
pub_key) is used to verify the signature — full nodes do not need additional lookups Wallet::verify_signature()internally calls secp256k1 to perform real elliptic curve mathematical verification
Complete validation flow when adding a transaction to the blockchain (Blockchain::add_transaction()):
#![allow(unused)]
fn main() {
// 1. ECDSA signature verification
if !transaction.verify() {
return Err("Transaction verification failed".to_string());
}
// 2. Verify UTXO exists and balance is sufficient
let mut input_sum = 0u64;
for input in &transaction.inputs {
if let Some(outputs) = self.find_transaction_outputs(&input.txid) {
if let Some((_, output)) = outputs.iter().find(|(idx, _)| *idx == input.vout) {
input_sum += output.value;
} else {
return Err("UTXO does not exist".to_string());
}
} else {
return Err("Referenced transaction does not exist".to_string());
}
}
let output_sum: u64 = transaction.outputs.iter().map(|o| o.value).sum();
if input_sum < output_sum {
return Err("Insufficient balance, transaction is invalid".to_string());
}
// 3. Record pending UTXOs (prevent the same UTXO from being double-spent
// by two pending transactions)
for input in &transaction.inputs {
self.pending_spent.insert(format!("{}:{}", input.txid, input.vout));
}
}
Fees and Fee Rate
Fee Calculation
fee = total inputs - total outputs
Example: spend a UTXO (100 satoshi), transfer 85, change 5, fee = 100 - 85 - 5 = 10 satoshi
Fee Rate
#![allow(unused)]
fn main() {
pub fn fee_rate(&self) -> f64 {
let size = self.size(); // Serialized transaction size in bytes
if size == 0 { return 0.0; }
self.fee as f64 / size as f64 // Unit: satoshi/byte
}
}
Typical fee rate reference (real Bitcoin — varies with network congestion):
| Priority | Fee Rate | Confirmation Time |
|---|---|---|
| Low | 1–5 sat/byte | Several hours or longer |
| Medium | 5–20 sat/byte | 30–60 minutes |
| High | 20–50 sat/byte | 10–20 minutes |
| Urgent | 50+ sat/byte | Next block (~10 minutes) |
SimpleBTC’s mempool sorts transactions by fee rate, and higher fee-rate transactions are packaged first:
#![allow(unused)]
fn main() {
// Retrieve top transactions sorted by fee rate (Mempool internal logic)
let pending_txs = self.mempool.get_top_transactions(usize::MAX);
}
Transaction Lifecycle
User initiates a transfer request
│
▼
blockchain.create_transaction(&wallet, to, amount, fee)
→ Select UTXOs, generate signatures, construct Transaction
│
▼
blockchain.add_transaction(tx)
→ Verify signatures (ECDSA)
→ Verify UTXO exists + balance sufficient
→ Add to Mempool (sorted by fee rate)
→ Mark spent UTXOs (pending_spent)
│
▼ Waiting to be packaged by a miner
│
▼
blockchain.mine_pending_transactions(miner_address)
→ Retrieve high-priority transactions from Mempool
→ Create Coinbase transaction (reward + fees)
→ Parallel PoW mining
→ Update UTXO set (atomic operation)
→ Block appended to chain, pending_spent cleared
│
▼
Transaction receives 1 confirmation
(each additional subsequent block = +1 confirmation)
Transaction Hash Calculation
#![allow(unused)]
fn main() {
pub fn calculate_hash(&self) -> String {
// Serialize the transaction to JSON and compute SHA-256
let tx_data = serde_json::to_string(&self).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(tx_data.as_bytes());
format!("{:x}", hasher.finalize())
}
}
Real Bitcoin uses double SHA-256 (SHA256d) with a compact binary serialization format. Here, for educational simplicity, JSON serialization and a single SHA-256 are used instead.
Output Sum Query
#![allow(unused)]
fn main() {
// Get the sum of all output amounts
let output_sum = tx.output_sum();
// Transaction size (in bytes, affects fee calculation)
let size = tx.size();
// Whether this is a Coinbase transaction
let is_cb = tx.is_coinbase();
}
Complete Transaction Example
use bitcoin_simulation::{blockchain::Blockchain, wallet::Wallet};
fn main() -> Result<(), String> {
let mut blockchain = Blockchain::new();
let genesis = Blockchain::genesis_wallet();
let alice = Wallet::new();
let bob = Wallet::new();
// First transaction: genesis → alice, transfer 10000 satoshi
let tx1 = blockchain.create_transaction(&genesis, alice.address.clone(), 10000, 50)?;
println!("tx1 id: {}", tx1.id);
println!("tx1 output count: {}", tx1.outputs.len()); // 2 (transfer + change)
println!("tx1 fee rate: {:.2} sat/byte", tx1.fee_rate());
blockchain.add_transaction(tx1)?;
// Mine to confirm (alice receives the mining reward)
blockchain.mine_pending_transactions(alice.address.clone())?;
println!("Alice's balance: {} satoshi", blockchain.get_balance(&alice.address));
// Second transaction: alice → bob, transfer 3000 satoshi
let tx2 = blockchain.create_transaction(&alice, bob.address.clone(), 3000, 100)?;
blockchain.add_transaction(tx2)?;
blockchain.mine_pending_transactions(bob.address.clone())?;
println!("Alice's balance: {} satoshi", blockchain.get_balance(&alice.address));
println!("Bob's balance: {} satoshi", blockchain.get_balance(&bob.address));
// Verify chain integrity
assert!(blockchain.is_valid());
Ok(())
}