Skip to content

Commit 1f23e98

Browse files
committed
simplification
1 parent ed8ae39 commit 1f23e98

3 files changed

Lines changed: 73 additions & 131 deletions

File tree

contracts/src/FeeVault.sol

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ contract FeeVault {
1919
uint256 public callFee;
2020

2121
// Split accounting
22-
uint256 public otherBucketBalance;
23-
uint256 public burnShareBps; // Basis points (0-10000) for burn share
22+
address public otherRecipient;
23+
uint256 public bridgeShareBps; // Basis points (0-10000) for bridge share
2424

2525
event SentToCelestia(uint256 amount, bytes32 recipient, bytes32 messageId);
2626
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
2727
event RecipientUpdated(uint32 destinationDomain, bytes32 recipientAddress);
2828
event MinimumAmountUpdated(uint256 minimumAmount);
2929
event CallFeeUpdated(uint256 callFee);
30-
event BurnShareUpdated(uint256 burnShareBps);
31-
event OtherWithdrawn(address indexed recipient, uint256 amount);
32-
event FundsSplit(uint256 totalNew, uint256 burnAmount, uint256 otherAmount);
30+
event BridgeShareUpdated(uint256 bridgeShareBps);
31+
event OtherRecipientUpdated(address otherRecipient);
32+
event FundsSplit(uint256 totalNew, uint256 bridgeAmount, uint256 otherAmount);
3333

3434
modifier onlyOwner() {
3535
require(msg.sender == owner, "FeeVault: caller is not the owner");
@@ -39,7 +39,7 @@ contract FeeVault {
3939
constructor(address _hypNativeMinter, address _owner) {
4040
hypNativeMinter = IHypNativeMinter(_hypNativeMinter);
4141
owner = _owner;
42-
burnShareBps = 10000; // Default to 100% burn
42+
bridgeShareBps = 10000; // Default to 100% bridge
4343
emit OwnershipTransferred(address(0), _owner);
4444
}
4545

@@ -48,31 +48,31 @@ contract FeeVault {
4848
function sendToCelestia() external payable {
4949
require(msg.value >= callFee, "FeeVault: insufficient fee");
5050

51-
// Calculate new funds available for splitting
52-
// Total Balance - Already Allocated to Other Bucket
5351
uint256 currentBalance = address(this).balance;
54-
require(currentBalance >= otherBucketBalance, "FeeVault: accounting error");
55-
56-
uint256 newFunds = currentBalance - otherBucketBalance;
5752

5853
// Calculate split
59-
uint256 burnAmount = (newFunds * burnShareBps) / 10000;
60-
uint256 otherAmount = newFunds - burnAmount;
54+
uint256 bridgeAmount = (currentBalance * bridgeShareBps) / 10000;
55+
uint256 otherAmount = currentBalance - bridgeAmount;
56+
57+
require(bridgeAmount >= minimumAmount, "FeeVault: minimum amount not met");
6158

62-
require(burnAmount >= minimumAmount, "FeeVault: minimum amount not met");
59+
emit FundsSplit(currentBalance, bridgeAmount, otherAmount);
6360

64-
// Update accounting
65-
otherBucketBalance += otherAmount;
66-
emit FundsSplit(newFunds, burnAmount, otherAmount);
61+
// Send other amount if any
62+
if (otherAmount > 0) {
63+
require(otherRecipient != address(0), "FeeVault: other recipient not set");
64+
(bool success, ) = otherRecipient.call{value: otherAmount}("");
65+
require(success, "FeeVault: transfer failed");
66+
}
6767

68-
// Bridge the burn amount
69-
bytes32 messageId = hypNativeMinter.transferRemote{value: burnAmount}(
68+
// Bridge the bridge amount
69+
bytes32 messageId = hypNativeMinter.transferRemote{value: bridgeAmount}(
7070
destinationDomain,
7171
recipientAddress,
72-
burnAmount
72+
bridgeAmount
7373
);
7474

75-
emit SentToCelestia(burnAmount, recipientAddress, messageId);
75+
emit SentToCelestia(bridgeAmount, recipientAddress, messageId);
7676
}
7777

7878
// Admin functions
@@ -99,19 +99,15 @@ contract FeeVault {
9999
emit CallFeeUpdated(_callFee);
100100
}
101101

102-
function setBurnShare(uint256 _burnShareBps) external onlyOwner {
103-
require(_burnShareBps <= 10000, "FeeVault: invalid bps");
104-
burnShareBps = _burnShareBps;
105-
emit BurnShareUpdated(_burnShareBps);
102+
function setBridgeShare(uint256 _bridgeShareBps) external onlyOwner {
103+
require(_bridgeShareBps <= 10000, "FeeVault: invalid bps");
104+
bridgeShareBps = _bridgeShareBps;
105+
emit BridgeShareUpdated(_bridgeShareBps);
106106
}
107107

108-
function withdrawOther(address payable _recipient, uint256 _amount) external onlyOwner {
109-
require(_amount <= otherBucketBalance, "FeeVault: insufficient other balance");
110-
otherBucketBalance -= _amount;
111-
112-
(bool success, ) = _recipient.call{value: _amount}("");
113-
require(success, "FeeVault: transfer failed");
114-
115-
emit OtherWithdrawn(_recipient, _amount);
108+
function setOtherRecipient(address _otherRecipient) external onlyOwner {
109+
require(_otherRecipient != address(0), "FeeVault: zero address");
110+
otherRecipient = _otherRecipient;
111+
emit OtherRecipientUpdated(_otherRecipient);
116112
}
117113
}

contracts/test/FeeVault.t.sol

Lines changed: 26 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ contract FeeVaultTest is Test {
2323
MockHypNativeMinter public mockMinter;
2424
address public owner;
2525
address public user;
26+
address public otherRecipient;
2627

2728
uint32 public destination = 1234;
2829
bytes32 public recipient = bytes32(uint256(0xdeadbeef));
@@ -32,14 +33,16 @@ contract FeeVaultTest is Test {
3233
function setUp() public {
3334
owner = address(this);
3435
user = address(0x1);
36+
otherRecipient = address(0x99);
3537
mockMinter = new MockHypNativeMinter();
3638
feeVault = new FeeVault(address(mockMinter), owner);
3739

3840
// Configure contract
3941
feeVault.setRecipient(destination, recipient);
4042
feeVault.setMinimumAmount(minAmount);
4143
feeVault.setCallFee(fee);
42-
// Default burn share is 10000 (100%)
44+
feeVault.setOtherRecipient(otherRecipient);
45+
// Default bridge share is 10000 (100%)
4346
}
4447

4548
function test_Receive() public {
@@ -49,7 +52,7 @@ contract FeeVaultTest is Test {
4952
assertEq(address(feeVault).balance, amount, "Balance mismatch");
5053
}
5154

52-
function test_SendToCelestia_100PercentBurn() public {
55+
function test_SendToCelestia_100PercentBridge() public {
5356
// Fund with minAmount
5457
(bool success, ) = address(feeVault).call{value: minAmount}("");
5558
require(success);
@@ -68,102 +71,34 @@ contract FeeVaultTest is Test {
6871
feeVault.sendToCelestia{value: fee}();
6972

7073
assertEq(address(feeVault).balance, 0, "Collector should be empty");
71-
assertEq(feeVault.otherBucketBalance(), 0, "Other bucket should be empty");
7274
}
7375

7476
function test_SendToCelestia_Split5050() public {
7577
// Set split to 50%
76-
feeVault.setBurnShare(5000);
78+
feeVault.setBridgeShare(5000);
7779

7880
// Fund with 2 ether.
7981
// Fee is 0.1 ether.
8082
// Total new funds = 2.1 ether.
81-
// Burn = 1.05 ether. Other = 1.05 ether.
83+
// Bridge = 1.05 ether. Other = 1.05 ether.
8284
// Min amount is 1 ether, so 1.05 >= 1.0 is OK.
8385
uint256 fundAmount = 2 ether;
8486
(bool success, ) = address(feeVault).call{value: fundAmount}("");
8587
require(success);
8688

8789
uint256 totalNew = fundAmount + fee;
88-
uint256 expectedBurn = totalNew / 2;
89-
uint256 expectedOther = totalNew - expectedBurn;
90+
uint256 expectedBridge = totalNew / 2;
91+
uint256 expectedOther = totalNew - expectedBridge;
9092

9193
vm.expectEmit(true, true, true, true, address(mockMinter));
92-
emit MockHypNativeMinter.TransferRemoteCalled(destination, recipient, expectedBurn);
94+
emit MockHypNativeMinter.TransferRemoteCalled(destination, recipient, expectedBridge);
9395

9496
vm.prank(user);
9597
vm.deal(user, fee);
9698
feeVault.sendToCelestia{value: fee}();
9799

98-
assertEq(address(feeVault).balance, expectedOther, "Collector should hold other funds");
99-
assertEq(feeVault.otherBucketBalance(), expectedOther, "Other bucket accounting incorrect");
100-
}
101-
102-
function test_SendToCelestia_AccumulateOther() public {
103-
feeVault.setBurnShare(5000); // 50%
104-
105-
// First call: 2 ether + 0.1 fee = 2.1 total. 1.05 burn, 1.05 other.
106-
(bool success, ) = address(feeVault).call{value: 2 ether}("");
107-
require(success);
108-
109-
vm.prank(user);
110-
vm.deal(user, fee);
111-
feeVault.sendToCelestia{value: fee}();
112-
113-
uint256 firstOther = 1.05 ether;
114-
assertEq(feeVault.otherBucketBalance(), firstOther);
115-
116-
// Second call: 2 ether + 0.1 fee = 2.1 total NEW funds.
117-
// Previous balance: 1.05. New balance before split: 1.05 + 2.1 = 3.15.
118-
// Logic: newFunds = balance (3.15) - otherBucket (1.05) = 2.1. Correct.
119-
(success, ) = address(feeVault).call{value: 2 ether}("");
120-
require(success);
121-
122-
vm.prank(user);
123-
vm.deal(user, fee);
124-
feeVault.sendToCelestia{value: fee}();
125-
126-
uint256 secondOther = 1.05 ether;
127-
assertEq(feeVault.otherBucketBalance(), firstOther + secondOther);
128-
assertEq(address(feeVault).balance, firstOther + secondOther);
129-
}
130-
131-
function test_WithdrawOther() public {
132-
feeVault.setBurnShare(5000);
133-
(bool success, ) = address(feeVault).call{value: 2 ether}("");
134-
require(success);
135-
136-
vm.prank(user);
137-
vm.deal(user, fee);
138-
feeVault.sendToCelestia{value: fee}();
139-
140-
uint256 otherAmount = 1.05 ether;
141-
assertEq(feeVault.otherBucketBalance(), otherAmount);
142-
143-
// Withdraw half
144-
uint256 withdrawAmount = 0.5 ether;
145-
address payable recipientAddr = payable(address(0x99));
146-
147-
feeVault.withdrawOther(recipientAddr, withdrawAmount);
148-
149-
assertEq(feeVault.otherBucketBalance(), otherAmount - withdrawAmount);
150-
assertEq(recipientAddr.balance, withdrawAmount);
151-
assertEq(address(feeVault).balance, otherAmount - withdrawAmount);
152-
}
153-
154-
function test_WithdrawOther_Insufficient() public {
155-
feeVault.setBurnShare(5000);
156-
(bool success, ) = address(feeVault).call{value: 2 ether}("");
157-
require(success);
158-
159-
vm.prank(user);
160-
vm.deal(user, fee);
161-
feeVault.sendToCelestia{value: fee}();
162-
163-
uint256 otherAmount = 1.05 ether;
164-
165-
vm.expectRevert("FeeVault: insufficient other balance");
166-
feeVault.withdrawOther(payable(owner), otherAmount + 1);
100+
assertEq(address(feeVault).balance, 0, "Collector should be empty");
101+
assertEq(otherRecipient.balance, expectedOther, "Other recipient should receive funds");
167102
}
168103

169104
function test_SendToCelestia_InsufficientFee() public {
@@ -175,10 +110,10 @@ contract FeeVaultTest is Test {
175110
}
176111

177112
function test_SendToCelestia_BelowMinAmount_AfterSplit() public {
178-
feeVault.setBurnShare(1000); // 10% burn
113+
feeVault.setBridgeShare(1000); // 10% bridge
179114

180115
// Fund with 2 ether. Total 2.1.
181-
// Burn = 0.21. Other = 1.89.
116+
// Bridge = 0.21. Other = 1.89.
182117
// Min amount is 1.0. 0.21 < 1.0. Should revert.
183118
(bool success, ) = address(feeVault).call{value: 2 ether}("");
184119
require(success);
@@ -212,14 +147,20 @@ contract FeeVaultTest is Test {
212147
vm.expectRevert("FeeVault: caller is not the owner");
213148
feeVault.setCallFee(2 ether);
214149

215-
// Test setBurnShare
150+
// Test setBridgeShare
216151
vm.prank(newOwner);
217-
feeVault.setBurnShare(5000);
218-
assertEq(feeVault.burnShareBps(), 5000);
152+
feeVault.setBridgeShare(5000);
153+
assertEq(feeVault.bridgeShareBps(), 5000);
219154

220155
vm.prank(newOwner);
221156
vm.expectRevert("FeeVault: invalid bps");
222-
feeVault.setBurnShare(10001);
157+
feeVault.setBridgeShare(10001);
158+
159+
// Test setOtherRecipient
160+
vm.prank(newOwner);
161+
address newOther = address(0x88);
162+
feeVault.setOtherRecipient(newOther);
163+
assertEq(feeVault.otherRecipient(), newOther);
223164
}
224165

225166
function test_AdminAccessControl() public {
@@ -241,10 +182,10 @@ contract FeeVaultTest is Test {
241182

242183
vm.prank(user);
243184
vm.expectRevert("FeeVault: caller is not the owner");
244-
feeVault.setBurnShare(5000);
185+
feeVault.setBridgeShare(5000);
245186

246187
vm.prank(user);
247188
vm.expectRevert("FeeVault: caller is not the owner");
248-
feeVault.withdrawOther(payable(user), 1);
189+
feeVault.setOtherRecipient(user);
249190
}
250191
}

docs/contracts/fee_vault.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
# FeeVault Design & Use Case
22

33
## Overview
4-
The `FeeVault` is a specialized smart contract designed to accumulate native tokens (ETH or gas tokens) and periodically bridge them to a specific destination chain (e.g., Celestia) via the Hyperlane protocol.
4+
The `FeeVault` is a specialized smart contract designed to accumulate native tokens (ETH or gas tokens) and automatically split them between bridging to a specific destination chain (e.g., Celestia) and sending to a secondary recipient.
55

66
## Use Case
7-
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.
7+
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).
88

99
1. **Fee Accumulation**: The contract receives funds from:
1010
- **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.
1111
- **Direct Transfers**: Anyone can send native tokens to the contract via the `receive()` function.
1212

13-
2. **Bridging to Celestia**: Once sufficient funds have accumulated, any user can trigger the `sendToCelestia()` function. This converts the native balance into a cross-chain message that mints tokens on the destination chain (Celestia) via the `HypNativeMinter`.
13+
2. **Splitting & Bridging**: Once sufficient funds have accumulated, any user can trigger the `sendToCelestia()` function. This splits the funds based on a configured percentage:
14+
- **Bridge Share**: Sent to the destination chain (Celestia) via the `HypNativeMinter`.
15+
- **Other Share**: Immediately transferred to a configured `otherRecipient` address.
1416

1517
## Architecture
1618

@@ -19,9 +21,10 @@ This contract serves as a **fee sink** and **bridging mechanism** for a rollup o
1921
- **Admin Controls**: An `owner` manages critical parameters to ensure security and flexibility.
2022

2123
### Key Features
22-
- **Stored Recipient**: The destination domain (Chain ID) and recipient address are stored in the contract state, preventing callers from redirecting funds to arbitrary addresses.
23-
- **Minimum Threshold**: A `minimumAmount` ensures that bridging only occurs when it is economically viable (avoiding dust transfers).
24-
- **Caller Incentive/Fee**: A `callFee` is required to trigger the bridge function. This fee is added to the total bridged amount, effectively making the caller pay for the privilege (or potentially subsidizing it if the protocol design changes). *Note: Currently, the caller pays the fee, which is added to the pot.*
24+
- **Automatic Splitting**: Funds are split automatically upon calling `sendToCelestia`. No manual withdrawal is required for the secondary recipient.
25+
- **Stored Recipient**: The destination domain (Chain ID) and recipient address are stored in the contract state.
26+
- **Minimum Threshold**: A `minimumAmount` ensures that bridging only occurs when it is economically viable.
27+
- **Caller Incentive/Fee**: A `callFee` is required to trigger the bridge function.
2528

2629
## Workflow
2730

@@ -30,22 +33,24 @@ This contract serves as a **fee sink** and **bridging mechanism** for a rollup o
3033
- Users/Contracts send ETH to `FeeVault`.
3134

3235
2. **Trigger Phase**:
33-
- A keeper or user notices the balance exceeds `minimumAmount`.
36+
- A keeper or user notices the bridge portion exceeds `minimumAmount`.
3437
- They call `sendToCelestia{value: callFee}()`.
3538
- The contract checks:
3639
- `msg.value >= callFee`
37-
- `address(this).balance >= minimumAmount`
40+
- `bridgeAmount >= minimumAmount`
3841

3942
3. **Execution Phase**:
40-
- The contract calls `hypNativeMinter.transferRemote`.
41-
- The entire balance (accumulated funds + `callFee`) is sent as `msg.value` to the minter.
42-
- The minter burns/locks the tokens and sends a message to the destination chain.
43-
- `SentToCelestia` event is emitted.
43+
- The contract calculates the split based on `bridgeShareBps`.
44+
- **Other Share**: Transferred immediately to `otherRecipient`.
45+
- **Bridge Share**: Bridged to Celestia via `hypNativeMinter.transferRemote`.
46+
- `SentToCelestia` and `FundsSplit` events are emitted.
4447

4548
## Configuration Parameters
4649
| Parameter | Description | Managed By |
4750
|-----------|-------------|------------|
4851
| `destinationDomain` | Hyperlane domain ID of the target chain (e.g., Celestia). | Owner |
4952
| `recipientAddress` | Address on the target chain to receive funds. | Owner |
50-
| `minimumAmount` | Minimum balance required to trigger a bridge tx. | Owner |
53+
| `minimumAmount` | Minimum bridge amount required to trigger a bridge tx. | Owner |
5154
| `callFee` | Fee required from the caller to execute the function. | Owner |
55+
| `bridgeShareBps` | Basis points (0-10000) determining the % of funds to bridge. | Owner |
56+
| `otherRecipient` | Address to receive the non-bridged portion of funds. | Owner |

0 commit comments

Comments
 (0)