|
| 1 | +# NV-2 execution plan: Experiment + upstream issue |
| 2 | + |
| 3 | +Approved by user: benchmark branch → run → GitHub issue → update audit record. |
| 4 | +Blocked by plan-mode permissions; execute when switched to normal mode. |
| 5 | + |
| 6 | +## Steps |
| 7 | + |
| 8 | +1. **Create** `chainstate/test-suite/benches/reorg_growth.rs` (source below) and add to `chainstate/test-suite/Cargo.toml` after the existing `[[bench]]`: |
| 9 | + ```toml |
| 10 | + [[bench]] |
| 11 | + name = "reorg_growth" |
| 12 | + harness = false |
| 13 | + ``` |
| 14 | +2. **Run**: `cargo bench --offline -p chainstate-test-suite --bench reorg_growth` (release profile; deps cached). Capture table. |
| 15 | +3. **Branch + push**: `git checkout -b bench/pos-sidechain-reorg-growth`, commit both files, push to origin. |
| 16 | +4. **Issue** on mintlayer/mintlayer-core: trace + refinements (reorg-before-consensus ordering = zero-marginal-cost garbage triggers; no trust pre-filter; losing branches persisted) + bench table + fix options (branch-state cache / configured connect-side cap / partial pre-checks). |
| 17 | +5. **Update** `~/security-audit-skill/mintlayer-core/run-1/NEEDS-VALIDATION.md` NV-2 entry with refinements + links. |
| 18 | + |
| 19 | +## Bench source (composed, signatures verified against framework.rs / pos_block_builder.rs / benches.rs) |
| 20 | + |
| 21 | +Key facts used: `tf.chainstate` public (framework.rs:62); `make_pos_block_builder()` (framework.rs:105); builder `with_parent/with_stake_pool_id/with_stake_spending_key/with_vrf_key/build` (pos_block_builder.rs:155-291); `process_block` via `ChainstateInterface` (chainstate re-exported from test-framework's use: `use chainstate::{BlockSource, ChainstateError, chainstate_interface::ChainstateInterface}`); setup copied from `pos_reorg` bench (benches.rs:51-78). |
| 22 | + |
| 23 | +```rust |
| 24 | +// (license header: MIT, same as benches.rs) |
| 25 | + |
| 26 | +use std::time::Instant; |
| 27 | + |
| 28 | +use chainstate::{BlockSource, chainstate_interface::ChainstateInterface}; |
| 29 | +use chainstate_test_framework::TestFramework; |
| 30 | +use common::{ |
| 31 | + chain::{Block, Destination, GenBlock, PoolId, config::create_unit_test_config, stakelock::StakePoolData}, |
| 32 | + primitives::{Amount, BlockDistance, H256, Id, Idable, per_thousand::PerThousand}, |
| 33 | +}; |
| 34 | +use crypto::{ |
| 35 | + key::{KeyKind, PrivateKey}, |
| 36 | + vrf::{VRFKeyKind, VRFPrivateKey}, |
| 37 | +}; |
| 38 | +use test_utils::random::make_seedable_rng; |
| 39 | + |
| 40 | +const MAIN_CHAIN_MARGIN: usize = 10; |
| 41 | +const WINDOW: usize = 5; |
| 42 | + |
| 43 | +fn make_pos_chain(rng: &mut impl CryptoRng) -> (TestFramework, PoolId, PrivateKey, VRFPrivateKey) { |
| 44 | + let (staking_sk, staking_pk) = PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); |
| 45 | + let (vrf_sk, vrf_pk) = VRFPrivateKey::new_from_rng(rng, VRFKeyKind::Schnorrkel); |
| 46 | + |
| 47 | + let genesis_pool_id = PoolId::new(H256::random_using(rng)); |
| 48 | + let stake_pool_pledge = create_unit_test_config().min_stake_pool_pledge(); |
| 49 | + let stake_pool_data = StakePoolData::new( |
| 50 | + stake_pool_pledge, |
| 51 | + Destination::PublicKey(staking_pk), |
| 52 | + vrf_pk, |
| 53 | + Destination::AnyoneCanSpend, |
| 54 | + PerThousand::new(1000).unwrap(), |
| 55 | + Amount::ZERO, |
| 56 | + ); |
| 57 | + |
| 58 | + let chain_config = chainstate_test_framework::create_chain_config_with_staking_pool( |
| 59 | + rng, |
| 60 | + Amount::from_atoms(1000), |
| 61 | + genesis_pool_id, |
| 62 | + stake_pool_data, |
| 63 | + ) |
| 64 | + .max_depth_for_reorg(BlockDistance::new(5000)) |
| 65 | + .build(); |
| 66 | + let target_block_time = chain_config.target_block_spacing(); |
| 67 | + |
| 68 | + let mut tf = TestFramework::builder(rng).with_chain_config(chain_config).build(); |
| 69 | + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); |
| 70 | + |
| 71 | + (tf, genesis_pool_id, staking_sk, vrf_sk) |
| 72 | +} |
| 73 | + |
| 74 | +fn build_side_block( |
| 75 | + tf: &mut TestFramework, |
| 76 | + rng: &mut impl CryptoRng, |
| 77 | + parent: &Id<GenBlock>, |
| 78 | + pool_id: PoolId, |
| 79 | + staking_sk: &PrivateKey, |
| 80 | + vrf_sk: &VRFPrivateKey, |
| 81 | +) -> Block { |
| 82 | + tf.make_pos_block_builder() |
| 83 | + .with_parent(*parent) |
| 84 | + .with_stake_pool_id(pool_id) |
| 85 | + .with_stake_spending_key(staking_sk.clone()) |
| 86 | + .with_vrf_key(vrf_sk.clone()) |
| 87 | + .build(rng) |
| 88 | +} |
| 89 | + |
| 90 | +/// Structurally valid PoS block signed by a random wrong key: passes |
| 91 | +/// parent/size/checkpoint/reorg-depth checks, triggers the full in-memory |
| 92 | +/// reorg, and only fails consensus validation afterwards. |
| 93 | +fn build_garbage_block( |
| 94 | + tf: &mut TestFramework, |
| 95 | + rng: &mut impl CryptoRng, |
| 96 | + parent: &Id<GenBlock>, |
| 97 | + pool_id: PoolId, |
| 98 | +) -> Block { |
| 99 | + let (wrong_sk, _) = PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); |
| 100 | + let (wrong_vrf_sk, _) = VRFPrivateKey::new_from_rng(rng, VRFKeyKind::Schnorrkel); |
| 101 | + tf.make_pos_block_builder() |
| 102 | + .with_parent(*parent) |
| 103 | + .with_stake_pool_id(pool_id) |
| 104 | + .with_stake_spending_key(wrong_sk) |
| 105 | + .with_vrf_key(wrong_vrf_sk) |
| 106 | + .build(rng) |
| 107 | +} |
| 108 | + |
| 109 | +fn report_row(label: &str, samples: &[f64]) { |
| 110 | + let n = samples.len(); |
| 111 | + let mean: f64 = samples.iter().sum::<f64>() / n as f64; |
| 112 | + let mut sorted = samples.to_vec(); |
| 113 | + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); |
| 114 | + let median = sorted[n / 2]; |
| 115 | + println!("{label:>28} | mean {mean:>10.3} ms | median {median:>10.3} ms"); |
| 116 | +} |
| 117 | + |
| 118 | +fn run_case(n: usize, rng: &mut impl CryptoRng) { |
| 119 | + let (mut tf, pool_id, staking_sk, vrf_sk) = make_pos_chain(rng); |
| 120 | + |
| 121 | + let common_block_id = tf |
| 122 | + .create_chain_pos(rng, &tf.genesis().get_id().into(), 5, pool_id, &staking_sk, &vrf_sk) |
| 123 | + .unwrap(); |
| 124 | + |
| 125 | + let started = Instant::now(); |
| 126 | + tf.create_chain_pos(rng, &common_block_id, n + MAIN_CHAIN_MARGIN, pool_id, &staking_sk, &vrf_sk) |
| 127 | + .unwrap(); |
| 128 | + println!( |
| 129 | + "N={n}: main chain of {} built in {:.2}s", |
| 130 | + n + MAIN_CHAIN_MARGIN, |
| 131 | + started.elapsed().as_secs_f64() |
| 132 | + ); |
| 133 | + |
| 134 | + // Losing-branch side chain: submit blocks one at a time, timing process_block only. |
| 135 | + let mut prev = common_block_id; |
| 136 | + let mut window_samples: Vec<f64> = Vec::with_capacity(WINDOW); |
| 137 | + let quarter = (n / 4).max(WINDOW); |
| 138 | + let mut checkpoints: Vec<usize> = [1, quarter, quarter * 2, quarter * 3, n] |
| 139 | + .into_iter() |
| 140 | + .map(|x| x.max(1).min(n)) |
| 141 | + .collect(); |
| 142 | + checkpoints.sort(); |
| 143 | + checkpoints.dedup(); |
| 144 | + |
| 145 | + let total_start = Instant::now(); |
| 146 | + for i in 1..=n { |
| 147 | + let block = build_side_block(&mut tf, rng, &prev, pool_id, &staking_sk, &vrf_sk); |
| 148 | + let block_id: Id<GenBlock> = block.get_id().into(); |
| 149 | + |
| 150 | + let t = Instant::now(); |
| 151 | + tf.chainstate.process_block(block, BlockSource::Local).unwrap(); |
| 152 | + window_samples.push(t.elapsed().as_secs_f64() * 1000.0); |
| 153 | + |
| 154 | + if Some(&i) == checkpoints.first() { |
| 155 | + let label = format!("side arrival #{i} (branch len {})", i - 1); |
| 156 | + report_row(&label, &window_samples); |
| 157 | + window_samples.clear(); |
| 158 | + checkpoints.remove(0); |
| 159 | + } |
| 160 | + prev = block_id; |
| 161 | + } |
| 162 | + println!( |
| 163 | + "N={n}: side chain of {n} accepted in {:.2}s", |
| 164 | + total_start.elapsed().as_secs_f64() |
| 165 | + ); |
| 166 | + |
| 167 | + // Trigger phase: garbage blocks pointing at the side-chain tip. Each is |
| 168 | + // rejected, but only after paying the full in-memory reorg. |
| 169 | + let mut garbage_samples = Vec::with_capacity(WINDOW); |
| 170 | + for _ in 0..WINDOW { |
| 171 | + let garbage = build_garbage_block(&mut tf, rng, &prev, pool_id); |
| 172 | + let t = Instant::now(); |
| 173 | + let res = tf.chainstate.process_block(garbage, BlockSource::Local); |
| 174 | + let elapsed = t.elapsed().as_secs_f64() * 1000.0; |
| 175 | + assert!(res.is_err(), "garbage block was unexpectedly accepted"); |
| 176 | + garbage_samples.push(elapsed); |
| 177 | + } |
| 178 | + report_row(&format!("garbage @branch len {n}"), &garbage_samples); |
| 179 | + println!(); |
| 180 | +} |
| 181 | + |
| 182 | +fn main() { |
| 183 | + let mut rng = make_seedable_rng(4242.into()); |
| 184 | + println!("PoS side-chain growth cost (per-arrival process_block, in-memory store)\n"); |
| 185 | + for n in [100usize, 200, 400] { |
| 186 | + run_case(n, &mut rng); |
| 187 | + } |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +## Notes |
| 192 | +- Garbage blocks use wrong staking/VRF keys (structurally valid, consensus-invalid) — distinct keys give distinct ids, no seal grinding needed. |
| 193 | +- Side chain length ≤ main chain length (margin 10) so it stays a losing branch; per-arrival reorg cost is paid regardless of trust (verified: no trust pre-filter, reorganize_in_memory at chainstateref/mod.rs:688 runs before validate_consensus at :703). |
| 194 | +- If compile fails on imports (e.g. `Block`/`GenBlock` paths), check `common::chain` re-exports used by framework.rs imports. |
0 commit comments