Skip to content

Commit 226d7b6

Browse files
committed
Merge remote-tracking branch 'origin/master' into release-v1.4.1
2 parents c60ed18 + cb1a748 commit 226d7b6

22 files changed

Lines changed: 3687 additions & 140 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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.

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎api-server/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/
1616
Transactions seen in the node's mempool are bridged into `tx_seen` events by the web server.
1717

1818
### Changed
19+
- The `items` query parameter is now validated the same way on every paginated `v2` endpoint: `items=0` is rejected with `400 invalid num items` instead of returning an empty page. Previously the offset-based endpoints returned an empty listing for `items=0`; the new keyset-paginated endpoints (pools, transactions, holders, order book) reject it from the start.
1920
- The api-server storage version was bumped from 25 to 26 (new `ml.emitted_events` table); the scanner re-initializes the database when it finds a different version, as before. Full resync is required.
2021
- `RemoteNode::is_connection_error` is now a required trait method (no default); external implementors of the `RemoteNode` trait must provide it. `ClientErrorExt::is_connection_error` now classifies `ClientError::RequestTimeout` as application-level (the connection may still be usable), and the node WS client enables WS-level pings and pins its request timeout to 60 seconds, so half-dead connections are detected and reconnected instead of wedging.
2122
- The stream event retention pruning no longer deletes events that the event pump has not consumed yet: the pump records its progress in the database and the pruning never overtakes it (with a hard limit of 100k retained events before the first progress record or during a pump outage, logged as an error when it kicks in).

0 commit comments

Comments
 (0)