Skip to content

Commit 38b4318

Browse files
authored
Merge pull request #54 from gosuda/atomic/checksig-census-cumulative-evidence
feat(checksig-census): capture cumulative script evidence
2 parents fe8c9e9 + 974592e commit 38b4318

5 files changed

Lines changed: 775 additions & 340 deletions

File tree

crates/consensus/examples/kernel_verify_spike.rs

Lines changed: 145 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,34 @@
77
//! the two backends cannot share a binary).
88
//!
99
//! Two idempotent phases:
10-
//! 1. **Extract** (skipped when the corpus file already exists): stream
11-
//! mainnet blocks `0..=150_000` from a local `bitcoind -rest` endpoint,
12-
//! maintain an in-memory outpoint -> prevout map, and keep the top N
13-
//! (default 300) blocks by non-coinbase input count, together with every
14-
//! spent prevout (script bytes + amount). Coinbase inputs have no prevout
15-
//! and are excluded from sampling and measurement.
10+
//! 1. **Extract** (skipped when the corpus file already exists): replay
11+
//! mainnet blocks `0..=--stop-height` from a local `bitcoind -rest`
12+
//! endpoint to seed an in-memory outpoint map, then keep the top
13+
//! `--sample-count` blocks in `--start-height..=--stop-height` by
14+
//! non-coinbase input count, together with every spent prevout.
15+
//! Extraction requires `--stop-height`, `--sample-count`, and
16+
//! `--min-inputs`. Coinbase inputs have no prevout and are excluded from
17+
//! sampling and measurement.
1618
//! 2. **Measure**: verify every sampled non-coinbase transaction's scripts
17-
//! through `KernelContext::verify_tx` at rayon widths 1/8/32 (a dedicated
18-
//! `ThreadPoolBuilder` pool per width), with per-height flags derived
19-
//! exactly as production's `compute_verify_flags` derives them. Every
20-
//! pristine input must verify OK; any rejection aborts loudly with
19+
//! through `KernelContext::verify_tx` at rayon widths 1/8/32, with one
20+
//! dedicated `ThreadPoolBuilder` pool per width and mainnet
21+
//! activation-height flags matching the active chain. Every pristine input
22+
//! must verify OK; any rejection aborts loudly with
2123
//! height/txid/input index. The timed window includes the per-tx
2224
//! serialization + `bitcoinkernel::Transaction` parse inside `verify_tx`
2325
//! (production pays both per tx) but excludes corpus load and work-item
2426
//! construction (production amortizes block decode and prevout lookup in
2527
//! its own pipeline stages).
2628
//!
29+
//! CLI:
30+
//! --rest-url <host:port> [127.0.0.1:8332]
31+
//! --corpus PATH (required)
32+
//! --output PATH (required)
33+
//! --start-height <u32> [0]
34+
//! --stop-height <u32> (required when extracting)
35+
//! --sample-count <usize> (required when extracting)
36+
//! --min-inputs <usize> (required when extracting)
37+
//!
2738
//! Corpus format (single file, all integers little-endian):
2839
//! ```text
2940
//! magic b"KSPIKE1\0" (8 bytes)
@@ -49,30 +60,29 @@ use std::time::{Duration, Instant};
4960

5061
use anyhow::{Context as _, Result, bail};
5162
use bitcoin::consensus::Decodable as _;
63+
use bitcoin::hashes::Hash as _;
5264
use bitcoin::{Amount, OutPoint, ScriptBuf, TxOut};
5365
use bitcoin_rs_consensus::UtxoView;
5466
use bitcoin_rs_consensus::kernel::KernelContext;
55-
use bitcoin_rs_primitives::{Network, Tx};
67+
use bitcoin_rs_primitives::{Hash256, Network, Tx};
5668
use bitcoin_rs_script::VerifyFlags;
5769
use rayon::prelude::*;
5870
use serde_json::json;
5971

6072
const CORPUS_MAGIC: &[u8; 8] = b"KSPIKE1\0";
61-
const STOP_HEIGHT: u32 = 150_000;
62-
const SAMPLE_BLOCKS: usize = 300;
63-
const MIN_CORPUS_INPUTS: usize = 20_000;
6473
const THREAD_WIDTHS: [usize; 3] = [1, 8, 32];
6574

6675
fn main() -> Result<()> {
6776
let args = Args::parse(std::env::args_os().skip(1))?;
6877

69-
if args.corpus.exists() {
78+
let needs_extract = !args.corpus.exists();
79+
if needs_extract {
80+
extract_corpus(&args)?;
81+
} else {
7082
eprintln!(
7183
"corpus {} exists; skipping extraction",
7284
args.corpus.display()
7385
);
74-
} else {
75-
extract_corpus(&args)?;
7686
}
7787

7888
let corpus = load_corpus(&args.corpus)?;
@@ -81,9 +91,7 @@ fn main() -> Result<()> {
8191

8292
let rendered = serde_json::to_string_pretty(&report).context("render report JSON")?;
8393
println!("{rendered}");
84-
if let Some(parent) = args.output.parent() {
85-
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
86-
}
94+
ensure_parent(&args.output)?;
8795
std::fs::write(&args.output, rendered + "\n")
8896
.with_context(|| format!("write {}", args.output.display()))?;
8997
Ok(())
@@ -94,31 +102,68 @@ struct Args {
94102
rest_url: String,
95103
corpus: PathBuf,
96104
output: PathBuf,
105+
start_height: u32,
106+
stop_height: Option<u32>,
107+
sample_count: Option<usize>,
108+
min_inputs: Option<usize>,
97109
}
98110

99111
impl Args {
100112
fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self> {
101-
let mut parsed = Self {
102-
rest_url: "127.0.0.1:8332".to_owned(),
103-
corpus: PathBuf::from("/home/alpha/bench-g14/results/u0-spike-corpus/corpus.bin"),
104-
output: PathBuf::from("/home/alpha/bench-g14/results/u0-kernel-spike.json"),
105-
};
113+
let mut rest_url = "127.0.0.1:8332".to_owned();
114+
let mut corpus: Option<PathBuf> = None;
115+
let mut output: Option<PathBuf> = None;
116+
let mut start_height: Option<u32> = None;
117+
let mut stop_height: Option<u32> = None;
118+
let mut sample_count: Option<usize> = None;
119+
let mut min_inputs: Option<usize> = None;
106120
let mut args = args.into_iter();
107121
while let Some(arg) = args.next() {
108122
let arg = arg
109123
.into_string()
110124
.map_err(|value| anyhow::anyhow!("argument is not UTF-8: {}", value.display()))?;
111125
match arg.as_str() {
112-
"--rest-url" => parsed.rest_url = next_arg(&mut args, "--rest-url")?,
113-
"--corpus" => parsed.corpus = PathBuf::from(next_arg(&mut args, "--corpus")?),
114-
"--output" => parsed.output = PathBuf::from(next_arg(&mut args, "--output")?),
126+
"--rest-url" => rest_url = next_arg(&mut args, "--rest-url")?,
127+
"--corpus" => corpus = Some(PathBuf::from(next_arg(&mut args, "--corpus")?)),
128+
"--output" => output = Some(PathBuf::from(next_arg(&mut args, "--output")?)),
129+
"--start-height" => {
130+
start_height = Some(parse_u32(
131+
&next_arg(&mut args, "--start-height")?,
132+
"--start-height",
133+
)?);
134+
}
135+
"--stop-height" => {
136+
stop_height = Some(parse_u32(
137+
&next_arg(&mut args, "--stop-height")?,
138+
"--stop-height",
139+
)?);
140+
}
141+
"--sample-count" => {
142+
sample_count = Some(parse_usize(
143+
&next_arg(&mut args, "--sample-count")?,
144+
"--sample-count",
145+
)?);
146+
}
147+
"--min-inputs" => {
148+
min_inputs = Some(parse_usize(
149+
&next_arg(&mut args, "--min-inputs")?,
150+
"--min-inputs",
151+
)?);
152+
}
115153
other => bail!(
116-
"unknown argument: {other}\nusage: kernel_verify_spike \
117-
[--rest-url <host:port>] [--corpus <path>] [--output <path>]"
154+
"unknown argument: {other}\nusage: kernel_verify_spike --corpus <path> --output <path> [--rest-url <host:port>] [--start-height <u32>] [--stop-height <u32>] [--sample-count <usize>] [--min-inputs <usize>]"
118155
),
119156
}
120157
}
121-
Ok(parsed)
158+
Ok(Self {
159+
rest_url,
160+
corpus: corpus.context("--corpus is required")?,
161+
output: output.context("--output is required")?,
162+
start_height: start_height.unwrap_or(0),
163+
stop_height,
164+
sample_count,
165+
min_inputs,
166+
})
122167
}
123168
}
124169

@@ -129,14 +174,32 @@ fn next_arg(args: &mut impl Iterator<Item = OsString>, name: &str) -> Result<Str
129174
.map_err(|value| anyhow::anyhow!("{name} value is not UTF-8: {}", value.display()))
130175
}
131176

132-
/// Mirrors `compute_verify_flags` in `crates/node/src/apply.rs`: P2SH is
133-
/// always-on for supported validation paths; DERSIG/CLTV/CSV/WITNESS/TAPROOT
134-
/// gate on activation height. Production resolves CSV/segwit through BIP9
135-
/// contextual state with these same height predicates as fallback; at the
136-
/// corpus heights (<= 150k, far below every activation) the two derivations
137-
/// are identical and every flag except P2SH is off.
138-
fn production_verify_flags(network: Network, height: u32) -> VerifyFlags {
139-
let mut flags = VerifyFlags::P2SH;
177+
fn parse_u32(value: &str, name: &str) -> Result<u32> {
178+
value
179+
.parse::<u32>()
180+
.with_context(|| format!("{name} must be a non-negative 32-bit integer, got {value}"))
181+
}
182+
183+
fn parse_usize(value: &str, name: &str) -> Result<usize> {
184+
value
185+
.parse::<usize>()
186+
.with_context(|| format!("{name} must be a non-negative integer, got {value}"))
187+
}
188+
189+
fn ensure_parent(path: &Path) -> Result<()> {
190+
if let Some(parent) = path.parent() {
191+
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
192+
}
193+
Ok(())
194+
}
195+
196+
/// Uses the mainnet activation-height fallback, including Core's hash-pinned
197+
/// BIP16 exception. Production derives BIP9 state from the active header chain.
198+
fn production_verify_flags(network: Network, height: u32, block_hash: Hash256) -> VerifyFlags {
199+
let mut flags = VerifyFlags::NONE;
200+
if !network.is_bip16_p2sh_exception(block_hash) {
201+
flags = flags.union(VerifyFlags::P2SH);
202+
}
140203
if network.is_bip66_active(height) {
141204
flags = flags.union(VerifyFlags::DERSIG);
142205
}
@@ -173,9 +236,32 @@ struct SampleBlock {
173236
}
174237

175238
fn extract_corpus(args: &Args) -> Result<()> {
239+
let stop_height = args
240+
.stop_height
241+
.context("--stop-height is required to extract a new corpus")?;
242+
let sample_count = args
243+
.sample_count
244+
.context("--sample-count is required to extract a new corpus")?;
245+
let min_inputs = args
246+
.min_inputs
247+
.context("--min-inputs is required to extract a new corpus")?;
248+
249+
if stop_height < args.start_height {
250+
bail!(
251+
"inconsistent bounds: stop-height {stop_height} < start-height {}",
252+
args.start_height
253+
);
254+
}
255+
if sample_count == 0 {
256+
bail!("--sample-count must be greater than zero");
257+
}
258+
if min_inputs == 0 {
259+
bail!("--min-inputs must be greater than zero");
260+
}
261+
176262
eprintln!(
177-
"extracting corpus: streaming blocks 0..={STOP_HEIGHT} from {}",
178-
args.rest_url
263+
"extracting corpus: replaying blocks 0..={stop_height}, sampling {}..={stop_height} from {}",
264+
args.start_height, args.rest_url
179265
);
180266
let started = Instant::now();
181267
let mut client = RestClient::connect(&args.rest_url)?;
@@ -184,7 +270,7 @@ fn extract_corpus(args: &Args) -> Result<()> {
184270
let mut keys: BinaryHeap<Reverse<(usize, u32)>> = BinaryHeap::new();
185271
let mut candidates: hashbrown::HashMap<u32, SampleBlock> = hashbrown::HashMap::new();
186272

187-
for height in 0..=STOP_HEIGHT {
273+
for height in 0..=stop_height {
188274
let raw = fetch_block(&mut client, height)?;
189275
let block = bitcoin::Block::consensus_decode(&mut std::io::Cursor::new(raw.as_slice()))
190276
.with_context(|| format!("decode block at height {height}"))?;
@@ -217,13 +303,13 @@ fn extract_corpus(args: &Args) -> Result<()> {
217303
}
218304

219305
let input_count = prevouts.len();
220-
if input_count > 0 {
306+
if height >= args.start_height && input_count > 0 {
221307
let candidate = SampleBlock {
222308
height,
223309
raw,
224310
prevouts,
225311
};
226-
if keys.len() < SAMPLE_BLOCKS {
312+
if keys.len() < sample_count {
227313
keys.push(Reverse((input_count, height)));
228314
candidates.insert(height, candidate);
229315
} else if let Some(&Reverse((min_count, min_height))) = keys.peek()
@@ -254,10 +340,8 @@ fn extract_corpus(args: &Args) -> Result<()> {
254340
samples.len(),
255341
started.elapsed()
256342
);
257-
if total_inputs < MIN_CORPUS_INPUTS {
258-
bail!(
259-
"corpus too small: {total_inputs} non-coinbase inputs < required {MIN_CORPUS_INPUTS}"
260-
);
343+
if total_inputs < min_inputs {
344+
bail!("corpus too small: {total_inputs} non-coinbase inputs < required {min_inputs}");
261345
}
262346
write_corpus(&args.corpus, &samples)
263347
}
@@ -276,9 +360,7 @@ fn fetch_block(client: &mut RestClient, height: u32) -> Result<Vec<u8>> {
276360
}
277361

278362
fn write_corpus(path: &Path, samples: &[SampleBlock]) -> Result<()> {
279-
if let Some(parent) = path.parent() {
280-
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
281-
}
363+
ensure_parent(path)?;
282364
let file = std::fs::File::create(path).with_context(|| format!("create {}", path.display()))?;
283365
let mut writer = BufWriter::new(file);
284366
writer.write_all(CORPUS_MAGIC)?;
@@ -379,7 +461,8 @@ fn build_work_items(corpus: &[SampleBlock]) -> Result<Vec<WorkItem>> {
379461
let block =
380462
bitcoin::Block::consensus_decode(&mut std::io::Cursor::new(sample.raw.as_slice()))
381463
.with_context(|| format!("decode corpus block at height {}", sample.height))?;
382-
let flags = production_verify_flags(Network::Mainnet, sample.height);
464+
let block_hash = Hash256::from_le_bytes(block.block_hash().as_byte_array());
465+
let flags = production_verify_flags(Network::Mainnet, sample.height, block_hash);
383466
let mut prevouts = sample.prevouts.iter();
384467
for tx in &block.txdata {
385468
if tx.is_coinbase() {
@@ -522,10 +605,14 @@ fn measure(corpus: &[SampleBlock], items: &[WorkItem], args: &Args) -> Result<se
522605
}
523606

524607
Ok(json!({
525-
"schema": "u0-kernel-verify-spike-v1",
608+
"schema": "u0-kernel-verify-spike-v2",
526609
"measurement_target": "bitcoinkernel per-input script verify",
527610
"git_head": git_head().ok(),
528611
"comparator": "recorded ~65 us/input effective-serial, bitcoinconsensus backend, same machine",
612+
"start_height": args.start_height,
613+
"stop_height": args.stop_height,
614+
"sample_count": args.sample_count,
615+
"min_inputs": args.min_inputs,
529616
"corpus": {
530617
"path": args.corpus.display().to_string(),
531618
"block_count": corpus.len(),
@@ -538,13 +625,15 @@ fn measure(corpus: &[SampleBlock], items: &[WorkItem], args: &Args) -> Result<se
538625
}))
539626
}
540627

628+
// Benchmark ratios intentionally trade integer precision for a fractional result.
629+
#[allow(clippy::as_conversions, clippy::cast_precision_loss)]
541630
fn duration_us_per_input(elapsed: Duration, total_inputs: usize) -> Result<f64> {
542-
let micros = u32::try_from(elapsed.as_micros()).context("elapsed micros exceed u32")?;
543-
let inputs = u32::try_from(total_inputs).context("input count exceeds u32")?;
631+
let micros = u64::try_from(elapsed.as_micros()).context("elapsed micros exceed u64")?;
632+
let inputs = u64::try_from(total_inputs).context("input count exceeds u64")?;
544633
if inputs == 0 {
545634
bail!("corpus contains zero inputs");
546635
}
547-
Ok(f64::from(micros) / f64::from(inputs))
636+
Ok((micros as f64) / (inputs as f64))
548637
}
549638

550639
fn git_head() -> Result<String> {

0 commit comments

Comments
 (0)