From ab96c20622c18e2d7f576977b0d060c877c24dd5 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 4 Dec 2025 14:16:10 +0100 Subject: [PATCH 1/5] modify contract for deploying native minter later --- contracts/script/DeployFeeVault.s.sol | 58 +++++++++++++++++++++++++++ contracts/src/FeeVault.sol | 13 ++++-- contracts/test/FeeVault.t.sol | 35 +++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 contracts/script/DeployFeeVault.s.sol diff --git a/contracts/script/DeployFeeVault.s.sol b/contracts/script/DeployFeeVault.s.sol new file mode 100644 index 0000000..e48ef1b --- /dev/null +++ b/contracts/script/DeployFeeVault.s.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Script, console} from "forge-std/Script.sol"; +import {FeeVault} from "../src/FeeVault.sol"; + +contract DeployFeeVault is Script { + function run() external { + // ========== CONFIGURATION ========== + address owner = vm.envAddress("OWNER"); + + // Optional: Post-deployment configuration + 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(10000)); + address otherRecipient = vm.envOr("OTHER_RECIPIENT", address(0)); + // =================================== + + vm.startBroadcast(); + + // Deploy FeeVault + FeeVault feeVault = new FeeVault(owner); + console.log("FeeVault deployed at:", address(feeVault)); + + // Configure if values provided + if (destinationDomain != 0 && recipientAddress != bytes32(0)) { + feeVault.setRecipient(destinationDomain, recipientAddress); + console.log("Recipient set - domain:", destinationDomain); + } + + if (minimumAmount > 0) { + feeVault.setMinimumAmount(minimumAmount); + console.log("Minimum amount set:", minimumAmount); + } + + if (callFee > 0) { + feeVault.setCallFee(callFee); + console.log("Call fee set:", callFee); + } + + if (bridgeShareBps != 10000) { + feeVault.setBridgeShare(bridgeShareBps); + console.log("Bridge share set:", bridgeShareBps, "bps"); + } + + if (otherRecipient != address(0)) { + feeVault.setOtherRecipient(otherRecipient); + console.log("Other recipient set:", otherRecipient); + } + + vm.stopBroadcast(); + + console.log(""); + console.log("NOTE: Call setHypNativeMinter() after deploying HypNativeMinter"); + } +} diff --git a/contracts/src/FeeVault.sol b/contracts/src/FeeVault.sol index 327bfa0..296d33d 100644 --- a/contracts/src/FeeVault.sol +++ b/contracts/src/FeeVault.sol @@ -9,7 +9,7 @@ interface IHypNativeMinter { } contract FeeVault { - IHypNativeMinter public immutable hypNativeMinter; + IHypNativeMinter public hypNativeMinter; address public owner; uint32 public destinationDomain; @@ -23,6 +23,7 @@ contract FeeVault { event SentToCelestia(uint256 amount, bytes32 recipient, bytes32 messageId); event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event HypNativeMinterUpdated(address hypNativeMinter); event RecipientUpdated(uint32 destinationDomain, bytes32 recipientAddress); event MinimumAmountUpdated(uint256 minimumAmount); event CallFeeUpdated(uint256 callFee); @@ -35,8 +36,7 @@ contract FeeVault { _; } - constructor(address _hypNativeMinter, address _owner) { - hypNativeMinter = IHypNativeMinter(_hypNativeMinter); + constructor(address _owner) { owner = _owner; bridgeShareBps = 10000; // Default to 100% bridge emit OwnershipTransferred(address(0), _owner); @@ -45,6 +45,7 @@ contract FeeVault { receive() external payable {} function sendToCelestia() external payable { + require(address(hypNativeMinter) != address(0), "FeeVault: minter not set"); require(msg.value >= callFee, "FeeVault: insufficient fee"); uint256 currentBalance = address(this).balance; @@ -106,4 +107,10 @@ contract FeeVault { otherRecipient = _otherRecipient; emit OtherRecipientUpdated(_otherRecipient); } + + function setHypNativeMinter(address _hypNativeMinter) external onlyOwner { + require(_hypNativeMinter != address(0), "FeeVault: zero address"); + hypNativeMinter = IHypNativeMinter(_hypNativeMinter); + emit HypNativeMinterUpdated(_hypNativeMinter); + } } diff --git a/contracts/test/FeeVault.t.sol b/contracts/test/FeeVault.t.sol index d158589..0af644c 100644 --- a/contracts/test/FeeVault.t.sol +++ b/contracts/test/FeeVault.t.sol @@ -35,9 +35,10 @@ contract FeeVaultTest is Test { user = address(0x1); otherRecipient = address(0x99); mockMinter = new MockHypNativeMinter(); - feeVault = new FeeVault(address(mockMinter), owner); + feeVault = new FeeVault(owner); // Configure contract + feeVault.setHypNativeMinter(address(mockMinter)); feeVault.setRecipient(destination, recipient); feeVault.setMinimumAmount(minAmount); feeVault.setCallFee(fee); @@ -187,5 +188,37 @@ contract FeeVaultTest is Test { vm.prank(user); vm.expectRevert("FeeVault: caller is not the owner"); feeVault.setOtherRecipient(user); + + vm.prank(user); + vm.expectRevert("FeeVault: caller is not the owner"); + feeVault.setHypNativeMinter(address(0x123)); + } + + function test_SetHypNativeMinter() public { + MockHypNativeMinter newMinter = new MockHypNativeMinter(); + feeVault.setHypNativeMinter(address(newMinter)); + assertEq(address(feeVault.hypNativeMinter()), address(newMinter)); + } + + function test_SetHypNativeMinter_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); + freshVault.setRecipient(destination, recipient); + freshVault.setMinimumAmount(minAmount); + freshVault.setCallFee(fee); + freshVault.setOtherRecipient(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}(); } } From 868986d050c30b897b1b0149c3cb6e3e9f094864 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 4 Dec 2025 14:35:46 +0100 Subject: [PATCH 2/5] create2 --- contracts/README.md | 125 ++++++++++++++++++++------ contracts/script/DeployFeeVault.s.sol | 52 ++++++++++- 2 files changed, 146 insertions(+), 31 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index 9265b45..181075c 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,66 +1,133 @@ -## Foundry +# EV-Reth Contracts -**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** +Smart contracts for EV-Reth, including the FeeVault for bridging collected fees to Celestia. -Foundry consists of: +## FeeVault -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. +The FeeVault contract collects base fees and bridges them to Celestia via Hyperlane. It supports: -## Documentation +- Configurable fee splitting between bridge and another recipient +- Minimum amount thresholds before bridging +- Call fee for incentivizing bridge calls +- Owner-controlled configuration -https://book.getfoundry.sh/ +## Prerequisites -## Usage +- [Foundry](https://book.getfoundry.sh/getting-started/installation) -### Build +## Build ```shell -$ forge build +forge build ``` -### Test +## Test ```shell -$ forge test +forge test ``` -### Format +## Deploying FeeVault -```shell -$ forge fmt -``` +The FeeVault uses CREATE2 for deterministic addresses across chains. + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `OWNER` | Yes | Owner address (can configure the vault) | +| `SALT` | No | CREATE2 salt (default: `0x0`). Use any bytes32 value | +| `DESTINATION_DOMAIN` | No | Hyperlane destination chain ID | +| `RECIPIENT_ADDRESS` | No | Recipient on destination chain (bytes32, left-padded) | +| `MINIMUM_AMOUNT` | No | Minimum wei to bridge | +| `CALL_FEE` | No | Fee in wei for calling `sendToCelestia()` | +| `BRIDGE_SHARE_BPS` | No | Basis points to bridge (default: 10000 = 100%) | +| `OTHER_RECIPIENT` | No | Address to receive non-bridged portion | -### Gas Snapshots +### Choosing a Salt + +Any bytes32 value works as a salt. Common approaches: ```shell -$ forge snapshot +# Simple approach - just use a version number +export SALT=0x0000000000000000000000000000000000000000000000000000000000000001 + +# Or hash a meaningful string +export SALT=$(cast keccak "FeeVault-v1") ``` -### Anvil +### Compute Address Before Deploying + +To see what address will be deployed to without actually deploying: ```shell -$ anvil +export OWNER=0xYourOwnerAddress +export SALT=0x0000000000000000000000000000000000000000000000000000000000000001 +export DEPLOYER=0xYourDeployerAddress # The address that will run the script + +forge script script/DeployFeeVault.s.sol:ComputeFeeVaultAddress ``` ### Deploy ```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +# Required +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 +export OTHER_RECIPIENT=0xOtherAddress + +# Dry run (no broadcast) +forge script script/DeployFeeVault.s.sol:DeployFeeVault \ + --rpc-url + +# Deploy for real +forge script script/DeployFeeVault.s.sol:DeployFeeVault \ + --rpc-url \ + --private-key \ + --broadcast ``` -### Cast +### Post-Deployment: Set HypNativeMinter + +After deploying the HypNativeMinter contract, link it to the FeeVault: ```shell -$ cast +cast send "setHypNativeMinter(address)" \ + --rpc-url \ + --private-key ``` -### Help +### 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 -$ forge --help -$ anvil --help -$ cast --help +# If you have the raw 20-byte hex address +cast to-bytes32 0x1234567890abcdef1234567890abcdef12345678 ``` + +## 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 | +| `setBridgeShare(uint256)` | Set bridge percentage (basis points) | +| `setOtherRecipient(address)` | Set recipient for non-bridged funds | +| `transferOwnership(address)` | Transfer contract ownership | diff --git a/contracts/script/DeployFeeVault.s.sol b/contracts/script/DeployFeeVault.s.sol index e48ef1b..548df04 100644 --- a/contracts/script/DeployFeeVault.s.sol +++ b/contracts/script/DeployFeeVault.s.sol @@ -8,6 +8,7 @@ contract DeployFeeVault is Script { function run() external { // ========== CONFIGURATION ========== address owner = vm.envAddress("OWNER"); + bytes32 salt = vm.envOr("SALT", bytes32(0)); // Optional: Post-deployment configuration uint32 destinationDomain = uint32(vm.envOr("DESTINATION_DOMAIN", uint256(0))); @@ -18,11 +19,16 @@ contract DeployFeeVault is Script { address otherRecipient = vm.envOr("OTHER_RECIPIENT", address(0)); // =================================== + // Compute address before deployment + address predicted = computeAddress(salt, owner); + console.log("Predicted FeeVault address:", predicted); + vm.startBroadcast(); - // Deploy FeeVault - FeeVault feeVault = new FeeVault(owner); + // Deploy FeeVault with CREATE2 + FeeVault feeVault = new FeeVault{salt: salt}(owner); console.log("FeeVault deployed at:", address(feeVault)); + require(address(feeVault) == predicted, "Address mismatch"); // Configure if values provided if (destinationDomain != 0 && recipientAddress != bytes32(0)) { @@ -55,4 +61,46 @@ contract DeployFeeVault is Script { console.log(""); console.log("NOTE: Call setHypNativeMinter() after deploying HypNativeMinter"); } + + /// @notice Compute the CREATE2 address for FeeVault deployment + function computeAddress(bytes32 salt, address owner) public view returns (address) { + bytes32 bytecodeHash = keccak256( + abi.encodePacked(type(FeeVault).creationCode, abi.encode(owner)) + ); + return address( + uint160( + uint256( + keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, bytecodeHash)) + ) + ) + ); + } +} + +/// @notice Standalone script to compute FeeVault address without deploying +contract ComputeFeeVaultAddress is Script { + function run() external view { + address owner = vm.envAddress("OWNER"); + bytes32 salt = vm.envOr("SALT", bytes32(0)); + address deployer = vm.envAddress("DEPLOYER"); + + bytes32 bytecodeHash = keccak256( + abi.encodePacked(type(FeeVault).creationCode, abi.encode(owner)) + ); + + address predicted = address( + uint160( + uint256( + keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, bytecodeHash)) + ) + ) + ); + + console.log("========== FeeVault Address Computation =========="); + console.log("Owner:", owner); + console.log("Salt:", vm.toString(salt)); + console.log("Deployer:", deployer); + console.log("Predicted address:", predicted); + console.log("=================================================="); + } } From 7fca23f72469132fda6f01801706f943af153b59 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 4 Dec 2025 14:38:49 +0100 Subject: [PATCH 3/5] update readme --- contracts/README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index 181075c..fac6a5e 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -33,16 +33,20 @@ The FeeVault uses CREATE2 for deterministic addresses across chains. ### Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `OWNER` | Yes | Owner address (can configure the vault) | -| `SALT` | No | CREATE2 salt (default: `0x0`). Use any bytes32 value | -| `DESTINATION_DOMAIN` | No | Hyperlane destination chain ID | -| `RECIPIENT_ADDRESS` | No | Recipient on destination chain (bytes32, left-padded) | -| `MINIMUM_AMOUNT` | No | Minimum wei to bridge | -| `CALL_FEE` | No | Fee in wei for calling `sendToCelestia()` | -| `BRIDGE_SHARE_BPS` | No | Basis points to bridge (default: 10000 = 100%) | -| `OTHER_RECIPIENT` | No | Address to receive non-bridged portion | +| Variable | Deploy | Operational | Description | +|----------|--------|-------------|-------------| +| `OWNER` | Required | - | Owner address (can configure the vault) | +| `SALT` | Optional | - | CREATE2 salt (default: `0x0`). Use any bytes32 value | +| `DESTINATION_DOMAIN` | Optional | Required | Hyperlane destination chain ID | +| `RECIPIENT_ADDRESS` | Optional | Required | Recipient on destination chain (bytes32, left-padded) | +| `MINIMUM_AMOUNT` | Optional | Optional | Minimum wei to bridge | +| `CALL_FEE` | Optional | Optional | Fee in wei for calling `sendToCelestia()` | +| `BRIDGE_SHARE_BPS` | Optional | Optional | Basis points to bridge (default: 10000 = 100%) | +| `OTHER_RECIPIENT` | Optional | Required* | Address to receive non-bridged portion | + +*`OTHER_RECIPIENT` is required only if `BRIDGE_SHARE_BPS` < 10000 + +**Note:** `HYP_NATIVE_MINTER` must be set via `setHypNativeMinter()` after deployment for the vault to be operational. ### Choosing a Salt From adcc953a3112e5fe753614257673138de270d58f Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 4 Dec 2025 14:59:44 +0100 Subject: [PATCH 4/5] adjust readme --- contracts/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index fac6a5e..70e91a4 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -118,10 +118,13 @@ The `recipientAddress` must be a bytes32. To convert a bech32 Cosmos address: Example using cast: ```shell -# If you have the raw 20-byte hex address -cast to-bytes32 0x1234567890abcdef1234567890abcdef12345678 +# 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: From 54116499a1153f12175fd572fd29bb21917b7a1e Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Thu, 4 Dec 2025 15:02:36 +0100 Subject: [PATCH 5/5] fmt --- contracts/script/DeployFeeVault.s.sol | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/contracts/script/DeployFeeVault.s.sol b/contracts/script/DeployFeeVault.s.sol index 548df04..6d9b986 100644 --- a/contracts/script/DeployFeeVault.s.sol +++ b/contracts/script/DeployFeeVault.s.sol @@ -64,16 +64,8 @@ contract DeployFeeVault is Script { /// @notice Compute the CREATE2 address for FeeVault deployment function computeAddress(bytes32 salt, address owner) public view returns (address) { - bytes32 bytecodeHash = keccak256( - abi.encodePacked(type(FeeVault).creationCode, abi.encode(owner)) - ); - return address( - uint160( - uint256( - keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, bytecodeHash)) - ) - ) - ); + bytes32 bytecodeHash = keccak256(abi.encodePacked(type(FeeVault).creationCode, abi.encode(owner))); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, bytecodeHash))))); } } @@ -84,17 +76,10 @@ contract ComputeFeeVaultAddress is Script { bytes32 salt = vm.envOr("SALT", bytes32(0)); address deployer = vm.envAddress("DEPLOYER"); - bytes32 bytecodeHash = keccak256( - abi.encodePacked(type(FeeVault).creationCode, abi.encode(owner)) - ); - - address predicted = address( - uint160( - uint256( - keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, bytecodeHash)) - ) - ) - ); + bytes32 bytecodeHash = keccak256(abi.encodePacked(type(FeeVault).creationCode, abi.encode(owner))); + + address predicted = + address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), deployer, salt, bytecodeHash))))); console.log("========== FeeVault Address Computation =========="); console.log("Owner:", owner);