Skip to content
BaseHub by wbnns Updated

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.

Activation is wired through three layers:

  1. Config layer — an optional per-upgrade activation timestamp lives in UpgradeConfig, which RollupConfig embeds; RollupConfig in turn surfaces the is_X_active(timestamp) helpers.
  2. Trait layer — typed, generic activation checks come from the BaseUpgrade enum and the BaseUpgrades trait, shared alike by the consensus and execution layers.
  3. Execution layer — an active upgrade is mapped to its EVM spec by BaseSpecId; spec_by_timestamp_after_bedrock and RollupConfig::spec_id decide which spec applies, and BasePrecompiles picks the matching precompile set.

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.

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
}
}
}

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.

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 cascade
BaseUpgrade::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)).

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:

Add named constants once an activation timestamp is confirmed:

mainnet.rs
/// 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.

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) }),
},

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.

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.

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 set
BaseUpgrade::Azul | BaseUpgrade::Beryl => Self::azul(),
// Or add a new set
BaseUpgrade::Beryl => Self::beryl(),

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 upgrade
forks.push((BaseUpgrade::Jovian.boxed(), self[BaseUpgrade::Jovian]));
forks.push((BaseUpgrade::Azul.boxed(), self[BaseUpgrade::Azul])); // <-- add
  • BaseUpgrade variant added in upgrade.rs; all four chain arrays updated
  • Index<BaseUpgrade> arm added in chain.rs
  • Config field (flat or nested struct) added to UpgradeConfig in upgrade.rs; iter() updated; new types re-exported
  • is_X_active + is_first_X_block added to RollupConfig; upgrade_activation arm added; previous terminal upgrade cascades to new one (unless standalone)
  • is_X_active_at_timestamp added to BaseUpgrades trait
  • 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
  • BaseSpecId variant added with into_eth_spec mapping and #[strum(serialize = "...")] attribute
  • Precompile match arm updated (or new precompile set added)
  • spec_by_timestamp_after_bedrock updated (common/evm/src/spec.rs)
  • RollupConfig::spec_id updated (common/genesis/src/rollup.rs)
  • to_chain_upgrades updated (execution/chainspec/src/upgrades.rs)