Skip to content
Merged
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
6 changes: 6 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Smart contracts for EV-Reth, including the FeeVault for bridging collected fees to Celestia.

## AdminProxy

The AdminProxy contract solves the bootstrap problem for admin addresses at genesis. It acts as an intermediary owner/admin for other contracts and precompiles (like the Mint Precompile) when the final admin (e.g., a multisig) is not known at genesis time.

See [AdminProxy documentation](../docs/contracts/admin_proxy.md) for detailed setup and usage instructions.

## FeeVault

The FeeVault contract collects base fees and bridges them to Celestia via Hyperlane. It supports:
Expand Down
113 changes: 113 additions & 0 deletions contracts/script/GenerateAdminProxyAlloc.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script, console} from "forge-std/Script.sol";
import {AdminProxy} from "../src/AdminProxy.sol";

/// @title GenerateAdminProxyAlloc
/// @notice Generates genesis alloc JSON for deploying AdminProxy at a deterministic address
/// @dev Run with: OWNER=0xYourAddress forge script script/GenerateAdminProxyAlloc.s.sol -vvv
///
/// This script outputs the bytecode and storage layout needed to deploy AdminProxy
/// in the genesis block. The owner is set directly in storage slot 0.
///
/// Usage:
/// 1. Set OWNER env var to your initial admin EOA address
/// 2. Run this script to get the bytecode and storage
/// 3. Add to genesis.json alloc section at desired address (e.g., 0x...Ad00)
/// 4. Set that address as mintAdmin in chainspec config
contract GenerateAdminProxyAlloc is Script {
// Suggested deterministic address for AdminProxy
// Using a memorable address in the precompile-adjacent range
address constant SUGGESTED_ADDRESS = 0x000000000000000000000000000000000000Ad00;

function run() external {
// Get owner from environment, default to zero if not set
address owner = vm.envOr("OWNER", address(0));

// Deploy to get runtime bytecode
AdminProxy proxy = new AdminProxy();

// Get runtime bytecode (not creation code)
bytes memory runtimeCode = address(proxy).code;

// Convert owner to storage slot value (left-padded to 32 bytes)
bytes32 ownerSlotValue = bytes32(uint256(uint160(owner)));

console.log("========== AdminProxy Genesis Alloc ==========");
console.log("");
console.log("Suggested address:", SUGGESTED_ADDRESS);
console.log("Owner (from OWNER env):", owner);
console.log("");

if (owner == address(0)) {
console.log("WARNING: OWNER not set! Set OWNER env var to your admin EOA.");
console.log("Example: OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 forge script ...");
console.log("");
}

console.log("Add this to your genesis.json 'alloc' section:");
console.log("");
console.log("{");
console.log(' "alloc": {');
console.log(' "000000000000000000000000000000000000Ad00": {');
console.log(' "balance": "0x0",');
console.log(' "code": "0x%s",', vm.toString(runtimeCode));
console.log(' "storage": {');
console.log(' "0x0": "0x%s"', vm.toString(ownerSlotValue));
console.log(" }");
console.log(" }");
console.log(" }");
console.log("}");
console.log("");
console.log("Then update chainspec config:");
console.log("");
console.log("{");
console.log(' "config": {');
console.log(' "evolve": {');
console.log(' "mintAdmin": "0x000000000000000000000000000000000000Ad00",');
console.log(' "mintPrecompileActivationHeight": 0');
console.log(" }");
console.log(" }");
console.log("}");
console.log("");
console.log("==============================================");
console.log("");
console.log("Post-genesis steps:");
console.log("1. Owner can immediately use the proxy (no claiming needed)");
console.log("2. Deploy multisig (e.g., Safe)");
console.log("3. Call transferOwnership(multisigAddress)");
console.log("4. From multisig, call acceptOwnership()");
console.log("");

// Also output raw values for programmatic use
console.log("Raw bytecode length:", runtimeCode.length);
console.log("Owner storage slot (0x0):", vm.toString(ownerSlotValue));
}
}

/// @title GenerateAdminProxyAllocJSON
/// @notice Outputs just the JSON snippet for easy copy-paste
/// @dev Run with: OWNER=0xYourAddress forge script script/GenerateAdminProxyAlloc.s.sol:GenerateAdminProxyAllocJSON -vvv
contract GenerateAdminProxyAllocJSON is Script {
function run() external {
address owner = vm.envOr("OWNER", address(0));

AdminProxy proxy = new AdminProxy();
bytes memory runtimeCode = address(proxy).code;
bytes32 ownerSlotValue = bytes32(uint256(uint160(owner)));

// Output minimal JSON that can be merged into genesis
string memory json = string(
abi.encodePacked(
'{"000000000000000000000000000000000000Ad00":{"balance":"0x0","code":"0x',
vm.toString(runtimeCode),
'","storage":{"0x0":"0x',
vm.toString(ownerSlotValue),
'"}}}'
)
);

console.log(json);
}
}
147 changes: 147 additions & 0 deletions contracts/src/AdminProxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @title AdminProxy
/// @notice A proxy contract for managing admin rights to precompiles and other contracts.
/// @dev Deployed at genesis with owner set via storage slot. Supports two-step
/// ownership transfer for safe handoff to multisigs or other governance contracts.
///
/// This contract solves the bootstrap problem where admin addresses (e.g., multisigs)
/// are not known at genesis time. The proxy is set as admin in the chainspec, and
/// an initial EOA owner is set in genesis storage. Post-genesis, ownership can be
/// transferred to a multisig.
///
/// Storage Layout:
/// - Slot 0: owner (address)
/// - Slot 1: pendingOwner (address)
///
/// Usage:
/// 1. Deploy at genesis via alloc with owner set in storage slot 0
/// 2. Set proxy address as `mintAdmin` in chainspec and as FeeVault owner
/// 3. Post-genesis: deploy multisig, then transferOwnership() -> acceptOwnership()
contract AdminProxy {
/// @notice Current owner of the proxy
address public owner;

/// @notice Pending owner for two-step transfer
address public pendingOwner;

/// @notice Emitted when ownership transfer is initiated
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);

/// @notice Emitted when ownership transfer is completed
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

/// @notice Emitted when a call is executed through the proxy
event Executed(address indexed target, bytes data, bytes result);

/// @notice Thrown when caller is not the owner
error NotOwner();

/// @notice Thrown when caller is not the pending owner
error NotPendingOwner();

/// @notice Thrown when a call to target contract fails
error CallFailed(bytes reason);

/// @notice Thrown when array lengths don't match in batch operations
error LengthMismatch();

/// @notice Thrown when trying to set zero address as pending owner
error ZeroAddress();

modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}

/// @notice Constructor is empty - owner is set via genesis storage slot 0
/// @dev When deploying at genesis, set storage["0x0"] to the owner address
constructor() {}

/// @notice Start two-step ownership transfer
/// @param newOwner Address of the new owner (e.g., multisig)
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
pendingOwner = newOwner;
emit OwnershipTransferStarted(owner, newOwner);
}

/// @notice Complete two-step ownership transfer
/// @dev Must be called by the pending owner
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotPendingOwner();
emit OwnershipTransferred(owner, msg.sender);
owner = msg.sender;
pendingOwner = address(0);
Comment on lines +74 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To adhere to the Checks-Effects-Interactions (CEI) pattern, state modifications should occur before external interactions like emitting events. This is a security best practice that helps prevent reentrancy vulnerabilities, even if no direct exploit is apparent here.

        address previousOwner = owner;
        owner = msg.sender;
        pendingOwner = address(0);
        emit OwnershipTransferred(previousOwner, owner);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would generally agree

}

/// @notice Cancel pending ownership transfer
function cancelTransfer() external onlyOwner {
pendingOwner = address(0);
}

/// @notice Execute a call to any target contract
/// @param target Address of the contract to call
/// @param data Calldata to send
/// @return result The return data from the call
/// @dev Use this to call admin functions on FeeVault, precompiles, etc.
///
/// Example - Add address to mint precompile allowlist:
/// execute(MINT_PRECOMPILE, abi.encodeCall(IMintPrecompile.addToAllowList, (account)))
///
/// Example - Transfer FeeVault ownership:
/// execute(feeVault, abi.encodeCall(FeeVault.transferOwnership, (newOwner)))
function execute(address target, bytes calldata data) external onlyOwner returns (bytes memory result) {
(bool success, bytes memory returnData) = target.call(data);
if (!success) {
revert CallFailed(returnData);
}
emit Executed(target, data, returnData);
return returnData;
}

/// @notice Execute multiple calls in a single transaction
/// @param targets Array of contract addresses to call
/// @param datas Array of calldata for each call
/// @return results Array of return data from each call
/// @dev Useful for batch operations like adding multiple addresses to allowlist
function executeBatch(address[] calldata targets, bytes[] calldata datas)
external
onlyOwner
returns (bytes[] memory results)
{
if (targets.length != datas.length) revert LengthMismatch();

results = new bytes[](targets.length);
for (uint256 i = 0; i < targets.length; i++) {
(bool success, bytes memory returnData) = targets[i].call(datas[i]);
if (!success) {
revert CallFailed(returnData);
}
emit Executed(targets[i], datas[i], returnData);
results[i] = returnData;
}
}

/// @notice Execute a call with ETH value
/// @param target Address of the contract to call
/// @param data Calldata to send
/// @param value Amount of ETH to send
/// @return result The return data from the call
function executeWithValue(address target, bytes calldata data, uint256 value)
external
onlyOwner
returns (bytes memory result)
{
(bool success, bytes memory returnData) = target.call{value: value}(data);
if (!success) {
revert CallFailed(returnData);
}
emit Executed(target, data, returnData);
return returnData;
}

/// @notice Receive ETH (needed for executeWithValue)
receive() external payable {}
}
Loading