Adding a New Network Upgrade
This guide walks through each code change needed to land a new network upgrade in base/base. The work falls into two groups: edits required for every upgrade, and extra edits that apply only when the upgrade changes EVM execution rules.
The Azul upgrade is the running example. Substitute Azul, azul, and BASE_AZUL with the actual upgrade name. For broader context on the codebase, see the architecture overview.
Architecture overview
Section titled “Architecture overview”Activation is wired through three layers:
- Config layer — an optional per-upgrade activation timestamp lives in
UpgradeConfig, whichRollupConfigembeds;RollupConfigin turn surfaces theis_X_active(timestamp)helpers. - Trait layer — typed, generic activation checks come from the
BaseUpgradeenum and theBaseUpgradestrait, shared alike by the consensus and execution layers. - Execution layer — an active upgrade is mapped to its EVM spec by
BaseSpecId;spec_by_timestamp_after_bedrockandRollupConfig::spec_iddecide which spec applies, andBasePrecompilespicks the matching precompile set.
Part 1 — Required for every upgrade
Section titled “Part 1 — Required for every upgrade”1. Add the variant to the BaseUpgrade enum
Section titled “1. Add the variant to the BaseUpgrade enum”File: crates/common/chains/src/upgrade.rs
Within the upgrade! macro, add the new variant just after the current final entry:
upgrade!( #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Default)] BaseUpgrade { // ... existing variants ... /// Jovian: Base network upgrade. Jovian, /// Azul: First Base-specific network upgrade. Azul, // <-- add here });Then bump each chain config array method’s length from [(Self, ForkCondition); N] to N+1, appending the new entry. Leave mainnet and sepolia on ForkCondition::Never until activation is scheduled; the generic devnet takes ForkCondition::ZERO_TIMESTAMP:
pub const fn mainnet() -> [(Self, ForkCondition); 10] { [ // ... existing entries ... (Self::Azul, ForkCondition::Never), ]}
pub const fn devnet() -> [(Self, ForkCondition); 10] { [ // ... existing entries ... (Self::Azul, ForkCondition::ZERO_TIMESTAMP), ]}Finally, extend check_base_upgrade_from_str in the test module to cover the new upgrade variant.
2. Add the BaseChainUpgrades index arm
Section titled “2. Add the BaseChainUpgrades index arm”File: crates/common/chains/src/chain.rs
Add Azul to the use BaseUpgrade::{...} import and add a match arm to Index<BaseUpgrade>:
use BaseUpgrade::{ Azul, Bedrock, Canyon, Ecotone, Fjord, Granite, Holocene, Isthmus, Jovian, Regolith,};
impl Index<BaseUpgrade> for BaseChainUpgrades { fn index(&self, hf: BaseUpgrade) -> &Self::Output { match hf { // ... existing arms ... Jovian => &self.forks[Jovian.idx()].1, Azul => &self.forks[Azul.idx()].1, // <-- add } }}3. Add the config field and nested struct
Section titled “3. Add the config field and nested struct”File: crates/common/genesis/src/chain/upgrade.rs
For standard upgrades (a flat timestamp field), add the field directly to UpgradeConfig:
/// `azul_time` sets the activation time for the Base Azul network upgrade.#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]pub azul_time: Option<u64>,For namespaced upgrades that take the { "base": { "azul": <timestamp> } } JSON shape, declare a sub-struct and embed it:
/// Upgrade configuration for Base-specific upgrades.#[derive(Debug, Copy, Clone, Default, Hash, Eq, PartialEq)]#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]pub struct BaseUpgradeConfig { #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub azul: Option<u64>,}
pub struct UpgradeConfig { // ... existing fields ... #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub base: Option<BaseUpgradeConfig>,}Add the new entry to UpgradeConfig::iter() as well, and re-export any newly added public types from crates/common/genesis/src/chain/mod.rs and crates/common/genesis/src/lib.rs.
4. Add activation methods to RollupConfig
Section titled “4. Add activation methods to RollupConfig”File: crates/common/genesis/src/rollup.rs
Add is_X_active and is_first_X_block right after the previous upgrade’s methods. The pattern you follow turns on whether the new upgrade is standalone or cascading.
Standalone (e.g. pectra_blob_schedule, Azul) — turned on in isolation, never inferred from a later upgrade being active. Reach for this pattern when the upgrade touches only protocol-level behavior and does not gate the upgrade that follows it:
/// Returns true if Base Azul is active at the given timestamp.pub fn is_base_azul_active(&self, timestamp: u64) -> bool { self.upgrades.base.as_ref().and_then(|b| b.azul).is_some_and(|t| timestamp >= t)}
/// Returns true if the timestamp marks the first Base Azul block.pub fn is_first_base_azul_block(&self, timestamp: u64) -> bool { self.is_base_azul_active(timestamp) && !self.is_base_azul_active(timestamp.saturating_sub(self.block_time))}Leave the previous terminal upgrade’s is_X_active method as-is — no cascade is added.
Cascading (e.g. Canyon, Ecotone, Isthmus) — whenever the new upgrade is active, the prior one counts as active too. Update the earlier terminal upgrade’s method, then add the new method beside it:
/// Returns true if Jovian is active at the given timestamp.pub fn is_jovian_active(&self, timestamp: u64) -> bool { self.upgrades.jovian_time.is_some_and(|t| timestamp >= t) || self.is_next_active(timestamp) // <-- cascade to next fork}
/// Returns true if Next is active at the given timestamp.pub fn is_next_active(&self, timestamp: u64) -> bool { self.upgrades.next_time.is_some_and(|t| timestamp >= t)}Add the new arm to upgrade_activation in impl BaseUpgrades for RollupConfig too. For a standalone upgrade, the prior arm retains unwrap_or(ForkCondition::Never):
BaseUpgrade::Jovian => self .upgrades .jovian_time .map(ForkCondition::Timestamp) .unwrap_or(ForkCondition::Never), // standalone: no cascadeBaseUpgrade::Azul => self .upgrades .base .as_ref() .and_then(|b| b.azul) .map(ForkCondition::Timestamp) .unwrap_or(ForkCondition::Never),_ => ForkCondition::Never, // required: BaseUpgrade is #[non_exhaustive]For a cascading upgrade, swap the prior arm’s unwrap_or(ForkCondition::Never) for .unwrap_or_else(|| self.upgrade_activation(BaseUpgrade::Next)).
5. Add the trait method
Section titled “5. Add the trait method”File: crates/common/chains/src/upgrades.rs
/// Returns `true` if [`Azul`](BaseUpgrade::Azul) is active at given block timestamp.fn is_azul_active_at_timestamp(&self, timestamp: u64) -> bool { self.upgrade_activation(BaseUpgrade::Azul).active_at_timestamp(timestamp)}6. Update timestamp constants and test fixtures
Section titled “6. Update timestamp constants and test fixtures”Files:
crates/common/chains/src/upgrade.rs(mainnet, sepolia, devnet constants)crates/common/chains/src/lib.rscrates/common/chains/src/test_utils.rs
Add named constants once an activation timestamp is confirmed:
/// Base Azul mainnet activation timestamp.pub const BASE_MAINNET_BASE_AZUL_TIMESTAMP: u64 = <timestamp>;
// sepolia.rs/// Base Azul sepolia activation timestamp.pub const BASE_SEPOLIA_BASE_AZUL_TIMESTAMP: u64 = <timestamp>;Re-export them from lib.rs next to the existing timestamp constants.
Then update the UpgradeConfig literal in both registry fixture files:
upgrades: UpgradeConfig { // ... existing fields ... jovian_time: Some(BASE_MAINNET_JOVIAN_TIMESTAMP), base: Some(BaseUpgradeConfig { azul: Some(BASE_MAINNET_BASE_AZUL_TIMESTAMP) }),},While the activation timestamp is still unconfirmed, keep base: None and hold the chain arrays at ForkCondition::Never.
7. Update the default rollup config
Section titled “7. Update the default rollup config”File: crates/common/chains/src/test_utils.rs
The default_rollup_config() function activates every upgrade at genesis for dev use. Add the new upgrade:
upgrades: UpgradeConfig { // ... existing fields ... jovian_time: Some(0), base: Some(BaseUpgradeConfig { azul: Some(0) }),},8. Verify the upgrade consistency tests
Section titled “8. Verify the upgrade consistency tests”File: crates/common/chains/tests/upgrade_consistency.rs
For every BaseUpgrade variant, these tests check that BaseChainConfig::mainnet().upgrade_activation(fork) agrees with BaseChainUpgrades::mainnet().upgrade_activation(fork). No edits should be needed as long as both sides return ForkCondition::Never for an unscheduled upgrade — or, once scheduled, the same timestamp.
If there is a known discrepancy (say, the cascade produces a mismatch for an unset upgrade), insert a skip with an explanatory comment, following the Regolith precedent:
if *fork == BaseUpgrade::Azul { continue; // explanation of why the two sides differ}Part 2 — Required when the upgrade changes EVM execution
Section titled “Part 2 — Required when the upgrade changes EVM execution”You can skip this part when the upgrade is purely protocol-level (batch decoding, derivation rules, system config) and adds no new EVM opcodes, precompile addresses, or gas-rule changes.
9. Add the BaseSpecId variant
Section titled “9. Add the BaseSpecId variant”File: crates/common/evm/src/spec.rs
pub enum BaseSpecId { // ... existing variants ... JOVIAN, AZUL, // <-- add OSAKA,}Touch BaseSpecId::into_eth_spec() only if the new Base upgrade shifts the paired Ethereum EL upgrade. Because BaseSpecId wraps BaseUpgrade, the variant must be added to BaseUpgrade first:
BaseUpgrade::Isthmus | BaseUpgrade::Jovian => SpecId::PRAGUE,BaseUpgrade::Azul | BaseUpgrade::Beryl => SpecId::OSAKA,Add the new BaseUpgrade variant with its canonical string name:
/// Beryl upgrade.Beryl,BaseSpecId parsing and display delegate to BaseUpgrade.
10. Route precompiles
Section titled “10. Route precompiles”File: crates/common/precompiles/src/provider.rs
When the upgrade brings new precompiles, define a new method on BasePrecompiles; when it reuses the prior set, just widen the existing arm in new_with_spec:
// Reuse previous precompile setBaseUpgrade::Azul | BaseUpgrade::Beryl => Self::azul(),
// Or add a new setBaseUpgrade::Beryl => Self::beryl(),11. Update spec resolution
Section titled “11. Update spec resolution”File: crates/common/chains/src/upgrade.rs
Place the new upgrade as the leading check, since the newest active upgrade takes precedence:
pub fn from_timestamp(chain_spec: impl Upgrades, timestamp: u64) -> Self { if chain_spec.is_beryl_active_at_timestamp(timestamp) { Self::Beryl } else if chain_spec.is_azul_active_at_timestamp(timestamp) { Self::Azul } else if chain_spec.is_jovian_active_at_timestamp(timestamp) { Self::Jovian } // ... remaining checks unchanged}12. Update the reth upgrade schedule builder
Section titled “12. Update the reth upgrade schedule builder”File: crates/common/chains/src/chain.rs
Add the new upgrade to to_chain_upgrades(). Where it pairs with a new Ethereum upgrade (as Canyon does with Shanghai), push both entries; otherwise push only the Base upgrade entry:
// No paired Ethereum upgradeforks.push((BaseUpgrade::Jovian.boxed(), self[BaseUpgrade::Jovian]));forks.push((BaseUpgrade::Azul.boxed(), self[BaseUpgrade::Azul])); // <-- addChecklist
Section titled “Checklist”Always required
Section titled “Always required”-
BaseUpgradevariant added inupgrade.rs; all four chain arrays updated -
Index<BaseUpgrade>arm added inchain.rs - Config field (flat or nested struct) added to
UpgradeConfiginupgrade.rs;iter()updated; new types re-exported -
is_X_active+is_first_X_blockadded toRollupConfig;upgrade_activationarm added; previous terminal upgrade cascades to new one (unless standalone) -
is_X_active_at_timestampadded toBaseUpgradestrait - Timestamp constants added to chain config modules and re-exported from
lib.rs - Registry fixtures (
test_utils/mod.rs) updated - Default rollup config updated (
defaults.rs) - Upgrade consistency tests pass
Required when EVM execution changes
Section titled “Required when EVM execution changes”-
BaseSpecIdvariant added withinto_eth_specmapping and#[strum(serialize = "...")]attribute - Precompile match arm updated (or new precompile set added)
-
spec_by_timestamp_after_bedrockupdated (common/evm/src/spec.rs) -
RollupConfig::spec_idupdated (common/genesis/src/rollup.rs) -
to_chain_upgradesupdated (execution/chainspec/src/upgrades.rs)