Skip to content
Draft
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 .github/workflows/ev_deployer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1

- name: Install Hyperlane soldeer dependencies
run: cd contracts/lib/hyperlane-monorepo/solidity && forge soldeer install

- name: Run bytecode verification tests
run: cargo test -p ev-deployer -- --ignored --test-threads=1

Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "contracts/lib/forge-std"]
path = contracts/lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "contracts/lib/hyperlane-monorepo"]
path = contracts/lib/hyperlane-monorepo
url = https://github.com/hyperlane-xyz/hyperlane-monorepo.git
22 changes: 22 additions & 0 deletions bin/ev-deployer/examples/devnet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,25 @@ call_fee = 0
bridge_share_bps = 10000
other_recipient = "0x0000000000000000000000000000000000000000"
hyp_native_minter = "0x0000000000000000000000000000000000000000"

[contracts.mailbox]
address = "0x0000000000000000000000000000000000001200"
owner = "0x000000000000000000000000000000000000Ad00"
default_ism = "0x0000000000000000000000000000000000001300"
default_hook = "0x0000000000000000000000000000000000001400"
required_hook = "0x0000000000000000000000000000000000001100"

[contracts.merkle_tree_hook]
address = "0x0000000000000000000000000000000000001100"
owner = "0x000000000000000000000000000000000000Ad00"
mailbox = "0x0000000000000000000000000000000000001200"

[contracts.noop_ism]
address = "0x0000000000000000000000000000000000001300"

[contracts.protocol_fee]
address = "0x0000000000000000000000000000000000001400"
owner = "0x000000000000000000000000000000000000Ad00"
max_protocol_fee = 1000000000000000000
protocol_fee = 0
beneficiary = "0x000000000000000000000000000000000000Ad00"
114 changes: 114 additions & 0 deletions bin/ev-deployer/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ pub(crate) struct ContractsConfig {
pub admin_proxy: Option<AdminProxyConfig>,
/// `FeeVault` contract config (optional).
pub fee_vault: Option<FeeVaultConfig>,
/// `MerkleTreeHook` contract config (optional).
pub merkle_tree_hook: Option<MerkleTreeHookConfig>,
/// `Mailbox` contract config (optional).
pub mailbox: Option<MailboxConfig>,
/// `NoopIsm` contract config (optional).
pub noop_ism: Option<NoopIsmConfig>,
/// `ProtocolFee` contract config (optional).
pub protocol_fee: Option<ProtocolFeeConfig>,
}

/// `AdminProxy` configuration.
Expand Down Expand Up @@ -70,6 +78,62 @@ pub(crate) struct FeeVaultConfig {
pub hyp_native_minter: Address,
}

/// `MerkleTreeHook` configuration (Hyperlane required hook).
#[derive(Debug, Deserialize)]
pub(crate) struct MerkleTreeHookConfig {
/// Address to deploy at.
pub address: Address,
/// Owner address (for post-genesis hook/ISM changes).
#[serde(default)]
pub owner: Address,
/// Mailbox address (patched into bytecode as immutable).
pub mailbox: Address,
}

/// `ProtocolFee` configuration (Hyperlane post-dispatch hook that charges a protocol fee).
#[derive(Debug, Deserialize)]
pub(crate) struct ProtocolFeeConfig {
/// Address to deploy at.
pub address: Address,
/// Owner address.
#[serde(default)]
pub owner: Address,
/// Maximum protocol fee in wei.
pub max_protocol_fee: u64,
/// Protocol fee charged per dispatch in wei.
#[serde(default)]
pub protocol_fee: u64,
/// Beneficiary address that receives collected fees.
#[serde(default)]
pub beneficiary: Address,
}

/// `Mailbox` configuration (Hyperlane core messaging hub).
#[derive(Debug, Deserialize)]
pub(crate) struct MailboxConfig {
/// Address to deploy at.
pub address: Address,
/// Owner address.
#[serde(default)]
pub owner: Address,
/// Default interchain security module.
#[serde(default)]
pub default_ism: Address,
/// Default post-dispatch hook.
#[serde(default)]
pub default_hook: Address,
/// Required post-dispatch hook (e.g. `MerkleTreeHook`).
#[serde(default)]
pub required_hook: Address,
}

/// `NoopIsm` configuration (Hyperlane ISM that accepts all messages).
#[derive(Debug, Deserialize)]
pub(crate) struct NoopIsmConfig {
/// Address to deploy at.
pub address: Address,
}

impl DeployConfig {
/// Load and validate config from a TOML file.
pub(crate) fn load(path: &Path) -> eyre::Result<Self> {
Expand Down Expand Up @@ -100,6 +164,24 @@ impl DeployConfig {
);
}

if let Some(ref mth) = self.contracts.merkle_tree_hook {
eyre::ensure!(
!mth.mailbox.is_zero(),
"merkle_tree_hook.mailbox must not be the zero address"
);
}

if let Some(ref pf) = self.contracts.protocol_fee {
eyre::ensure!(
!pf.owner.is_zero(),
"protocol_fee.owner must not be the zero address"
);
eyre::ensure!(
!pf.beneficiary.is_zero(),
"protocol_fee.beneficiary must not be the zero address"
);
}

if let (Some(ap), Some(fv)) = (&self.contracts.admin_proxy, &self.contracts.fee_vault) {
eyre::ensure!(
ap.address != fv.address,
Expand Down Expand Up @@ -172,6 +254,38 @@ bridge_share_bps = 10001
assert!(config.validate().is_err());
}

#[test]
fn parse_merkle_tree_hook_config() {
let toml = r#"
[chain]
chain_id = 1234

[contracts.merkle_tree_hook]
address = "0x0000000000000000000000000000000000001100"
owner = "0x000000000000000000000000000000000000ad00"
mailbox = "0x0000000000000000000000000000000000001200"
"#;
let config: DeployConfig = toml::from_str(toml).unwrap();
config.validate().unwrap();
assert!(config.contracts.merkle_tree_hook.is_some());
let mth = config.contracts.merkle_tree_hook.unwrap();
assert!(!mth.mailbox.is_zero());
}

#[test]
fn reject_zero_mailbox_merkle_tree_hook() {
let toml = r#"
[chain]
chain_id = 1

[contracts.merkle_tree_hook]
address = "0x0000000000000000000000000000000000001100"
mailbox = "0x0000000000000000000000000000000000000000"
"#;
let config: DeployConfig = toml::from_str(toml).unwrap();
assert!(config.validate().is_err());
}

#[test]
fn reject_duplicate_addresses() {
let toml = r#"
Expand Down
127 changes: 127 additions & 0 deletions bin/ev-deployer/src/contracts/immutables.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Bytecode patching for Solidity immutable variables.
//!
//! Solidity `immutable` values are embedded in the **runtime bytecode** by the
//! compiler, not in storage. When compiling with placeholder values (e.g.
//! `address(0)`, `uint32(0)`), the compiler leaves zero-filled regions at known
//! byte offsets. This module replaces those regions with the actual values from
//! the deploy config at genesis-generation time.

use alloy_primitives::{Address, B256, U256};

/// A single immutable reference inside a bytecode blob.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ImmutableRef {
/// Byte offset into the **runtime** bytecode.
pub start: usize,
/// Number of bytes (always 32 for EVM words).
pub length: usize,
}

/// Patch a mutable bytecode slice, writing `value` at every listed offset.
///
/// # Panics
///
/// Panics if any reference extends past the end of `bytecode`.
pub(crate) fn patch_bytes(bytecode: &mut [u8], refs: &[ImmutableRef], value: &[u8; 32]) {
for r in refs {
assert!(
r.start + r.length <= bytecode.len(),
"immutable ref out of bounds: start={} length={} bytecode_len={}",
r.start,
r.length,
bytecode.len()
);
bytecode[r.start..r.start + r.length].copy_from_slice(value);
}
}

/// Convenience: patch with an ABI-encoded `address` (left-padded to 32 bytes).
pub(crate) fn patch_address(bytecode: &mut [u8], refs: &[ImmutableRef], addr: Address) {
let word: B256 = B256::from(U256::from_be_bytes(addr.into_word().0));
patch_bytes(bytecode, refs, &word.0);
}

/// Convenience: patch with an ABI-encoded `uint32` (left-padded to 32 bytes).
pub(crate) fn patch_u32(bytecode: &mut [u8], refs: &[ImmutableRef], val: u32) {
let word = B256::from(U256::from(val));
patch_bytes(bytecode, refs, &word.0);
}

/// Convenience: patch with an ABI-encoded `uint256`.
pub(crate) fn patch_u256(bytecode: &mut [u8], refs: &[ImmutableRef], val: U256) {
let word = B256::from(val);
patch_bytes(bytecode, refs, &word.0);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn patch_single_ref() {
let mut bytecode = vec![0u8; 64];
let refs = [ImmutableRef {
start: 10,
length: 32,
}];
let value = B256::from(U256::from(42u64));
patch_bytes(&mut bytecode, &refs, &value.0);

assert_eq!(bytecode[41], 42);
// bytes before are untouched
assert_eq!(bytecode[9], 0);
// bytes after are untouched
assert_eq!(bytecode[42], 0);
}

#[test]
fn patch_multiple_refs() {
let mut bytecode = vec![0u8; 128];
let refs = [
ImmutableRef {
start: 0,
length: 32,
},
ImmutableRef {
start: 64,
length: 32,
},
];
let addr = Address::repeat_byte(0xAB);
patch_address(&mut bytecode, &refs, addr);

// Both locations should have the address (last 20 bytes of the 32-byte word)
assert_eq!(bytecode[12..32], [0xAB; 20]);
assert_eq!(bytecode[76..96], [0xAB; 20]);
// Padding bytes should be zero
assert_eq!(bytecode[0..12], [0u8; 12]);
assert_eq!(bytecode[64..76], [0u8; 12]);
}

#[test]
fn patch_u32_value() {
let mut bytecode = vec![0u8; 64];
let refs = [ImmutableRef {
start: 0,
length: 32,
}];
patch_u32(&mut bytecode, &refs, 1234);

// uint32 1234 = 0x04D2, left-padded to 32 bytes
assert_eq!(bytecode[30], 0x04);
assert_eq!(bytecode[31], 0xD2);
assert_eq!(bytecode[0..30], [0u8; 30]);
}

#[test]
#[should_panic(expected = "immutable ref out of bounds")]
fn patch_out_of_bounds_panics() {
let mut bytecode = vec![0u8; 16];
let refs = [ImmutableRef {
start: 0,
length: 32,
}];
let value = [0u8; 32];
patch_bytes(&mut bytecode, &refs, &value);
}
}
295 changes: 295 additions & 0 deletions bin/ev-deployer/src/contracts/mailbox.rs

Large diffs are not rendered by default.

Loading
Loading