diff --git a/.claude/skills/contracts.md b/.claude/skills/contracts.md index 292b7c8a..d93142f2 100644 --- a/.claude/skills/contracts.md +++ b/.claude/skills/contracts.md @@ -1,5 +1,5 @@ --- -description: This skill should be used when the user asks about "ev-reth contracts", "FeeVault", "AdminProxy", "fee bridging to Celestia", "Hyperlane integration", "Foundry deployment scripts", "genesis allocations", or wants to understand how base fees are redirected and bridged. +description: This skill should be used when the user asks about "ev-reth contracts", "FeeVault", "AdminProxy", "fee distribution", "Foundry deployment scripts", "genesis allocations", or wants to understand how base fees are redirected and distributed. --- # Contracts Onboarding @@ -9,13 +9,13 @@ description: This skill should be used when the user asks about "ev-reth contrac The contracts live in `contracts/` and use Foundry for development. There are two main contracts: 1. **AdminProxy** (`src/AdminProxy.sol`) - Bootstrap contract for admin addresses at genesis -2. **FeeVault** (`src/FeeVault.sol`) - Collects base fees, bridges to Celestia via Hyperlane (cross-chain messaging protocol) +2. **FeeVault** (`src/FeeVault.sol`) - Collects base fees and distributes them between configured recipients ## Key Files ### Contract Sources - `contracts/src/AdminProxy.sol` - Transparent proxy pattern for admin control -- `contracts/src/FeeVault.sol` - Fee collection and bridging logic +- `contracts/src/FeeVault.sol` - Fee collection and distribution logic ### Deployment Scripts - `contracts/script/DeployFeeVault.s.sol` - FeeVault deployment with CREATE2 @@ -34,7 +34,7 @@ The AdminProxy contract provides a bootstrap mechanism for setting admin address ### FeeVault The FeeVault serves as the destination for redirected base fees (instead of burning them). Key responsibilities: - Receive base fees from block production -- Bridge accumulated fees to Celestia via Hyperlane +- Distribute accumulated fees between configured recipients - Manage withdrawal permissions ## Connection to Rust Code diff --git a/.gitmodules b/.gitmodules index c65a5965..9df9f949 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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/permit2"] + path = contracts/lib/permit2 + url = https://github.com/Uniswap/permit2 diff --git a/bin/ev-deployer/README.md b/bin/ev-deployer/README.md index 2e659459..fe7ae4dc 100644 --- a/bin/ev-deployer/README.md +++ b/bin/ev-deployer/README.md @@ -14,15 +14,6 @@ The binary is output to `target/release/ev-deployer`. EV Deployer uses a TOML config file to define what contracts to include and how to configure them. See [`examples/devnet.toml`](examples/devnet.toml) for a complete example. -```toml -[chain] -chain_id = 1234 - -[contracts.admin_proxy] -address = "0x000000000000000000000000000000000000Ad00" -owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -``` - ### Config reference #### `[chain]` @@ -38,6 +29,12 @@ owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" | `address` | address | Address to deploy at | | `owner` | address | Owner (must not be zero) | +#### `[contracts.permit2]` + +| Field | Type | Description | +|-----------|---------|----------------------------------------------------------| +| `address` | address | Address to deploy at (canonical: `0x000000000022D473...`) | + ## Usage ### Generate a starter config @@ -88,7 +85,8 @@ Output: ```json { - "admin_proxy": "0x000000000000000000000000000000000000Ad00" + "admin_proxy": "0x000000000000000000000000000000000000Ad00", + "permit2": "0x000000000022D473030F116dDEE9F6B43aC78BA3" } ``` @@ -100,9 +98,10 @@ ev-deployer compute-address --config deploy.toml --contract admin_proxy ## Contracts -| Contract | Description | -|----------------|-----------------------------------------------------| -| `admin_proxy` | Proxy contract with owner-based access control | +| Contract | Description | +|---------------|----------------------------------------------------| +| `admin_proxy` | Proxy contract with owner-based access control | +| `permit2` | Uniswap canonical token approval manager | Runtime bytecodes are embedded in the binary — no external toolchain is needed at deploy time. diff --git a/bin/ev-deployer/examples/devnet.toml b/bin/ev-deployer/examples/devnet.toml index c0201807..5e3d9096 100644 --- a/bin/ev-deployer/examples/devnet.toml +++ b/bin/ev-deployer/examples/devnet.toml @@ -4,3 +4,8 @@ chain_id = 1234 [contracts.admin_proxy] address = "0x000000000000000000000000000000000000Ad00" owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + +[contracts.permit2] +# Canonical Uniswap Permit2 address (same on all chains via CREATE2). +# Using it here so frontends, SDKs and routers work out of the box. +address = "0x000000000022D473030F116dDEE9F6B43aC78BA3" diff --git a/bin/ev-deployer/src/config.rs b/bin/ev-deployer/src/config.rs index b4794b4d..bf1f8671 100644 --- a/bin/ev-deployer/src/config.rs +++ b/bin/ev-deployer/src/config.rs @@ -28,6 +28,8 @@ pub(crate) struct ChainConfig { pub(crate) struct ContractsConfig { /// `AdminProxy` contract config (optional). pub admin_proxy: Option, + /// `Permit2` contract config (optional). + pub permit2: Option, } /// `AdminProxy` configuration. @@ -39,6 +41,13 @@ pub(crate) struct AdminProxyConfig { pub owner: Address, } +/// `Permit2` configuration (Uniswap token approval manager). +#[derive(Debug, Deserialize)] +pub(crate) struct Permit2Config { + /// 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 { diff --git a/bin/ev-deployer/src/contracts/immutables.rs b/bin/ev-deployer/src/contracts/immutables.rs new file mode 100644 index 00000000..09341047 --- /dev/null +++ b/bin/ev-deployer/src/contracts/immutables.rs @@ -0,0 +1,76 @@ +//! 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::{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 `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] + #[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); + } +} diff --git a/bin/ev-deployer/src/contracts/mod.rs b/bin/ev-deployer/src/contracts/mod.rs index 569e4510..9e61b58f 100644 --- a/bin/ev-deployer/src/contracts/mod.rs +++ b/bin/ev-deployer/src/contracts/mod.rs @@ -1,6 +1,8 @@ //! Contract bytecode and storage encoding. pub(crate) mod admin_proxy; +pub(crate) mod immutables; +pub(crate) mod permit2; use alloy_primitives::{Address, Bytes, B256}; use std::collections::BTreeMap; diff --git a/bin/ev-deployer/src/contracts/permit2.rs b/bin/ev-deployer/src/contracts/permit2.rs new file mode 100644 index 00000000..ee56f85b --- /dev/null +++ b/bin/ev-deployer/src/contracts/permit2.rs @@ -0,0 +1,233 @@ +//! `Permit2` bytecode and immutable encoding. +//! +//! Uniswap's `Permit2` provides gas-efficient token approval management +//! via signature-based permits (EIP-2612 style) for any ERC-20 token. +//! +//! ## Immutables (in bytecode, not storage) +//! +//! | Variable | Type | Offset | +//! |-----------------------------|---------|--------| +//! | `_CACHED_CHAIN_ID` | uint256 | \[6945\] | +//! | `_CACHED_DOMAIN_SEPARATOR` | bytes32 | \[6983\] | +//! +//! Both come from the EIP-712 base contract (`src/EIP712.sol`). The +//! constructor normally caches `block.chainid` and the resulting domain +//! separator so that `DOMAIN_SEPARATOR()` can skip recomputation when the +//! chain ID hasn't changed. For a genesis deploy we patch the correct +//! values directly into the bytecode. +//! +//! ## Storage layout +//! +//! Permit2 has no storage that needs initialization at genesis. All state +//! (allowances, nonces, …) starts at zero. + +use crate::{ + config::Permit2Config, + contracts::{ + immutables::{patch_bytes, patch_u256, ImmutableRef}, + GenesisContract, + }, +}; +use alloy_primitives::{hex, keccak256, Bytes, B256, U256}; +use std::collections::BTreeMap; + +/// `Permit2` runtime bytecode compiled from Uniswap/permit2 (commit cc56ad0) +/// with solc 0.8.17 (via-ir, optimizer `1_000_000` runs, `bytecode_hash="none"`). +/// +/// Compiled with placeholder immutables (all zeros). Actual values are patched +/// at genesis time via [`build`]. +/// +/// Regenerate with: +/// ```sh +/// cd contracts/lib/permit2 && forge inspect Permit2 deployedBytecode +/// ``` +const PERMIT2_BYTECODE: &[u8] = &hex!("6040608081526004908136101561001557600080fd5b600090813560e01c80630d58b1db1461126c578063137c29fe146110755780632a2d80d114610db75780632b67b57014610bde57806330f28b7a14610ade5780633644e51514610a9d57806336c7851614610a285780633ff9dcb1146109a85780634fe02b441461093f57806365d9723c146107ac57806387517c451461067a578063927da105146105c3578063cc53287f146104a3578063edd9444b1461033a5763fe8ec1a7146100c657600080fd5b346103365760c07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103365767ffffffffffffffff833581811161033257610114903690860161164b565b60243582811161032e5761012b903690870161161a565b6101336114e6565b9160843585811161032a5761014b9036908a016115c1565b98909560a43590811161032657610164913691016115c1565b969095815190610173826113ff565b606b82527f5065726d697442617463685769746e6573735472616e7366657246726f6d285460208301527f6f6b656e5065726d697373696f6e735b5d207065726d69747465642c61646472838301527f657373207370656e6465722c75696e74323536206e6f6e63652c75696e74323560608301527f3620646561646c696e652c000000000000000000000000000000000000000000608083015282519a8b9181610222602085018096611f93565b918237018a8152039961025b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe09b8c8101835282611437565b5190209085515161026b81611ebb565b908a5b8181106102f95750506102f6999a6102ed9183516102a081610294602082018095611f66565b03848101835282611437565b519020602089810151858b015195519182019687526040820192909252336060820152608081019190915260a081019390935260643560c08401528260e081015b03908101835282611437565b51902093611cf7565b80f35b8061031161030b610321938c5161175e565b51612054565b61031b828661175e565b52611f0a565b61026e565b8880fd5b8780fd5b8480fd5b8380fd5b5080fd5b5091346103365760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103365767ffffffffffffffff9080358281116103325761038b903690830161164b565b60243583811161032e576103a2903690840161161a565b9390926103ad6114e6565b9160643590811161049f576103c4913691016115c1565b949093835151976103d489611ebb565b98885b81811061047d5750506102f697988151610425816103f9602082018095611f66565b037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101835282611437565b5190206020860151828701519083519260208401947ffcf35f5ac6a2c28868dc44c302166470266239195f02b0ee408334829333b7668652840152336060840152608083015260a082015260a081526102ed8161141b565b808b61031b8261049461030b61049a968d5161175e565b9261175e565b6103d7565b8680fd5b5082346105bf57602090817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126103325780359067ffffffffffffffff821161032e576104f49136910161161a565b929091845b848110610504578580f35b8061051a610515600193888861196c565b61197c565b61052f84610529848a8a61196c565b0161197c565b3389528385528589209173ffffffffffffffffffffffffffffffffffffffff80911692838b528652868a20911690818a5285528589207fffffffffffffffffffffffff000000000000000000000000000000000000000081541690558551918252848201527f89b1add15eff56b3dfe299ad94e01f2b52fbcb80ae1a3baea6ae8c04cb2b98a4853392a2016104f9565b8280fd5b50346103365760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610676816105ff6114a0565b936106086114c3565b6106106114e6565b73ffffffffffffffffffffffffffffffffffffffff968716835260016020908152848420928816845291825283832090871683528152919020549251938316845260a083901c65ffffffffffff169084015260d09190911c604083015281906060820190565b0390f35b50346103365760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610336576106b26114a0565b906106bb6114c3565b916106c46114e6565b65ffffffffffff926064358481169081810361032a5779ffffffffffff0000000000000000000000000000000000000000947fda9fa7c1b00402c17d0161b249b1ab8bbec047c5a52207b9c112deffd817036b94338a5260016020527fffffffffffff0000000000000000000000000000000000000000000000000000858b209873ffffffffffffffffffffffffffffffffffffffff809416998a8d5260205283878d209b169a8b8d52602052868c209486156000146107a457504216925b8454921697889360a01b16911617179055815193845260208401523392a480f35b905092610783565b5082346105bf5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576107e56114a0565b906107ee6114c3565b9265ffffffffffff604435818116939084810361032a57338852602091600183528489209673ffffffffffffffffffffffffffffffffffffffff80911697888b528452858a20981697888a5283528489205460d01c93848711156109175761ffff9085840316116108f05750907f55eb90d810e1700b35a8e7e25395ff7f2b2259abd7415ca2284dfb1c246418f393929133895260018252838920878a528252838920888a5282528389209079ffffffffffffffffffffffffffffffffffffffffffffffffffff7fffffffffffff000000000000000000000000000000000000000000000000000083549260d01b16911617905582519485528401523392a480f35b84517f24d35a26000000000000000000000000000000000000000000000000000000008152fd5b5084517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b503461033657807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610336578060209273ffffffffffffffffffffffffffffffffffffffff61098f6114a0565b1681528084528181206024358252845220549051908152f35b5082346105bf57817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf577f3704902f963766a4e561bbaab6e6cdc1b1dd12f6e9e99648da8843b3f46b918d90359160243533855284602052818520848652602052818520818154179055815193845260208401523392a280f35b8234610a9a5760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610a9a57610a606114a0565b610a686114c3565b610a706114e6565b6064359173ffffffffffffffffffffffffffffffffffffffff8316830361032e576102f6936117a1565b80fd5b503461033657817ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657602090610ad7611b1e565b9051908152f35b508290346105bf576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf57610b1a3661152a565b90807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c36011261033257610b4c611478565b9160e43567ffffffffffffffff8111610bda576102f694610b6f913691016115c1565b939092610b7c8351612054565b6020840151828501519083519260208401947f939c21a48a8dbe3a9a2404a1d46691e4d39f6583d6ec6b35714604c986d801068652840152336060840152608083015260a082015260a08152610bd18161141b565b51902091611c25565b8580fd5b509134610336576101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033657610c186114a0565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc360160c08112610332576080855191610c51836113e3565b1261033257845190610c6282611398565b73ffffffffffffffffffffffffffffffffffffffff91602435838116810361049f578152604435838116810361049f57602082015265ffffffffffff606435818116810361032a5788830152608435908116810361049f576060820152815260a435938285168503610bda576020820194855260c4359087830182815260e43567ffffffffffffffff811161032657610cfe90369084016115c1565b929093804211610d88575050918591610d786102f6999a610d7e95610d238851611fbe565b90898c511690519083519260208401947ff3841cd1ff0085026a6327b620b67997ce40f282c88a8e905a7a5626e310f3d086528401526060830152608082015260808152610d70816113ff565b519020611bd9565b916120c7565b519251169161199d565b602492508a51917fcd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b5091346103365760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc93818536011261033257610df36114a0565b9260249081359267ffffffffffffffff9788851161032a578590853603011261049f578051978589018981108282111761104a578252848301358181116103265785019036602383011215610326578382013591610e50836115ef565b90610e5d85519283611437565b838252602093878584019160071b83010191368311611046578801905b828210610fe9575050508a526044610e93868801611509565b96838c01978852013594838b0191868352604435908111610fe557610ebb90369087016115c1565b959096804211610fba575050508998995151610ed681611ebb565b908b5b818110610f9757505092889492610d7892610f6497958351610f02816103f98682018095611f66565b5190209073ffffffffffffffffffffffffffffffffffffffff9a8b8b51169151928551948501957faf1b0d30d2cab0380e68f0689007e3254993c596f2fdd0aaa7f4d04f794408638752850152830152608082015260808152610d70816113ff565b51169082515192845b848110610f78578580f35b80610f918585610f8b600195875161175e565b5161199d565b01610f6d565b80610311610fac8e9f9e93610fb2945161175e565b51611fbe565b9b9a9b610ed9565b8551917fcd21db4f000000000000000000000000000000000000000000000000000000008352820152fd5b8a80fd5b6080823603126110465785608091885161100281611398565b61100b85611509565b8152611018838601611509565b838201526110278a8601611607565b8a8201528d611037818701611607565b90820152815201910190610e7a565b8c80fd5b84896041867f4e487b7100000000000000000000000000000000000000000000000000000000835252fd5b5082346105bf576101407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126105bf576110b03661152a565b91807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c360112610332576110e2611478565b67ffffffffffffffff93906101043585811161049f5761110590369086016115c1565b90936101243596871161032a57611125610bd1966102f6983691016115c1565b969095825190611134826113ff565b606482527f5065726d69745769746e6573735472616e7366657246726f6d28546f6b656e5060208301527f65726d697373696f6e73207065726d69747465642c6164647265737320737065848301527f6e6465722c75696e74323536206e6f6e63652c75696e7432353620646561646c60608301527f696e652c0000000000000000000000000000000000000000000000000000000060808301528351948591816111e3602085018096611f93565b918237018b8152039361121c7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe095868101835282611437565b5190209261122a8651612054565b6020878101518589015195519182019687526040820192909252336060820152608081019190915260a081019390935260e43560c08401528260e081016102e1565b5082346105bf576020807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261033257813567ffffffffffffffff92838211610bda5736602383011215610bda5781013592831161032e576024906007368386831b8401011161049f57865b8581106112e5578780f35b80821b83019060807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc83360301126103265761139288876001946060835161132c81611398565b611368608461133c8d8601611509565b9485845261134c60448201611509565b809785015261135d60648201611509565b809885015201611509565b918291015273ffffffffffffffffffffffffffffffffffffffff80808093169516931691166117a1565b016112da565b6080810190811067ffffffffffffffff8211176113b457604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6060810190811067ffffffffffffffff8211176113b457604052565b60a0810190811067ffffffffffffffff8211176113b457604052565b60c0810190811067ffffffffffffffff8211176113b457604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176113b457604052565b60c4359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b600080fd5b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b6044359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b359073ffffffffffffffffffffffffffffffffffffffff8216820361149b57565b7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc01906080821261149b576040805190611563826113e3565b8082941261149b57805181810181811067ffffffffffffffff8211176113b457825260043573ffffffffffffffffffffffffffffffffffffffff8116810361149b578152602435602082015282526044356020830152606435910152565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020838186019501011161149b57565b67ffffffffffffffff81116113b45760051b60200190565b359065ffffffffffff8216820361149b57565b9181601f8401121561149b5782359167ffffffffffffffff831161149b576020808501948460061b01011161149b57565b91909160608184031261149b576040805191611666836113e3565b8294813567ffffffffffffffff9081811161149b57830182601f8201121561149b578035611693816115ef565b926116a087519485611437565b818452602094858086019360061b8501019381851161149b579086899897969594939201925b8484106116e3575050505050855280820135908501520135910152565b90919293949596978483031261149b578851908982019082821085831117611730578a928992845261171487611509565b81528287013583820152815201930191908897969594936116c6565b602460007f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b80518210156117725760209160051b010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b92919273ffffffffffffffffffffffffffffffffffffffff604060008284168152600160205282828220961695868252602052818120338252602052209485549565ffffffffffff8760a01c16804211611884575082871696838803611812575b5050611810955016926118b5565b565b878484161160001461184f57602488604051907ff96fb0710000000000000000000000000000000000000000000000000000000082526004820152fd5b7fffffffffffffffffffffffff000000000000000000000000000000000000000084846118109a031691161790553880611802565b602490604051907fd81b2f2e0000000000000000000000000000000000000000000000000000000082526004820152fd5b9060006064926020958295604051947f23b872dd0000000000000000000000000000000000000000000000000000000086526004860152602485015260448401525af13d15601f3d116001600051141617161561190e57565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f5452414e534645525f46524f4d5f4641494c45440000000000000000000000006044820152fd5b91908110156117725760061b0190565b3573ffffffffffffffffffffffffffffffffffffffff8116810361149b5790565b9065ffffffffffff908160608401511673ffffffffffffffffffffffffffffffffffffffff908185511694826020820151169280866040809401511695169560009187835260016020528383208984526020528383209916988983526020528282209184835460d01c03611af5579185611ace94927fc6a377bfc4eb120024a8ac08eef205be16b817020812c73223e81d1bdb9708ec98979694508715600014611ad35779ffffffffffff00000000000000000000000000000000000000009042165b60a01b167fffffffffffff00000000000000000000000000000000000000000000000000006001860160d01b1617179055519384938491604091949373ffffffffffffffffffffffffffffffffffffffff606085019616845265ffffffffffff809216602085015216910152565b0390a4565b5079ffffffffffff000000000000000000000000000000000000000087611a60565b600484517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b467f000000000000000000000000000000000000000000000000000000000000000003611b69577f000000000000000000000000000000000000000000000000000000000000000090565b60405160208101907f8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d5857980a86682527f9ac997416e8ff9d2ff6bebeb7149f65cdae5e32e2b90440b566bb3044041d36a604082015246606082015230608082015260808152611bd3816113ff565b51902090565b611be1611b1e565b906040519060208201927f190100000000000000000000000000000000000000000000000000000000000084526022830152604282015260428152611bd381611398565b9192909360a435936040840151804211611cc65750602084510151808611611c955750918591610d78611c6594611c60602088015186611e47565b611bd9565b73ffffffffffffffffffffffffffffffffffffffff809151511692608435918216820361149b57611810936118b5565b602490604051907f3728b83d0000000000000000000000000000000000000000000000000000000082526004820152fd5b602490604051907fcd21db4f0000000000000000000000000000000000000000000000000000000082526004820152fd5b959093958051519560409283830151804211611e175750848803611dee57611d2e918691610d7860209b611c608d88015186611e47565b60005b868110611d42575050505050505050565b611d4d81835161175e565b5188611d5a83878a61196c565b01359089810151808311611dbe575091818888886001968596611d84575b50505050505001611d31565b611db395611dad9273ffffffffffffffffffffffffffffffffffffffff6105159351169561196c565b916118b5565b803888888883611d78565b6024908651907f3728b83d0000000000000000000000000000000000000000000000000000000082526004820152fd5b600484517fff633a38000000000000000000000000000000000000000000000000000000008152fd5b6024908551907fcd21db4f0000000000000000000000000000000000000000000000000000000082526004820152fd5b9073ffffffffffffffffffffffffffffffffffffffff600160ff83161b9216600052600060205260406000209060081c6000526020526040600020818154188091551615611e9157565b60046040517f756688fe000000000000000000000000000000000000000000000000000000008152fd5b90611ec5826115ef565b611ed26040519182611437565b8281527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0611f0082946115ef565b0190602036910137565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114611f375760010190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b805160208092019160005b828110611f7f575050505090565b835185529381019392810192600101611f71565b9081519160005b838110611fab575050016000815290565b8060208092840101518185015201611f9a565b60405160208101917f65626cad6cb96493bf6f5ebea28756c966f023ab9e8a83a7101849d5573b3678835273ffffffffffffffffffffffffffffffffffffffff8082511660408401526020820151166060830152606065ffffffffffff9182604082015116608085015201511660a082015260a0815260c0810181811067ffffffffffffffff8211176113b45760405251902090565b6040516020808201927f618358ac3db8dc274f0cd8829da7e234bd48cd73c4a740aede1adec9846d06a1845273ffffffffffffffffffffffffffffffffffffffff81511660408401520151606082015260608152611bd381611398565b919082604091031261149b576020823592013590565b6000843b61222e5750604182036121ac576120e4828201826120b1565b939092604010156117725760209360009360ff6040608095013560f81c5b60405194855216868401526040830152606082015282805260015afa156121a05773ffffffffffffffffffffffffffffffffffffffff806000511691821561217657160361214c57565b60046040517f815e1d64000000000000000000000000000000000000000000000000000000008152fd5b60046040517f8baa579f000000000000000000000000000000000000000000000000000000008152fd5b6040513d6000823e3d90fd5b60408203612204576121c0918101906120b1565b91601b7f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff84169360ff1c019060ff8211611f375760209360009360ff608094612102565b60046040517f4be6321b000000000000000000000000000000000000000000000000000000008152fd5b929391601f928173ffffffffffffffffffffffffffffffffffffffff60646020957fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0604051988997889687947f1626ba7e000000000000000000000000000000000000000000000000000000009e8f8752600487015260406024870152816044870152868601378b85828601015201168101030192165afa9081156123a857829161232a575b507fffffffff000000000000000000000000000000000000000000000000000000009150160361230057565b60046040517fb0669cbc000000000000000000000000000000000000000000000000000000008152fd5b90506020813d82116123a0575b8161234460209383611437565b810103126103365751907fffffffff0000000000000000000000000000000000000000000000000000000082168203610a9a57507fffffffff0000000000000000000000000000000000000000000000000000000090386122d4565b3d9150612337565b6040513d84823e3d90fdfea164736f6c6343000811000a"); + +// ── Immutable reference offsets (from compiled artifact `immutableReferences`) ── + +/// `_CACHED_CHAIN_ID` (uint256) — from `EIP712.sol`. +const CACHED_CHAIN_ID_REFS: &[ImmutableRef] = &[ImmutableRef { + start: 6945, + length: 32, +}]; + +/// `_CACHED_DOMAIN_SEPARATOR` (bytes32) — from `EIP712.sol`. +const CACHED_DOMAIN_SEPARATOR_REFS: &[ImmutableRef] = &[ImmutableRef { + start: 6983, + length: 32, +}]; + +/// EIP-712 type hash: `keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)")` +const EIP712_TYPE_HASH: B256 = B256::new(hex!( + "8cad95687ba82c2ce50e74f7b754645e5117c3a5bec8151c0726d5857980a866" +)); + +/// `keccak256("Permit2")` +const HASHED_NAME: B256 = B256::new(hex!( + "9ac997416e8ff9d2ff6bebeb7149f65cdae5e32e2b90440b566bb3044041d36a" +)); + +/// Build a genesis alloc entry for `Permit2`. +pub(crate) fn build(config: &Permit2Config, chain_id: u64) -> GenesisContract { + let mut bytecode = PERMIT2_BYTECODE.to_vec(); + + // Patch _CACHED_CHAIN_ID + let chain_id_u256 = U256::from(chain_id); + patch_u256(&mut bytecode, CACHED_CHAIN_ID_REFS, chain_id_u256); + + // Compute and patch _CACHED_DOMAIN_SEPARATOR: + // keccak256(abi.encode(_TYPE_HASH, _HASHED_NAME, chainId, contractAddress)) + let mut buf = [0u8; 128]; + buf[0..32].copy_from_slice(EIP712_TYPE_HASH.as_slice()); + buf[32..64].copy_from_slice(HASHED_NAME.as_slice()); + buf[64..96].copy_from_slice(&B256::from(chain_id_u256).0); + buf[96..128].copy_from_slice(config.address.into_word().as_slice()); + let domain_separator = keccak256(buf); + patch_bytes( + &mut bytecode, + CACHED_DOMAIN_SEPARATOR_REFS, + &domain_separator.0, + ); + + GenesisContract { + address: config.address, + code: Bytes::from(bytecode), + storage: BTreeMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, Address}; + use std::{path::PathBuf, process::Command}; + + fn test_config() -> Permit2Config { + Permit2Config { + address: address!("000000000022D473030F116dDEE9F6B43aC78BA3"), + } + } + + #[test] + fn no_storage_entries() { + let contract = build(&test_config(), 1234); + assert!( + contract.storage.is_empty(), + "Permit2 should have no storage at genesis" + ); + } + + #[test] + fn bytecode_is_patched_with_chain_id() { + let contract = build(&test_config(), 42); + let code = contract.code.to_vec(); + let word = &code[6945..6945 + 32]; + assert_eq!(word[31], 42); + assert_eq!(word[0..31], [0u8; 31]); + } + + #[test] + fn bytecode_is_patched_with_domain_separator() { + let config = test_config(); + let chain_id: u64 = 1234; + let contract = build(&config, chain_id); + let code = contract.code.to_vec(); + + // Compute expected domain separator + let mut buf = [0u8; 128]; + buf[0..32].copy_from_slice(EIP712_TYPE_HASH.as_slice()); + buf[32..64].copy_from_slice(HASHED_NAME.as_slice()); + buf[64..96].copy_from_slice(&B256::from(U256::from(chain_id)).0); + buf[96..128].copy_from_slice(config.address.into_word().as_slice()); + let expected = keccak256(buf); + + let word = &code[6983..6983 + 32]; + assert_eq!(word, expected.as_slice()); + } + + #[test] + fn domain_separator_changes_with_chain_id() { + let config = test_config(); + let c1 = build(&config, 1); + let c2 = build(&config, 2); + + let ds1 = &c1.code[6983..6983 + 32]; + let ds2 = &c2.code[6983..6983 + 32]; + assert_ne!( + ds1, ds2, + "different chain IDs should produce different domain separators" + ); + } + + #[test] + fn domain_separator_changes_with_address() { + let c1 = build( + &Permit2Config { + address: Address::repeat_byte(0x01), + }, + 1234, + ); + let c2 = build( + &Permit2Config { + address: Address::repeat_byte(0x02), + }, + 1234, + ); + + let ds1 = &c1.code[6983..6983 + 32]; + let ds2 = &c2.code[6983..6983 + 32]; + assert_ne!( + ds1, ds2, + "different addresses should produce different domain separators" + ); + } + + #[test] + fn eip712_constants_are_correct() { + // Verify the hardcoded constants match the expected values + assert_eq!( + EIP712_TYPE_HASH, + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"), + ); + assert_eq!(HASHED_NAME, keccak256("Permit2")); + } + + #[test] + #[ignore = "requires forge CLI"] + fn permit2_bytecode_matches_solidity_source() { + let contracts_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .join("contracts") + .join("lib") + .join("permit2"); + + let output = Command::new("forge") + .args(["inspect", "Permit2", "deployedBytecode", "--root"]) + .arg(&contracts_root) + .output() + .expect("forge not found"); + + assert!( + output.status.success(), + "forge inspect failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let forge_hex = String::from_utf8(output.stdout) + .unwrap() + .trim() + .strip_prefix("0x") + .unwrap() + .to_lowercase(); + + let hardcoded_hex = hex::encode(PERMIT2_BYTECODE); + + assert_eq!( + forge_hex, hardcoded_hex, + "Permit2 bytecode mismatch! Regenerate with: \ + cd contracts/lib/permit2 && forge inspect Permit2 deployedBytecode" + ); + } +} diff --git a/bin/ev-deployer/src/genesis.rs b/bin/ev-deployer/src/genesis.rs index 167499bb..e270b697 100644 --- a/bin/ev-deployer/src/genesis.rs +++ b/bin/ev-deployer/src/genesis.rs @@ -17,6 +17,11 @@ pub(crate) fn build_alloc(config: &DeployConfig) -> Value { insert_contract(&mut alloc, &contract); } + if let Some(ref p2_config) = config.contracts.permit2 { + let contract = contracts::permit2::build(p2_config, config.chain.chain_id); + insert_contract(&mut alloc, &contract); + } + Value::Object(alloc) } @@ -102,6 +107,7 @@ mod tests { address: address!("000000000000000000000000000000000000ad00"), owner: address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), }), + permit2: None, }, } } diff --git a/bin/ev-deployer/src/init_template.toml b/bin/ev-deployer/src/init_template.toml index d147156f..e44eb6fb 100644 --- a/bin/ev-deployer/src/init_template.toml +++ b/bin/ev-deployer/src/init_template.toml @@ -14,3 +14,7 @@ chain_id = 0 # [contracts.admin_proxy] # address = "0x000000000000000000000000000000000000Ad00" # owner = "0x..." + +# Permit2: Uniswap canonical token approval manager. +# [contracts.permit2] +# address = "0x000000000022D473030F116dDEE9F6B43aC78BA3" diff --git a/bin/ev-deployer/src/main.rs b/bin/ev-deployer/src/main.rs index 78b88ec4..5d87ae9b 100644 --- a/bin/ev-deployer/src/main.rs +++ b/bin/ev-deployer/src/main.rs @@ -119,6 +119,12 @@ fn main() -> eyre::Result<()> { .as_ref() .map(|c| c.address) .ok_or_else(|| eyre::eyre!("admin_proxy not configured"))?, + "permit2" => cfg + .contracts + .permit2 + .as_ref() + .map(|c| c.address) + .ok_or_else(|| eyre::eyre!("permit2 not configured"))?, other => eyre::bail!("unknown contract: {other}"), }; diff --git a/bin/ev-deployer/src/output.rs b/bin/ev-deployer/src/output.rs index b30e373c..f683377a 100644 --- a/bin/ev-deployer/src/output.rs +++ b/bin/ev-deployer/src/output.rs @@ -14,5 +14,12 @@ pub(crate) fn build_manifest(config: &DeployConfig) -> Value { ); } + if let Some(ref p2) = config.contracts.permit2 { + manifest.insert( + "permit2".to_string(), + Value::String(format!("{}", p2.address)), + ); + } + Value::Object(manifest) } diff --git a/bin/ev-deployer/tests/e2e_genesis.sh b/bin/ev-deployer/tests/e2e_genesis.sh index c1bee05d..b7c085ae 100755 --- a/bin/ev-deployer/tests/e2e_genesis.sh +++ b/bin/ev-deployer/tests/e2e_genesis.sh @@ -77,10 +77,12 @@ echo "=== Generating genesis with ev-deployer ===" echo "Genesis written to $GENESIS" # Quick sanity: address should be in the alloc -grep -q "000000000000000000000000000000000000Ad00" "$GENESIS" \ +grep -qi "000000000000000000000000000000000000Ad00" "$GENESIS" \ || fail "AdminProxy address not found in genesis" +grep -qi "000000000022D473030F116dDEE9F6B43aC78BA3" "$GENESIS" \ + || fail "Permit2 address not found in genesis" -pass "genesis contains AdminProxy address" +pass "genesis contains all contract addresses" # ── Step 3: Start ev-reth ──────────────────────────────── @@ -125,6 +127,26 @@ expected_owner_slot="0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cff || fail "AdminProxy slot 0 (owner) mismatch: got $admin_slot0, expected $expected_owner_slot" pass "AdminProxy owner slot 0 = $ADMIN_OWNER" +# ── Step 5: Verify Permit2 ────────────────────────────── + +PERMIT2="0x000000000022D473030F116dDEE9F6B43aC78BA3" + +echo "=== Verifying Permit2 at $PERMIT2 ===" + +# Check code is present +p2_code=$(rpc_call "eth_getCode" "[\"$PERMIT2\", \"latest\"]") +[[ "$p2_code" != "0x" && "$p2_code" != "0x0" && ${#p2_code} -gt 10 ]] \ + || fail "Permit2 has no bytecode (got: $p2_code)" +pass "Permit2 has bytecode (${#p2_code} hex chars)" + +# Call DOMAIN_SEPARATOR() — selector 0x3644e515 +# Should return the cached domain separator matching chain_id=1234 and the contract address +p2_domain_sep=$(rpc_call "eth_call" "[{\"to\":\"$PERMIT2\",\"data\":\"0x3644e515\"}, \"latest\"]") +expected_domain_sep="0x6cda538cafce36292a6ef27740629597f85f6716f5694d26d5c59fc1d07cfd95" +[[ "$(echo "$p2_domain_sep" | tr '[:upper:]' '[:lower:]')" == "$(echo "$expected_domain_sep" | tr '[:upper:]' '[:lower:]')" ]] \ + || fail "Permit2 DOMAIN_SEPARATOR() mismatch: got $p2_domain_sep, expected $expected_domain_sep" +pass "Permit2 DOMAIN_SEPARATOR() correct for chain_id=1234" + # ── Done ───────────────────────────────────────────────── echo "" diff --git a/contracts/README.md b/contracts/README.md index 4c6e0a37..1de36cea 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,6 +1,6 @@ # EV-Reth Contracts -Smart contracts for EV-Reth, including the FeeVault for bridging collected fees to Celestia. +Smart contracts for EV-Reth, including the FeeVault for collecting and distributing fees. ## AdminProxy @@ -8,14 +8,17 @@ The AdminProxy contract solves the bootstrap problem for admin addresses at gene See [AdminProxy documentation](../docs/contracts/admin_proxy.md) for detailed setup and usage instructions. -## FeeVault +## FeeVault (optional) -The FeeVault contract collects base fees and bridges them to Celestia via Hyperlane. It supports: +The FeeVault is an **optional** contract for chains that need on-chain fee splitting logic. The base fee redirect (`baseFeeSink`) works with any address — an EOA or multisig is sufficient if you just need fees sent to a single destination. -- Configurable fee splitting between bridge and another recipient -- Minimum amount thresholds before bridging -- Call fee for incentivizing bridge calls -- Owner-controlled configuration +FeeVault is useful when you need: + +- **Automatic splitting** of accumulated fees between two recipients (e.g., 80% to a bridge contract, 20% to a treasury) +- **Minimum threshold** to avoid distributing uneconomically small amounts +- **Keeper incentive** (`callFee`) so anyone can trigger distribution and get compensated + +If your chain only needs fees routed to a single address, skip FeeVault and point `baseFeeSink` directly at that address. ## Prerequisites @@ -45,17 +48,14 @@ All configuration is set via constructor arguments at deploy time: |----------|----------|-------------| | `OWNER` | Yes | Owner address (can configure the vault post-deployment) | | `SALT` | No | CREATE2 salt (default: `0x0`). Use any bytes32 value | -| `DESTINATION_DOMAIN` | Yes* | Hyperlane destination chain ID | -| `RECIPIENT_ADDRESS` | Yes* | Recipient on destination chain (bytes32, left-padded) | -| `MINIMUM_AMOUNT` | No | Minimum wei to bridge (default: 0) | -| `CALL_FEE` | No | Fee in wei for calling `sendToCelestia()` (default: 0) | +| `MINIMUM_AMOUNT` | No | Minimum wei to distribute (default: 0) | +| `CALL_FEE` | No | Fee in wei for calling `distribute()` (default: 0) | | `BRIDGE_SHARE_BPS` | No | Basis points to bridge (default: 10000 = 100%) | -| `OTHER_RECIPIENT` | No** | Address to receive non-bridged portion | +| `OTHER_RECIPIENT` | No* | Address to receive non-bridged portion | -*Required for the vault to be operational (can be set to 0 at deploy and configured later via setters) -**Required if `BRIDGE_SHARE_BPS` < 10000 +*Required if `BRIDGE_SHARE_BPS` < 10000 -**Note:** `HYP_NATIVE_MINTER` must be set via `setHypNativeMinter()` after deployment for the vault to be operational. +**Note:** `BRIDGE_RECIPIENT` must be set via `setBridgeRecipient()` after deployment for the vault to be operational. ### Choosing a Salt @@ -89,8 +89,6 @@ export OWNER=0xYourOwnerAddress export SALT=0x0000000000000000000000000000000000000000000000000000000000000001 # Optional - configure at deploy time (can also be set later) -export DESTINATION_DOMAIN=1234 -export RECIPIENT_ADDRESS=0x000000000000000000000000... # bytes32, left-padded cosmos address export MINIMUM_AMOUNT=1000000000000000000 # 1 ETH in wei export CALL_FEE=100000000000000 # 0.0001 ETH export BRIDGE_SHARE_BPS=8000 # 80% to bridge @@ -107,43 +105,25 @@ forge script script/DeployFeeVault.s.sol:DeployFeeVault \ --broadcast ``` -### Post-Deployment: Set HypNativeMinter +### Post-Deployment: Set Bridge Recipient -After deploying the HypNativeMinter contract, link it to the FeeVault: +After deployment, set the bridge recipient address: ```shell -cast send "setHypNativeMinter(address)" \ +cast send "setBridgeRecipient(address)" \ --rpc-url \ --private-key ``` -### Converting Cosmos Addresses to bytes32 - -The `recipientAddress` must be a bytes32. To convert a bech32 Cosmos address: - -1. Decode the bech32 to get the 20-byte address -2. Left-pad with zeros to 32 bytes - -Example using cast: - -```shell -# Left-pad a 20-byte address to 32 bytes -cast pad --left --len 32 1234567890abcdef1234567890abcdef12345678 -# Output: 0x0000000000000000000000001234567890abcdef1234567890abcdef12345678 -``` - -Note: When calling `transferRemote()` via cast, you may need to omit the `0x` prefix depending on your invocation method. - ## Admin Functions All functions are owner-only: | Function | Description | |----------|-------------| -| `setHypNativeMinter(address)` | Set the Hyperlane minter contract | -| `setRecipient(uint32, bytes32)` | Set destination domain and recipient | -| `setMinimumAmount(uint256)` | Set minimum amount to bridge | -| `setCallFee(uint256)` | Set fee for calling sendToCelestia | +| `setBridgeRecipient(address)` | Set the bridge recipient address | +| `setMinimumAmount(uint256)` | Set minimum amount to distribute | +| `setCallFee(uint256)` | Set fee for calling distribute | | `setBridgeShare(uint256)` | Set bridge percentage (basis points) | | `setOtherRecipient(address)` | Set recipient for non-bridged funds | | `transferOwnership(address)` | Transfer contract ownership | diff --git a/contracts/foundry.lock b/contracts/foundry.lock index aee2c9a8..19f75092 100644 --- a/contracts/foundry.lock +++ b/contracts/foundry.lock @@ -1,5 +1,8 @@ { "lib/forge-std": { "rev": "887e87251562513a7b5ab1ea517c039fe6ee0984" + }, + "lib/permit2": { + "rev": "cc56ad0f3439c502c246fc5cfcc3db92bb8b7219" } } \ No newline at end of file diff --git a/contracts/lib/permit2 b/contracts/lib/permit2 new file mode 160000 index 00000000..cc56ad0f --- /dev/null +++ b/contracts/lib/permit2 @@ -0,0 +1 @@ +Subproject commit cc56ad0f3439c502c246fc5cfcc3db92bb8b7219 diff --git a/contracts/script/DeployFeeVault.s.sol b/contracts/script/DeployFeeVault.s.sol index 54b7e0f8..675010d4 100644 --- a/contracts/script/DeployFeeVault.s.sol +++ b/contracts/script/DeployFeeVault.s.sol @@ -10,8 +10,6 @@ contract DeployFeeVault is Script { address owner = vm.envAddress("OWNER"); bytes32 salt = vm.envOr("SALT", bytes32(0)); - uint32 destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); - bytes32 recipientAddress = vm.envOr("RECIPIENT_ADDRESS", bytes32(0)); uint256 minimumAmount = vm.envOr("MINIMUM_AMOUNT", uint256(0)); uint256 callFee = vm.envOr("CALL_FEE", uint256(0)); uint256 bridgeShareBps = vm.envOr("BRIDGE_SHARE_BPS", uint256(0)); // 0 defaults to 10000 in constructor @@ -21,34 +19,27 @@ contract DeployFeeVault is Script { vm.startBroadcast(); // Deploy FeeVault with CREATE2 - FeeVault feeVault = new FeeVault{salt: salt}( - owner, destinationDomain, recipientAddress, minimumAmount, callFee, bridgeShareBps, otherRecipient - ); + FeeVault feeVault = new FeeVault{salt: salt}(owner, minimumAmount, callFee, bridgeShareBps, otherRecipient); vm.stopBroadcast(); console.log("FeeVault deployed at:", address(feeVault)); console.log("Owner:", owner); - console.log("Destination domain:", destinationDomain); console.log("Minimum amount:", minimumAmount); console.log("Call fee:", callFee); console.log("Bridge share bps:", feeVault.bridgeShareBps()); console.log(""); - console.log("NOTE: Call setHypNativeMinter() after deploying HypNativeMinter"); + console.log("NOTE: Call setBridgeRecipient() to set the bridge destination"); } } /// @notice Compute FeeVault CREATE2 address off-chain -/// @dev Use this to predict the address before deploying -/// Requires env vars: DEPLOYER (EOA), OWNER, SALT (optional), and all constructor args contract ComputeFeeVaultAddress is Script { function run() external view { address deployer = vm.envAddress("DEPLOYER"); bytes32 salt = vm.envOr("SALT", bytes32(0)); address owner = vm.envAddress("OWNER"); - uint32 destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); - bytes32 recipientAddress = vm.envOr("RECIPIENT_ADDRESS", bytes32(0)); uint256 minimumAmount = vm.envOr("MINIMUM_AMOUNT", uint256(0)); uint256 callFee = vm.envOr("CALL_FEE", uint256(0)); uint256 bridgeShareBps = vm.envOr("BRIDGE_SHARE_BPS", uint256(0)); @@ -56,10 +47,7 @@ contract ComputeFeeVaultAddress is Script { bytes32 initCodeHash = keccak256( abi.encodePacked( - type(FeeVault).creationCode, - abi.encode( - owner, destinationDomain, recipientAddress, minimumAmount, callFee, bridgeShareBps, otherRecipient - ) + type(FeeVault).creationCode, abi.encode(owner, minimumAmount, callFee, bridgeShareBps, otherRecipient) ) ); diff --git a/contracts/script/GenerateFeeVaultAlloc.s.sol b/contracts/script/GenerateFeeVaultAlloc.s.sol index 0215452f..e332c1e5 100644 --- a/contracts/script/GenerateFeeVaultAlloc.s.sol +++ b/contracts/script/GenerateFeeVaultAlloc.s.sol @@ -8,27 +8,23 @@ abstract contract FeeVaultAllocBase is Script { struct Config { address feeVaultAddress; address owner; - uint32 destinationDomain; - bytes32 recipientAddress; + address bridgeRecipient; + address otherRecipient; uint256 minimumAmount; uint256 callFee; uint256 bridgeShareBpsRaw; uint256 bridgeShareBps; - address otherRecipient; - address hypNativeMinter; bytes32 salt; address deployer; } function loadConfig() internal view returns (Config memory cfg) { cfg.owner = vm.envAddress("OWNER"); - cfg.destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); - cfg.recipientAddress = vm.envOr("RECIPIENT_ADDRESS", bytes32(0)); + cfg.bridgeRecipient = vm.envOr("BRIDGE_RECIPIENT", address(0)); + cfg.otherRecipient = vm.envOr("OTHER_RECIPIENT", address(0)); cfg.minimumAmount = vm.envOr("MINIMUM_AMOUNT", uint256(0)); cfg.callFee = vm.envOr("CALL_FEE", uint256(0)); cfg.bridgeShareBpsRaw = vm.envOr("BRIDGE_SHARE_BPS", uint256(0)); - cfg.otherRecipient = vm.envOr("OTHER_RECIPIENT", address(0)); - cfg.hypNativeMinter = vm.envOr("HYP_NATIVE_MINTER", address(0)); cfg.feeVaultAddress = vm.envOr("FEE_VAULT_ADDRESS", address(0)); cfg.deployer = vm.envOr("DEPLOYER", address(0)); cfg.salt = vm.envOr("SALT", bytes32(0)); @@ -42,15 +38,7 @@ abstract contract FeeVaultAllocBase is Script { bytes32 initCodeHash = keccak256( abi.encodePacked( type(FeeVault).creationCode, - abi.encode( - cfg.owner, - cfg.destinationDomain, - cfg.recipientAddress, - cfg.minimumAmount, - cfg.callFee, - cfg.bridgeShareBpsRaw, - cfg.otherRecipient - ) + abi.encode(cfg.owner, cfg.minimumAmount, cfg.callFee, cfg.bridgeShareBpsRaw, cfg.otherRecipient) ) ); cfg.feeVaultAddress = address( @@ -64,29 +52,19 @@ abstract contract FeeVaultAllocBase is Script { function computeSlots(Config memory cfg) internal pure - returns ( - bytes32 slot0, - bytes32 slot1, - bytes32 slot2, - bytes32 slot3, - bytes32 slot4, - bytes32 slot5, - bytes32 slot6 - ) + returns (bytes32 slot0, bytes32 slot1, bytes32 slot2, bytes32 slot3, bytes32 slot4, bytes32 slot5) { - slot0 = bytes32(uint256(uint160(cfg.hypNativeMinter))); - slot1 = bytes32((uint256(cfg.destinationDomain) << 160) | uint256(uint160(cfg.owner))); - slot2 = cfg.recipientAddress; + slot0 = bytes32(uint256(uint160(cfg.owner))); + slot1 = bytes32(uint256(uint160(cfg.bridgeRecipient))); + slot2 = bytes32(uint256(uint160(cfg.otherRecipient))); slot3 = bytes32(cfg.minimumAmount); slot4 = bytes32(cfg.callFee); - slot5 = bytes32(uint256(uint160(cfg.otherRecipient))); - slot6 = bytes32(cfg.bridgeShareBps); + slot5 = bytes32(cfg.bridgeShareBps); } function addressKey(address addr) internal pure returns (string memory) { bytes memory full = bytes(vm.toString(addr)); bytes memory key = new bytes(40); - // Fixed-length copy for address key without 0x prefix. for (uint256 i = 0; i < 40; i++) { key[i] = full[i + 2]; } @@ -102,13 +80,11 @@ contract GenerateFeeVaultAlloc is FeeVaultAllocBase { Config memory cfg = loadConfig(); bytes memory runtimeCode = type(FeeVault).runtimeCode; - (bytes32 slot0, bytes32 slot1, bytes32 slot2, bytes32 slot3, bytes32 slot4, bytes32 slot5, bytes32 slot6) = - computeSlots(cfg); + (bytes32 slot0, bytes32 slot1, bytes32 slot2, bytes32 slot3, bytes32 slot4, bytes32 slot5) = computeSlots(cfg); console.log("========== FeeVault Genesis Alloc =========="); console.log("FeeVault address:", cfg.feeVaultAddress); console.log("Owner:", cfg.owner); - console.log("Destination domain:", cfg.destinationDomain); console.log("Bridge share bps (raw):", cfg.bridgeShareBpsRaw); console.log("Bridge share bps (effective):", cfg.bridgeShareBps); console.log(""); @@ -119,8 +95,8 @@ contract GenerateFeeVaultAlloc is FeeVaultAllocBase { if (cfg.bridgeShareBps < 10000 && cfg.otherRecipient == address(0)) { console.log("WARNING: OTHER_RECIPIENT is zero but bridge share < 10000."); } - if (cfg.hypNativeMinter == address(0)) { - console.log("NOTE: HYP_NATIVE_MINTER is zero; set it before calling sendToCelestia()."); + if (cfg.bridgeRecipient == address(0)) { + console.log("NOTE: BRIDGE_RECIPIENT is zero; set it before calling distribute()."); } console.log(""); @@ -137,8 +113,7 @@ contract GenerateFeeVaultAlloc is FeeVaultAllocBase { console.log(' "0x2": "%s",', vm.toString(slot2)); console.log(' "0x3": "%s",', vm.toString(slot3)); console.log(' "0x4": "%s",', vm.toString(slot4)); - console.log(' "0x5": "%s",', vm.toString(slot5)); - console.log(' "0x6": "%s"', vm.toString(slot6)); + console.log(' "0x5": "%s"', vm.toString(slot5)); console.log(" }"); console.log(" }"); console.log(" }"); @@ -157,8 +132,7 @@ contract GenerateFeeVaultAllocJSON is FeeVaultAllocBase { Config memory cfg = loadConfig(); bytes memory runtimeCode = type(FeeVault).runtimeCode; - (bytes32 slot0, bytes32 slot1, bytes32 slot2, bytes32 slot3, bytes32 slot4, bytes32 slot5, bytes32 slot6) = - computeSlots(cfg); + (bytes32 slot0, bytes32 slot1, bytes32 slot2, bytes32 slot3, bytes32 slot4, bytes32 slot5) = computeSlots(cfg); string memory json = string( abi.encodePacked( @@ -178,8 +152,6 @@ contract GenerateFeeVaultAllocJSON is FeeVaultAllocBase { vm.toString(slot4), '","0x5":"', vm.toString(slot5), - '","0x6":"', - vm.toString(slot6), '"}}}' ) ); diff --git a/contracts/src/FeeVault.sol b/contracts/src/FeeVault.sol index 7069258c..b0ec6c10 100644 --- a/contracts/src/FeeVault.sol +++ b/contracts/src/FeeVault.sol @@ -1,35 +1,21 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -interface IHypNativeMinter { - function transferRemote(uint32 _destination, bytes32 _recipient, uint256 _amount) - external - payable - returns (bytes32 messageId); -} - contract FeeVault { - IHypNativeMinter public hypNativeMinter; - address public owner; - uint32 public destinationDomain; - bytes32 public recipientAddress; + address public bridgeRecipient; + address public otherRecipient; uint256 public minimumAmount; uint256 public callFee; - - // Split accounting - address public otherRecipient; uint256 public bridgeShareBps; // Basis points (0-10000) for bridge share - event SentToCelestia(uint256 amount, bytes32 recipient, bytes32 messageId); + event FundsDistributed(uint256 total, uint256 bridgeAmount, uint256 otherAmount); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); - event HypNativeMinterUpdated(address hypNativeMinter); - event RecipientUpdated(uint32 destinationDomain, bytes32 recipientAddress); + event BridgeRecipientUpdated(address bridgeRecipient); event MinimumAmountUpdated(uint256 minimumAmount); event CallFeeUpdated(uint256 callFee); event BridgeShareUpdated(uint256 bridgeShareBps); event OtherRecipientUpdated(address otherRecipient); - event FundsSplit(uint256 totalNew, uint256 bridgeAmount, uint256 otherAmount); modifier onlyOwner() { require(msg.sender == owner, "FeeVault: caller is not the owner"); @@ -38,8 +24,6 @@ contract FeeVault { constructor( address _owner, - uint32 _destinationDomain, - bytes32 _recipientAddress, uint256 _minimumAmount, uint256 _callFee, uint256 _bridgeShareBps, @@ -49,8 +33,6 @@ contract FeeVault { require(_bridgeShareBps <= 10000, "FeeVault: invalid bps"); owner = _owner; - destinationDomain = _destinationDomain; - recipientAddress = _recipientAddress; minimumAmount = _minimumAmount; callFee = _callFee; bridgeShareBps = _bridgeShareBps == 0 ? 10000 : _bridgeShareBps; @@ -61,8 +43,8 @@ contract FeeVault { receive() external payable {} - function sendToCelestia() external payable { - require(address(hypNativeMinter) != address(0), "FeeVault: minter not set"); + function distribute() external payable { + require(bridgeRecipient != address(0), "FeeVault: bridge recipient not set"); require(msg.value >= callFee, "FeeVault: insufficient fee"); uint256 currentBalance = address(this).balance; @@ -73,20 +55,18 @@ contract FeeVault { require(bridgeAmount >= minimumAmount, "FeeVault: minimum amount not met"); - emit FundsSplit(currentBalance, bridgeAmount, otherAmount); + emit FundsDistributed(currentBalance, bridgeAmount, otherAmount); // Send other amount if any if (otherAmount > 0) { require(otherRecipient != address(0), "FeeVault: other recipient not set"); - (bool success,) = otherRecipient.call{value: otherAmount}(""); - require(success, "FeeVault: transfer failed"); + (bool sent,) = otherRecipient.call{value: otherAmount}(""); + require(sent, "FeeVault: transfer failed"); } - // Bridge the bridge amount - bytes32 messageId = - hypNativeMinter.transferRemote{value: bridgeAmount}(destinationDomain, recipientAddress, bridgeAmount); - - emit SentToCelestia(bridgeAmount, recipientAddress, messageId); + // Send bridge amount + (bool success,) = bridgeRecipient.call{value: bridgeAmount}(""); + require(success, "FeeVault: bridge transfer failed"); } // Admin functions @@ -97,10 +77,10 @@ contract FeeVault { owner = newOwner; } - function setRecipient(uint32 _destinationDomain, bytes32 _recipientAddress) external onlyOwner { - destinationDomain = _destinationDomain; - recipientAddress = _recipientAddress; - emit RecipientUpdated(_destinationDomain, _recipientAddress); + function setBridgeRecipient(address _bridgeRecipient) external onlyOwner { + require(_bridgeRecipient != address(0), "FeeVault: zero address"); + bridgeRecipient = _bridgeRecipient; + emit BridgeRecipientUpdated(_bridgeRecipient); } function setMinimumAmount(uint256 _minimumAmount) external onlyOwner { @@ -125,36 +105,18 @@ contract FeeVault { emit OtherRecipientUpdated(_otherRecipient); } - function setHypNativeMinter(address _hypNativeMinter) external onlyOwner { - require(_hypNativeMinter != address(0), "FeeVault: zero address"); - hypNativeMinter = IHypNativeMinter(_hypNativeMinter); - emit HypNativeMinterUpdated(_hypNativeMinter); - } - - /// @notice Return the full configuration currently stored in the contract. function getConfig() external view returns ( address _owner, - uint32 _destinationDomain, - bytes32 _recipientAddress, + address _bridgeRecipient, + address _otherRecipient, uint256 _minimumAmount, uint256 _callFee, - uint256 _bridgeShareBps, - address _otherRecipient, - address _hypNativeMinter + uint256 _bridgeShareBps ) { - return ( - owner, - destinationDomain, - recipientAddress, - minimumAmount, - callFee, - bridgeShareBps, - otherRecipient, - address(hypNativeMinter) - ); + return (owner, bridgeRecipient, otherRecipient, minimumAmount, callFee, bridgeShareBps); } } diff --git a/contracts/test/AdminProxy.t.sol b/contracts/test/AdminProxy.t.sol index 4404e4e7..d998ebcd 100644 --- a/contracts/test/AdminProxy.t.sol +++ b/contracts/test/AdminProxy.t.sol @@ -362,8 +362,6 @@ contract AdminProxyTest is Test { // Deploy FeeVault with proxy as owner FeeVault vault = new FeeVault( address(proxy), // proxy is owner - 1234, - bytes32(uint256(0xbeef)), 1 ether, 0.1 ether, 10000, diff --git a/contracts/test/FeeVault.t.sol b/contracts/test/FeeVault.t.sol index fdc379db..7814b339 100644 --- a/contracts/test/FeeVault.t.sol +++ b/contracts/test/FeeVault.t.sol @@ -4,71 +4,49 @@ pragma solidity ^0.8.24; import {Test, console} from "forge-std/Test.sol"; import {FeeVault} from "../src/FeeVault.sol"; -contract MockHypNativeMinter { - event TransferRemoteCalled(uint32 destination, bytes32 recipient, uint256 amount); - - function transferRemote(uint32 _destination, bytes32 _recipient, uint256 _amount) - external - payable - returns (bytes32 messageId) - { - require(msg.value == _amount, "MockHypNativeMinter: value mismatch"); - emit TransferRemoteCalled(_destination, _recipient, _amount); - return bytes32(uint256(1)); // Return a dummy messageId - } -} - contract FeeVaultTest is Test { FeeVault public feeVault; - MockHypNativeMinter public mockMinter; address public owner; address public user; + address public bridgeRecipient; address public otherRecipient; - uint32 public destination = 1234; - bytes32 public recipient = bytes32(uint256(0xdeadbeef)); uint256 public minAmount = 1 ether; uint256 public fee = 0.1 ether; function setUp() public { owner = address(this); user = address(0x1); + bridgeRecipient = address(0x42); otherRecipient = address(0x99); - mockMinter = new MockHypNativeMinter(); feeVault = new FeeVault( owner, - destination, - recipient, minAmount, fee, 10000, // 100% bridge share otherRecipient ); - feeVault.setHypNativeMinter(address(mockMinter)); + feeVault.setBridgeRecipient(bridgeRecipient); } - function test_GetConfig() public { + function test_GetConfig() public view { ( address cfgOwner, - uint32 cfgDestination, - bytes32 cfgRecipient, + address cfgBridgeRecipient, + address cfgOtherRecipient, uint256 cfgMinAmount, uint256 cfgCallFee, - uint256 cfgBridgeShare, - address cfgOtherRecipient, - address cfgHypNativeMinter + uint256 cfgBridgeShare ) = feeVault.getConfig(); assertEq(cfgOwner, owner); - assertEq(cfgDestination, destination); - assertEq(cfgRecipient, recipient); + assertEq(cfgBridgeRecipient, bridgeRecipient); + assertEq(cfgOtherRecipient, otherRecipient); assertEq(cfgMinAmount, minAmount); assertEq(cfgCallFee, fee); assertEq(cfgBridgeShare, 10000); - assertEq(cfgOtherRecipient, otherRecipient); - assertEq(cfgHypNativeMinter, address(mockMinter)); } function test_Receive() public { @@ -78,36 +56,28 @@ contract FeeVaultTest is Test { assertEq(address(feeVault).balance, amount, "Balance mismatch"); } - function test_SendToCelestia_100PercentBridge() public { + function test_Distribute_100PercentBridge() public { // Fund with minAmount (bool success,) = address(feeVault).call{value: minAmount}(""); require(success); uint256 totalAmount = minAmount + fee; - vm.expectEmit(true, true, true, true, address(mockMinter)); - emit MockHypNativeMinter.TransferRemoteCalled(destination, recipient, totalAmount); - - // Expect the event from FeeVault vm.expectEmit(true, true, true, true, address(feeVault)); - emit FeeVault.SentToCelestia(totalAmount, recipient, bytes32(uint256(1))); + emit FeeVault.FundsDistributed(totalAmount, totalAmount, 0); vm.prank(user); vm.deal(user, fee); - feeVault.sendToCelestia{value: fee}(); + feeVault.distribute{value: fee}(); - assertEq(address(feeVault).balance, 0, "Collector should be empty"); + assertEq(address(feeVault).balance, 0, "Vault should be empty"); + assertEq(bridgeRecipient.balance, totalAmount, "Bridge recipient should receive funds"); } - function test_SendToCelestia_Split5050() public { + function test_Distribute_Split5050() public { // Set split to 50% feeVault.setBridgeShare(5000); - // Fund with 2 ether. - // Fee is 0.1 ether. - // Total new funds = 2.1 ether. - // Bridge = 1.05 ether. Other = 1.05 ether. - // Min amount is 1 ether, so 1.05 >= 1.0 is OK. uint256 fundAmount = 2 ether; (bool success,) = address(feeVault).call{value: fundAmount}(""); require(success); @@ -116,46 +86,47 @@ contract FeeVaultTest is Test { uint256 expectedBridge = totalNew / 2; uint256 expectedOther = totalNew - expectedBridge; - vm.expectEmit(true, true, true, true, address(mockMinter)); - emit MockHypNativeMinter.TransferRemoteCalled(destination, recipient, expectedBridge); - vm.prank(user); vm.deal(user, fee); - feeVault.sendToCelestia{value: fee}(); + feeVault.distribute{value: fee}(); - assertEq(address(feeVault).balance, 0, "Collector should be empty"); + assertEq(address(feeVault).balance, 0, "Vault should be empty"); + assertEq(bridgeRecipient.balance, expectedBridge, "Bridge recipient should receive funds"); assertEq(otherRecipient.balance, expectedOther, "Other recipient should receive funds"); } - function test_SendToCelestia_InsufficientFee() public { + function test_Distribute_InsufficientFee() public { vm.prank(user); vm.deal(user, fee); - // Send less than fee vm.expectRevert("FeeVault: insufficient fee"); - feeVault.sendToCelestia{value: fee - 1}(); + feeVault.distribute{value: fee - 1}(); } - function test_SendToCelestia_BelowMinAmount_AfterSplit() public { + function test_Distribute_BelowMinAmount_AfterSplit() public { feeVault.setBridgeShare(1000); // 10% bridge - // Fund with 2 ether. Total 2.1. - // Bridge = 0.21. Other = 1.89. - // Min amount is 1.0. 0.21 < 1.0. Should revert. (bool success,) = address(feeVault).call{value: 2 ether}(""); require(success); vm.prank(user); vm.deal(user, fee); vm.expectRevert("FeeVault: minimum amount not met"); - feeVault.sendToCelestia{value: fee}(); + feeVault.distribute{value: fee}(); } - function test_AdminFunctions() public { - // Test setRecipient - feeVault.setRecipient(5678, bytes32(uint256(0xbeef))); - assertEq(feeVault.destinationDomain(), 5678); - assertEq(feeVault.recipientAddress(), bytes32(uint256(0xbeef))); + function test_Distribute_BridgeRecipientNotSet() public { + FeeVault freshVault = new FeeVault(owner, minAmount, fee, 10000, otherRecipient); + (bool success,) = address(freshVault).call{value: minAmount}(""); + require(success); + + vm.prank(user); + vm.deal(user, fee); + vm.expectRevert("FeeVault: bridge recipient not set"); + freshVault.distribute{value: fee}(); + } + + function test_AdminFunctions() public { // Test setMinimumAmount feeVault.setMinimumAmount(5 ether); assertEq(feeVault.minimumAmount(), 5 ether); @@ -190,10 +161,6 @@ contract FeeVaultTest is Test { } function test_AdminAccessControl() public { - vm.prank(user); - vm.expectRevert("FeeVault: caller is not the owner"); - feeVault.setRecipient(1, bytes32(0)); - vm.prank(user); vm.expectRevert("FeeVault: caller is not the owner"); feeVault.setMinimumAmount(1); @@ -216,30 +183,17 @@ contract FeeVaultTest is Test { vm.prank(user); vm.expectRevert("FeeVault: caller is not the owner"); - feeVault.setHypNativeMinter(address(0x123)); + feeVault.setBridgeRecipient(address(0x123)); } - function test_SetHypNativeMinter() public { - MockHypNativeMinter newMinter = new MockHypNativeMinter(); - feeVault.setHypNativeMinter(address(newMinter)); - assertEq(address(feeVault.hypNativeMinter()), address(newMinter)); + function test_SetBridgeRecipient() public { + address newRecipient = address(0x55); + feeVault.setBridgeRecipient(newRecipient); + assertEq(feeVault.bridgeRecipient(), newRecipient); } - function test_SetHypNativeMinter_ZeroAddress() public { + function test_SetBridgeRecipient_ZeroAddress() public { vm.expectRevert("FeeVault: zero address"); - feeVault.setHypNativeMinter(address(0)); - } - - function test_SendToCelestia_MinterNotSet() public { - // Deploy fresh vault without minter - FeeVault freshVault = new FeeVault(owner, destination, recipient, minAmount, fee, 10000, otherRecipient); - - (bool success,) = address(freshVault).call{value: minAmount}(""); - require(success); - - vm.prank(user); - vm.deal(user, fee); - vm.expectRevert("FeeVault: minter not set"); - freshVault.sendToCelestia{value: fee}(); + feeVault.setBridgeRecipient(address(0)); } } diff --git a/docs/contracts/fee_vault.md b/docs/contracts/fee_vault.md index ed4ffe54..d701ed3d 100644 --- a/docs/contracts/fee_vault.md +++ b/docs/contracts/fee_vault.md @@ -2,33 +2,42 @@ ## Overview -The `FeeVault` is a specialized smart contract designed to accumulate native tokens (gas tokens) and automatically split them between bridging to a specific destination chain (e.g., Celestia) and sending to a secondary recipient. +The `FeeVault` is an **optional** smart contract for chains that need on-chain fee splitting logic. It accumulates native tokens (gas tokens) and automatically splits them between two configurable recipients. + +## When to Use FeeVault + +The base fee redirect (`baseFeeSink`) works with any address. You **do not need** FeeVault if fees should go to a single destination — just point `baseFeeSink` at an EOA or multisig. + +FeeVault adds value when you need: + +- **Splitting**: Automatically divide fees between two recipients (e.g., 80% to a bridge, 20% to treasury). +- **Minimum threshold**: Only distribute when enough has accumulated to be economically worthwhile. +- **Keeper incentive**: A `callFee` rewards anyone who triggers the distribution, removing the need for a centralized operator. ## Use Case -This contract serves as a **fee sink** and **bridging mechanism** for a rollup or chain that wants to redirect collected fees (e.g., EIP-1559 base fees) to another ecosystem while retaining a portion for other purposes (e.g., developer rewards, treasury). +This contract serves as a **fee sink** and **distribution mechanism** for a rollup or chain that wants to redirect collected fees (e.g., EIP-1559 base fees) to multiple recipients. 1. **Fee Accumulation**: The contract receives funds from: - **Base Fee Redirect**: The chain's execution layer (e.g., `ev-revm`) can be configured to direct burned base fees directly to this contract's address. - **Direct Transfers**: Anyone can send native tokens to the contract via the `receive()` function. -2. **Splitting & Bridging**: Once sufficient funds have accumulated, any user can trigger the `sendToCelestia()` function. This splits the funds based on a configured percentage: - - **Bridge Share**: Sent to the destination chain (Celestia) via the `HypNativeMinter`. +2. **Splitting & Distribution**: Once sufficient funds have accumulated, any user can trigger the `distribute()` function. This splits the funds based on a configured percentage: + - **Bridge Share**: Sent to the configured `bridgeRecipient`. - **Other Share**: Immediately transferred to a configured `otherRecipient` address. ## Architecture ### Core Components -- **HypNativeMinter Integration**: The contract interacts with a Hyperlane `HypNativeMinter` to handle the cross-chain transfer logic. +- **Split Logic**: Configurable basis-point split between bridge and secondary recipient. - **Admin Controls**: An `owner` manages critical parameters to ensure security and flexibility. ### Key Features -- **Automatic Splitting**: Funds are split automatically upon calling `sendToCelestia`. No manual withdrawal is required for the secondary recipient. -- **Stored Recipient**: The destination domain (Chain ID) and recipient address are stored in the contract state. -- **Minimum Threshold**: A `minimumAmount` ensures that bridging only occurs when it is economically viable. -- **Caller Incentive/Fee**: A `callFee` is required to trigger the bridge function. +- **Automatic Splitting**: Funds are split automatically upon calling `distribute`. No manual withdrawal is required for the secondary recipient. +- **Minimum Threshold**: A `minimumAmount` ensures that distribution only occurs when it is economically viable. +- **Caller Incentive/Fee**: A `callFee` is required to trigger the distribution function. ## Workflow @@ -38,7 +47,7 @@ This contract serves as a **fee sink** and **bridging mechanism** for a rollup o 2. **Trigger Phase**: - A keeper or user notices the bridge portion exceeds `minimumAmount`. - - They call `sendToCelestia{value: callFee}()`. + - They call `distribute{value: callFee}()`. - The contract checks: - `msg.value >= callFee` - `bridgeAmount >= minimumAmount` @@ -46,19 +55,18 @@ This contract serves as a **fee sink** and **bridging mechanism** for a rollup o 3. **Execution Phase**: - The contract calculates the split based on `bridgeShareBps`. - **Other Share**: Transferred immediately to `otherRecipient`. - - **Bridge Share**: Bridged to Celestia via `hypNativeMinter.transferRemote`. - - `SentToCelestia` and `FundsSplit` events are emitted. + - **Bridge Share**: Sent to `bridgeRecipient`. + - `FundsDistributed` event is emitted. ## Configuration Parameters | Parameter | Description | Managed By | |-----------|-------------|------------| -| `destinationDomain` | Hyperlane domain ID of the target chain (e.g., Celestia). | Owner | -| `recipientAddress` | Address on the target chain to receive funds. | Owner | -| `minimumAmount` | Minimum bridge amount required to trigger a bridge tx. | Owner | +| `bridgeRecipient` | Address to receive the bridge share of funds. | Owner | +| `otherRecipient` | Address to receive the non-bridged portion of funds. | Owner | +| `minimumAmount` | Minimum bridge amount required to trigger distribution. | Owner | | `callFee` | Fee required from the caller to execute the function. | Owner | | `bridgeShareBps` | Basis points (0-10000) determining the % of funds to bridge. | Owner | -| `otherRecipient` | Address to receive the non-bridged portion of funds. | Owner | ## Embedding FeeVault in Genesis @@ -72,8 +80,6 @@ If you want a deterministic address across chains, compute the CREATE2 address a export OWNER=0xYourOwnerOrAdminProxy export SALT=0x0000000000000000000000000000000000000000000000000000000000000001 export DEPLOYER=0xYourDeployerAddress -export DESTINATION_DOMAIN=1234 -export RECIPIENT_ADDRESS=0x0000000000000000000000000000000000000000000000000000000000000000 export MINIMUM_AMOUNT=0 export CALL_FEE=0 export BRIDGE_SHARE_BPS=10000 @@ -106,13 +112,11 @@ export SALT=0x0000000000000000000000000000000000000000000000000000000000000001 export FEE_VAULT_ADDRESS=0xYourFeeVaultAddress # Optional configuration (defaults to zero) -export DESTINATION_DOMAIN=1234 -export RECIPIENT_ADDRESS=0x0000000000000000000000000000000000000000000000000000000000000000 +export BRIDGE_RECIPIENT=0x0000000000000000000000000000000000000000 +export OTHER_RECIPIENT=0x0000000000000000000000000000000000000000 export MINIMUM_AMOUNT=0 export CALL_FEE=0 export BRIDGE_SHARE_BPS=10000 -export OTHER_RECIPIENT=0x0000000000000000000000000000000000000000 -export HYP_NATIVE_MINTER=0x0000000000000000000000000000000000000000 forge script script/GenerateFeeVaultAlloc.s.sol -vvv ``` @@ -123,19 +127,18 @@ Storage layout is derived from declaration order in `FeeVault.sol`: | Slot | Variable | Encoding | |------|----------|----------| -| `0x0` | `hypNativeMinter` | Address (20 bytes, left-padded) | -| `0x1` | `owner` + `destinationDomain` | `0x0000000000000000` | -| `0x2` | `recipientAddress` | bytes32 | +| `0x0` | `owner` | Address (20 bytes, left-padded) | +| `0x1` | `bridgeRecipient` | Address (20 bytes, left-padded) | +| `0x2` | `otherRecipient` | Address (20 bytes, left-padded) | | `0x3` | `minimumAmount` | uint256 | | `0x4` | `callFee` | uint256 | -| `0x5` | `otherRecipient` | Address (20 bytes, left-padded) | -| `0x6` | `bridgeShareBps` | uint256 | +| `0x5` | `bridgeShareBps` | uint256 | Notes: - `owner` must be non-zero, otherwise no one can administer the vault. - The constructor default (`bridgeShareBps = 10000 when 0`) does **not** apply at genesis. Set `0x2710` (10000) explicitly if you want 100% bridging. The helper script applies this default for you when `BRIDGE_SHARE_BPS=0`. -- `hypNativeMinter` can be zero at genesis, but it must be set before calling `sendToCelestia()`. +- `bridgeRecipient` can be zero at genesis, but it must be set before calling `distribute()`. Example alloc entry (address key without `0x`): @@ -146,13 +149,12 @@ Example alloc entry (address key without `0x`): "balance": "0x0", "code": "0x", "storage": { - "0x0": "0x0000000000000000000000001111111111111111111111111111111111111111", - "0x1": "0x0000000000000000000004d22222222222222222222222222222222222222222", - "0x2": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0": "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x1": "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x2": "0x0000000000000000000000000000000000000000", "0x3": "0x0", "0x4": "0x0", - "0x5": "0x0000000000000000000000000000000000000000", - "0x6": "0x2710" + "0x5": "0x2710" } } } @@ -169,7 +171,7 @@ cast code --rpc-url # Inspect full config in one call cast call \ - "getConfig()(address,uint32,bytes32,uint256,uint256,uint256,address,address)" \ + "getConfig()(address,address,address,uint256,uint256,uint256)" \ --rpc-url # Or read individual storage slots (optional) diff --git a/docs/guide/fee-systems.md b/docs/guide/fee-systems.md index d3740575..aa9dfa76 100644 --- a/docs/guide/fee-systems.md +++ b/docs/guide/fee-systems.md @@ -5,7 +5,7 @@ This guide connects three related components that move or manage native token value: - Base fee redirect: redirects EIP-1559 base fees to a configured sink instead of burning them. -- FeeVault: a contract that accumulates native tokens and can split and bridge them. +- FeeVault: a contract that accumulates native tokens and can split and distribute them. - Native minting precompile: a privileged mint/burn interface for controlled supply changes. These components are independent but commonly deployed together. The base fee redirect is a value transfer, not minting. Native minting is explicit supply change and should remain tightly controlled. @@ -31,17 +31,17 @@ These components are independent but commonly deployed together. The base fee re See `docs/adr/ADR-0001-base-fee-redirect.md` for implementation details. -## FeeVault (contract level) +## FeeVault (contract level, optional) -**Purpose**: Accumulate native tokens and split them between a bridge destination and a secondary recipient. +**Purpose**: Accumulate native tokens and split them between two configurable recipients. + +FeeVault is **optional**. The base fee redirect works with any address — if fees should go to a single destination, point `baseFeeSink` at an EOA or multisig and skip FeeVault entirely. Use FeeVault when you need automatic on-chain splitting, minimum thresholds, or keeper incentives. **Mechanics**: - Receives base fees when `baseFeeSink` is set to the FeeVault address. -- Anyone can trigger `sendToCelestia` (or equivalent) once the minimum threshold is met. -- Splits balance by `bridgeShareBps`, sends the bridge share to `HypNativeMinter`, and transfers the remainder to `otherRecipient`. - -**Why it pairs with base fee redirect**: the redirect funnels base fees into the FeeVault automatically, turning burned fees into recoverable value for treasury or bridging. +- Anyone can trigger `distribute()` once the minimum threshold is met. +- Splits balance by `bridgeShareBps`, sends the bridge share to `bridgeRecipient`, and transfers the remainder to `otherRecipient`. See `docs/contracts/fee_vault.md` for parameters and deployment details. @@ -69,17 +69,19 @@ See `docs/adr/ADR-0002-native-minting-precompile.md` for the full interface and ## How They Fit Together -1. **Base fee redirect** credits base fees to a sink address instead of burning them. -2. **FeeVault** can be that sink, so base fees accumulate in a contract with deterministic split logic. +1. **Base fee redirect** credits base fees to a sink address instead of burning them. The sink can be any address (EOA, multisig, or contract). +2. **FeeVault** is one option for that sink when you need automatic splitting between two recipients. If fees go to a single destination, skip it. 3. **Native minting** is separate and optional; it is used for controlled supply changes (bootstrapping liquidity, treasury operations), not for redirecting fees. In other words, base fee redirect and FeeVault are about re-routing existing value, while native minting explicitly changes total supply. Keep those responsibilities separate and limit minting access to minimize systemic risk. -## Suggested Deployment Pattern +## Suggested Deployment Patterns + +**Simple (no FeeVault):** Set `baseFeeSink` to an EOA or multisig. Fees accumulate there directly. + +**With splitting (FeeVault):** Set `baseFeeSink` to the FeeVault address. Configure the split between `bridgeRecipient` and `otherRecipient`. Use `AdminProxy` as the FeeVault owner if you need a safe, upgradeable admin. -- Set `baseFeeSink` to the FeeVault address. -- Use `AdminProxy` as the `mintAdmin` and FeeVault owner if you need a safe, upgradeable admin. -- Activate both features at a planned height for existing networks. +Both patterns can be combined with native minting if needed. Activate features at a planned height for existing networks. References: