Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
[submodule "solidity/lib/openzeppelin-contracts"]
path = solidity/lib/openzeppelin-contracts
url = https://github.com/openzeppelin/openzeppelin-contracts
[submodule "solidity/lib/openzeppelin-contracts-upgradeable"]
path = solidity/lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
all: build

build:
forge build
cargo build
cd custom-reth && cargo build

Expand Down
3 changes: 3 additions & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ alloy-sol-types = { workspace = true }
alloy-primitives = { workspace = true }
alloy-contract = { workspace = true }

[build-dependencies]
serde_json = "1"

[dev-dependencies]
alloy-network = "1.4.3"
alloy-node-bindings = "1.4.3"
Expand Down
109 changes: 80 additions & 29 deletions contracts/build.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,88 @@
//! Extract UUPS `__self` immutable reference offsets from the Foundry build artifact.
//!
//! The ValidatorManager contract uses UUPS (ERC1822) which stores an immutable
//! `__self = address(this)` set during construction. When genesis injects deployed
//! bytecode directly (bypassing the constructor), these locations must be patched.
//! This build script reads the offsets from the Foundry JSON artifact so they stay
//! in sync automatically after every `forge build`.

use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::path::Path;

fn main() {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");

println!("cargo::rerun-if-changed=../solidity/src");
println!("cargo::rerun-if-changed=../foundry.toml");

let status = Command::new("forge")
.args(["build", "--skip", "test", "script"])
.current_dir(&workspace_root)
.status();

match status {
Ok(s) if s.success() => {}
Ok(s) => match s.code() {
Some(code) => panic!("forge build failed with exit code {code}"),
None => panic!("forge build terminated by signal"),
},
Err(e) => {
panic!("failed to run forge: {e}\ninstall Foundry: https://getfoundry.sh/");
let artifact_path = Path::new("../solidity/out/ValidatorManager.sol/ValidatorManager.json");

// Re-run if the artifact changes (i.e. after `forge build`)
println!(
"cargo::rerun-if-changed={}",
artifact_path.display()
);

let json_str = fs::read_to_string(artifact_path).unwrap_or_else(|e| {
panic!(
"Failed to read Foundry artifact at {}: {e}. Run `forge build` in the solidity/ directory first.",
artifact_path.display()
)
});

let artifact: serde_json::Value =
serde_json::from_str(&json_str).expect("failed to parse Foundry artifact JSON");

let refs = artifact["deployedBytecode"]["immutableReferences"]
.as_object()
.expect("missing deployedBytecode.immutableReferences in artifact");

// Collect all (offset, length) pairs across all immutable IDs.
// For ValidatorManager there is exactly one immutable (__self from UUPSUpgradeable).
let mut offsets: Vec<usize> = Vec::new();
let mut length: Option<usize> = None;

for (_ast_id, locations) in refs {
for loc in locations.as_array().expect("immutable locations should be an array") {
let start = loc["start"]
.as_u64()
.expect("immutable reference missing 'start'") as usize;
let len = loc["length"]
.as_u64()
.expect("immutable reference missing 'length'") as usize;

// All references to the same immutable have the same length
if let Some(prev) = length {
assert_eq!(prev, len, "inconsistent immutable reference lengths");
}
length = Some(len);
offsets.push(start);
}
}

let compiled_contracts = ["ValidatorManager"];
offsets.sort();

for name in compiled_contracts {
let artifact = workspace_root.join(format!("solidity/out/{name}.sol/{name}.json"));
assert!(
fs::metadata(&artifact).is_ok(),
"expected artifact not found: {}",
artifact.display()
);
}
assert!(
!offsets.is_empty(),
"no immutable references found in ValidatorManager artifact"
);

let length = length.unwrap();

// Generate Rust source
let out_dir = env::var("OUT_DIR").unwrap();
let dest = Path::new(&out_dir).join("uups_immutable_offsets.rs");

let offsets_str = offsets
.iter()
.map(|o| o.to_string())
.collect::<Vec<_>>()
.join(", ");

let generated = format!(
"/// Byte offsets of the UUPS `__self` immutable in ValidatorManager DEPLOYED_BYTECODE.\n\
/// Auto-generated by build.rs from the Foundry artifact.\n\
pub const UUPS_SELF_IMMUTABLE_OFFSETS: &[usize] = &[{offsets_str}];\n\
\n\
/// Byte length of each immutable reference (address left-padded to 32 bytes).\n\
pub const UUPS_SELF_IMMUTABLE_LENGTH: usize = {length};\n"
);

fs::write(&dest, generated).expect("failed to write generated offsets file");
}
5 changes: 4 additions & 1 deletion contracts/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
pub mod validator_manager;

pub use validator_manager::{
ValidatorManager, GENESIS_ACCOUNT as GENESIS_VALIDATOR_MANAGER_ACCOUNT,
ValidatorManager, ValidatorManagerProxy,
GENESIS_ACCOUNT as GENESIS_VALIDATOR_MANAGER_ACCOUNT,
GENESIS_IMPL_ACCOUNT as GENESIS_VALIDATOR_MANAGER_IMPL_ACCOUNT,
UUPS_SELF_IMMUTABLE_LENGTH, UUPS_SELF_IMMUTABLE_OFFSETS,
};
32 changes: 29 additions & 3 deletions contracts/src/validator_manager.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,39 @@
use alloy_primitives::{address, Address};

/// Genesis validator manager account address
/// Proxy address: all callers interact with this address (0x2000)
pub const GENESIS_ACCOUNT: Address = address!("0x0000000000000000000000000000000000002000");

/// Implementation address: the ValidatorManager logic contract deployed at genesis (0x2001)
pub const GENESIS_IMPL_ACCOUNT: Address = address!("0x0000000000000000000000000000000000002001");

// UUPS __self immutable offsets, auto-generated by build.rs from the Foundry artifact.
include!(concat!(env!("OUT_DIR"), "/uups_immutable_offsets.rs"));

alloy_sol_types::sol!(
#[derive(Debug)]
#[sol(rpc)]
ValidatorManager,
"../solidity/out/ValidatorManager.sol/ValidatorManager.json"
);

alloy_sol_types::sol!(
#[derive(Debug)]
#[sol(rpc)]
ValidatorManagerProxy,
"../solidity/out/ValidatorManagerProxy.sol/ValidatorManagerProxy.json"
);

#[cfg(test)]
mod tests {
use alloy_network::EthereumWallet;
use alloy_node_bindings::Anvil;
use alloy_primitives::{hex, Bytes, U256};
use alloy_provider::ProviderBuilder;
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_types::SolCall;
use color_eyre::Result;

use super::ValidatorManager;
use super::{ValidatorManager, ValidatorManagerProxy};

const SECP256K1_G_UNCOMPRESSED: [u8; 65] = hex!(
"04"
Expand All @@ -42,7 +56,19 @@ mod tests {
.wallet(EthereumWallet::from(signer))
.connect_http(anvil.endpoint_url());

let contract = ValidatorManager::deploy(provider).await?;
// Deploy implementation then proxy
let impl_contract = ValidatorManager::deploy(provider.clone()).await?;
let init_data = ValidatorManager::initializeCall {
initialOwner: deployer,
}
.abi_encode();
let proxy = ValidatorManagerProxy::deploy(
provider.clone(),
*impl_contract.address(),
Bytes::from(init_data),
)
.await?;
let contract = ValidatorManager::new(*proxy.address(), provider);

assert_eq!(contract.owner().call().await?, deployer);
assert_eq!(contract.getValidatorCount().call().await?, U256::ZERO);
Expand Down
1 change: 1 addition & 0 deletions docs/operational-docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- [Consensus Layer](./architecture/consensus.md)
- [Execution Layer](./architecture/execution-client.md)
- [Proof-of-Authority Model](./architecture/poa.md)
- [Contract Upgrades](./architecture/contract-upgrades.md)
- [Block Syncing](./architecture/syncing.md)
- [EVM Compatibility](./evm-compatibility.md)

Expand Down
129 changes: 129 additions & 0 deletions docs/operational-docs/src/architecture/contract-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# ValidatorManager Contract Upgrades

The `ValidatorManager` contract is deployed behind a UUPS (Universal Upgradeable Proxy Standard) proxy at the predefined address `0x0000000000000000000000000000000000002000`. This allows the contract logic to be upgraded without changing the address that validators, the consensus engine, and tooling interact with.

## Architecture

```
Callers (Rust app, CLI, etc.)
|
v
ERC1967Proxy at 0x2000 <-- permanent address, holds all storage
| delegatecall
v
ValidatorManager impl at 0x2001 <-- replaceable logic contract
```

- The **proxy** at `0x2000` holds all storage (validators, owner, total power) and delegates every call to the current implementation.
- The **implementation** at `0x2001` (at genesis) contains only the contract logic. It can be replaced by deploying a new implementation and calling `upgradeToAndCall` on the proxy.
- Only the contract **owner** can authorize upgrades.

## How to Upgrade

### 1. Prepare the New Implementation

Write the updated `ValidatorManager` contract. The new version **must**:

- Inherit from the same base contracts (`Initializable`, `OwnableUpgradeable`, `ReentrancyGuardUpgradeable`, `UUPSUpgradeable`)
- Keep all existing state variables in the same order
- Only append new state variables at the end

### 2. Deploy the New Implementation

Deploy the new implementation contract to any address using a standard transaction:

```bash
forge create --rpc-url <RPC_URL> --private-key <DEPLOYER_KEY> src/ValidatorManager.sol:ValidatorManager
```

Note the deployed address (e.g. `0xNewImplAddress`).

### 3. Call `upgradeToAndCall` on the Proxy

As the contract owner, call the upgrade function on the proxy address (`0x2000`):

```bash
cast send 0x0000000000000000000000000000000000002000 \
"upgradeToAndCall(address,bytes)" \
<NEW_IMPL_ADDRESS> \
"0x" \
--rpc-url <RPC_URL> \
--private-key <OWNER_KEY>
```

If the new version needs migration logic, encode a `reinitializer(n)` call as the second argument instead of `"0x"`.

### 4. Verify

```bash
# Check the implementation address changed
cast storage 0x0000000000000000000000000000000000002000 \
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \
--rpc-url <RPC_URL>

# Verify existing state is intact
cast call 0x0000000000000000000000000000000000002000 \
"getValidatorCount()(uint256)" \
--rpc-url <RPC_URL>
```

## Storage Layout Rules

The proxy holds all storage. Upgrades must preserve storage compatibility:

| Rule | Detail |
|------|--------|
| Never reorder state variables | Changing the order shifts all subsequent slots, corrupting data |
| Never remove state variables | Removing a variable shifts all subsequent slots |
| Never insert state variables in the middle | Same as reorder -- shifts slots |
| Only append new variables at the end | New variables get fresh sequential slots after existing ones |
| Do not change variable types to different sizes | e.g. changing `uint64` to `uint256` changes the slot packing |

OpenZeppelin base contracts (`OwnableUpgradeable`, etc.) use ERC-7201 namespaced storage, so their internal storage will not collide with the contract's sequential slots.

## Using `reinitializer` for Migration Logic

If an upgrade needs to run one-time migration logic (e.g. initializing a new state variable), use the `reinitializer` modifier:

```solidity
function initializeV2(uint256 newParam) public reinitializer(2) {
_newStateVar = newParam;
}
```

Then pass the encoded call as the second argument to `upgradeToAndCall`:

```bash
cast send 0x0000000000000000000000000000000000002000 \
"upgradeToAndCall(address,bytes)" \
<NEW_IMPL_ADDRESS> \
$(cast calldata "initializeV2(uint256)" 42) \
--rpc-url <RPC_URL> \
--private-key <OWNER_KEY>
```

The `reinitializer(n)` modifier ensures the migration can only run once and in sequence (version 2 after version 1, etc.).

## Limitations

- **Proxy address is permanent**: `0x2000` can never change. All callers reference this address.
- **Constructor logic does not run on upgrade**: Use `reinitializer(n)` for any migration logic needed by a new version.
- **Owner key compromise is critical**: The owner can upgrade to an arbitrary implementation, effectively taking full control. Protect the owner key accordingly.
- **`renounceOwnership` permanently locks upgrades**: Once ownership is renounced, no further upgrades are possible. This is irreversible.
- **Existing chains migration**: Chains already running with the non-upgradeable contract require a hard fork to migrate to the proxy pattern. This involves replacing the code at `0x2000` with proxy bytecode, deploying the implementation at a new address, and adjusting storage layout (the slot positions change between the non-upgradeable and upgradeable versions).

## Pre-Upgrade Checklist

1. Verify storage compatibility: all existing state variables are in the same position
2. Run `forge test` against the new implementation with upgrade tests
3. Test the upgrade on a local devnet first
4. Audit the new implementation for correctness and security
5. Verify the owner key is available and functional
6. Communicate the upgrade plan to all network participants

## Genesis Deployment Details

At genesis, the proxy and implementation are deployed via alloc (no constructor execution):

- **`0x2000`**: `ValidatorManagerProxy` runtime bytecode + pre-computed storage (EIP-1967 implementation slot, ERC-7201 Ownable/ReentrancyGuard/Initializable slots, validator state)
- **`0x2001`**: `ValidatorManager` runtime bytecode + Initializable storage set to `type(uint64).max` (locks the implementation against direct initialization, since the constructor's `_disableInitializers()` doesn't run during genesis alloc)
1 change: 1 addition & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ out = "out"
libs = [ "lib" ]
remappings = [
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/",
"@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/",
"forge-std/=lib/forge-std/src/",
]

Expand Down
Loading