feat(harness): Add ForkMatrix for Parametrized Hardfork#1468
feat(harness): Add ForkMatrix for Parametrized Hardfork#1468
Conversation
🟡 Heimdall Review Status
|
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
| pub fn from_granite() -> Self { | ||
| static PROGRESSION: &[(&str, ForkSetter)] = &[ | ||
| ("granite", |h| h.granite_time = Some(0)), | ||
| ("holocene", |h| h.holocene_time = Some(0)), | ||
| ("isthmus", |h| h.isthmus_time = Some(0)), | ||
| ("jovian", |h| h.jovian_time = Some(0)), | ||
| ]; | ||
| Self::build( | ||
| PROGRESSION, | ||
| HardForkConfig { | ||
| regolith_time: Some(0), | ||
| canyon_time: Some(0), | ||
| delta_time: Some(0), | ||
| ecotone_time: Some(0), | ||
| fjord_time: Some(0), | ||
| ..Default::default() | ||
| }, | ||
| ) | ||
| } |
There was a problem hiding this comment.
from_granite duplicates fork setters and the pre-Granite base config that already exist in FORK_PROGRESSION. If a fork setter changes (e.g., granite needs to set an additional field), or if a new fork is inserted between fjord and granite, this method silently diverges from the canonical progression.
Consider building from FORK_PROGRESSION directly to keep a single source of truth. For example, building from the full progression and then stripping pectra-blob-schedule and base-specific entries from the configs (not just filtering names):
pub fn from_granite() -> Self {
let base = Self::build(
&FORK_PROGRESSION[..5], // regolith..fjord
HardForkConfig::default(),
);
let last_config = base.forks.last().map(|(_, c)| *c).unwrap_or_default();
static OP_ONLY: &[(&str, ForkSetter)] = &[
("granite", |h| h.granite_time = Some(0)),
("holocene", |h| h.holocene_time = Some(0)),
("isthmus", |h| h.isthmus_time = Some(0)),
("jovian", |h| h.jovian_time = Some(0)),
];
Self::build(OP_ONLY, last_config)
}This still duplicates the post-granite setters (intentionally, since pectra-blob-schedule is excluded from the progression), but at least the pre-granite base is derived from the single source of truth.
Not blocking — the existing unit tests catch most divergences — but worth considering as the fork table grows.
| /// Runs a test closure once per configured fork, annotating any panic with the fork name. | ||
| pub fn run<F>(&self, mut test: F) | ||
| where | ||
| F: FnMut(&'static str, HardForkConfig), | ||
| { | ||
| for (name, config) in self.iter() { | ||
| if let Err(e) = panic::catch_unwind(AssertUnwindSafe(|| test(name, config))) { | ||
| Self::panic_with_fork_context(name, e); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
run stops at the first failing fork and re-panics with a new message, which discards the original panic's backtrace. When a test fails inside run, the developer sees the fork name and assertion message but loses the source location of the actual assert! that fired.
Two options to consider:
- Collect all failures and report them together, so one test run surfaces every broken fork instead of requiring fix-rerun cycles:
pub fn run<F>(&self, mut test: F)
where
F: FnMut(&'static str, HardForkConfig),
{
let mut failures = Vec::new();
for (name, config) in self.iter() {
if let Err(e) = panic::catch_unwind(AssertUnwindSafe(|| test(name, config))) {
failures.push((name, e));
}
}
if !failures.is_empty() {
let msgs: Vec<_> = failures.iter().map(|(name, e)| {
let msg = e.downcast_ref::<String>().map(String::as_str)
.or_else(|| e.downcast_ref::<&str>().copied())
.unwrap_or("non-string panic payload");
format!(" `{name}`: {msg}")
}).collect();
panic!("fork matrix failures:\n{}", msgs.join("\n"));
}
}- Use
resume_unwindinstead ofpanic!to preserve the original backtrace (at the cost of losing the fork-name annotation in the panic message):
std::panic::resume_unwind(e);The current fail-fast approach is a valid choice for a test helper — just noting the trade-off.
| let expected_format = matches!( | ||
| (fork_name, &l1_info), | ||
| ("isthmus", L1BlockInfoTx::Isthmus(_)) | ("jovian", L1BlockInfoTx::Jovian(_)) | ||
| ); |
There was a problem hiding this comment.
This matches! must be extended every time a new post-Isthmus OP fork is added. If a new fork (e.g., after Jovian) is added to FORK_PROGRESSION and included by from_isthmus(), this arm silently returns false and the test fails with a message that doesn't explain why the format is unexpected — only that it is.
Consider inverting the assertion to check that the format is not Ecotone (the pre-Isthmus format), which is the actual invariant being tested and doesn't require updating when new forks are added:
assert!(
!matches!(l1_info, L1BlockInfoTx::Ecotone(_)),
"{fork_name}: post-Isthmus L1 info must not use Ecotone format, got {l1_info:?}"
);This asserts what the test actually cares about (operator fees are encoded, i.e., not Ecotone) without coupling to the exhaustive list of post-Isthmus variants.
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>
dd6b062 to
3aff8be
Compare
| ("pectra-blob-schedule", |h| h.pectra_blob_schedule_time = Some(0)), | ||
| ("isthmus", |h| h.isthmus_time = Some(0)), | ||
| ("jovian", |h| h.jovian_time = Some(0)), | ||
| ("base-v1", |h| h.base.get_or_insert_with(BaseHardforkConfig::default).v1 = Some(0)), |
There was a problem hiding this comment.
HardForkConfig::base is typed BaseHardforkConfig (not Option<BaseHardforkConfig>), so get_or_insert_with won't compile — it's an Option method.
Same issue on line 57 where from_isthmus() calls h.base.is_none().
Either:
- This PR depends on a separate change that wraps the field in
Option, in which case that dependency should be noted in the PR description and merged first, or - The setter needs to assign directly:
("base-v1", |h| h.base = BaseHardforkConfig { v1: Some(0) }),and the from_isthmus filter needs BaseHardforkConfig::is_empty:
Self::all().retain(|_, h| h.isthmus_time.is_some() && h.base.is_empty())
Review SummaryBlockingType mismatch: Non-blocking (from prior review, preserved for context)
OverallThe |
Pull request was converted to draft
Summary
Replaces #1462.
Adds
ForkMatrix, a concise test helper for running the same assertion across every hardfork stage without hand-writing cumulativeHardForkConfigstructs.A single
FORK_PROGRESSIONstatic drivesall(): each entry is onefn(&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 fromall()or its subset with a singleretainpredicate, 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.