-
Notifications
You must be signed in to change notification settings - Fork 3
feat!: fork schedule primitives and genesis/STF params split #187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c44c76d
fb177ab
d09a20c
8924b59
f51831d
32bd1d3
3db9ecc
924b4aa
5c4cffd
322587a
59d3408
0683d28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| //! Fork-based upgradeability primitives. | ||
| //! | ||
| //! The ASM upgrades EVM-style: STF logic is gated on named forks with L1 | ||
| //! activation heights, so a single binary can execute both sides of an upgrade | ||
| //! boundary. The schedule is *not* part of committed state — it is baked into | ||
| //! each proving artifact (guest ELF / native host) and supplied to the worker | ||
| //! via params, with the invariant that every artifact agrees on the gate's | ||
| //! outcome at every height it executes (see `AsmStfParams`). | ||
|
|
||
| use serde::{Deserialize, Serialize}; | ||
| use strata_identifiers::L1Height; | ||
|
|
||
| /// Identifies a named fork. | ||
| /// | ||
| /// One variant per protocol upgrade, in activation order. The numeric | ||
| /// discriminant is the stable identity: it keys persisted fork-activation | ||
| /// records (stored as the raw discriminant byte) and is the raw id carried in | ||
| /// ASM VK upgrade actions. Neither path goes through this type's serde, so a | ||
| /// persisted record or an in-flight action is unaffected by a variant being | ||
| /// renamed — the byte and the id stay the same. | ||
| /// | ||
| /// The variant name is the human-readable form: it is this type's serde | ||
| /// representation (snake_case) and is mirrored by the [`ForkSchedule`] params | ||
| /// field. Names are meant to change — `Fork1` is a placeholder for an upgrade | ||
| /// not yet defined — so renaming one once defined is a routine migration of the | ||
| /// human-facing config, leaving persisted records and wire actions untouched. | ||
| /// | ||
| /// The id crosses two boundaries with opposite tolerances: | ||
| /// | ||
| /// - Parse-time: ASM VK upgrade actions carry the raw id, not this enum, so an artifact predating a | ||
| /// fork can still parse and enact the upgrade that activates it — the wire format never requires | ||
| /// knowing the fork. | ||
| /// - Act-time: a consumer that must *apply* the fork's rules (the worker) maps the id via | ||
| /// [`TryFrom`]. An id it does not know is not skipped: it means the worker is running old | ||
| /// software past an upgrade it cannot execute, so it MUST halt rather than silently limp along on | ||
| /// stale rules. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | ||
| #[serde(rename_all = "snake_case")] | ||
| #[repr(u8)] | ||
|
prajwolrg marked this conversation as resolved.
|
||
| pub enum ForkId { | ||
| /// Placeholder for the first protocol upgrade; renamed once that upgrade is | ||
| /// defined. The rename is a migration of the human-readable name (this | ||
| /// variant's serde form and the [`ForkSchedule`] params field); persisted | ||
| /// records and wire actions key on the numeric discriminant and are | ||
| /// unaffected. | ||
| Fork1 = 0, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the doc-comment says discriminants are stable and key persisted records, but the derived serde uses the name ( Whichever way the
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pinning serde to
So a persisted record or an in-flight action is already immune to a rename — the byte and the id don't change. That's the "stable numeric id on wire/persisted" you asked for, and it's already true via those encodings, not via serde. Given that, the human-readable string serde is the better form — and renaming is the expected path, not a hazard: |
||
| } | ||
|
|
||
| impl From<ForkId> for u8 { | ||
| fn from(fork: ForkId) -> Self { | ||
| fork as u8 | ||
| } | ||
| } | ||
|
|
||
| impl From<ForkId> for u16 { | ||
| fn from(fork: ForkId) -> Self { | ||
| fork as u16 | ||
| } | ||
| } | ||
|
|
||
| impl TryFrom<u8> for ForkId { | ||
| type Error = u8; | ||
|
|
||
| fn try_from(value: u8) -> Result<Self, Self::Error> { | ||
| match value { | ||
| 0 => Ok(ForkId::Fork1), | ||
| invalid => Err(invalid), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl TryFrom<u16> for ForkId { | ||
| type Error = u16; | ||
|
|
||
| fn try_from(value: u16) -> Result<Self, Self::Error> { | ||
| u8::try_from(value) | ||
| .ok() | ||
| .and_then(|v| ForkId::try_from(v).ok()) | ||
| .ok_or(value) | ||
| } | ||
| } | ||
|
Comment on lines
+61
to
+81
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's an |
||
|
|
||
| /// Activation heights for every named fork. | ||
| /// | ||
| /// A fork with activation height `Some(n)` is active at L1 height `h` iff | ||
| /// `h >= n` — so `Some(0)` means active since genesis. `None` means disabled. | ||
| /// Proving artifacts bake one of the two extremes (`Some(0)` or `None` — an | ||
| /// artifact only ever executes one side of an upgrade boundary), while the | ||
| /// worker tracks the real activation height discovered from the ASM VK | ||
| /// upgrade log. | ||
| #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | ||
| pub struct ForkSchedule { | ||
| /// Activation height of [`ForkId::Fork1`], or `None` if disabled. | ||
| pub fork1: Option<L1Height>, | ||
| } | ||
|
|
||
| impl ForkSchedule { | ||
| /// Schedule with every fork disabled (no activation height). | ||
| pub const fn all_disabled() -> Self { | ||
| Self { fork1: None } | ||
| } | ||
|
|
||
| /// Returns the activation height of `fork`, or `None` if disabled. | ||
| pub fn activation_height_of(&self, fork: ForkId) -> Option<L1Height> { | ||
| match fork { | ||
| ForkId::Fork1 => self.fork1, | ||
| } | ||
| } | ||
|
|
||
| /// Returns whether `fork` is active at L1 `height`. | ||
| pub fn is_active(&self, fork: ForkId, height: L1Height) -> bool { | ||
| self.activation_height_of(fork) | ||
| .is_some_and(|activation| height >= activation) | ||
| } | ||
|
|
||
| /// Sets the activation height of `fork`. | ||
| pub fn set_fork_activation(&mut self, fork: ForkId, height: L1Height) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the overlay should build a fresh effective schedule per height rather than calling this on a long-lived base. A base mutated in place survives a sync rebase, which is exactly how a reorged-out activation could leak into the new branch. Nothing
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good instinct on the primitive —
The two exist precisely so the effective schedule is always derivable, not accumulated: the authoritative record of activations is the persisted So the fresh-per-branch rebuild you're asking for is wired at the one place staleness can arise — the rebase, before any block is re-processed ( state.rollback_fork_activations(base_block.height())?;// rollback_fork_activations
self.context.prune_fork_activations_after(base_height)?;
let activations = self.context.list_fork_activations()?;
self.fork_schedule = effective_schedule(&self.base_forks, &activations);Prune persistence above the fork point, then rebuild the overlay from the immutable base + survivors — a reorged-out activation is dropped, not leaked. We rebuild per rebase rather than per height: within one branch the overlay only ever gains activations moving forward, and any branch switch already forces the full rebuild, so per-height would be redundant work. Covered by the TL;DR: the primitive alone can't prevent the leak, agreed — the consumer does, by never mutating the base and rebuilding the effective schedule from persisted records on every rebase. |
||
| match fork { | ||
| ForkId::Fork1 => self.fork1 = Some(height), | ||
| } | ||
| } | ||
|
prajwolrg marked this conversation as resolved.
|
||
| } | ||
|
|
||
| impl Default for ForkSchedule { | ||
| fn default() -> Self { | ||
| Self::all_disabled() | ||
| } | ||
| } | ||
|
|
||
| /// Protocol-rule parameters consumed by the STF, as opposed to the genesis | ||
| /// params that only seed the initial state. | ||
| /// | ||
| /// Every executor of the STF carries its own copy: guest programs hardcode it | ||
| /// (so the proof's verifying key commits to it), native proving hosts bake it | ||
| /// into their closure, and the worker derives an effective copy from params | ||
| /// plus discovered fork activations. | ||
| /// | ||
| /// `Default` inherits [`ForkSchedule`]'s default: everything disabled. | ||
| #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] | ||
| pub struct AsmStfParams { | ||
| /// Fork activation schedule. | ||
| pub forks: ForkSchedule, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn is_active_boundaries() { | ||
| let sched = ForkSchedule { fork1: Some(100) }; | ||
| assert!(!sched.is_active(ForkId::Fork1, 99)); | ||
| assert!(sched.is_active(ForkId::Fork1, 100)); | ||
| assert!(sched.is_active(ForkId::Fork1, 101)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn zero_means_always_active() { | ||
| let sched = ForkSchedule { fork1: Some(0) }; | ||
| assert!(sched.is_active(ForkId::Fork1, 0)); | ||
| assert!(sched.is_active(ForkId::Fork1, L1Height::MAX)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn none_means_never_active() { | ||
| let sched = ForkSchedule::all_disabled(); | ||
| assert_eq!(sched.activation_height_of(ForkId::Fork1), None); | ||
| assert!(!sched.is_active(ForkId::Fork1, 0)); | ||
| assert!(!sched.is_active(ForkId::Fork1, L1Height::MAX)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn set_fork_activation_overrides() { | ||
| let mut sched = ForkSchedule::all_disabled(); | ||
| sched.set_fork_activation(ForkId::Fork1, 42); | ||
| assert_eq!(sched.activation_height_of(ForkId::Fork1), Some(42)); | ||
| assert!(sched.is_active(ForkId::Fork1, 42)); | ||
| assert!(!sched.is_active(ForkId::Fork1, 41)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn serde_roundtrip() { | ||
| let params = AsmStfParams { | ||
| forks: ForkSchedule { fork1: Some(7) }, | ||
| }; | ||
| let json = serde_json::to_string(¶ms).unwrap(); | ||
| assert_eq!(json, r#"{"forks":{"fork1":7}}"#); | ||
| let back: AsmStfParams = serde_json::from_str(&json).unwrap(); | ||
| assert_eq!(back, params); | ||
|
|
||
| let disabled = AsmStfParams { | ||
| forks: ForkSchedule::all_disabled(), | ||
| }; | ||
| let json = serde_json::to_string(&disabled).unwrap(); | ||
| assert_eq!(json, r#"{"forks":{"fork1":null}}"#); | ||
| let back: AsmStfParams = serde_json::from_str(&json).unwrap(); | ||
| assert_eq!(back, disabled); | ||
| } | ||
|
|
||
| /// Serde is the human-readable form (the variant name). The stable numeric | ||
| /// identity used for persistence and the wire is exercised by | ||
| /// [`fork_id_u16_roundtrip`] instead. | ||
| #[test] | ||
| fn fork_id_serde_is_the_variant_name() { | ||
| assert_eq!(serde_json::to_string(&ForkId::Fork1).unwrap(), r#""fork1""#); | ||
| assert_eq!( | ||
| serde_json::from_str::<ForkId>(r#""fork1""#).unwrap(), | ||
| ForkId::Fork1 | ||
| ); | ||
| assert!(serde_json::from_str::<ForkId>(r#""nope""#).is_err()); | ||
| } | ||
|
|
||
| /// Raw fork ids on the wire round-trip through the enum; unknown ids | ||
| /// surface as errors instead of misparsing. | ||
| #[test] | ||
| fn fork_id_u16_roundtrip() { | ||
| assert_eq!(u16::from(ForkId::Fork1), 0); | ||
| assert_eq!(ForkId::try_from(0u16).unwrap(), ForkId::Fork1); | ||
| assert_eq!(ForkId::try_from(0xFFFFu16), Err(0xFFFF)); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I'm going to raise again that I dislike centering the logic around the forks as opposed to versions, as centering the changes makes it so that the "version 0" condition before any fork is trigger has to be kinda a special default case.