blvm-node 0.1.91

Bitcoin Commons BLVM: Minimal Bitcoin node implementation using blvm-protocol and blvm-consensus
use super::Storage;
use super::blockstore::BlockStore;
use super::chainstate::{ChainState, utxo_money_total};
use super::database::{DatabaseBackend, create_database};
use super::hashing::double_sha256;
use super::pruning::PruningManager;
use super::txindex::TxIndex;
use super::utxostore::UtxoStore;
use crate::config::{PruningConfig, PruningMode};
use blvm_consensus::reorganization::BlockUndoLog;
use blvm_protocol::constants::MAX_MONEY;
use blvm_protocol::{
    Block, BlockHeader, OutPoint, Transaction, TransactionInput, TransactionOutput, UTXO,
};
use std::sync::Arc;

fn temp_db() -> (tempfile::TempDir, Arc<dyn super::database::Database>) {
    let dir = tempfile::tempdir().unwrap();
    let db = Arc::from(create_database(dir.path(), DatabaseBackend::Heed3, None).unwrap());
    (dir, db)
}

fn header_hash(header: &BlockHeader) -> [u8; 32] {
    let mut data = [0u8; 80];
    data[0..4].copy_from_slice(&(header.version as i32).to_le_bytes());
    data[4..36].copy_from_slice(&header.prev_block_hash);
    data[36..68].copy_from_slice(&header.merkle_root);
    data[68..72].copy_from_slice(&(header.timestamp as u32).to_le_bytes());
    data[72..76].copy_from_slice(&(header.bits as u32).to_le_bytes());
    data[76..80].copy_from_slice(&(header.nonce as u32).to_le_bytes());
    double_sha256(&data)
}

fn sample_block(nonce: u64) -> Block {
    Block {
        header: BlockHeader {
            version: 1,
            prev_block_hash: [0; 32],
            merkle_root: [2; 32],
            timestamp: 1_231_006_505,
            bits: 0x1d00_ffff,
            nonce,
        },
        transactions: vec![Transaction {
            version: 1,
            inputs: blvm_protocol::tx_inputs![TransactionInput {
                prevout: OutPoint {
                    hash: [3; 32],
                    index: 0,
                },
                sequence: 0xffff_ffff,
                script_sig: vec![1, 2, 3],
            }],
            outputs: blvm_protocol::tx_outputs![TransactionOutput {
                value: 50,
                script_pubkey: vec![0x51],
            }],
            lock_time: 0,
        }]
        .into_boxed_slice(),
    }
}

fn store_body(store: &BlockStore, block: &Block, height: u64) {
    let hash = header_hash(&block.header);
    store.store_block(block).unwrap();
    store.store_height(height, &hash).unwrap();
    store.store_undo_log(&hash, &BlockUndoLog::new()).unwrap();
}

#[test]
fn money_total_rejects_a_value_outside_the_range() {
    assert_eq!(utxo_money_total([MAX_MONEY]).unwrap(), MAX_MONEY as u128);
    assert!(utxo_money_total([MAX_MONEY + 1]).is_err());
    assert!(utxo_money_total([-1]).is_err());
}

#[test]
fn legacy_36_byte_utxo_key_is_readable() {
    let (_dir, db) = temp_db();
    let store = UtxoStore::new(Arc::clone(&db)).unwrap();
    let outpoint = OutPoint {
        hash: [9; 32],
        index: 7,
    };
    let utxo = UTXO {
        value: 50,
        script_pubkey: vec![0x51].into(),
        height: 3,
        is_coinbase: false,
    };
    let bytes =
        crate::storage::utxo_value_codec::encode_utxo_with_codec(store.value_codec(), &utxo)
            .unwrap();
    let mut key = [0u8; 36];
    key[..32].copy_from_slice(&outpoint.hash);
    key[32..].copy_from_slice(&outpoint.index.to_be_bytes());
    db.open_tree("utxos").unwrap().insert(&key, &bytes).unwrap();
    let loaded = store.get_utxo(&outpoint).unwrap().expect("legacy key");
    assert_eq!(loaded.value, 50);
    store.remove_utxo(&outpoint).unwrap();
    assert!(store.get_utxo(&outpoint).unwrap().is_none());
}

#[test]
fn wire_body_is_indexed_by_hash() {
    let (_dir, db) = temp_db();
    let store = BlockStore::new(Arc::clone(&db)).unwrap();
    let block = sample_block(7);
    let hash = header_hash(&block.header);
    let wire = blvm_protocol::serialization::serialize_transaction(&block.transactions[0]);
    store.store_block_wire_bytes(&block, 7, &wire).unwrap();
    assert!(store.has_block_body(&hash).unwrap());
    assert_eq!(store.get_height_by_hash(&hash).unwrap(), Some(7));
    assert!(store.get_block(&hash).is_err());
}

#[test]
fn a_height_gap_stays_off_the_contiguous_tip() {
    let (_dir, db) = temp_db();
    let store = BlockStore::new(db).unwrap();
    let block = sample_block(2);
    let hash = header_hash(&block.header);
    store.store_height(2, &hash).unwrap();
    assert_eq!(store.highest_stored_height().unwrap(), None);
    assert_eq!(store.stored_height_gap().unwrap(), Some(0));
}

#[test]
fn indexed_size_matches_the_wire_length() {
    let (_dir, db) = temp_db();
    let index = TxIndex::new(db).unwrap();
    let block = sample_block(4);
    let tx = &block.transactions[0];
    let wire = blvm_protocol::serialization::serialize_transaction(tx).len() as u32;
    index.index_transaction(tx, &[5; 32], 1, 0).unwrap();
    let txid = blvm_protocol::block::calculate_tx_id(tx);
    let meta = index.get_metadata(&txid).unwrap().unwrap();
    assert_eq!(meta.size, wire);
    assert_eq!(meta.weight, wire * 4);
}

#[test]
fn undo_batch_and_recent_header_batch_round_trip() {
    let (_dir, db) = temp_db();
    let store = BlockStore::new(db).unwrap();
    let hash = [6u8; 32];
    store
        .store_undo_logs_batch(&[(&hash, &BlockUndoLog::new())])
        .unwrap();
    assert!(store.get_undo_log(&hash).unwrap().is_some());
    let block = sample_block(12);
    let header = block.header.clone();
    store.store_height(0, &[1u8; 32]).unwrap();
    store
        .store_recent_headers_ibd_batch(&[(0, &header)])
        .unwrap();
    let recent = store.get_recent_headers(1).unwrap();
    assert_eq!(recent[0].nonce, header.nonce);
}

#[test]
fn nosync_switch_returns() {
    let dir = tempfile::tempdir().unwrap();
    let storage = Storage::with_backend(dir.path(), DatabaseBackend::Heed3).unwrap();
    storage.set_ibd_nosync(true).unwrap();
    let tree = storage.open_tree("chain_info").unwrap();
    tree.insert(b"nosync", b"1").unwrap();
    storage.set_ibd_nosync(false).unwrap();
    tree.insert(b"sync", b"1").unwrap();
    assert_eq!(tree.get(b"sync").unwrap().unwrap(), b"1");
}

#[test]
fn custom_prune_drops_the_tx_index_when_asked() {
    let (_dir, db) = temp_db();
    let blockstore = Arc::new(BlockStore::new(Arc::clone(&db)).unwrap());
    let index = Arc::new(TxIndex::new(Arc::clone(&db)).unwrap());
    let block = sample_block(1);
    store_body(&blockstore, &block, 0);
    let tx = &block.transactions[0];
    let txid = blvm_protocol::block::calculate_tx_id(tx);
    let hash = header_hash(&block.header);
    index.index_transaction(tx, &hash, 0, 0).unwrap();
    let config = PruningConfig {
        mode: PruningMode::Custom {
            keep_headers: true,
            keep_bodies_from_height: 1,
            keep_commitments: false,
            keep_filters: false,
            keep_filtered_blocks: false,
            keep_witnesses: true,
            keep_tx_index: false,
        },
        ..PruningConfig::default()
    };
    let manager = PruningManager::new(config, Arc::clone(&blockstore)).with_tx_index(index.clone());
    manager.prune_to_height(2, 300, false).unwrap();
    assert!(index.get_metadata(&txid).unwrap().is_none());
    assert!(blockstore.get_block(&hash).unwrap().is_none());

    let (_kept_dir, kept_db) = temp_db();
    let kept_store = Arc::new(BlockStore::new(Arc::clone(&kept_db)).unwrap());
    let kept_index = Arc::new(TxIndex::new(Arc::clone(&kept_db)).unwrap());
    let kept_block = sample_block(2);
    store_body(&kept_store, &kept_block, 0);
    let kept_tx = &kept_block.transactions[0];
    let kept_txid = blvm_protocol::block::calculate_tx_id(kept_tx);
    let kept_hash = header_hash(&kept_block.header);
    kept_index
        .index_transaction(kept_tx, &kept_hash, 0, 0)
        .unwrap();
    let kept_config = PruningConfig {
        mode: PruningMode::Custom {
            keep_headers: true,
            keep_bodies_from_height: 1,
            keep_commitments: false,
            keep_filters: false,
            keep_filtered_blocks: false,
            keep_witnesses: true,
            keep_tx_index: true,
        },
        ..PruningConfig::default()
    };
    let kept_manager =
        PruningManager::new(kept_config, Arc::clone(&kept_store)).with_tx_index(kept_index.clone());
    kept_manager.prune_to_height(2, 300, false).unwrap();
    assert!(kept_index.get_metadata(&kept_txid).unwrap().is_some());
    assert!(kept_store.get_block(&kept_hash).unwrap().is_none());
}

#[test]
fn normal_prune_stops_at_the_keep_window() {
    let (_dir, db) = temp_db();
    let blockstore = Arc::new(BlockStore::new(db).unwrap());
    let early = sample_block(1);
    let kept = sample_block(8);
    store_body(&blockstore, &early, 0);
    store_body(&blockstore, &kept, 8);
    let config = PruningConfig {
        mode: PruningMode::Normal {
            keep_from_height: 8,
            min_recent_blocks: 1000,
        },
        ..PruningConfig::default()
    };
    let manager = PruningManager::new(config, Arc::clone(&blockstore));
    manager.prune_to_height(10, 300, false).unwrap();
    assert!(
        blockstore
            .get_block(&header_hash(&early.header))
            .unwrap()
            .is_none()
    );
    assert!(
        blockstore
            .get_block(&header_hash(&kept.header))
            .unwrap()
            .is_some()
    );
}

#[test]
fn aggressive_prune_runs() {
    let (_dir, db) = temp_db();
    let blockstore = Arc::new(BlockStore::new(db).unwrap());
    let config = PruningConfig {
        mode: PruningMode::Aggressive {
            keep_from_height: 0,
            keep_commitments: false,
            keep_filtered_blocks: false,
            min_blocks: 0,
        },
        ..PruningConfig::default()
    };
    let manager = PruningManager::new(config, blockstore);
    assert!(manager.prune_to_height(10, 300, false).is_ok());
}

#[test]
fn snapshot_clear_removes_every_resume_tree() {
    let dir = tempfile::tempdir().unwrap();
    let storage = Storage::with_backend(dir.path(), DatabaseBackend::Heed3).unwrap();
    for name in ["ibd_utxos", "ibd_utxos_ckpt_a", "ibd_utxos_ckpt_b"] {
        storage
            .open_tree(name)
            .unwrap()
            .insert(b"row", b"1")
            .unwrap();
    }
    storage.clear_ibd_snapshot_trees();
    for name in ["ibd_utxos", "ibd_utxos_ckpt_a", "ibd_utxos_ckpt_b"] {
        assert!(
            storage.open_tree(name).unwrap().is_empty().unwrap(),
            "{name}"
        );
    }
    assert!(storage.open_engine_resume_snapshot().unwrap().is_none());
}

#[test]
fn tip_update_stores_the_header_with_the_index() {
    let (_dir, db) = temp_db();
    let chain = ChainState::new(db).unwrap();
    let block = sample_block(9);
    let hash = header_hash(&block.header);
    chain.update_tip(&hash, &block.header, 0).unwrap();
    let info = chain.load_chain_info().unwrap().unwrap();
    assert_eq!(info.tip_hash, hash);
    assert_eq!(info.tip_header.nonce, 9);
    assert!(chain.block_index().get(&hash).unwrap().is_some());
    chain.mark_invalid(&hash).unwrap();
    assert!(chain.is_invalid(&hash).unwrap());
    chain.reconsider(&hash).unwrap();
    assert!(!chain.is_invalid(&hash).unwrap());
    assert_eq!(
        chain.block_index().get(&hash).unwrap().unwrap().status,
        super::block_index::BlockIndexStatus::Valid
    );
}

fn fdatasync_total(mode: &str) -> i32 {
    let so = std::env::var("COUNT_SYNC_SO")
        .unwrap_or_else(|_| "/home/josh/tmp/count-sync.so".to_string());
    let output = std::process::Command::new(std::env::current_exe().unwrap())
        .arg("storage::pass_fix_tests::update_tip_syncs_once")
        .arg("--exact")
        .arg("--test-threads=1")
        .env("F2_SYNC_MODE", mode)
        .env("LD_PRELOAD", &so)
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let text = format!("{stdout}{stderr}");
    assert!(output.status.success(), "sync child {mode} failed\n{text}");
    assert!(
        text.contains("update_tip_syncs_once"),
        "sync child {mode} did not run the test\n{text}"
    );
    let line = text
        .lines()
        .rev()
        .find(|line| line.contains("SYNC_TOTAL fdatasync="))
        .unwrap_or_else(|| panic!("no sync total\n{stderr}"));
    line.split("fdatasync=")
        .nth(1)
        .unwrap()
        .split_whitespace()
        .next()
        .unwrap()
        .parse()
        .unwrap()
}

#[test]
fn update_tip_syncs_once() {
    if let Ok(mode) = std::env::var("F2_SYNC_MODE") {
        let (_dir, db) = temp_db();
        let chain = ChainState::new(db).unwrap();
        if mode == "tip" {
            let block = sample_block(9);
            let hash = header_hash(&block.header);
            chain.update_tip(&hash, &block.header, 0).unwrap();
        }
        return;
    }
    let open = fdatasync_total("open");
    let both = fdatasync_total("tip");
    assert_eq!(both - open, 1, "open={open} tip={both}");
}