Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ Prefer **one high-level scenario** per behavior cluster. Delete lower-level test
| `resume_head_off_warms_cache_for_external_prev` | Query+consensus | Resume head-off: warm Class A cache fixes external-prev missing prevout |
| `consensus_rules` (test binary) | Consensus | Focused reject paths for structure/header/connect rules we own — see [`docs/consensus-tests.md`](./docs/consensus-tests.md). Hornet-mapped subset: `./scripts/test-hornet-rules.sh` |
| `core_analogs::analog_milestone_and_mempool_persist` | Consensus | Milestone skip-below/check-above, missing prevout under high milestone, mempool persist (one pad) |
| `core_analogs::analog_reconstruct_after_lost_head` | Store+query | Wipe `tx.head/`, reopen, reconstruct height 1 and txid probe. Does **not** pin empty-head / truncated-head / v1 fuse refuse |
| `unified_wire_pipeline_multi_block_to_tip` | Consensus+query | Class A archived ahead of tip then `confirm_wire_run` (no re-append + re-entry); then heights 2..=4 unified load/scripts/write |
| `direct_indexes_then_sh_bulk_at_tip` | Query | Direct IBD fills `tx.head`; SH bulk at tip; wipe SH shards, reopen, history still works. Keep unsorted pack/lag guts |
| `electrum_server_version_history_balance` | Electrum | One mature pad: version/history/balance/headers, ping/features/tx/errors, confirmed history omits `fee`, scripthash subscribe notify, skip restatus when a new block misses the SH |
| `electrum_scripthash_sub_cap_unsubscribe_frees_slot` | Electrum | Per-connection subscribe cap + unsubscribe frees a slot |
| `electrum_leftover_mempool_does_not_double_count` | Electrum | Relay-off leftover is confirmed, not a second mempool UTXO |
Expand Down
38 changes: 0 additions & 38 deletions crates/rbitcoin-store/src/scripthash_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2150,44 +2150,6 @@ fn unsorted_collect_partitions_by_prefix_and_is_not_scripthash_sorted() {
}
}

#[test]
fn unsorted_materialize_four_shards_from_class_a_no_catalog_runs() {
{
let dir = tmp();
let s = crate::Store::create_tiny(&dir).unwrap();
let n_shards = 4usize;
let mut keys = Vec::new();
for shard in 0..n_shards {
let script = script_for_prefix_shard(shard, n_shards);
keys.push(script_hash(&script));
let mut txid = [0u8; 32];
txid[0] = shard as u8;
s.put_tx_full_batch_indexed(&[class_a_coinbase(txid, script)], true)
.unwrap();
}
let sh_dir = dir.join("sh4");
std::fs::create_dir_all(&sh_dir).unwrap();
let table = four_shard_dir_table(&sh_dir);
let udir = sh_dir.join(UNSORTED_SHARD_DIR);
crate::collect_unsorted_shard_files(&s, &udir, n_shards, 2, None).unwrap();
let mat = materialize_sh_from_unsorted(&table, &udir, 2, None).unwrap();
assert_eq!(mat.creates, 4, "all Class A creates packed");
assert_eq!(mat.keys, 4);
for k in &keys {
assert_eq!(table.entries(k).unwrap().len(), 1, "key must be queryable");
}
let runs = sh_dir.join("scripthash.runs");
assert!(
!runs.exists()
|| std::fs::read_dir(&runs)
.map(|it| it.count() == 0)
.unwrap_or(true),
"unsorted path must not write catalog runs"
);
let _ = std::fs::remove_dir_all(&dir);
}
}

#[test]
fn unsorted_pack_sorts_numeric_fk_and_keeps_all_creates() {
{
Expand Down
70 changes: 0 additions & 70 deletions crates/rbitcoin-store/src/tx_table/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1294,76 +1294,6 @@ fn head_insert_many_tiny_roundtrip() {
let _ = std::fs::remove_dir_all(&dir);
}

/// Operator recovery: delete `tx.head/` (+ legacy flat files) → open rebuilds.
#[test]
fn missing_tx_head_rebuilds_from_bodies_on_open() {
with_env_lock(|| {
let dir = std::env::temp_dir().join(format!(
"rbitcoin-tx-head-rebuild-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();

let mk = |i: u64| {
let mut txid = [0u8; 32];
txid[0..8].copy_from_slice(&i.to_le_bytes());
let rec = TxRecord {
txid,
version: 1,
locktime: 0,
input_start_fk: Fk::NULL,
input_count: 1,
output_start_fk: Fk::NULL,
output_count: 1,
};
let inputs = vec![InputRecord {
prev_txid: [0u8; 32],
create_fk: Fk::NULL,
prev_index: u32::MAX,
sequence: u32::MAX,
script_sig: vec![],
witness: vec![],
}];
let outputs = vec![OutputRecord::unspent(1, vec![0x51])];
(rec, inputs, outputs)
};

{
let t = create_tiny(&dir);
for i in 1..=20u64 {
let _ = t.put_full_batch_indexed(&[mk(i)], true).unwrap();
}
assert_eq!(t.count(), 20);
let mut txid = [0u8; 32];
txid[0..8].copy_from_slice(&7u64.to_le_bytes());
assert_eq!(t.probe_body_match_fk(&txid).unwrap(), Some(Fk(7)));
t.flush().unwrap();
}

// Wipe segmented head meta + files.
assert!(crate::segmented_head::head_meta_exists(&dir));
crate::segmented_head::wipe_segmented_head_files(&dir);

let t = TxTable::open_tiny(&dir).unwrap();
assert_eq!(t.count(), 20);
assert!(crate::segmented_head::head_meta_exists(&dir));
for i in 1..=20u64 {
let mut txid = [0u8; 32];
txid[0..8].copy_from_slice(&i.to_le_bytes());
assert_eq!(
t.probe_body_match_fk(&txid).unwrap(),
Some(Fk(i)),
"txid {i} missing after head rebuild"
);
}
let _ = std::fs::remove_dir_all(&dir);
});
}

#[test]
fn missing_tx_head_with_no_bodies_creates_empty() {
with_env_lock(|| {
Expand Down
23 changes: 18 additions & 5 deletions crates/rbitcoin-test/tests/core_analogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
//! 1. `--milestone` skip-below / check-above + mempool persist + missing
//! prevout still fails when scripts are skipped (`feature_assumevalid.py`,
//! `mempool_persist.py`)
//! 2. Reconstruct height 1 after process restart / lost RAM head
//! (`feature_reindex*.py`)
//! 2. Reconstruct height 1 after wiping `tx.head/` (`feature_reindex*.py`)

use bitcoin::hashes::Hash;
use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness};
Expand Down Expand Up @@ -123,14 +122,16 @@ fn analog_milestone_and_mempool_persist() {
);
}

/// Archive reconstruct of height 1 after dropping RAM (reindex / lost-head analog).
/// Archive reconstruct of height 1 after dropping RAM and wiping `tx.head/`
/// (`feature_reindex*.py` / operator delete-head reopen).
#[test]
fn analog_reconstruct_after_lost_head() {
let td = TestDatadir::new().unwrap();
let params = ChainParams::regtest();
let genesis = regtest_genesis();
let store = td.store_path();
let b1;
let cb_txid;
{
let q = Query::open_or_create_tiny(&store).unwrap();
accept_and_connect_block(&q, &params, Height::GENESIS, &genesis, Milestone::NONE).unwrap();
Expand All @@ -140,14 +141,26 @@ fn analog_reconstruct_after_lost_head() {
accept_and_connect_block(&q, &params, Height(2), &b2, Milestone::NONE).unwrap();
q.flush().unwrap();
assert_eq!(q.tip_height(), Some(Height(2)));
// Drop `q` — RAM cache / process head is gone; Class A archive stays.
cb_txid = b1.txdata[0].compute_txid().to_byte_array();
}

let head = store.join("tx.head");
assert!(head.is_dir(), "tiny store writes segmented tx.head/");
std::fs::remove_dir_all(&head).expect("wipe tx.head");

let q2 = Query::open_or_create_tiny(&store).unwrap();
assert_eq!(q2.tip_height(), Some(Height(2)));
assert!(
q2.tx_head_occupied() >= 3,
"open must rebuild tx.head from Class A bodies"
);
assert!(
q2.get_tx_by_txid(&cb_txid).unwrap().is_some(),
"txid must resolve after head rebuild"
);
assert_reconstruct_eq(&q2, 1, &b1);
let rec = q2
.reconstruct_block_at_height(Height(1))
.expect("reconstruct height 1 after lost RAM head");
.expect("reconstruct height 1 after wiped tx.head");
assert_eq!(rec.block_hash(), b1.block_hash());
}
28 changes: 28 additions & 0 deletions crates/rbitcoin-test/tests/electrum_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,4 +1163,32 @@ fn direct_indexes_then_sh_bulk_at_tip() {
!hist.is_empty(),
"scripthash history non-empty after SH bulk"
);
q.flush().unwrap();
drop(q);

let store = dir.path().join("store");
for name in [
"scripthash.body",
"scripthash.head",
"scripthash.runs",
"scripthash.ovf",
"scripthash.include_hwm",
"scripthash.cold_progress",
] {
let p = store.join(name);
let _ = std::fs::remove_dir_all(&p);
let _ = std::fs::remove_file(&p);
}

let q = Query::open_or_create_tiny(&store).unwrap();
q.enter_direct_index_mode().unwrap();
let n_rebuild = q.finalize_sh_runs().unwrap();
assert!(
n_rebuild > 0 || !q.scripthash_history(&sh).unwrap().is_empty(),
"wipe SH shards + reopen + finalize must restore history"
);
assert!(
!q.scripthash_history(&sh).unwrap().is_empty(),
"scripthash history must survive SH wipe + rematerialize"
);
}
50 changes: 0 additions & 50 deletions crates/rbitcoin-test/tests/integration_multinode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,56 +1454,6 @@ fn reorg_same_height_then_multi_block_branch() {
));
}

/// Class A archived ahead of tip, then wire confirm: second confirm attempt
/// must not grow Class A body count (idempotent commit after partial fail shape).
#[test]
fn confirm_wire_idempotent_when_class_a_already_present() {
use rbitcoin_consensus::{
accept_and_connect_block, commit_class_a_block, confirm_wire_run, header_to_record,
ChainParams, Milestone,
};
use rbitcoin_primitives::Height as H;

let dir = TempDir::new().unwrap();
let q = Query::open_or_create_tiny(dir.path().join("store")).unwrap();
q.enter_direct_index_mode().unwrap();
let params = ChainParams::regtest();
let ms = Milestone { height: 1_000_000 };
let genesis = regtest_genesis();
accept_and_connect_block(&q, &params, H::GENESIS, &genesis, ms).unwrap();
let g_fk = q
.get_header_by_hash(&genesis.block_hash().to_byte_array())
.unwrap()
.unwrap()
.0;
let b1 = mine_regtest_block(genesis.block_hash(), genesis.header.time + 600, 1, vec![]);
let hfk = q
.ensure_header(&header_to_record(
g_fk,
&b1.header,
b1.header.block_hash().to_byte_array(),
))
.unwrap();
commit_class_a_block(&q, &params, H(1), &b1, ms).unwrap();
assert!(q
.is_block_archived(&b1.block_hash().to_byte_array())
.unwrap());
let n_before = q.tx_body_count();

// Wire confirm with Class A already present — plan should be empty / no-op commit.
confirm_wire_run(&q, &params, ms, &[(H(1), b1.clone())]).unwrap();
assert_eq!(q.tip_height(), Some(H(1)));
let n_mid = q.tx_body_count();
assert_eq!(n_mid, n_before, "confirm must not re-append Class A");

// Idempotent re-entry (AlreadyHave / no tip change).
let tip = q.tip_height();
let _ = confirm_wire_run(&q, &params, ms, &[(H(1), b1)]);
assert_eq!(q.tip_height(), tip);
assert_eq!(q.tx_body_count(), n_before);
let _ = hfk;
}

/// Full `run_p2p` entry: listen, connect to seeder, exit via max_run_secs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "full run_p2p entry; run via scripts/integration.sh"]
Expand Down
29 changes: 24 additions & 5 deletions crates/rbitcoin-test/tests/scenarios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1788,8 +1788,8 @@ fn block_cache_and_mempool_hub_surface() {
#[test]
fn unified_wire_pipeline_multi_block_to_tip() {
use rbitcoin_consensus::{
confirm_scripts_phase, confirm_wire_load_phase, confirm_write_phase, ChainParams,
Milestone, ScriptPreverified,
commit_class_a_block, confirm_scripts_phase, confirm_wire_load_phase, confirm_wire_run,
confirm_write_phase, ChainParams, Milestone, ScriptPreverified,
};

let td = TestDatadir::new().unwrap();
Expand All @@ -1803,8 +1803,27 @@ fn unified_wire_pipeline_multi_block_to_tip() {
let mut tip = genesis.block_hash();
let mut tip_time = genesis.header.time;

let b1 = mine_regtest_block(tip, tip_time + 600, 1, vec![]);
commit_class_a_block(&q, &params, Height(1), &b1, ms).unwrap();
assert!(q
.is_block_archived(&b1.block_hash().to_byte_array())
.unwrap());
let n_before = q.tx_body_count();
confirm_wire_run(&q, &params, ms, &[(Height(1), b1.clone())]).unwrap();
assert_eq!(q.tip_height(), Some(Height(1)));
assert_eq!(
q.tx_body_count(),
n_before,
"confirm must not re-append Class A already on disk"
);
let _ = confirm_wire_run(&q, &params, ms, &[(Height(1), b1.clone())]);
assert_eq!(q.tip_height(), Some(Height(1)));
assert_eq!(q.tx_body_count(), n_before);
tip = b1.block_hash();
tip_time = b1.header.time;

let mut batch: Vec<(Height, bitcoin::Block)> = Vec::new();
for h in 1u32..=4 {
for h in 2u32..=4 {
let b = mine_regtest_block(tip, tip_time + 600, h, vec![]);
tip = b.block_hash();
tip_time = b.header.time;
Expand All @@ -1814,7 +1833,7 @@ fn unified_wire_pipeline_multi_block_to_tip() {
rbitcoin_query::reset_body_ok_reads();
let mat = confirm_wire_load_phase(&q, &params, ms, &batch, &ScriptPreverified::new())
.expect("wire prep");
assert_eq!(mat.batch.len(), 4);
assert_eq!(mat.batch.len(), 3);
assert!(
mat.batch.archive_plan.is_some(),
"wire prep carries Class A plan for single commit era"
Expand All @@ -1829,7 +1848,7 @@ fn unified_wire_pipeline_multi_block_to_tip() {
let ok = confirm_scripts_phase(mat.batch).expect("scripts");
assert!(ok.batch.archive_plan.is_some());
let fks = confirm_write_phase(&q, &params, ms, ok.batch).expect("commit");
assert_eq!(fks.len(), 4);
assert_eq!(fks.len(), 3);
// Commit must not re-pread Class A bodies for layout (offline denserels).
assert_eq!(
rbitcoin_query::body_ok_reads(),
Expand Down
2 changes: 1 addition & 1 deletion docs/core-functional.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Named scenarios in `crates/rbitcoin-test/tests/core_analogs.rs`:
| Core skip | Analog |
|-----------|--------|
| `feature_assumevalid.py` | `analog_milestone_and_mempool_persist` (skip-below / check-above + missing prevout under high milestone) |
| `feature_reindex*.py` | `analog_reconstruct_after_lost_head` |
| `feature_reindex*.py` | `analog_reconstruct_after_lost_head` (wipe `tx.head/`, reopen) |
| `mempool_persist.py` | `analog_milestone_and_mempool_persist` (same pad) |

`rpc-missing` also requires `analog` (a follow-up row or `none`).
Expand Down