The AdminProxy is a smart contract that solves the bootstrap problem for admin addresses at genesis. It acts as an intermediary owner/admin for other contracts and precompiles when the final admin (e.g., a multisig) is not known at genesis time.
Several components in ev-reth require admin addresses configured at genesis:
- Mint Precompile: Requires
mintAdminin chainspec to manage the allowlist - FeeVault: Requires an
owneraddress in its constructor
The challenge: these admin addresses often need to be multisigs (like Safe) for security, but multisigs cannot be deployed at genesis because they require transactions to be created.
Deploy AdminProxy at genesis with owner set directly in storage slot 0. This eliminates any race condition and ensures the designated admin has control from block 0.
Post-genesis:
- The owner (set at genesis) can immediately use the proxy
- When ready, deploy the multisig
- Transfer ownership to multisig via two-step transfer (
transferOwnership+acceptOwnership)
The proxy then forwards admin calls to the underlying contracts/precompiles.
┌─────────────────┐
│ Multisig │
│ (Safe, etc) │
└────────┬────────┘
│
│ owns
▼
┌─────────────────────────────────────────────────────────────┐
│ AdminProxy │
│ - owner: address (initially 0, then EOA, then multisig) │
│ - execute(target, data): forward calls │
│ - executeBatch(targets, datas): batch operations │
└──────────────┬────────────────────────┬─────────────────────┘
│ │
│ admin calls │ owner calls
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Mint Precompile │ │ FeeVault │
│ (0xF100) │ │ │
└──────────────────┘ └──────────────────┘
This section provides detailed instructions for deploying AdminProxy at genesis.
cd contracts
forge buildOption A: Use the helper script (recommended)
Set the OWNER environment variable to your initial admin EOA address:
OWNER=0xYourEOAAddress forge script script/GenerateAdminProxyAlloc.s.sol -vvvThis outputs the complete alloc entry with bytecode and storage, ready to copy into your genesis file.
Option B: Get bytecode directly from artifacts
After building, the runtime bytecode is in the compiled artifacts:
# Extract just the deployed bytecode (not creation code)
cat out/AdminProxy.sol/AdminProxy.json | jq -r '.deployedBytecode.object'This outputs the hex string starting with 0x608060.... You'll need to manually construct the storage entry for the owner (see Step 3).
The genesis alloc section pre-deploys contracts at specific addresses. For AdminProxy, you must set the owner in storage slot 0.
Storage Layout:
| Slot | Variable | Type |
|---|---|---|
| 0 | owner |
address |
| 1 | pendingOwner |
address |
Converting owner address to storage value:
The owner address must be left-padded to 32 bytes. For example, if your owner EOA is 0x1234567890abcdef1234567890abcdef12345678:
Storage slot 0x0 = 0x0000000000000000000000001234567890abcdef1234567890abcdef12345678
Example alloc entry:
{
"alloc": {
"000000000000000000000000000000000000Ad00": {
"balance": "0x0",
"code": "0x<YOUR_BYTECODE_HERE>",
"storage": {
"0x0": "0x0000000000000000000000001234567890abcdef1234567890abcdef12345678"
}
}
}
}Important notes:
- Address format: The address key does NOT have the
0xprefix in the alloc section - Code format: The code value MUST have the
0xprefix - Storage key: Must be
"0x0"(slot 0 for owner) - Storage value: Owner address left-padded to 32 bytes with
0xprefix
Here's a complete example showing how AdminProxy fits into the full genesis file.
In this example, the owner EOA is 0xYourEOAAddressHere (replace with your actual address):
{
"config": {
"chainId": 1,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"parisBlock": 0,
"shanghaiTime": 0,
"cancunTime": 0,
"terminalTotalDifficulty": 0,
"terminalTotalDifficultyPassed": true,
"evolve": {
"baseFeeSink": "0x00000000000000000000000000000000000000fe",
"baseFeeRedirectActivationHeight": 0,
"mintAdmin": "0x000000000000000000000000000000000000Ad00",
"mintPrecompileActivationHeight": 0,
"deployAllowlist": [
"0xYourEOAAddressHere"
],
"deployAllowlistActivationHeight": 0,
"contractSizeLimit": 131072,
"contractSizeLimitActivationHeight": 0
}
},
"difficulty": "0x1",
"gasLimit": "0x1c9c380",
"alloc": {
"000000000000000000000000000000000000Ad00": {
"balance": "0x0",
"code": "0x<YOUR_BYTECODE_HERE>",
"storage": {
"0x0": "0x000000000000000000000000<YOUR_EOA_ADDRESS_WITHOUT_0x_PREFIX>"
}
},
"<YOUR_EOA_ADDRESS_WITHOUT_0x_PREFIX>": {
"balance": "0x56bc75e2d63100000"
}
}
}Note: The owner EOA must also be funded with gas at genesis to execute transactions. In the example above, 0x56bc75e2d63100000 equals 100 ETH in wei.
Example with concrete addresses:
If your owner EOA is 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266:
"storage": {
"0x0": "0x000000000000000000000000f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
}After creating your genesis file, you can verify the AdminProxy is correctly configured:
-
Start the node with your genesis file
-
Query the contract code at the proxy address to confirm deployment:
cast code 0x000000000000000000000000000000000000Ad00 --rpc-url <YOUR_RPC>
-
Verify owner is set correctly:
cast call 0x000000000000000000000000000000000000Ad00 "owner()" --rpc-url <YOUR_RPC> # Should return your EOA address (e.g., 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266)
When deploying FeeVault (post-genesis), use the AdminProxy address as the owner:
OWNER=0x000000000000000000000000000000000000Ad00 \
forge script script/DeployFeeVault.s.sol --broadcast --rpc-url <YOUR_RPC>Alternatively, if deploying FeeVault at genesis too, add it to the alloc section with its storage slot 0 (owner) set to the proxy address:
{
"alloc": {
"000000000000000000000000000000000000Ad00": {
"balance": "0x0",
"code": "0x<ADMIN_PROXY_BYTECODE>",
"storage": {}
},
"<FEE_VAULT_ADDRESS>": {
"balance": "0x0",
"code": "0x<FEE_VAULT_BYTECODE>",
"storage": {
"0x0": "0x000000000000000000000000000000000000000000000000000000000000Ad00"
}
}
}
}Note: FeeVault has additional storage slots that need to be set. See docs/contracts/fee_vault.md for details.
Since the owner is set at genesis, no claiming is required. The designated EOA can immediately use the proxy.
Confirm the owner was set correctly:
cast call 0x000000000000000000000000000000000000Ad00 "owner()" --rpc-url <YOUR_RPC>
# Should return your EOA addressDeploy your multisig (e.g., Safe) through normal transaction flow.
Two-step transfer to multisig for safety:
AdminProxy proxy = AdminProxy(0x000000000000000000000000000000000000Ad00);
// Step 1: Current owner initiates transfer
proxy.transferOwnership(multisigAddress);
// Step 2: Multisig accepts (requires multisig transaction)
// This must be called FROM the multisig
proxy.acceptOwnership();Using cast:
# Step 1: Owner initiates transfer
cast send 0x000000000000000000000000000000000000Ad00 \
"transferOwnership(address)" <MULTISIG_ADDRESS> \
--private-key <OWNER_PRIVATE_KEY> \
--rpc-url <YOUR_RPC>
# Step 2: Multisig accepts (execute via multisig UI/CLI)
# The multisig must call: acceptOwnership()AdminProxy proxy = AdminProxy(ADMIN_PROXY_ADDRESS);
// Add address to allowlist
proxy.execute(
MINT_PRECOMPILE,
abi.encodeWithSignature("addToAllowList(address)", userAddress)
);
// Remove from allowlist
proxy.execute(
MINT_PRECOMPILE,
abi.encodeWithSignature("removeFromAllowList(address)", userAddress)
);
// Batch add multiple addresses
address[] memory targets = new address[](3);
bytes[] memory datas = new bytes[](3);
targets[0] = targets[1] = targets[2] = MINT_PRECOMPILE;
datas[0] = abi.encodeWithSignature("addToAllowList(address)", user1);
datas[1] = abi.encodeWithSignature("addToAllowList(address)", user2);
datas[2] = abi.encodeWithSignature("addToAllowList(address)", user3);
proxy.executeBatch(targets, datas);AdminProxy proxy = AdminProxy(ADMIN_PROXY_ADDRESS);
FeeVault vault = FeeVault(FEE_VAULT_ADDRESS);
// Update minimum amount
proxy.execute(
address(vault),
abi.encodeWithSignature("setMinimumAmount(uint256)", 2 ether)
);
// Update bridge share
proxy.execute(
address(vault),
abi.encodeWithSignature("setBridgeShare(uint256)", 8000) // 80%
);The proxy uses a two-step transfer pattern (transferOwnership + acceptOwnership) to prevent accidental transfers to wrong addresses. The pending owner must explicitly accept.
If a transfer was initiated to the wrong address, the current owner can cancel:
proxy.cancelTransfer();The owner is set directly in storage slot 0 at genesis. This eliminates race conditions and ensures the designated admin has control from block 0. No claimOwnership() function exists, so there's no risk of front-running.
The execute function forwards calls with the proxy as msg.sender. Target contracts see the proxy as the caller, not the original sender. This is intentional for the admin pattern.
| Function | Description | Access |
|---|---|---|
owner() |
Current owner address | View |
pendingOwner() |
Pending owner for two-step transfer | View |
transferOwnership(address) |
Start two-step transfer | Owner |
acceptOwnership() |
Complete two-step transfer | Pending owner |
cancelTransfer() |
Cancel pending transfer | Owner |
execute(address, bytes) |
Forward single call | Owner |
executeBatch(address[], bytes[]) |
Forward multiple calls | Owner |
executeWithValue(address, bytes, uint256) |
Forward call with ETH | Owner |
| Event | Description |
|---|---|
OwnershipTransferStarted(address, address) |
Transfer initiated |
OwnershipTransferred(address, address) |
Transfer completed |
Executed(address, bytes, bytes) |
Call forwarded |
We suggest deploying AdminProxy at 0x000000000000000000000000000000000000Ad00 for easy identification. The Ad prefix suggests "Admin".