Skip to content

Commit a883183

Browse files
committed
Add ConsensusTest boiler plate for appending a block
Signed-off-by: Jacinta Ferrant <jacinta.ferrant@gmail.com>
1 parent 9e7ebfa commit a883183

File tree

3 files changed

+557
-0
lines changed

3 files changed

+557
-0
lines changed
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
// Copyright (C) 2025 Stacks Open Internet Foundation
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU General Public License as published by
5+
// the Free Software Foundation, either version 3 of the License, or
6+
// (at your option) any later version.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License
14+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
use std::collections::HashMap;
16+
17+
use clarity::codec::StacksMessageCodec;
18+
use clarity::types::chainstate::{StacksAddress, StacksPrivateKey, TrieHash};
19+
use clarity::types::{Address, StacksEpochId};
20+
use clarity::util::get_epoch_time_secs;
21+
use clarity::util::hash::{MerkleTree, Sha512Trunc256Sum};
22+
use clarity::util::secp256k1::MessageSignature;
23+
use clarity::vm::costs::ExecutionCost;
24+
use serde::{Deserialize, Serialize};
25+
use stacks_common::bitvec::BitVec;
26+
27+
use crate::burnchains::PoxConstants;
28+
use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoBlockHeader, NakamotoChainState};
29+
use crate::chainstate::stacks::boot::RewardSet;
30+
use crate::chainstate::stacks::{
31+
StacksTransaction, TenureChangeCause, TransactionAuth, TransactionPayload, TransactionVersion,
32+
};
33+
use crate::chainstate::tests::TestChainstate;
34+
use crate::net::tests::NakamotoBootPlan;
35+
36+
pub struct ConsensusTest<'a> {
37+
pub chain: TestChainstate<'a>,
38+
pub test_vector: ConsensusTestVector,
39+
}
40+
41+
impl ConsensusTest<'_> {
42+
pub fn new(test_name: &str, test_vector: ConsensusTestVector) -> Self {
43+
let privk = StacksPrivateKey::from_hex(
44+
"510f96a8efd0b11e211733c1ac5e3fa6f3d3fcdd62869e376c47decb3e14fea101",
45+
)
46+
.unwrap();
47+
48+
let initial_balances = test_vector
49+
.initial_balances
50+
.iter()
51+
.map(|(addr, amount)| (StacksAddress::from_string(addr).unwrap().into(), *amount))
52+
.collect();
53+
let epoch_id = StacksEpochId::try_from(test_vector.epoch_id).unwrap();
54+
let chain = match epoch_id {
55+
StacksEpochId::Epoch30
56+
| StacksEpochId::Epoch31
57+
| StacksEpochId::Epoch32
58+
| StacksEpochId::Epoch33 => {
59+
let mut chain = NakamotoBootPlan::new(test_name)
60+
.with_pox_constants(10, 3)
61+
.with_initial_balances(initial_balances)
62+
.with_private_key(privk)
63+
.boot_nakamoto_chainstate(None);
64+
let (burn_ops, mut tenure_change, miner_key) =
65+
chain.begin_nakamoto_tenure(TenureChangeCause::BlockFound);
66+
let (_, header_hash, consensus_hash) = chain.next_burnchain_block(burn_ops);
67+
let vrf_proof = chain.make_nakamoto_vrf_proof(miner_key);
68+
69+
tenure_change.tenure_consensus_hash = consensus_hash.clone();
70+
tenure_change.burn_view_consensus_hash = consensus_hash.clone();
71+
let tenure_change_tx = chain.miner.make_nakamoto_tenure_change(tenure_change);
72+
let coinbase_tx = chain.miner.make_nakamoto_coinbase(None, vrf_proof);
73+
74+
let blocks_and_sizes =
75+
chain.make_nakamoto_tenure(tenure_change_tx, coinbase_tx, Some(0));
76+
chain
77+
}
78+
StacksEpochId::Epoch10
79+
| StacksEpochId::Epoch20
80+
| StacksEpochId::Epoch2_05
81+
| StacksEpochId::Epoch21
82+
| StacksEpochId::Epoch22
83+
| StacksEpochId::Epoch23
84+
| StacksEpochId::Epoch24
85+
| StacksEpochId::Epoch25 => {
86+
unimplemented!("Not bothering with pre nakamoto tests.");
87+
}
88+
};
89+
Self { chain, test_vector }
90+
}
91+
92+
/// Run a single test vector, validating consensus.
93+
pub fn run(mut self) {
94+
debug!("--------- Running test vector ---------");
95+
let txs: Vec<_> = self
96+
.test_vector
97+
.payloads
98+
.iter()
99+
.map(|payload_str| {
100+
let payload: TransactionPayload = serde_json::from_str(payload_str).unwrap();
101+
StacksTransaction::new(
102+
TransactionVersion::Testnet,
103+
TransactionAuth::from_p2pkh(&StacksPrivateKey::random()).unwrap(),
104+
payload,
105+
)
106+
})
107+
.collect();
108+
109+
let expected_state_index_root =
110+
TrieHash::from_hex(&self.test_vector.expected_state_index_root).unwrap();
111+
112+
let (block, block_size) = self.construct_nakamoto_block(txs, expected_state_index_root);
113+
let test_vector = self.test_vector.clone();
114+
115+
let mut stacks_node = self.chain.stacks_node.take().unwrap();
116+
let sortdb = self.chain.sortdb.take().unwrap();
117+
let chain_tip =
118+
NakamotoChainState::get_canonical_block_header(stacks_node.chainstate.db(), &sortdb)
119+
.unwrap()
120+
.unwrap();
121+
let pox_constants = PoxConstants::test_default();
122+
let chain_tip_burn_header_timestamp = get_epoch_time_secs();
123+
124+
let (mut chainstate_tx, clarity_instance) =
125+
stacks_node.chainstate.chainstate_tx_begin().unwrap();
126+
127+
let mut burndb_conn = sortdb.index_handle_at_tip();
128+
129+
debug!("--------- Appending block {} ---------", block.header.signer_signature_hash(); "block" => ?block);
130+
let result = NakamotoChainState::append_block(
131+
&mut chainstate_tx,
132+
clarity_instance,
133+
&mut burndb_conn,
134+
&chain_tip.consensus_hash,
135+
&pox_constants,
136+
&chain_tip,
137+
&chain_tip.burn_header_hash,
138+
chain_tip.burn_header_height,
139+
chain_tip_burn_header_timestamp,
140+
&block,
141+
block_size.try_into().unwrap(),
142+
block.header.burn_spent,
143+
1500,
144+
&RewardSet::empty(),
145+
false,
146+
);
147+
148+
let mut mismatches = Vec::new();
149+
150+
match (&result, &test_vector.expected_result) {
151+
(Ok((epoch_receipt, _, _, tx_events)), ExpectedResult::Success(expected_outputs)) => {
152+
debug!("--------- Appended Block ---------";
153+
"epoch_receipt" => ?epoch_receipt,
154+
"tx_events" => ?tx_events
155+
);
156+
157+
let actual_results = ExpectedOutputs {
158+
transaction_return_types: epoch_receipt
159+
.tx_receipts
160+
.iter()
161+
.map(|r| serde_json::to_string(&r.result).unwrap())
162+
.collect(),
163+
transaction_costs: epoch_receipt
164+
.tx_receipts
165+
.iter()
166+
.map(|r| r.execution_cost.clone())
167+
.collect(),
168+
total_block_cost: epoch_receipt.anchored_block_cost.clone(),
169+
marf_hash: epoch_receipt.header.index_root.to_hex(),
170+
};
171+
172+
if actual_results != *expected_outputs {
173+
if actual_results.transaction_return_types
174+
!= expected_outputs.transaction_return_types
175+
{
176+
mismatches.push(format!(
177+
"Tx return types mismatch: actual {:?}, expected {:?}",
178+
actual_results.transaction_return_types,
179+
expected_outputs.transaction_return_types
180+
));
181+
}
182+
if actual_results.transaction_costs != expected_outputs.transaction_costs {
183+
mismatches.push(format!(
184+
"Tx costs mismatch: actual {:?}, expected {:?}",
185+
actual_results.transaction_costs, expected_outputs.transaction_costs
186+
));
187+
}
188+
if actual_results.total_block_cost != expected_outputs.total_block_cost {
189+
mismatches.push(format!(
190+
"Total block cost mismatch: actual {:?}, expected {:?}",
191+
actual_results.total_block_cost, expected_outputs.total_block_cost
192+
));
193+
}
194+
if actual_results.marf_hash != expected_outputs.marf_hash {
195+
mismatches.push(format!(
196+
"MARF hash mismatch: actual {}, expected {}",
197+
actual_results.marf_hash, expected_outputs.marf_hash
198+
));
199+
}
200+
}
201+
}
202+
(Ok(_), ExpectedResult::Failure(_)) => {
203+
mismatches.push("Expected failure but got success".to_string());
204+
}
205+
(Err(e), ExpectedResult::Failure(expected_err)) => {
206+
debug!("--------- Block Errored: {e} ---------");
207+
let actual_err = e.to_string();
208+
if !actual_err.contains(expected_err) {
209+
mismatches.push(format!(
210+
"Error mismatch: actual '{actual_err}', expected contains '{expected_err}'"
211+
));
212+
}
213+
}
214+
(Err(_), ExpectedResult::Success(_)) => {
215+
mismatches.push("Expected success but got failure".to_string());
216+
}
217+
}
218+
assert!(mismatches.is_empty(), "Mismatches: {mismatches:?}");
219+
}
220+
221+
/// Construct a NakamotoBlock from the test vector.
222+
fn construct_nakamoto_block(
223+
&self,
224+
txs: Vec<StacksTransaction>,
225+
state_index_root: TrieHash,
226+
) -> (NakamotoBlock, usize) {
227+
let chain_tip = NakamotoChainState::get_canonical_block_header(
228+
self.chain.stacks_node.as_ref().unwrap().chainstate.db(),
229+
self.chain.sortdb.as_ref().unwrap(),
230+
)
231+
.unwrap()
232+
.unwrap();
233+
let mut block = NakamotoBlock {
234+
header: NakamotoBlockHeader {
235+
version: 1,
236+
chain_length: chain_tip.stacks_block_height + 1,
237+
burn_spent: 17000,
238+
consensus_hash: chain_tip.consensus_hash.clone(),
239+
parent_block_id: chain_tip.index_block_hash(),
240+
tx_merkle_root: Sha512Trunc256Sum::from_data(&[]),
241+
state_index_root,
242+
timestamp: 1,
243+
miner_signature: MessageSignature::empty(),
244+
signer_signature: vec![],
245+
pox_treatment: BitVec::ones(1).unwrap(),
246+
},
247+
txs,
248+
};
249+
250+
let tx_merkle_root = {
251+
let txid_vecs: Vec<_> = block
252+
.txs
253+
.iter()
254+
.map(|tx| tx.txid().as_bytes().to_vec())
255+
.collect();
256+
257+
MerkleTree::<Sha512Trunc256Sum>::new(&txid_vecs).root()
258+
};
259+
block.header.tx_merkle_root = tx_merkle_root;
260+
self.chain.miner.sign_nakamoto_block(&mut block);
261+
let mut signers = self.chain.config.test_signers.clone().unwrap_or_default();
262+
signers.sign_nakamoto_block(&mut block, self.chain.get_reward_cycle());
263+
let block_len = block.serialize_to_vec().len();
264+
265+
(block, block_len)
266+
}
267+
}
268+
269+
/// Test vector struct for `append_block` consensus testing.
270+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
271+
pub struct ConsensusTestVector {
272+
/// A hex stacks address and amount pair for populating initial balances
273+
pub initial_balances: HashMap<String, u64>,
274+
/// Desired epoch of chainstate
275+
pub epoch_id: u32,
276+
/// Transaction payloads to stuff into the block
277+
pub payloads: Vec<String>,
278+
/// Expected state root trie hash
279+
pub expected_state_index_root: String,
280+
/// Expected result: success with outputs or failure with error
281+
pub expected_result: ExpectedResult,
282+
}
283+
284+
/// Enum representing expected result: success with outputs or failure with error
285+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
286+
pub enum ExpectedResult {
287+
Success(ExpectedOutputs),
288+
// TODO: should match maybe on actual Error type?
289+
Failure(String),
290+
}
291+
292+
/// Expected outputs for a successful block append
293+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
294+
pub struct ExpectedOutputs {
295+
pub transaction_return_types: Vec<String>,
296+
pub transaction_costs: Vec<ExecutionCost>,
297+
pub total_block_cost: ExecutionCost,
298+
pub marf_hash: String,
299+
}
300+
301+
fn default_test_vector() -> ConsensusTestVector {
302+
let outputs = ExpectedOutputs {
303+
transaction_return_types: vec![],
304+
transaction_costs: vec![],
305+
total_block_cost: ExecutionCost::ZERO,
306+
marf_hash: "f86c9ceaf2a17a4d9e502af73b6f00f89c18e5b58be501b3840f707f7b372dea".into(),
307+
};
308+
ConsensusTestVector {
309+
initial_balances: HashMap::new(),
310+
expected_state_index_root:
311+
"6fe3e70b95f5f56c9c7c2c59ba8fc9c19cdfede25d2dcd4d120438bc27dfa88b".into(),
312+
epoch_id: StacksEpochId::Epoch30 as u32,
313+
payloads: vec![],
314+
expected_result: ExpectedResult::Success(outputs),
315+
}
316+
}
317+
318+
fn failing_test_vector() -> ConsensusTestVector {
319+
ConsensusTestVector {
320+
initial_balances: HashMap::new(),
321+
expected_state_index_root:
322+
"0000000000000000000000000000000000000000000000000000000000000000".into(),
323+
epoch_id: StacksEpochId::Epoch30 as u32,
324+
payloads: vec![],
325+
expected_result: ExpectedResult::Failure("state root mismatch".to_string()),
326+
}
327+
}
328+
329+
#[test]
330+
fn test_append_empty_block() {
331+
ConsensusTest::new(function_name!(), default_test_vector()).run()
332+
}
333+
334+
#[test]
335+
fn test_append_state_index_root_mismatch() {
336+
ConsensusTest::new(function_name!(), failing_test_vector()).run()
337+
}

stackslib/src/chainstate/tests/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
//
1313
// You should have received a copy of the GNU General Public License
1414
// along with this program. If not, see <http://www.gnu.org/licenses/>.
15+
pub mod consensus;
16+
1517
use std::fs;
1618

1719
use clarity::types::chainstate::{BlockHeaderHash, BurnchainHeaderHash, StacksBlockId};

0 commit comments

Comments
 (0)