Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 103 additions & 29 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -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 <your_rpc_url> --private-key <your_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 <RPC_URL>

# Deploy for real
forge script script/DeployFeeVault.s.sol:DeployFeeVault \
--rpc-url <RPC_URL> \
--private-key <PRIVATE_KEY> \
--broadcast
```

### Cast
### Post-Deployment: Set HypNativeMinter

After deploying the HypNativeMinter contract, link it to the FeeVault:

```shell
$ cast <subcommand>
cast send <FEEVAULT_ADDRESS> "setHypNativeMinter(address)" <HYP_NATIVE_MINTER_ADDRESS> \
--rpc-url <RPC_URL> \
--private-key <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 |
91 changes: 91 additions & 0 deletions contracts/script/DeployFeeVault.s.sol
Original file line number Diff line number Diff line change
@@ -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("==================================================");
}
}
13 changes: 10 additions & 3 deletions contracts/src/FeeVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface IHypNativeMinter {
}

contract FeeVault {
IHypNativeMinter public immutable hypNativeMinter;
IHypNativeMinter public hypNativeMinter;

address public owner;
uint32 public destinationDomain;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Comment on lines +112 to +114
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The hypNativeMinter address can be updated multiple times by the owner. This was previously an immutable variable, which provided stronger security guarantees. If the intention is to only set this address once after deployment, consider adding a check to prevent it from being updated again. This would reduce the trust placed in the owner and prevent potential malicious updates in the future.

        require(address(hypNativeMinter) == address(0), "FeeVault: minter already set");
        require(_hypNativeMinter != address(0), "FeeVault: zero address");
        hypNativeMinter = IHypNativeMinter(_hypNativeMinter);
        emit HypNativeMinterUpdated(_hypNativeMinter);

}
}
35 changes: 34 additions & 1 deletion contracts/test/FeeVault.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}();
}
}