Skip to content

Commit dd6b062

Browse files
refcellesorense
andcommitted
feat(harness): add ForkMatrix for parametrized hardfork testing
Adds `ForkMatrix`, a concise test helper for running the same assertion across every hardfork stage without hand-writing cumulative `HardForkConfig` structs. A single `FORK_PROGRESSION` static drives `all()`: each entry is one `fn(&mut HardForkConfig)` setter; `build()` applies them in sequence to produce a cumulative snapshot after each step. Adding a new fork is a one-line change to the table. The filtered constructors (`pre_isthmus`, `from_isthmus`, `from_granite`) all derive from `all()` or its subset with a single `retain` predicate, so they update automatically. Migrates the two operator-fee tests that previously fixed a single fork to run across the full relevant slice of the matrix. Co-authored-by: Emil Sørensen <esorense@users.noreply.github.com>
1 parent e69e630 commit dd6b062

3 files changed

Lines changed: 338 additions & 61 deletions

File tree

actions/harness/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ mod batcher;
2222
pub use base_batcher_encoder::{BatchType, DaType, EncoderConfig};
2323
pub use batcher::{Batcher, BatcherConfig, BatcherError, L1MinerTxManager};
2424

25+
mod matrix;
26+
pub use matrix::ForkMatrix;
27+
2528
mod test_rollup_config;
2629
pub use test_rollup_config::TestRollupConfigBuilder;
2730

actions/harness/src/matrix.rs

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
use std::{
2+
any::Any,
3+
panic::{self, AssertUnwindSafe},
4+
};
5+
6+
use base_consensus_genesis::{BaseHardforkConfig, HardForkConfig};
7+
8+
type ForkSetter = fn(&mut HardForkConfig);
9+
10+
/// All supported hardfork stages in canonical order. Each setter activates
11+
/// exactly one additional fork; [`ForkMatrix::all`] applies these in sequence
12+
/// to produce a cumulative snapshot after each step.
13+
///
14+
/// To add a new fork: insert one entry here in the correct position. All
15+
/// matrix constructors update automatically.
16+
static FORK_PROGRESSION: &[(&str, ForkSetter)] = &[
17+
("regolith", |h| h.regolith_time = Some(0)),
18+
("canyon", |h| h.canyon_time = Some(0)),
19+
("delta", |h| h.delta_time = Some(0)),
20+
("ecotone", |h| h.ecotone_time = Some(0)),
21+
("fjord", |h| h.fjord_time = Some(0)),
22+
("granite", |h| h.granite_time = Some(0)),
23+
("holocene", |h| h.holocene_time = Some(0)),
24+
("pectra-blob-schedule", |h| h.pectra_blob_schedule_time = Some(0)),
25+
("isthmus", |h| h.isthmus_time = Some(0)),
26+
("jovian", |h| h.jovian_time = Some(0)),
27+
("base-v1", |h| h.base.get_or_insert_with(BaseHardforkConfig::default).v1 = Some(0)),
28+
];
29+
30+
/// Named hardfork schedules for parametrizing harness tests across protocol upgrades.
31+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
32+
pub struct ForkMatrix {
33+
forks: Vec<(&'static str, HardForkConfig)>,
34+
}
35+
36+
impl ForkMatrix {
37+
/// Returns every cumulative hardfork stage supported by the harness.
38+
///
39+
/// Each entry activates one additional fork on top of all previous ones,
40+
/// derived automatically from [`FORK_PROGRESSION`].
41+
pub fn all() -> Self {
42+
Self::build(FORK_PROGRESSION, HardForkConfig::default())
43+
}
44+
45+
/// Returns the cumulative forks from Granite through Holocene (pre-Isthmus).
46+
///
47+
/// Includes the `pectra-blob-schedule` compatibility patch, which sits
48+
/// between Holocene and Isthmus in the progression.
49+
pub fn pre_isthmus() -> Self {
50+
Self::all().retain(|_, h| h.granite_time.is_some() && h.isthmus_time.is_none())
51+
}
52+
53+
/// Returns the cumulative OP hardforks from Isthmus onward.
54+
///
55+
/// Base-specific forks (e.g. `base-v1`) are excluded.
56+
pub fn from_isthmus() -> Self {
57+
Self::all().retain(|_, h| h.isthmus_time.is_some() && h.base.is_none())
58+
}
59+
60+
/// Returns the canonical OP fault-proof fork progression from Granite onward.
61+
///
62+
/// The `pectra-blob-schedule` compatibility patch (a Base Sepolia-only quirk)
63+
/// and Base-specific forks are excluded; this matrix covers only the upstream
64+
/// OP mainnet upgrade sequence.
65+
pub fn from_granite() -> Self {
66+
static PROGRESSION: &[(&str, ForkSetter)] = &[
67+
("granite", |h| h.granite_time = Some(0)),
68+
("holocene", |h| h.holocene_time = Some(0)),
69+
("isthmus", |h| h.isthmus_time = Some(0)),
70+
("jovian", |h| h.jovian_time = Some(0)),
71+
];
72+
Self::build(
73+
PROGRESSION,
74+
HardForkConfig {
75+
regolith_time: Some(0),
76+
canyon_time: Some(0),
77+
delta_time: Some(0),
78+
ecotone_time: Some(0),
79+
fjord_time: Some(0),
80+
..Default::default()
81+
},
82+
)
83+
}
84+
85+
/// Iterates through the named fork schedules.
86+
pub fn iter(&self) -> impl Iterator<Item = (&'static str, HardForkConfig)> + '_ {
87+
self.forks.iter().copied()
88+
}
89+
90+
/// Keeps only the fork schedules matching the predicate.
91+
pub fn retain<F>(mut self, mut f: F) -> Self
92+
where
93+
F: FnMut(&'static str, HardForkConfig) -> bool,
94+
{
95+
self.forks.retain(|(name, config)| f(name, *config));
96+
self
97+
}
98+
99+
/// Runs a test closure once per configured fork, annotating any panic with the fork name.
100+
pub fn run<F>(&self, mut test: F)
101+
where
102+
F: FnMut(&'static str, HardForkConfig),
103+
{
104+
for (name, config) in self.iter() {
105+
if let Err(e) = panic::catch_unwind(AssertUnwindSafe(|| test(name, config))) {
106+
Self::panic_with_fork_context(name, e);
107+
}
108+
}
109+
}
110+
111+
fn build(progression: &[(&'static str, ForkSetter)], base: HardForkConfig) -> Self {
112+
let mut config = base;
113+
Self {
114+
forks: progression
115+
.iter()
116+
.map(|(name, apply)| {
117+
apply(&mut config);
118+
(*name, config)
119+
})
120+
.collect(),
121+
}
122+
}
123+
124+
fn panic_with_fork_context(fork: &str, payload: Box<dyn Any + Send + 'static>) -> ! {
125+
let msg = payload
126+
.downcast_ref::<String>()
127+
.map(String::as_str)
128+
.or_else(|| payload.downcast_ref::<&str>().copied())
129+
.unwrap_or("non-string panic payload");
130+
panic!("fork matrix case `{fork}` failed: {msg}");
131+
}
132+
}
133+
134+
#[cfg(test)]
135+
mod tests {
136+
use base_consensus_genesis::RollupConfig;
137+
138+
use super::*;
139+
140+
fn rollup_config(hardforks: HardForkConfig) -> RollupConfig {
141+
RollupConfig { block_time: 2, hardforks, ..Default::default() }
142+
}
143+
144+
fn panic_message(payload: Box<dyn Any + Send>) -> String {
145+
payload
146+
.downcast_ref::<String>()
147+
.cloned()
148+
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned()))
149+
.unwrap_or_else(|| "non-string panic payload".to_owned())
150+
}
151+
152+
#[test]
153+
fn all_covers_the_supported_hardfork_progression() {
154+
let names: Vec<_> = ForkMatrix::all().iter().map(|(name, _)| name).collect();
155+
assert_eq!(
156+
names,
157+
vec![
158+
"regolith",
159+
"canyon",
160+
"delta",
161+
"ecotone",
162+
"fjord",
163+
"granite",
164+
"holocene",
165+
"pectra-blob-schedule",
166+
"isthmus",
167+
"jovian",
168+
"base-v1",
169+
]
170+
);
171+
}
172+
173+
#[test]
174+
fn from_granite_matches_the_fault_proof_forks() {
175+
let names: Vec<_> = ForkMatrix::from_granite().iter().map(|(name, _)| name).collect();
176+
assert_eq!(names, vec!["granite", "holocene", "isthmus", "jovian"]);
177+
}
178+
179+
#[test]
180+
fn pre_isthmus_includes_pectra_and_excludes_isthmus_and_later() {
181+
let names: Vec<_> = ForkMatrix::pre_isthmus().iter().map(|(name, _)| name).collect();
182+
assert_eq!(names, vec!["granite", "holocene", "pectra-blob-schedule"]);
183+
}
184+
185+
#[test]
186+
fn from_isthmus_includes_only_op_forks_from_isthmus_onward() {
187+
let names: Vec<_> = ForkMatrix::from_isthmus().iter().map(|(name, _)| name).collect();
188+
assert_eq!(names, vec!["isthmus", "jovian"]);
189+
}
190+
191+
#[test]
192+
fn each_case_is_cumulative_without_enabling_the_next_fork() {
193+
for (fork_name, hardforks) in ForkMatrix::all().iter() {
194+
let cfg = rollup_config(hardforks);
195+
match fork_name {
196+
"regolith" => {
197+
assert!(cfg.is_regolith_active(0));
198+
assert!(!cfg.is_canyon_active(0));
199+
}
200+
"canyon" => {
201+
assert!(cfg.is_canyon_active(0));
202+
assert!(!cfg.is_delta_active(0));
203+
}
204+
"delta" => {
205+
assert!(cfg.is_delta_active(0));
206+
assert!(!cfg.is_ecotone_active(0));
207+
}
208+
"ecotone" => {
209+
assert!(cfg.is_ecotone_active(0));
210+
assert!(!cfg.is_fjord_active(0));
211+
}
212+
"fjord" => {
213+
assert!(cfg.is_fjord_active(0));
214+
assert!(!cfg.is_granite_active(0));
215+
}
216+
"granite" => {
217+
assert!(cfg.is_granite_active(0));
218+
assert!(!cfg.is_holocene_active(0));
219+
}
220+
"holocene" => {
221+
assert!(cfg.is_holocene_active(0));
222+
assert!(!cfg.is_pectra_blob_schedule_active(0));
223+
assert!(!cfg.is_isthmus_active(0));
224+
}
225+
"pectra-blob-schedule" => {
226+
assert!(cfg.is_holocene_active(0));
227+
assert!(cfg.is_pectra_blob_schedule_active(0));
228+
assert!(!cfg.is_isthmus_active(0));
229+
}
230+
"isthmus" => {
231+
assert!(cfg.is_isthmus_active(0));
232+
assert!(!cfg.is_jovian_active(0));
233+
}
234+
"jovian" => {
235+
assert!(cfg.is_jovian_active(0));
236+
assert!(!cfg.is_base_v1_active(0));
237+
}
238+
"base-v1" => {
239+
assert!(cfg.is_jovian_active(0));
240+
assert!(cfg.is_base_v1_active(0));
241+
}
242+
_ => unreachable!("unexpected fork {fork_name}"),
243+
}
244+
}
245+
}
246+
247+
#[test]
248+
fn run_includes_the_fork_name_in_panics() {
249+
let panic = std::panic::catch_unwind(|| {
250+
ForkMatrix::from_granite().run(|fork_name, _| {
251+
assert_ne!(fork_name, "granite", "boom");
252+
});
253+
})
254+
.expect_err("granite case must panic");
255+
256+
let message = panic_message(panic);
257+
assert!(message.contains("granite"));
258+
assert!(message.contains("boom"));
259+
}
260+
}

0 commit comments

Comments
 (0)