diff --git a/contracts/README.md b/contracts/README.md index 9265b45..70e91a4 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,66 +1,140 @@ -## 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 | 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 -### Gas Snapshots +**Note:** `HYP_NATIVE_MINTER` must be set via `setHypNativeMinter()` after deployment for the vault to be operational. + +### 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 +# 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 | +| `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 new file mode 100644 index 0000000..6d9b986 --- /dev/null +++ b/contracts/script/DeployFeeVault.s.sol @@ -0,0 +1,91 @@ +// 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"); + bytes32 salt = vm.envOr("SALT", bytes32(0)); + + // 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)); + // =================================== + + // Compute address before deployment + address predicted = computeAddress(salt, owner); + console.log("Predicted FeeVault address:", predicted); + + vm.startBroadcast(); + + // 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)) { + 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"); + } + + /// @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("=================================================="); + } +} 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}(); } }