From 2481fa9cddaf316d7b3ae21ed124ceade68e9300 Mon Sep 17 00:00:00 2001 From: adu Date: Mon, 14 Oct 2024 16:31:34 +0800 Subject: [PATCH] feat: redesign and implement the reward vault (#102) * feat: redesign and implement the reward vault * docs: update design doc * feat: implement RewardVault * feat: add message handler for submitReward * fix: wrong imports * save doc changes * fix: build failure * fix: tests and scripts * fix: slither detector * fix: test * test: add test for withdrawReward * refactor: CustomProxyAdmin should inherit ICustomProxyAdmin * docs: fix comment * refactor: claim => withdrawPrincipal, withdrawPrincipalFromExocore => claimPrincipalFromExocore * docs: add comments * docs: add comments * docs: update docs * docs: update design doc * test: check expected events * test: add unit tests for vault and rewardvault * docs: remove outdated entries in json * docs: fix comments * fix: ai comments * fix: resolve AI comments * Update src/storage/RewardVaultStorage.sol Co-authored-by: Max <82761650+MaxMustermann2@users.noreply.github.com> --------- Co-authored-by: Max <82761650+MaxMustermann2@users.noreply.github.com> --- README.md | 2 +- docs/client-chain-contracts-design.md | 104 ++++------ docs/reward-vault.md | 183 +++++++++++++++++ script/10_DeployExocoreGatewayOnly.s.sol | 4 +- script/12_RedeployClientChainGateway.s.sol | 10 + ...equisities.s.sol => 1_Prerequisites.s.sol} | 9 +- script/2_DeployBoth.s.sol | 42 ++-- script/5_Withdraw.s.sol | 2 +- script/7_DeployBootstrap.s.sol | 18 +- script/BaseScript.sol | 17 +- script/TestPrecompileErrorFixed.s.sol | 7 +- script/deployBeaconOracle.s.sol | 2 +- script/prerequisiteContracts.json | 6 +- src/core/BaseRestakingController.sol | 48 ++++- src/core/Bootstrap.sol | 30 ++- src/core/ClientChainGateway.sol | 24 ++- src/core/ClientGatewayLzReceiver.sol | 19 +- src/core/ExoCapsule.sol | 10 +- src/core/ExocoreGateway.sol | 24 ++- src/core/LSTRestakingController.sol | 18 +- src/core/RewardVault.sol | 68 +++++++ src/core/Vault.sol | 18 +- src/interfaces/IBaseRestakingController.sol | 23 ++- src/interfaces/ICustomProxyAdmin.sol | 4 +- src/interfaces/IExoCapsule.sol | 6 +- src/interfaces/ILSTRestakingController.sol | 15 +- src/interfaces/IRewardVault.sol | 53 +++++ src/interfaces/IVault.sol | 14 +- src/interfaces/precompiles/IClaimReward.sol | 33 --- src/interfaces/precompiles/IReward.sol | 43 ++++ src/libraries/ActionAttributes.sol | 4 + src/libraries/Errors.sol | 7 + src/storage/BootstrapStorage.sol | 4 +- src/storage/ClientChainGatewayStorage.sol | 20 +- src/storage/ExocoreGatewayStorage.sol | 17 +- src/storage/GatewayStorage.sol | 7 +- src/storage/RewardVaultStorage.sol | 43 ++++ src/storage/VaultStorage.sol | 27 ++- src/utils/CustomProxyAdmin.sol | 12 +- test/foundry/DepositWithdrawPrinciple.t.sol | 22 +- test/foundry/ExocoreDeployer.t.sol | 23 ++- test/foundry/Governance.t.sol | 8 + test/foundry/WithdrawReward.t.sol | 184 +++++++++++++++-- test/foundry/unit/Bootstrap.t.sol | 63 +++--- test/foundry/unit/ClientChainGateway.t.sol | 18 +- test/foundry/unit/CustomProxyAdmin.t.sol | 2 +- test/foundry/unit/ExocoreGateway.t.sol | 14 +- test/foundry/unit/RewardVault.t.sol | 158 ++++++++++++++ test/foundry/unit/Vault.t.sol | 192 ++++++++++++++++++ test/mocks/ClaimRewardMock.sol | 18 -- test/mocks/ExocoreGatewayMock.sol | 94 +++++---- test/mocks/RewardMock.sol | 65 ++++++ 52 files changed, 1487 insertions(+), 371 deletions(-) create mode 100644 docs/reward-vault.md rename script/{1_Prerequisities.s.sol => 1_Prerequisites.s.sol} (92%) create mode 100644 src/core/RewardVault.sol create mode 100644 src/interfaces/IRewardVault.sol delete mode 100644 src/interfaces/precompiles/IClaimReward.sol create mode 100644 src/interfaces/precompiles/IReward.sol create mode 100644 src/storage/RewardVaultStorage.sol create mode 100644 test/foundry/unit/RewardVault.t.sol create mode 100644 test/foundry/unit/Vault.t.sol delete mode 100644 test/mocks/ClaimRewardMock.sol create mode 100644 test/mocks/RewardMock.sol diff --git a/README.md b/README.md index 9efe9e18..31c839d3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ We have several tests against the local anvil testnet. To pass these tests, you Then run the test like: -`forge script script/1_Prerequisities.s.sol --broadcast -vvvv` +`forge script script/1_Prerequisites.s.sol --broadcast -vvvv` `forge test -vvvv --match-path/--match-path xxxx` diff --git a/docs/client-chain-contracts-design.md b/docs/client-chain-contracts-design.md index 74f6c8e1..e159d48c 100644 --- a/docs/client-chain-contracts-design.md +++ b/docs/client-chain-contracts-design.md @@ -148,29 +148,34 @@ interface IVault { /// @notice Deposits a specified amount into the vault. /// @param depositor The address initiating the deposit. /// @param amount The amount to be deposited. - function deposit(address depositor, uint256 amount) external payable; + function deposit(address depositor, uint256 amount) external; - /// @notice Updates the principal balance for a user. - /// @param user The address of the user whose principal balance is being updated. - /// @param lastlyUpdatedPrincipalBalance The new principal balance for the user. - function updatePrincipalBalance(address user, uint256 lastlyUpdatedPrincipalBalance) external; - - /// @notice Updates the reward balance for a user. - /// @param user The address of the user whose reward balance is being updated. - /// @param lastlyUpdatedRewardBalance The new reward balance for the user. - function updateRewardBalance(address user, uint256 lastlyUpdatedRewardBalance) external; - - /// @notice Updates the withdrawable balance for a user. - /// @param user The address of the user whose withdrawable balance is being updated. - /// @param unlockPrincipalAmount The amount of principal to be unlocked. - /// @param unlockRewardAmount The amount of reward to be unlocked. - function updateWithdrawableBalance(address user, uint256 unlockPrincipalAmount, uint256 unlockRewardAmount) - external; + /// @notice Unlock and increase the withdrawable balance of a user for later withdrawal. + /// @param staker The address of the staker whose principal balance is being unlocked. + /// @param amount The amount of principal to be unlocked. + function unlockPrincipal(address staker, uint256 amount) external; /// @notice Returns the address of the underlying token. /// @return The address of the underlying token. function getUnderlyingToken() external returns (address); + /// @notice Sets the TVL limit for the vault. + /// @param tvlLimit_ The new TVL limit for the vault. + /// @dev It is possible to reduce or increase the TVL limit. Even if the consumed TVL limit is more than the new TVL + /// limit, this transaction will go through and future deposits will be blocked until sufficient withdrawals are + /// made. + function setTvlLimit(uint256 tvlLimit_) external; + + /// @notice Gets the TVL limit for the vault. + /// @return The TVL limit for the vault. + // This is a function so that IVault can be used in other contracts without importing the Vault contract. + function getTvlLimit() external returns (uint256); + + /// @notice Gets the total value locked in the vault. + /// @return The total value locked in the vault. + // This is a function so that IVault can be used in other contracts without importing the Vault contract. + function getConsumedTvl() external returns (uint256); + } ``` @@ -209,46 +214,29 @@ interface IBaseRestakingController { /// @param amount The amount of tokens to undelegate. function undelegateFrom(string calldata operator, address token, uint256 amount) external payable; - /// @notice Client chain users call to claim their unlocked assets from the vault. - /// @dev This function assumes that the claimable assets should have been unlocked before calling this. + /// @notice Client chain users call to withdraw their unlocked assets from the vault. + /// @dev This function assumes that the withdrawable assets should have been unlocked before calling this. /// @dev This function does not interact with Exocore. /// @param token The address of specific token that the user wants to claim from the vault. /// @param amount The amount of @param token that the user wants to claim from the vault. /// @param recipient The destination address that the assets would be transfered to. - function claim(address token, uint256 amount, address recipient) external; + function withdrawPrincipal(address token, uint256 amount, address recipient) external; -} + /// @notice Submits reward to the reward module on behalf of the AVS + /// @param token The address of the specific token that the user wants to submit as a reward. + /// @param rewardAmount The amount of reward tokens that the user wants to submit. + function submitReward(address token, address avs, uint256 rewardAmount) external payable; -interface ILSTRestakingController is IBaseRestakingController { + /// @notice Claims reward tokens from Exocore. + /// @param token The address of the specific token that the user wants to claim as a reward. + /// @param rewardAmount The amount of reward tokens that the user wants to claim. + function claimRewardFromExocore(address token, uint256 rewardAmount) external payable; - /// @notice Deposits tokens into the Exocore system for further operations like delegation and staking. - /// @dev This function locks the specified amount of tokens into a vault and forwards the information to Exocore. - /// @dev Deposit is always considered successful on the Exocore chain side. - /// @param token The address of the specific token that the user wants to deposit. - /// @param amount The amount of the token that the user wants to deposit. - function deposit(address token, uint256 amount) external payable; - - /// @notice Requests withdrawal of the principal amount from Exocore to the client chain. - /// @dev This function requests withdrawal approval from Exocore. If approved, the assets are - /// unlocked and can be claimed by the user. Otherwise, they remain locked. - /// @param token The address of the specific token that the user wants to withdraw from Exocore. - /// @param principalAmount The principal amount of assets the user deposited into Exocore for delegation and - /// staking. - function withdrawPrincipalFromExocore(address token, uint256 principalAmount) external payable; - - /// @notice Withdraws reward tokens from Exocore. + /// @notice Withdraws reward tokens from vault to the recipient. /// @param token The address of the specific token that the user wants to withdraw as a reward. + /// @param recipient The address of the recipient of the reward tokens. /// @param rewardAmount The amount of reward tokens that the user wants to withdraw. - function withdrawRewardFromExocore(address token, uint256 rewardAmount) external payable; - - /// @notice Deposits tokens and then delegates them to a specific node operator. - /// @dev This function locks the specified amount of tokens into a vault, informs Exocore, and - /// delegates the tokens to the specified node operator. - /// Delegation can fail if the node operator is not registered in Exocore. - /// @param token The address of the specific token that the user wants to deposit and delegate. - /// @param amount The amount of the token that the user wants to deposit and delegate. - /// @param operator The address of a registered node operator that the user wants to delegate to. - function depositThenDelegateTo(address token, uint256 amount, string calldata operator) external payable; + function withdrawReward(address token, address recipient, uint256 rewardAmount) external; } ``` @@ -271,29 +259,25 @@ Since the `ClientChainGateway` by itself does not store enough information to ch This function is the reverse of [`delegatTo`](#delegateto), except that it requires an unbonding period before the undelegation is released for withdrawal. The unbonding period is determined by Exocore based on all the AVSs in which the operator was participating at the time of undelegation. -### `withdrawPrincipalFromExocore` +### `claimPrincipalFromExocore` -This function is aimed for user withdrawing principal from Exocore chain to client chain. This involves the correct accounting on Exocore chain as well as the correct update of user’s `principalBalance` and claimable balance. If this process is successful, user should be able to claim the corresponding assets on client chain to destination address. +This function is aimed for user claiming principal from Exocore chain to client chain. This involves the correct accounting on Exocore chain as well as the correct update of user's `principalBalance` and claimable balance. If this process is successful, user should be able to withdraw the corresponding assets on client chain to destination address. -The principal withdrawal workflow is also separated into two trasactions: +The principal withdrawal workflow is separated into two transactions: 1. Transaction from the user: call `ClientChainGateway.sendInterchainMsg` to send principal withdrawal request to Exocore chain. -2. Response from Exocore: call `ClientChainGateway.receiveInterchainMsg` to receive the response from Exocore chain, and call `unlock` to update user’s `principalBalance` and claimable balance. If response indicates failure, no user balance should be modified. +2. Response from Exocore: call `ClientChainGateway.receiveInterchainMsg` to receive the response from Exocore chain, and call `unlockPrincipal` to update user's `principalBalance` and claimable balance. If response indicates failure, no user balance should be modified. -The withdrawable amount of principal is defined as follows: +The claimable amount of principal is defined as follows: 1. The asset is not staked (delegated) on any operators. 2. The asset is not frozen/slashed. 3. The asset is not in unbonding state. -### `claim` - -This function is aimed for user claiming the unlocked amount of principal. Before claiming, user must make sure that thre is enogh principal unlocked by calling `withdrawPrincipalFromExocore`. The implementaion of this function should check against user’s claimable balance and transfer tokens to the destination address that the user specified. - -### `withdrawRewardFromExocore` +### `withdrawPrincipal` -TBD +This function is aimed for user withdrawing the unlocked amount of principal. Before withdrawing, user must make sure that there is enough principal unlocked by calling `claimPrincipalFromExocore`. The implementation of this function should check against user's claimable balance and transfer tokens to the destination address that the user specified. ### `depositThenDelegateTo` -It is an ease-of-use feature to allow deposit and then delegation in one transaction. It has the same assumptions as the underlying two features. +It is an ease-of-use feature to allow deposit and then delegation in one transaction. It has the same assumptions as the underlying two features. \ No newline at end of file diff --git a/docs/reward-vault.md b/docs/reward-vault.md new file mode 100644 index 00000000..6d0177bb --- /dev/null +++ b/docs/reward-vault.md @@ -0,0 +1,183 @@ +# Reward Vault Design Document + +## 1. Overview + +The Reward Vault is a crucial component of the Exocore ecosystem, designed to securely custody reward tokens distributed by the Exocore chain. It supports permissionless reward token deposits on behalf of AVS (Actively Validated Service) providers and allows stakers to claim their rewards after verification by the Exocore chain. The Reward Vault is managed by the Gateway contract, which acts as an intermediary for all operations. + +The Reward Vault is implemented using the beacon proxy pattern for upgradeability, and a single instance of the Reward Vault is deployed when the ClientChainGateway is initialized. + +## 2. Design Principles + +2.1. Permissionless Reward System: + - The Reward Vault should handle standard ERC20 tokens without requiring prior whitelisting or governance approval. + - Depositors should be able to deposit rewards in any standard ERC20 token on behalf of AVS providers without restrictions. + +2.2. Exocore Chain as Source of Truth: The Exocore chain maintains the record of reward balances and handles reward distribution/accounting for each staker. The Reward Vault only tracks withdrawable balances after claim approval. + +2.3. Separation of Concerns: The Reward Vault is distinct from principal vaults, maintaining a clear separation between staked principals and earned rewards. + +2.4. Security: Despite its permissionless nature, the Reward Vault must maintain high security standards to protect users' rewards. + +2.5. Gateway-Managed Operations: All interactions with the Reward Vault are managed through the Gateway contract, ensuring consistency with the existing architecture. + +2.6. Upgradeability: The Reward Vault uses the beacon proxy pattern to allow for future upgrades while maintaining a consistent address for all interactions. + +## 3. Architecture + +### 3.1. Smart Contract: RewardVault.sol + +Key Functions: +- `deposit(address token, address avs, uint256 amount)`: Allows the Gateway to deposit reward tokens on behalf of an AVS. Increases the total deposited rewards for the specified token and AVS. +- `unlockReward(address token, address staker, uint256 amount)`: Allows the Gateway to unlock rewards for a staker after claim approval from the Exocore chain. +- `withdraw(address token, address withdrawer, address recipient, uint256 amount)`: Allows the Gateway to withdraw claimed rewards for a staker. +- `getWithdrawableBalance(address token, address staker)`: Returns the withdrawable balance of a specific reward token for a staker. +- `getTotalDepositedRewards(address token, address avs)`: Returns the total deposited rewards of a specific token for an AVS. + +Implementation: +- The RewardVault contract is implemented as an upgradeable contract using the beacon proxy pattern. +- A single instance of the RewardVault is deployed and initialized when the ClientChainGateway is deployed and initialized. + +### 3.2. Smart Contract: ClientChainGateway.sol (existing contract, modified) + +New Functions: +- `submitReward(address token, uint256 amount, address avs)`: Receives reward submissions and calls RewardVault's `deposit`. +- `claimRewardFromExocore(address token, uint256 amount)`: Initiates a claim request to the Exocore chain. +- `withdrawReward(address token, address recipient, uint256 amount)`: Calls RewardVault's `withdraw` to transfer claimed rewards to the staker. + +Additional Responsibility: +- Deploys and initializes a single instance of the RewardVault during its own initialization process. + +### 3.3. Data Structures + +#### 3.3.1. Withdrawable Balances Mapping (in RewardVault.sol) + +```solidity +mapping(address => mapping(address => uint256)) public withdrawableBalances; +``` + +This nested mapping tracks withdrawable reward balances: +- First key: Token address +- Second key: Staker address +- Value: Withdrawable balance amount + +#### 3.3.2. Total Deposited Rewards Mapping (in RewardVault.sol) + +```solidity +mapping(address => mapping(address => uint256)) public totalDepositedRewards; +``` + +This nested mapping tracks the total deposited rewards for each token and AVS: +- First key: Token address +- Second key: AVS address +- Value: Total deposited amount + +### 3.4. Beacon Proxy Pattern + +The Reward Vault uses the beacon proxy pattern for upgradeability: + +```solidity +contract RewardVaultBeacon { + address public implementation; + address public owner; + + constructor(address _implementation) { + implementation = _implementation; + owner = msg.sender; + } + + function upgrade(address newImplementation) external { + require(msg.sender == owner, "Not authorized"); + implementation = newImplementation; + } +} + +contract RewardVaultProxy { + address private immutable _beacon; + + constructor(address beacon) { + _beacon = beacon; + } + + fallback() external payable { + address impl = RewardVaultBeacon(_beacon).implementation(); + assembly { + calldatacopy(0, 0, calldatasize()) + let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + switch result + case 0 { revert(0, returndatasize()) } + default { return(0, returndatasize()) } + } + } +} +``` + +## 4. Key Processes + +### 4.1. Reward Submission + +1. Depositor calls `submitReward` on the Gateway, specifying the token, amount, and AVS ID. +2. Gateway calls RewardVault's `deposit`, which: + a. Transfers the specified amount of tokens from the depositor to itself. + b. Increases the total deposited rewards for the specified token and AVS in the `totalDepositedRewards` mapping. + c. Emits a `RewardDeposited` event. +3. Gateway sends a message to the Exocore chain to account for the deposited rewards. +4. Exocore chain processes the request and emits a `RewardOperationResult` event to indicate the result of the submission. + +### 4.2. Reward Distribution and Accounting + +1. Exocore chain handles the distribution and accounting of rewards to stakers based on their staking activities and the rewards submitted. +2. Exocore chain maintains the record of each staker's earned rewards. + +### 4.3. Reward Claiming and Withdrawal + +1. Staker calls `claimRewardFromExocore` on the Gateway. +2. Gateway sends a claim request to the Exocore chain. +3. Exocore chain verifies the request and sends a response back to the Gateway, emitting a `RewardOperation` event. +4. If the claim is approved, Gateway calls RewardVault's `unlockReward`, which: + a. Increases the staker's withdrawable balance for the specified token. + b. Emits a `RewardUnlocked` event. +5. At any time after unlocking, the staker can call `withdrawReward` on the Gateway. +6. Gateway calls RewardVault's `withdraw`, which: + a. Transfers the tokens from the vault to the staker's address. + b. Decreases the staker's withdrawable balance. + c. Emits a `RewardWithdrawn` event. + +## 5. Security Considerations + +5.1. Access Control: +- Only the Gateway should be able to call RewardVault's functions. +- Any address should be able to call `ClientChainGateway.submitReward`. +- Only stakers should be able to call `ClientChainGateway.claimRewardFromExocore` for their own rewards. + +5.2. Token Compatibility: While the system is permissionless, it is designed to work with standard ERC20 tokens to ensure consistent behavior and accounting. + +5.3. Upgradeability: +- The beacon proxy pattern allows for upgrading the RewardVault implementation while maintaining a consistent address. +- Upgrades should be carefully managed and go through a thorough governance process to ensure security and prevent potential vulnerabilities. + +## 6. Gas Optimization + +6.1. Batch Operations: Consider implementing functions for batch reward submissions and claims to reduce gas costs. + +## 7. Upgradability + +The Reward Vault should be implemented as an upgradeable contract using the OpenZeppelin Upgrades plugin. The contract owner, which will be a multisig wallet controlled by the protocol governors, will have the ability to upgrade the contract. This allows for future improvements and bug fixes while maintaining transparency and security. + +## 8. Events + +Emit events for all significant actions in the RewardVault contract: +- `RewardDeposited(address indexed token, address indexed avs, uint256 amount)` +- `RewardUnlocked(address indexed token, address indexed staker, uint256 amount)` +- `RewardWithdrawn(address indexed token, address indexed staker, uint256 amount)` + +The ClientChainGateway contract will emit the following event (as previously defined): +- `RewardOperation(bool isSubmitReward, bool indexed success, bytes32 indexed token, bytes32 indexed avsOrWithdrawer, uint256 amount)` + +## 9. Future Considerations + +9.1. Emergency Withdrawal: Consider an emergency withdrawal function for unclaimed rewards, accessible only by governance in case of critical issues. + +9.2. AVS Reward Tracking: Implement a function to report the total deposited rewards across all tokens for a given AVS, which could be useful for AVS providers to track their reward distribution. + +9.3. Multiple Reward Vaults: While currently a single Reward Vault is deployed, the beacon proxy pattern allows for easy deployment of multiple Reward Vaults if needed in the future, all sharing the same implementation but with separate storage. \ No newline at end of file diff --git a/script/10_DeployExocoreGatewayOnly.s.sol b/script/10_DeployExocoreGatewayOnly.s.sol index d637d479..2fe48330 100644 --- a/script/10_DeployExocoreGatewayOnly.s.sol +++ b/script/10_DeployExocoreGatewayOnly.s.sol @@ -16,8 +16,8 @@ contract DeployExocoreGatewayOnly is BaseScript { // load keys super.setUp(); // load contracts - string memory prerequisities = vm.readFile("script/prerequisiteContracts.json"); - exocoreLzEndpoint = ILayerZeroEndpointV2(stdJson.readAddress(prerequisities, ".exocore.lzEndpoint")); + string memory prerequisites = vm.readFile("script/prerequisiteContracts.json"); + exocoreLzEndpoint = ILayerZeroEndpointV2(stdJson.readAddress(prerequisites, ".exocore.lzEndpoint")); require(address(exocoreLzEndpoint) != address(0), "exocore l0 endpoint should not be empty"); // fork exocore = vm.createSelectFork(exocoreRPCURL); diff --git a/script/12_RedeployClientChainGateway.s.sol b/script/12_RedeployClientChainGateway.s.sol index 5d07ba81..0e58543d 100644 --- a/script/12_RedeployClientChainGateway.s.sol +++ b/script/12_RedeployClientChainGateway.s.sol @@ -7,9 +7,12 @@ import {Bootstrap} from "../src/core/Bootstrap.sol"; import {ClientChainGateway} from "../src/core/ClientChainGateway.sol"; import "../src/core/ExoCapsule.sol"; + +import {RewardVault} from "../src/core/RewardVault.sol"; import {Vault} from "../src/core/Vault.sol"; import "../src/utils/BeaconProxyBytecode.sol"; import {CustomProxyAdmin} from "../src/utils/CustomProxyAdmin.sol"; +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import {BaseScript} from "./BaseScript.sol"; @@ -34,6 +37,9 @@ contract RedeployClientChainGateway is BaseScript { require(address(beaconOracle) != address(0), "beacon oracle should not be empty"); vaultBeacon = UpgradeableBeacon(stdJson.readAddress(prerequisiteContracts, ".clientChain.vaultBeacon")); require(address(vaultBeacon) != address(0), "vault beacon should not be empty"); + rewardVaultBeacon = + UpgradeableBeacon(stdJson.readAddress(prerequisiteContracts, ".clientChain.rewardVaultBeacon")); + require(address(rewardVaultBeacon) != address(0), "reward vault beacon should not be empty"); capsuleBeacon = UpgradeableBeacon(stdJson.readAddress(prerequisiteContracts, ".clientChain.capsuleBeacon")); require(address(capsuleBeacon) != address(0), "capsule beacon should not be empty"); beaconProxyBytecode = @@ -41,17 +47,21 @@ contract RedeployClientChainGateway is BaseScript { require(address(beaconProxyBytecode) != address(0), "beacon proxy bytecode should not be empty"); bootstrap = Bootstrap(stdJson.readAddress(prerequisiteContracts, ".clientChain.bootstrap")); require(address(bootstrap) != address(0), "bootstrap should not be empty"); + clientChainProxyAdmin = CustomProxyAdmin(stdJson.readAddress(prerequisiteContracts, ".clientChain.proxyAdmin")); + require(address(clientChainProxyAdmin) != address(0), "client chain proxy admin should not be empty"); clientChain = vm.createSelectFork(clientChainRPCURL); } function run() public { vm.selectFork(clientChain); vm.startBroadcast(exocoreValidatorSet.privateKey); + ClientChainGateway clientGatewayLogic = new ClientChainGateway( address(clientChainLzEndpoint), exocoreChainId, address(beaconOracle), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); diff --git a/script/1_Prerequisities.s.sol b/script/1_Prerequisites.s.sol similarity index 92% rename from script/1_Prerequisities.s.sol rename to script/1_Prerequisites.s.sol index e7f5b905..c29eada8 100644 --- a/script/1_Prerequisities.s.sol +++ b/script/1_Prerequisites.s.sol @@ -7,11 +7,12 @@ import {ERC20PresetFixedSupply} from "@openzeppelin/contracts/token/ERC20/preset import "forge-std/Script.sol"; import "test/mocks/AssetsMock.sol"; -import "test/mocks/ClaimRewardMock.sol"; + import "test/mocks/DelegationMock.sol"; import {NonShortCircuitEndpointV2Mock} from "test/mocks/NonShortCircuitEndpointV2Mock.sol"; +import "test/mocks/RewardMock.sol"; -contract PrerequisitiesScript is BaseScript { +contract PrerequisitesScript is BaseScript { function setUp() public virtual override { super.setUp(); @@ -45,7 +46,7 @@ contract PrerequisitiesScript is BaseScript { vm.startBroadcast(deployer.privateKey); assetsMock = address(new AssetsMock(clientChainId)); delegationMock = address(new DelegationMock()); - claimRewardMock = address(new ClaimRewardMock()); + rewardMock = address(new RewardMock()); vm.stopBroadcast(); } @@ -63,7 +64,7 @@ contract PrerequisitiesScript is BaseScript { if (useExocorePrecompileMock) { vm.serializeAddress(exocoreContracts, "assetsPrecompileMock", assetsMock); vm.serializeAddress(exocoreContracts, "delegationPrecompileMock", delegationMock); - vm.serializeAddress(exocoreContracts, "claimRewardPrecompileMock", claimRewardMock); + vm.serializeAddress(exocoreContracts, "rewardPrecompileMock", rewardMock); } string memory exocoreContractsOutput = diff --git a/script/2_DeployBoth.s.sol b/script/2_DeployBoth.s.sol index 0fddec02..ea6962c4 100644 --- a/script/2_DeployBoth.s.sol +++ b/script/2_DeployBoth.s.sol @@ -3,15 +3,17 @@ pragma solidity ^0.8.19; import "../src/core/ClientChainGateway.sol"; import "../src/core/ExoCapsule.sol"; import "../src/core/ExocoreGateway.sol"; + +import {RewardVault} from "../src/core/RewardVault.sol"; import {Vault} from "../src/core/Vault.sol"; import "../src/utils/BeaconProxyBytecode.sol"; +import "../src/utils/CustomProxyAdmin.sol"; import {ExocoreGatewayMock} from "../test/mocks/ExocoreGatewayMock.sol"; import {BaseScript} from "./BaseScript.sol"; import "@beacon-oracle/contracts/src/EigenLayerBeaconOracle.sol"; import "@layerzero-v2/protocol/contracts/interfaces/ILayerZeroEndpointV2.sol"; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; -import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {ERC20PresetFixedSupply} from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; import "forge-std/Script.sol"; @@ -21,26 +23,26 @@ contract DeployScript is BaseScript { function setUp() public virtual override { super.setUp(); - string memory prerequisities = vm.readFile("script/prerequisiteContracts.json"); + string memory prerequisites = vm.readFile("script/prerequisiteContracts.json"); - clientChainLzEndpoint = ILayerZeroEndpointV2(stdJson.readAddress(prerequisities, ".clientChain.lzEndpoint")); + clientChainLzEndpoint = ILayerZeroEndpointV2(stdJson.readAddress(prerequisites, ".clientChain.lzEndpoint")); require(address(clientChainLzEndpoint) != address(0), "client chain l0 endpoint should not be empty"); - restakeToken = ERC20PresetFixedSupply(stdJson.readAddress(prerequisities, ".clientChain.erc20Token")); + restakeToken = ERC20PresetFixedSupply(stdJson.readAddress(prerequisites, ".clientChain.erc20Token")); require(address(restakeToken) != address(0), "restake token address should not be empty"); - exocoreLzEndpoint = ILayerZeroEndpointV2(stdJson.readAddress(prerequisities, ".exocore.lzEndpoint")); + exocoreLzEndpoint = ILayerZeroEndpointV2(stdJson.readAddress(prerequisites, ".exocore.lzEndpoint")); require(address(exocoreLzEndpoint) != address(0), "exocore l0 endpoint should not be empty"); if (useExocorePrecompileMock) { - assetsMock = stdJson.readAddress(prerequisities, ".exocore.assetsPrecompileMock"); + assetsMock = stdJson.readAddress(prerequisites, ".exocore.assetsPrecompileMock"); require(assetsMock != address(0), "assetsMock should not be empty"); - delegationMock = stdJson.readAddress(prerequisities, ".exocore.delegationPrecompileMock"); + delegationMock = stdJson.readAddress(prerequisites, ".exocore.delegationPrecompileMock"); require(delegationMock != address(0), "delegationMock should not be empty"); - claimRewardMock = stdJson.readAddress(prerequisities, ".exocore.claimRewardPrecompileMock"); - require(claimRewardMock != address(0), "claimRewardMock should not be empty"); + rewardMock = stdJson.readAddress(prerequisites, ".exocore.rewardPrecompileMock"); + require(rewardMock != address(0), "rewardMock should not be empty"); } clientChain = vm.createSelectFork(clientChainRPCURL); @@ -57,25 +59,30 @@ contract DeployScript is BaseScript { // deploy beacon chain oracle beaconOracle = _deployBeaconOracle(); - /// deploy vault implementation contract and capsule implementation contract + /// deploy vault implementation contract, capsule implementation contract, reward vault implementation contract /// that has logics called by proxy vaultImplementation = new Vault(); capsuleImplementation = new ExoCapsule(); + rewardVaultImplementation = new RewardVault(); - /// deploy the vault beacon and capsule beacon that store the implementation contract address + /// deploy the vault beacon, capsule beacon, reward vault beacon that store the implementation contract address vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); capsuleBeacon = new UpgradeableBeacon(address(capsuleImplementation)); + rewardVaultBeacon = new UpgradeableBeacon(address(rewardVaultImplementation)); // deploy BeaconProxyBytecode to store BeaconProxyBytecode beaconProxyBytecode = new BeaconProxyBytecode(); + // deploy custom proxy admin + clientChainProxyAdmin = new CustomProxyAdmin(); + /// deploy client chain gateway - ProxyAdmin clientChainProxyAdmin = new ProxyAdmin(); ClientChainGateway clientGatewayLogic = new ClientChainGateway( address(clientChainLzEndpoint), exocoreChainId, address(beaconOracle), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); @@ -93,8 +100,13 @@ contract DeployScript is BaseScript { ) ); + // get the reward vault address since it would be deployed during initialization + rewardVault = ClientChainGateway(payable(address(clientGateway))).rewardVault(); + require(address(rewardVault) != address(0), "reward vault should not be empty"); + // find vault according to uderlying token address vault = Vault(address(ClientChainGateway(payable(address(clientGateway))).tokenToVault(address(restakeToken)))); + require(address(vault) != address(0), "vault should not be empty"); vm.stopBroadcast(); @@ -107,7 +119,7 @@ contract DeployScript is BaseScript { if (useExocorePrecompileMock) { ExocoreGatewayMock exocoreGatewayLogic = - new ExocoreGatewayMock(address(exocoreLzEndpoint), assetsMock, claimRewardMock, delegationMock); + new ExocoreGatewayMock(address(exocoreLzEndpoint), assetsMock, rewardMock, delegationMock); exocoreGateway = ExocoreGateway( payable( address( @@ -147,8 +159,10 @@ contract DeployScript is BaseScript { vm.serializeAddress(clientChainContracts, "beaconOracle", address(beaconOracle)); vm.serializeAddress(clientChainContracts, "clientChainGateway", address(clientGateway)); vm.serializeAddress(clientChainContracts, "resVault", address(vault)); + vm.serializeAddress(clientChainContracts, "rewardVault", address(rewardVault)); vm.serializeAddress(clientChainContracts, "erc20Token", address(restakeToken)); vm.serializeAddress(clientChainContracts, "vaultBeacon", address(vaultBeacon)); + vm.serializeAddress(clientChainContracts, "rewardVaultBeacon", address(rewardVaultBeacon)); vm.serializeAddress(clientChainContracts, "capsuleBeacon", address(capsuleBeacon)); vm.serializeAddress(clientChainContracts, "beaconProxyBytecode", address(beaconProxyBytecode)); string memory clientChainContractsOutput = @@ -160,7 +174,7 @@ contract DeployScript is BaseScript { if (useExocorePrecompileMock) { vm.serializeAddress(exocoreContracts, "assetsPrecompileMock", assetsMock); vm.serializeAddress(exocoreContracts, "delegationPrecompileMock", delegationMock); - vm.serializeAddress(exocoreContracts, "claimRewardPrecompileMock", claimRewardMock); + vm.serializeAddress(exocoreContracts, "rewardPrecompileMock", rewardMock); } string memory exocoreContractsOutput = diff --git a/script/5_Withdraw.s.sol b/script/5_Withdraw.s.sol index 6364d766..92011269 100644 --- a/script/5_Withdraw.s.sol +++ b/script/5_Withdraw.s.sol @@ -68,7 +68,7 @@ contract DepositScript is BaseScript { uint256 nativeFee = clientGateway.quote(msg_); console.log("l0 native fee:", nativeFee); - clientGateway.withdrawPrincipalFromExocore{value: nativeFee}(address(restakeToken), WITHDRAW_AMOUNT); + clientGateway.claimPrincipalFromExocore{value: nativeFee}(address(restakeToken), WITHDRAW_AMOUNT); vm.stopBroadcast(); if (useEndpointMock) { diff --git a/script/7_DeployBootstrap.s.sol b/script/7_DeployBootstrap.s.sol index 804a2cc3..d28df780 100644 --- a/script/7_DeployBootstrap.s.sol +++ b/script/7_DeployBootstrap.s.sol @@ -7,6 +7,8 @@ import {Bootstrap} from "../src/core/Bootstrap.sol"; import {ClientChainGateway} from "../src/core/ClientChainGateway.sol"; import "../src/core/ExoCapsule.sol"; + +import {RewardVault} from "../src/core/RewardVault.sol"; import {Vault} from "../src/core/Vault.sol"; import "../src/utils/BeaconProxyBytecode.sol"; import {CustomProxyAdmin} from "../src/utils/CustomProxyAdmin.sol"; @@ -56,10 +58,11 @@ contract DeployBootstrapOnly is BaseScript { tvlLimits.push(ERC20PresetFixedSupply(wstETH).totalSupply() / 20); // proxy deployment - CustomProxyAdmin proxyAdmin = new CustomProxyAdmin(); + clientChainProxyAdmin = new CustomProxyAdmin(); // vault, shared between bootstrap and client chain gateway vaultImplementation = new Vault(); vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); + // bootstrap logic Bootstrap bootstrapLogic = new Bootstrap( address(clientChainLzEndpoint), exocoreChainId, address(vaultBeacon), address(beaconProxyBytecode) @@ -67,11 +70,14 @@ contract DeployBootstrapOnly is BaseScript { // client chain constructor (upgrade details) capsuleImplementation = new ExoCapsule(); capsuleBeacon = new UpgradeableBeacon(address(capsuleImplementation)); + rewardVaultImplementation = new RewardVault(); + rewardVaultBeacon = new UpgradeableBeacon(address(rewardVaultImplementation)); ClientChainGateway clientGatewayLogic = new ClientChainGateway( address(clientChainLzEndpoint), exocoreChainId, address(beaconOracle), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); @@ -85,7 +91,7 @@ contract DeployBootstrapOnly is BaseScript { address( new TransparentUpgradeableProxy( address(bootstrapLogic), - address(proxyAdmin), + address(clientChainProxyAdmin), abi.encodeCall( Bootstrap.initialize, ( @@ -95,7 +101,7 @@ contract DeployBootstrapOnly is BaseScript { 2 seconds, whitelistTokens, // vault is auto deployed tvlLimits, - address(proxyAdmin), + address(clientChainProxyAdmin), address(clientGatewayLogic), initialization ) @@ -106,7 +112,7 @@ contract DeployBootstrapOnly is BaseScript { ); // initialize proxyAdmin with bootstrap address - proxyAdmin.initialize(address(bootstrap)); + clientChainProxyAdmin.initialize(address(bootstrap)); vm.stopBroadcast(); @@ -114,7 +120,7 @@ contract DeployBootstrapOnly is BaseScript { vm.serializeAddress(clientChainContracts, "lzEndpoint", address(clientChainLzEndpoint)); vm.serializeAddress(clientChainContracts, "erc20Token", address(restakeToken)); vm.serializeAddress(clientChainContracts, "wstETH", wstETH); - vm.serializeAddress(clientChainContracts, "proxyAdmin", address(proxyAdmin)); + vm.serializeAddress(clientChainContracts, "proxyAdmin", address(clientChainProxyAdmin)); vm.serializeAddress(clientChainContracts, "vaultImplementation", address(vaultImplementation)); vm.serializeAddress(clientChainContracts, "vaultBeacon", address(vaultBeacon)); vm.serializeAddress(clientChainContracts, "beaconProxyBytecode", address(beaconProxyBytecode)); @@ -123,6 +129,8 @@ contract DeployBootstrapOnly is BaseScript { vm.serializeAddress(clientChainContracts, "beaconOracle", address(beaconOracle)); vm.serializeAddress(clientChainContracts, "capsuleImplementation", address(capsuleImplementation)); vm.serializeAddress(clientChainContracts, "capsuleBeacon", address(capsuleBeacon)); + vm.serializeAddress(clientChainContracts, "rewardVaultImplementation", address(rewardVaultImplementation)); + vm.serializeAddress(clientChainContracts, "rewardVaultBeacon", address(rewardVaultBeacon)); string memory clientChainContractsOutput = vm.serializeAddress(clientChainContracts, "clientGatewayLogic", address(clientGatewayLogic)); diff --git a/script/BaseScript.sol b/script/BaseScript.sol index a2659319..2d251a56 100644 --- a/script/BaseScript.sol +++ b/script/BaseScript.sol @@ -3,12 +3,16 @@ pragma solidity ^0.8.19; import "../src/interfaces/IClientChainGateway.sol"; import "../src/interfaces/IExoCapsule.sol"; import "../src/interfaces/IExocoreGateway.sol"; + +import "../src/interfaces/IRewardVault.sol"; import "../src/interfaces/IVault.sol"; import "../src/utils/BeaconProxyBytecode.sol"; +import "../src/utils/CustomProxyAdmin.sol"; import "../src/interfaces/precompiles/IAssets.sol"; -import "../src/interfaces/precompiles/IClaimReward.sol"; + import "../src/interfaces/precompiles/IDelegation.sol"; +import "../src/interfaces/precompiles/IReward.sol"; import "@beacon-oracle/contracts/src/EigenLayerBeaconOracle.sol"; import "@layerzero-v2/protocol/contracts/interfaces/ILayerZeroEndpointV2.sol"; @@ -18,8 +22,9 @@ import "forge-std/Script.sol"; import {StdCheats} from "forge-std/StdCheats.sol"; import "../test/mocks/AssetsMock.sol"; -import "../test/mocks/ClaimRewardMock.sol"; + import "../test/mocks/DelegationMock.sol"; +import "../test/mocks/RewardMock.sol"; contract BaseScript is Script, StdCheats { @@ -44,20 +49,24 @@ contract BaseScript is Script, StdCheats { IClientChainGateway clientGateway; IVault vault; + IRewardVault rewardVault; IExocoreGateway exocoreGateway; ILayerZeroEndpointV2 clientChainLzEndpoint; ILayerZeroEndpointV2 exocoreLzEndpoint; EigenLayerBeaconOracle beaconOracle; ERC20PresetFixedSupply restakeToken; IVault vaultImplementation; + IRewardVault rewardVaultImplementation; IExoCapsule capsuleImplementation; IBeacon vaultBeacon; + IBeacon rewardVaultBeacon; IBeacon capsuleBeacon; BeaconProxyBytecode beaconProxyBytecode; + CustomProxyAdmin clientChainProxyAdmin; address delegationMock; address assetsMock; - address claimRewardMock; + address rewardMock; uint256 clientChain; uint256 exocore; @@ -135,7 +144,7 @@ contract BaseScript is Script, StdCheats { // with cast or remix. deployCodeTo("AssetsMock.sol", abi.encode(clientChainId), ASSETS_PRECOMPILE_ADDRESS); deployCodeTo("DelegationMock.sol", DELEGATION_PRECOMPILE_ADDRESS); - deployCodeTo("ClaimRewardMock.sol", CLAIM_REWARD_PRECOMPILE_ADDRESS); + deployCodeTo("RewardMock.sol", REWARD_PRECOMPILE_ADDRESS); // go to the original fork, if one was selected if (previousFork != type(uint256).max) { vm.selectFork(previousFork); diff --git a/script/TestPrecompileErrorFixed.s.sol b/script/TestPrecompileErrorFixed.s.sol index 742560e2..a119899b 100644 --- a/script/TestPrecompileErrorFixed.s.sol +++ b/script/TestPrecompileErrorFixed.s.sol @@ -6,8 +6,9 @@ import "../src/interfaces/IExocoreGateway.sol"; import "../src/interfaces/IVault.sol"; import "../src/interfaces/precompiles/IAssets.sol"; -import "../src/interfaces/precompiles/IClaimReward.sol"; + import "../src/interfaces/precompiles/IDelegation.sol"; +import "../src/interfaces/precompiles/IReward.sol"; import {Action, GatewayStorage} from "../src/storage/GatewayStorage.sol"; import {NonShortCircuitEndpointV2Mock} from "../test/mocks/NonShortCircuitEndpointV2Mock.sol"; @@ -67,8 +68,8 @@ contract DepositScript is BaseScript { bytes memory DelegationMockCode = vm.getDeployedCode("DelegationMock.sol"); vm.etch(DELEGATION_PRECOMPILE_ADDRESS, DelegationMockCode); - bytes memory WithdrawRewardMockCode = vm.getDeployedCode("ClaimRewardMock.sol"); - vm.etch(CLAIM_REWARD_PRECOMPILE_ADDRESS, WithdrawRewardMockCode); + bytes memory WithdrawRewardMockCode = vm.getDeployedCode("RewardMock.sol"); + vm.etch(REWARD_PRECOMPILE_ADDRESS, WithdrawRewardMockCode); } function run() public { diff --git a/script/deployBeaconOracle.s.sol b/script/deployBeaconOracle.s.sol index bdacef0d..425d855d 100644 --- a/script/deployBeaconOracle.s.sol +++ b/script/deployBeaconOracle.s.sol @@ -8,7 +8,7 @@ import "forge-std/Script.sol"; import {NonShortCircuitEndpointV2Mock} from "test/mocks/NonShortCircuitEndpointV2Mock.sol"; -contract PrerequisitiesScript is BaseScript { +contract PrerequisitesScript is BaseScript { function setUp() public virtual override { super.setUp(); diff --git a/script/prerequisiteContracts.json b/script/prerequisiteContracts.json index a8a5c5e8..2f12a570 100644 --- a/script/prerequisiteContracts.json +++ b/script/prerequisiteContracts.json @@ -7,10 +7,6 @@ "lzEndpoint": "0x6EDCE65403992e310A62460808c4b910D972f10f" }, "exocore": { - "claimRewardPrecompileMock": "0xE66204F6BdE875035C63437dbfbf1B497e8CF455", - "delegationPrecompileMock": "0x0b63680102cba1F0eD462028e2DBDde4234c1C7B", - "depositPrecompileMock": "0x07097210995b2ec23582Feeb0a8c234BB0c50787", - "lzEndpoint": "0x6EDCE65403992e310A62460808c4b910D972f10f", - "withdrawPrecompileMock": "0x07097210995b2ec23582Feeb0a8c234BB0c50787" + "lzEndpoint": "0x6EDCE65403992e310A62460808c4b910D972f10f" } } \ No newline at end of file diff --git a/src/core/BaseRestakingController.sol b/src/core/BaseRestakingController.sol index c8e06ba2..740b78ae 100644 --- a/src/core/BaseRestakingController.sol +++ b/src/core/BaseRestakingController.sol @@ -33,7 +33,7 @@ abstract contract BaseRestakingController is receive() external payable {} /// @inheritdoc IBaseRestakingController - function claim(address token, uint256 amount, address recipient) + function withdrawPrincipal(address token, uint256 amount, address recipient) external isTokenWhitelisted(token) isValidAmount(amount) @@ -48,8 +48,6 @@ abstract contract BaseRestakingController is IVault vault = _getVault(token); vault.withdraw(msg.sender, recipient, amount); } - - emit ClaimSucceeded(token, recipient, amount); } /// @inheritdoc IBaseRestakingController @@ -82,6 +80,50 @@ abstract contract BaseRestakingController is _processRequest(Action.REQUEST_UNDELEGATE_FROM, actionArgs, bytes("")); } + /// @inheritdoc IBaseRestakingController + function submitReward(address token, address avs, uint256 amount) + external + payable + isValidAmount(amount) + whenNotPaused + nonReentrant + { + require(token != address(0), "BaseRestakingController: token address cannot be empty or zero address"); + require(avs != address(0), "BaseRestakingController: avs address cannot be empty or zero address"); + // deposit reward to reward vault + rewardVault.deposit(token, msg.sender, avs, amount); + // send request to exocore, and this would not expect a response since deposit is supposed to be must success by + // protocol + bytes memory actionArgs = abi.encodePacked(bytes32(bytes20(token)), bytes32(bytes20(avs)), amount); + _processRequest(Action.REQUEST_SUBMIT_REWARD, actionArgs, bytes("")); + } + + /// @inheritdoc IBaseRestakingController + function claimRewardFromExocore(address token, uint256 amount) + external + payable + isValidAmount(amount) + whenNotPaused + nonReentrant + { + require(token != address(0), "BaseRestakingController: token address cannot be empty or zero address"); + bytes memory actionArgs = abi.encodePacked(bytes32(bytes20(token)), bytes32(bytes20(msg.sender)), amount); + bytes memory encodedRequest = abi.encode(token, msg.sender, amount); + _processRequest(Action.REQUEST_CLAIM_REWARD, actionArgs, encodedRequest); + } + + /// @inheritdoc IBaseRestakingController + function withdrawReward(address token, address recipient, uint256 amount) + external + isValidAmount(amount) + whenNotPaused + nonReentrant + { + require(token != address(0), "BaseRestakingController: token address cannot be empty or zero address"); + require(recipient != address(0), "BaseRestakingController: recipient address cannot be empty or zero address"); + rewardVault.withdraw(token, msg.sender, recipient, amount); + } + /// @dev Processes the request by sending it to Exocore. /// @dev If the encodedRequest is not empty, it is regarded as a request that expects a response and the request /// would be cached diff --git a/src/core/Bootstrap.sol b/src/core/Bootstrap.sol index ea50cd23..04e2c454 100644 --- a/src/core/Bootstrap.sol +++ b/src/core/Bootstrap.sol @@ -390,7 +390,7 @@ contract Bootstrap is } /// @inheritdoc ILSTRestakingController - function withdrawPrincipalFromExocore(address token, uint256 amount) + function claimPrincipalFromExocore(address token, uint256 amount) external payable override @@ -403,14 +403,14 @@ contract Bootstrap is if (msg.value > 0) { revert Errors.NonZeroValue(); } - _withdraw(msg.sender, token, amount); + _claim(msg.sender, token, amount); } - /// @dev Internal version of withdraw. + /// @dev Internal version of claim. /// @param user The address of the withdrawer. /// @param token The address of the token. /// @param amount The amount of the @param token to withdraw. - function _withdraw(address user, address token, uint256 amount) internal { + function _claim(address user, address token, uint256 amount) internal { IVault vault = _getVault(token); uint256 deposited = totalDepositAmounts[user][token]; @@ -428,19 +428,31 @@ contract Bootstrap is depositsByToken[token] -= amount; // afterReceiveWithdrawPrincipalResponse - vault.updateWithdrawableBalance(user, amount, 0); + vault.unlockPrincipal(user, amount); - emit WithdrawPrincipalResult(true, token, user, amount); + emit ClaimPrincipalResult(true, token, user, amount); } - /// @inheritdoc ILSTRestakingController + /// @inheritdoc IBaseRestakingController + /// @dev This is not yet supported. + function submitReward(address, address, uint256) external payable override beforeLocked whenNotPaused { + revert Errors.NotYetSupported(); + } + + /// @inheritdoc IBaseRestakingController + /// @dev This is not yet supported. + function claimRewardFromExocore(address, uint256) external payable override beforeLocked whenNotPaused { + revert Errors.NotYetSupported(); + } + + /// @inheritdoc IBaseRestakingController /// @dev This is not yet supported. - function withdrawRewardFromExocore(address, uint256) external payable override beforeLocked whenNotPaused { + function withdrawReward(address, address, uint256) external view override beforeLocked whenNotPaused { revert Errors.NotYetSupported(); } /// @inheritdoc IBaseRestakingController - function claim(address token, uint256 amount, address recipient) + function withdrawPrincipal(address token, uint256 amount, address recipient) external override beforeLocked diff --git a/src/core/ClientChainGateway.sol b/src/core/ClientChainGateway.sol index a986ea00..b96a6fd2 100644 --- a/src/core/ClientChainGateway.sol +++ b/src/core/ClientChainGateway.sol @@ -2,9 +2,10 @@ pragma solidity ^0.8.19; import {IClientChainGateway} from "../interfaces/IClientChainGateway.sol"; + +import {IRewardVault} from "../interfaces/IRewardVault.sol"; import {ITokenWhitelister} from "../interfaces/ITokenWhitelister.sol"; import {IVault} from "../interfaces/IVault.sol"; - import {OAppCoreUpgradeable} from "../lzApp/OAppCoreUpgradeable.sol"; import {OAppReceiverUpgradeable} from "../lzApp/OAppReceiverUpgradeable.sol"; import {MessagingFee, OAppSenderUpgradeable} from "../lzApp/OAppSenderUpgradeable.sol"; @@ -21,6 +22,7 @@ import {OptionsBuilder} from "@layerzero-v2/oapp/contracts/oapp/libs/OptionsBuil import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; /// @title ClientChainGateway /// @author ExocoreNetwork @@ -49,6 +51,7 @@ contract ClientChainGateway is uint32 exocoreChainId_, address beaconOracleAddress_, address vaultBeacon_, + address rewardVaultBeacon_, address exoCapsuleBeacon_, address beaconProxyBytecode_ ) @@ -57,6 +60,7 @@ contract ClientChainGateway is exocoreChainId_, beaconOracleAddress_, vaultBeacon_, + rewardVaultBeacon_, exoCapsuleBeacon_, beaconProxyBytecode_ ) @@ -81,6 +85,8 @@ contract ClientChainGateway is bootstrapped = true; + _deployRewardVault(); + _transferOwnership(owner_); __OAppCore_init_unchained(owner_); __Pausable_init_unchained(); @@ -157,4 +163,20 @@ contract ClientChainGateway is return (SENDER_VERSION, RECEIVER_VERSION); } + // The bytecode returned by the BEACON_PROXY_BYTECODE contract is static, so there is no risk of collision. + // slither-disable-next-line encode-packed-collision + function _deployRewardVault() internal { + rewardVault = IRewardVault( + Create2.deploy( + 0, + bytes32(bytes("REWARD_VAULT")), + // for clarity, this BEACON_PROXY is not related to beacon chain + // but rather it is the bytecode for the beacon proxy upgrade pattern. + abi.encodePacked(BEACON_PROXY_BYTECODE.getBytecode(), abi.encode(address(REWARD_VAULT_BEACON), "")) + ) + ); + rewardVault.initialize(address(this)); + emit RewardVaultCreated(address(rewardVault)); + } + } diff --git a/src/core/ClientGatewayLzReceiver.sol b/src/core/ClientGatewayLzReceiver.sol index d20e2cc3..93babf79 100644 --- a/src/core/ClientGatewayLzReceiver.sol +++ b/src/core/ClientGatewayLzReceiver.sol @@ -86,9 +86,9 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp (address token, address staker, uint256 amount) = _decodeCachedRequest(cachedRequest); if (requestAct.isPrincipal()) { - _updatePrincipalWithdrawableBalance(token, staker, amount); + _unlockPrincipal(token, staker, amount); } else if (requestAct.isReward()) { - _updateRewardWithdrawableBalance(token, staker, amount); + _unlockReward(token, staker, amount); } else { revert Errors.UnsupportedResponse(requestAct); } @@ -144,30 +144,29 @@ abstract contract ClientGatewayLzReceiver is PausableUpgradeable, OAppReceiverUp } /** - * @dev Updates the withdrawable balance of the principal. + * @dev Unlocks the principal by increasing the withdrawable balance of the principal. * @param token The address of the token. * @param staker The address of the staker. * @param amount The amount of the operation. */ - function _updatePrincipalWithdrawableBalance(address token, address staker, uint256 amount) internal { + function _unlockPrincipal(address token, address staker, uint256 amount) internal { if (token == VIRTUAL_NST_ADDRESS) { IExoCapsule capsule = _getCapsule(staker); - capsule.updateWithdrawableBalance(amount); + capsule.unlockETHPrincipal(amount); } else { IVault vault = _getVault(token); - vault.updateWithdrawableBalance(staker, amount, 0); + vault.unlockPrincipal(staker, amount); } } /** - * @dev Updates the withdrawable balance of the reward. + * @dev Unlocks the reward by increasing the withdrawable balance of the reward. * @param token The address of the token. * @param staker The address of the staker. * @param amount The amount of the operation. */ - function _updateRewardWithdrawableBalance(address token, address staker, uint256 amount) internal { - IVault vault = _getVault(token); - vault.updateWithdrawableBalance(staker, 0, amount); + function _unlockReward(address token, address staker, uint256 amount) internal { + rewardVault.unlockReward(token, staker, amount); } /// @notice Called after an add-whitelist-token response is received. diff --git a/src/core/ExoCapsule.sol b/src/core/ExoCapsule.sol index d89db913..5e364d1f 100644 --- a/src/core/ExoCapsule.sol +++ b/src/core/ExoCapsule.sol @@ -23,10 +23,10 @@ contract ExoCapsule is ReentrancyGuardUpgradeable, ExoCapsuleStorage, IExoCapsul using ValidatorContainer for bytes32[]; using WithdrawalContainer for bytes32[]; - /// @notice Emitted when the withdrawable balance of the capsule is updated. + /// @notice Emitted when the ETH principal balance is unlocked. /// @param owner The address of the capsule owner. - /// @param additionalAmount The amount added to the withdrawable balance. - event WithdrawableBalanceUpdated(address owner, uint256 additionalAmount); + /// @param unlockedAmount The amount added to the withdrawable balance. + event ETHPrincipalUnlocked(address owner, uint256 unlockedAmount); /// @notice Emitted when a withdrawal is successfully completed. /// @param owner The address of the capsule owner. @@ -283,10 +283,10 @@ contract ExoCapsule is ReentrancyGuardUpgradeable, ExoCapsuleStorage, IExoCapsul } /// @inheritdoc IExoCapsule - function updateWithdrawableBalance(uint256 unlockPrincipalAmount) external onlyGateway { + function unlockETHPrincipal(uint256 unlockPrincipalAmount) external onlyGateway { withdrawableBalance += unlockPrincipalAmount; - emit WithdrawableBalanceUpdated(capsuleOwner, unlockPrincipalAmount); + emit ETHPrincipalUnlocked(capsuleOwner, unlockPrincipalAmount); } /// @inheritdoc IExoCapsule diff --git a/src/core/ExocoreGateway.sol b/src/core/ExocoreGateway.sol index b9655b75..c99f6545 100644 --- a/src/core/ExocoreGateway.sol +++ b/src/core/ExocoreGateway.sol @@ -6,8 +6,9 @@ import {IExocoreGateway} from "../interfaces/IExocoreGateway.sol"; import {Action} from "../storage/GatewayStorage.sol"; import {ASSETS_CONTRACT} from "../interfaces/precompiles/IAssets.sol"; -import {CLAIM_REWARD_CONTRACT} from "../interfaces/precompiles/IClaimReward.sol"; + import {DELEGATION_CONTRACT} from "../interfaces/precompiles/IDelegation.sol"; +import {REWARD_CONTRACT} from "../interfaces/precompiles/IReward.sol"; import { MessagingFee, @@ -80,6 +81,7 @@ contract ExocoreGateway is _whiteListFunctionSelectors[Action.REQUEST_WITHDRAW_LST] = this.handleLSTTransfer.selector; _whiteListFunctionSelectors[Action.REQUEST_DEPOSIT_NST] = this.handleNSTTransfer.selector; _whiteListFunctionSelectors[Action.REQUEST_WITHDRAW_NST] = this.handleNSTTransfer.selector; + _whiteListFunctionSelectors[Action.REQUEST_SUBMIT_REWARD] = this.handleRewardOperation.selector; _whiteListFunctionSelectors[Action.REQUEST_CLAIM_REWARD] = this.handleRewardOperation.selector; _whiteListFunctionSelectors[Action.REQUEST_DELEGATE_TO] = this.handleDelegation.selector; _whiteListFunctionSelectors[Action.REQUEST_UNDELEGATE_FROM] = this.handleDelegation.selector; @@ -387,26 +389,34 @@ contract ExocoreGateway is response = isDeposit ? bytes("") : abi.encodePacked(lzNonce, success); } - /// @notice Handles rewards request from a client chain. + /// @notice Handles rewards request from a client chain, submit reward or claim reward. /// @dev Can only be called from this contract via low-level call. /// @dev Returns the response to client chain including lzNonce and success flag. /// @param srcChainId The source chain id. /// @param lzNonce The layer zero nonce. + /// @param act The action type. /// @param payload The request payload. // slither-disable-next-line unused-return - function handleRewardOperation(uint32 srcChainId, uint64 lzNonce, Action, bytes calldata payload) + function handleRewardOperation(uint32 srcChainId, uint64 lzNonce, Action act, bytes calldata payload) public onlyCalledFromThis returns (bytes memory response) { bytes calldata token = payload[:32]; - bytes calldata withdrawer = payload[32:64]; + // it could be either avsId or withdrawer, depending on the action + bytes calldata avsOrWithdrawer = payload[32:64]; uint256 amount = uint256(bytes32(payload[64:96])); - (bool success,) = CLAIM_REWARD_CONTRACT.claimReward(srcChainId, token, withdrawer, amount); - emit ClaimRewardResult(success, bytes32(token), bytes32(withdrawer), amount); + bool isSubmitReward = act == Action.REQUEST_SUBMIT_REWARD; + bool success; + if (isSubmitReward) { + (success,) = REWARD_CONTRACT.submitReward(srcChainId, token, avsOrWithdrawer, amount); + } else { + (success,) = REWARD_CONTRACT.claimReward(srcChainId, token, avsOrWithdrawer, amount); + } + emit RewardOperation(isSubmitReward, success, bytes32(token), bytes32(avsOrWithdrawer), amount); - response = abi.encodePacked(lzNonce, success); + response = isSubmitReward ? bytes("") : abi.encodePacked(lzNonce, success); } /// @notice Handles delegation request from a client chain. diff --git a/src/core/LSTRestakingController.sol b/src/core/LSTRestakingController.sol index 2be5999c..942f656d 100644 --- a/src/core/LSTRestakingController.sol +++ b/src/core/LSTRestakingController.sol @@ -42,7 +42,7 @@ abstract contract LSTRestakingController is } /// @inheritdoc ILSTRestakingController - function withdrawPrincipalFromExocore(address token, uint256 principalAmount) + function claimPrincipalFromExocore(address token, uint256 principalAmount) external payable isTokenWhitelisted(token) @@ -62,22 +62,6 @@ abstract contract LSTRestakingController is _processRequest(Action.REQUEST_WITHDRAW_LST, actionArgs, encodedRequest); } - /// @inheritdoc ILSTRestakingController - function withdrawRewardFromExocore(address token, uint256 rewardAmount) - external - payable - isTokenWhitelisted(token) - isValidAmount(rewardAmount) - whenNotPaused - nonReentrant - { - bytes memory actionArgs = abi.encodePacked(bytes32(bytes20(token)), bytes32(bytes20(msg.sender)), rewardAmount); - bytes memory encodedRequest = abi.encode(token, msg.sender, rewardAmount); - - // we need to check the response to unlock the reward for later claim - _processRequest(Action.REQUEST_CLAIM_REWARD, actionArgs, encodedRequest); - } - /// @inheritdoc ILSTRestakingController function depositThenDelegateTo(address token, uint256 amount, string calldata operator) external diff --git a/src/core/RewardVault.sol b/src/core/RewardVault.sol new file mode 100644 index 00000000..d8740dc3 --- /dev/null +++ b/src/core/RewardVault.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IRewardVault} from "../interfaces/IRewardVault.sol"; +import {Errors} from "../libraries/Errors.sol"; +import {RewardVaultStorage} from "../storage/RewardVaultStorage.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract RewardVault is RewardVaultStorage, Initializable, IRewardVault { + + using SafeERC20 for IERC20; + + modifier onlyGateway() { + if (msg.sender != gateway) { + revert Errors.VaultCallerIsNotGateway(); + } + _; + } + + constructor() { + _disableInitializers(); + } + + /// @inheritdoc IRewardVault + function initialize(address gateway_) external initializer { + gateway = gateway_; + } + + /// @inheritdoc IRewardVault + // slither-disable-next-line arbitrary-send-erc20 + function deposit(address token, address depositor, address avs, uint256 amount) external onlyGateway { + IERC20(token).safeTransferFrom(depositor, address(this), amount); + totalDepositedRewards[token][avs] += amount; + + emit RewardDeposited(token, avs, amount); + } + + /// @inheritdoc IRewardVault + function withdraw(address token, address withdrawer, address recipient, uint256 amount) external onlyGateway { + if (withdrawableBalances[token][withdrawer] < amount) { + revert Errors.InsufficientBalance(); + } + withdrawableBalances[token][withdrawer] -= amount; + IERC20(token).safeTransfer(recipient, amount); + + emit RewardWithdrawn(token, withdrawer, recipient, amount); + } + + /// @inheritdoc IRewardVault + function unlockReward(address token, address withdrawer, uint256 amount) external onlyGateway { + withdrawableBalances[token][withdrawer] += amount; + + emit RewardUnlocked(token, withdrawer, amount); + } + + /// @inheritdoc IRewardVault + function getWithdrawableBalance(address token, address withdrawer) external view returns (uint256) { + return withdrawableBalances[token][withdrawer]; + } + + /// @inheritdoc IRewardVault + function getTotalDepositedRewards(address token, address avs) external view returns (uint256) { + return totalDepositedRewards[token][avs]; + } + +} diff --git a/src/core/Vault.sol b/src/core/Vault.sol index 98d8c65f..be895402 100644 --- a/src/core/Vault.sol +++ b/src/core/Vault.sol @@ -74,15 +74,15 @@ contract Vault is Initializable, VaultStorage, IVault { consumedTvl -= amount; underlyingToken.safeTransfer(recipient, amount); - emit WithdrawalSuccess(withdrawer, recipient, amount); emit ConsumedTvlChanged(consumedTvl); + emit PrincipalWithdrawn(withdrawer, recipient, amount); } /// @inheritdoc IVault // Though `safeTransferFrom` has arbitrary passed in `depositor` as sender, this function is only callable by // `gateway` and `gateway` would make sure only the `msg.sender` would be the depositor. // slither-disable-next-line arbitrary-send-erc20 - function deposit(address depositor, uint256 amount) external payable onlyGateway { + function deposit(address depositor, uint256 amount) external onlyGateway { underlyingToken.safeTransferFrom(depositor, address(this), amount); totalDepositedPrincipalAmount[depositor] += amount; consumedTvl += amount; @@ -94,26 +94,24 @@ contract Vault is Initializable, VaultStorage, IVault { revert Errors.VaultTvlLimitExceeded(); } emit ConsumedTvlChanged(consumedTvl); + emit PrincipalDeposited(depositor, amount); } /// @inheritdoc IVault - function updateWithdrawableBalance(address user, uint256 unlockPrincipalAmount, uint256 unlockRewardAmount) - external - onlyGateway - { + function unlockPrincipal(address user, uint256 amount) external onlyGateway { uint256 totalDeposited = totalDepositedPrincipalAmount[user]; - if (unlockPrincipalAmount > totalDeposited) { + if (amount > totalDeposited) { revert Errors.VaultPrincipalExceedsTotalDeposit(); } - totalUnlockPrincipalAmount[user] += unlockPrincipalAmount; + totalUnlockPrincipalAmount[user] += amount; if (totalUnlockPrincipalAmount[user] > totalDeposited) { revert Errors.VaultTotalUnlockPrincipalExceedsDeposit(); } - withdrawableBalances[user] = withdrawableBalances[user] + unlockPrincipalAmount + unlockRewardAmount; + withdrawableBalances[user] = withdrawableBalances[user] + amount; - emit WithdrawableBalanceUpdated(user, unlockPrincipalAmount, unlockRewardAmount); + emit PrincipalUnlocked(user, amount); } /// @inheritdoc IVault diff --git a/src/interfaces/IBaseRestakingController.sol b/src/interfaces/IBaseRestakingController.sol index 2383257c..66d127ee 100644 --- a/src/interfaces/IBaseRestakingController.sol +++ b/src/interfaces/IBaseRestakingController.sol @@ -18,12 +18,29 @@ interface IBaseRestakingController { /// @param amount The amount of tokens to undelegate. function undelegateFrom(string calldata operator, address token, uint256 amount) external payable; - /// @notice Client chain users call to claim their unlocked assets from the vault. - /// @dev This function assumes that the claimable assets should have been unlocked before calling this. + /// @notice Client chain users call to withdraw their unlocked assets from the vault. + /// @dev This function assumes that the withdrawable assets should have been unlocked before calling this. /// @dev This function does not interact with Exocore. /// @param token The address of specific token that the user wants to claim from the vault. /// @param amount The amount of @param token that the user wants to claim from the vault. /// @param recipient The destination address that the assets would be transfered to. - function claim(address token, uint256 amount, address recipient) external; + function withdrawPrincipal(address token, uint256 amount, address recipient) external; + + /// @notice Submits reward to the reward module on behalf of the AVS + /// @param token The address of the specific token that the user wants to submit as a reward. + /// @param avs The address of the AVS that the user wants to submit the reward to. + /// @param rewardAmount The amount of reward tokens that the user wants to submit. + function submitReward(address token, address avs, uint256 rewardAmount) external payable; + + /// @notice Claims reward tokens from Exocore. + /// @param token The address of the specific token that the user wants to claim as a reward. + /// @param rewardAmount The amount of reward tokens that the user wants to claim. + function claimRewardFromExocore(address token, uint256 rewardAmount) external payable; + + /// @notice Withdraws reward tokens from vault to the recipient. + /// @param token The address of the specific token that the user wants to withdraw as a reward. + /// @param recipient The address of the recipient of the reward tokens. + /// @param rewardAmount The amount of reward tokens that the user wants to withdraw. + function withdrawReward(address token, address recipient, uint256 rewardAmount) external; } diff --git a/src/interfaces/ICustomProxyAdmin.sol b/src/interfaces/ICustomProxyAdmin.sol index fef8a3ac..28efd61c 100644 --- a/src/interfaces/ICustomProxyAdmin.sol +++ b/src/interfaces/ICustomProxyAdmin.sol @@ -13,10 +13,8 @@ interface ICustomProxyAdmin { /// @param proxy The address of the proxy to change the implementation of. /// @param implementation The address of the new implementation. /// @param data The data to send to the new implementation. - /// @dev This function is payable to allow for the implementation to receive ETH for initialization. /// @dev This function is only callable by the proxy itself to upgrade itself. function changeImplementation(ITransparentUpgradeableProxy proxy, address implementation, bytes memory data) - external - payable; + external; } diff --git a/src/interfaces/IExoCapsule.sol b/src/interfaces/IExoCapsule.sol index 727e67d3..d80eea41 100644 --- a/src/interfaces/IExoCapsule.sol +++ b/src/interfaces/IExoCapsule.sol @@ -56,9 +56,9 @@ interface IExoCapsule { /// @param amountToWithdraw The amount to withdraw. function withdrawNonBeaconChainETHBalance(address payable recipient, uint256 amountToWithdraw) external; - /// @notice Increases the withdrawable balance of the ExoCapsule. - /// @param unlockPrincipalAmount The additionally unlocked withdrawable amount. - function updateWithdrawableBalance(uint256 unlockPrincipalAmount) external; + /// @notice Unlock and increase the withdrawable balance of the ExoCapsule for later withdrawal. + /// @param amount The amount of the ETH balance unlocked. + function unlockETHPrincipal(uint256 amount) external; /// @notice Returns the withdrawal credentials of the ExoCapsule. /// @return The withdrawal credentials. diff --git a/src/interfaces/ILSTRestakingController.sol b/src/interfaces/ILSTRestakingController.sol index 4e9cc1cf..69b151d9 100644 --- a/src/interfaces/ILSTRestakingController.sol +++ b/src/interfaces/ILSTRestakingController.sol @@ -18,18 +18,13 @@ interface ILSTRestakingController is IBaseRestakingController { /// @param amount The amount of the token that the user wants to deposit. function deposit(address token, uint256 amount) external payable; - /// @notice Requests withdrawal of the principal amount from Exocore to the client chain. - /// @dev This function requests withdrawal approval from Exocore. If approved, the assets are - /// unlocked and can be claimed by the user. Otherwise, they remain locked. - /// @param token The address of the specific token that the user wants to withdraw from Exocore. + /// @notice Send request to Exocore to claim the LST principal. + /// @dev This function requests claim approval from Exocore. If approved, the assets are + /// unlocked and can be withdrawn by the user. Otherwise, they remain locked. + /// @param token The address of the specific token that the user wants to claim from Exocore. /// @param principalAmount The principal amount of assets the user deposited into Exocore for delegation and /// staking. - function withdrawPrincipalFromExocore(address token, uint256 principalAmount) external payable; - - /// @notice Withdraws reward tokens from Exocore. - /// @param token The address of the specific token that the user wants to withdraw as a reward. - /// @param rewardAmount The amount of reward tokens that the user wants to withdraw. - function withdrawRewardFromExocore(address token, uint256 rewardAmount) external payable; + function claimPrincipalFromExocore(address token, uint256 principalAmount) external payable; /// @notice Deposits tokens and then delegates them to a specific node operator. /// @dev This function locks the specified amount of tokens into a vault, informs Exocore, and diff --git a/src/interfaces/IRewardVault.sol b/src/interfaces/IRewardVault.sol new file mode 100644 index 00000000..33cfdb32 --- /dev/null +++ b/src/interfaces/IRewardVault.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IRewardVault { + + /** + * @notice Initializes the reward vault. + * @param gateway_ The address of the gateway. + */ + function initialize(address gateway_) external; + + /** + * @notice Deposits a token into the reward vault. + * @param token The address of the token to be deposited. + * @param avs The avs ID to which the token is deposited. + * @param amount The amount of the token to be deposited. + */ + function deposit(address token, address depositor, address avs, uint256 amount) external; + + /** + * @notice Withdraws a token from the reward vault. + * @param token The address of the token to be withdrawn. + * @param withdrawer The address of the withdrawer. + * @param recipient The address of the recipient. + * @param amount The amount of the token to be withdrawn. + */ + function withdraw(address token, address withdrawer, address recipient, uint256 amount) external; + + /** + * @notice Unlocks and increases the withdrawable balance of a user for later withdrawal. + * @param token The address of the token to be unlocked. + * @param withdrawer The address of the withdrawer. + * @param amount The amount of the token to be unlocked. + */ + function unlockReward(address token, address withdrawer, uint256 amount) external; + + /** + * @notice Returns the withdrawable balance of a user. + * @param token The address of the token. + * @param withdrawer The address of the withdrawer. + * @return The withdrawable balance of the user. + */ + function getWithdrawableBalance(address token, address withdrawer) external view returns (uint256); + + /** + * @notice Returns the total deposited rewards of a token for a specific avs. + * @param token The address of the token. + * @param avs The address of the avs. + * @return The total deposited rewards of the token for the avs. + */ + function getTotalDepositedRewards(address token, address avs) external view returns (uint256); + +} diff --git a/src/interfaces/IVault.sol b/src/interfaces/IVault.sol index 74e6957f..0d04fb54 100644 --- a/src/interfaces/IVault.sol +++ b/src/interfaces/IVault.sol @@ -16,14 +16,12 @@ interface IVault { /// @notice Deposits a specified amount into the vault. /// @param depositor The address initiating the deposit. /// @param amount The amount to be deposited. - function deposit(address depositor, uint256 amount) external payable; - - /// @notice Updates the withdrawable balance for a user. - /// @param user The address of the user whose withdrawable balance is being updated. - /// @param unlockPrincipalAmount The amount of principal to be unlocked. - /// @param unlockRewardAmount The amount of reward to be unlocked. - function updateWithdrawableBalance(address user, uint256 unlockPrincipalAmount, uint256 unlockRewardAmount) - external; + function deposit(address depositor, uint256 amount) external; + + /// @notice Unlock and increase the withdrawable balance of a user for later withdrawal. + /// @param staker The address of the staker whose principal balance is being unlocked. + /// @param amount The amount of principal to be unlocked. + function unlockPrincipal(address staker, uint256 amount) external; /// @notice Returns the address of the underlying token. /// @return The address of the underlying token. diff --git a/src/interfaces/precompiles/IClaimReward.sol b/src/interfaces/precompiles/IClaimReward.sol deleted file mode 100644 index 49e30623..00000000 --- a/src/interfaces/precompiles/IClaimReward.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.17; - -/// TODO: we might remove this precompile contract and merge it into assets precompile -/// if we decide to handle reward withdrawal request by assets precompile - -/// @dev The claimReward contract's address. -address constant CLAIM_REWARD_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000806; - -/// @dev The claimReward contract's instance. -IClaimReward constant CLAIM_REWARD_CONTRACT = IClaimReward(CLAIM_REWARD_PRECOMPILE_ADDRESS); - -/// @author Exocore Team -/// @title ClaimReward Precompile Contract -/// @dev The interface through which solidity contracts will interact with ClaimReward -/// @custom:address 0x0000000000000000000000000000000000000806 -interface IClaimReward { - - /// TRANSACTIONS - /// @dev ClaimReward To the staker, that will change the state in reward module - /// Note that this address cannot be a module account. - /// @param clientChainLzId The lzId of client chain - /// @param assetsAddress The client chain asset Address - /// @param withdrawRewardAddress The claim reward address - /// @param opAmount The reward amount - function claimReward( - uint32 clientChainLzId, - bytes calldata assetsAddress, - bytes calldata withdrawRewardAddress, - uint256 opAmount - ) external returns (bool success, uint256 latestAssetState); - -} diff --git a/src/interfaces/precompiles/IReward.sol b/src/interfaces/precompiles/IReward.sol new file mode 100644 index 00000000..af30462a --- /dev/null +++ b/src/interfaces/precompiles/IReward.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.17; + +/// TODO: we might remove this precompile contract and merge it into assets precompile +/// if we decide to handle reward withdrawal request by assets precompile + +/// @dev The reward contract's address. +address constant REWARD_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000806; + +/// @dev The reward contract's instance. +IReward constant REWARD_CONTRACT = IReward(REWARD_PRECOMPILE_ADDRESS); + +/// @author Exocore Team +/// @title reward Precompile Contract +/// @dev The interface through which solidity contracts will interact with Reward precompile. +/// @custom:address 0x0000000000000000000000000000000000000806 +interface IReward { + + /// TRANSACTIONS + /// @dev Submit reward on behalf of the AVS to the reward module + /// @param clientChainLzId The lzId of client chain + /// @param assetsAddress The client chain asset Address, represented as bytes. + /// @param avsId The contract address of the AVS, represented as bytes. + /// @param amount The reward amount + function submitReward(uint32 clientChainLzId, bytes calldata assetsAddress, bytes calldata avsId, uint256 amount) + external + returns (bool success, uint256 latestAssetState); + + /// TRANSACTIONS + /// @dev ClaimReward To the staker, that will change the state in reward module + /// Note that this address cannot be a module account. + /// @param clientChainLzId The lzId of client chain + /// @param assetsAddress The client chain asset Address, represented as bytes. + /// @param withdrawer The address of the withdrawer, represented as bytes. + /// @param opAmount The reward amount + function claimReward( + uint32 clientChainLzId, + bytes calldata assetsAddress, + bytes calldata withdrawer, + uint256 opAmount + ) external returns (bool success, uint256 latestAssetState); + +} diff --git a/src/libraries/ActionAttributes.sol b/src/libraries/ActionAttributes.sol index f4b25f6a..7fa19f69 100644 --- a/src/libraries/ActionAttributes.sol +++ b/src/libraries/ActionAttributes.sol @@ -40,6 +40,10 @@ library ActionAttributes { } else if (action == Action.REQUEST_CLAIM_REWARD) { attributes = REWARD | WITHDRAWAL; messageLength = ASSET_OPERATION_LENGTH; + } else if (action == Action.REQUEST_SUBMIT_REWARD) { + // New action + attributes = REWARD; + messageLength = ASSET_OPERATION_LENGTH; } else if (action == Action.REQUEST_DELEGATE_TO || action == Action.REQUEST_UNDELEGATE_FROM) { messageLength = DELEGATION_OPERATION_LENGTH; } else if (action == Action.REQUEST_DEPOSIT_THEN_DELEGATE_TO) { diff --git a/src/libraries/Errors.sol b/src/libraries/Errors.sol index beef06e5..653ab3ed 100644 --- a/src/libraries/Errors.sol +++ b/src/libraries/Errors.sol @@ -304,4 +304,11 @@ library Errors { /// @dev Vault: forbid to deploy vault for the virtual token address representing natively staked ETH error ForbidToDeployVault(); + /* -------------------------------------------------------------------------- */ + /* RewardVault Errors */ + /* -------------------------------------------------------------------------- */ + + /// @dev RewardVault: insufficient balance + error InsufficientBalance(); + } diff --git a/src/storage/BootstrapStorage.sol b/src/storage/BootstrapStorage.sol index d147603e..3ebb10d9 100644 --- a/src/storage/BootstrapStorage.sol +++ b/src/storage/BootstrapStorage.sol @@ -180,9 +180,7 @@ contract BootstrapStorage is GatewayStorage { /// @param token The address of the token being withdrawn, on this chain. /// @param withdrawer The address of the withdrawer, on this chain. /// @param amount The amount of the token available to claim. - event WithdrawPrincipalResult( - bool indexed success, address indexed token, address indexed withdrawer, uint256 amount - ); + event ClaimPrincipalResult(bool indexed success, address indexed token, address indexed withdrawer, uint256 amount); /// @notice Emitted when a delegation is made to an operator. /// @dev This event is triggered whenever a delegator delegates tokens to an operator. diff --git a/src/storage/ClientChainGatewayStorage.sol b/src/storage/ClientChainGatewayStorage.sol index 33c835f6..9d06452b 100644 --- a/src/storage/ClientChainGatewayStorage.sol +++ b/src/storage/ClientChainGatewayStorage.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.19; import {IETHPOSDeposit} from "../interfaces/IETHPOSDeposit.sol"; import {IExoCapsule} from "../interfaces/IExoCapsule.sol"; +import {IRewardVault} from "../interfaces/IRewardVault.sol"; import {Errors} from "../libraries/Errors.sol"; import {BootstrapStorage} from "../storage/BootstrapStorage.sol"; @@ -31,7 +32,10 @@ contract ClientChainGatewayStorage is BootstrapStorage { /// @notice The address of the beacon chain oracle. address public immutable BEACON_ORACLE_ADDRESS; - /// @notice The beacon proxy for the ExoCapsule contract. + /// @notice The beacon for the reward vault contract, which stores the reward vault implementation. + IBeacon public immutable REWARD_VAULT_BEACON; + + /// @notice The beacon for the ExoCapsule contract, which stores the ExoCapsule implementation. IBeacon public immutable EXO_CAPSULE_BEACON; /// @dev The address of the ETHPOS deposit contract. @@ -47,6 +51,9 @@ contract ClientChainGatewayStorage is BootstrapStorage { /// @dev The msg.value for all the destination chains. uint128 internal constant DESTINATION_MSG_VALUE = 0; + /// @notice The reward vault contract. + IRewardVault public rewardVault; + /// @dev Storage gap to allow for future upgrades. uint256[40] private __gap; @@ -82,16 +89,22 @@ contract ClientChainGatewayStorage is BootstrapStorage { /// @param success Whether the corresponding request was successful on Exocore. event ResponseProcessed(Action indexed action, uint64 indexed requestId, bool indexed success); + /// @notice Emitted when a reward vault is created. + /// @param vault Address of the reward vault. + event RewardVaultCreated(address vault); + /// @notice Initializes the ClientChainGatewayStorage contract. /// @param exocoreChainId_ The chain ID of the Exocore chain. /// @param beaconOracleAddress_ The address of the beacon chain oracle. /// @param vaultBeacon_ The address of the beacon for the vault proxy. + /// @param rewardVaultBeacon_ The address of the beacon for the reward vault proxy. /// @param exoCapsuleBeacon_ The address of the beacon for the ExoCapsule proxy. /// @param beaconProxyBytecode_ The address of the beacon proxy bytecode contract. constructor( uint32 exocoreChainId_, address beaconOracleAddress_, address vaultBeacon_, + address rewardVaultBeacon_, address exoCapsuleBeacon_, address beaconProxyBytecode_ ) BootstrapStorage(exocoreChainId_, vaultBeacon_, beaconProxyBytecode_) { @@ -103,9 +116,14 @@ contract ClientChainGatewayStorage is BootstrapStorage { exoCapsuleBeacon_ != address(0), "ClientChainGatewayStorage: the exoCapsuleBeacon address for beacon proxy should not be empty" ); + require( + rewardVaultBeacon_ != address(0), + "ClientChainGatewayStorage: the reward vault beacon address for beacon proxy should not be empty" + ); BEACON_ORACLE_ADDRESS = beaconOracleAddress_; EXO_CAPSULE_BEACON = IBeacon(exoCapsuleBeacon_); + REWARD_VAULT_BEACON = IBeacon(rewardVaultBeacon_); } /// @dev Returns the ExoCapsule for the given owner, if it exists. Fails if the ExoCapsule does not exist. diff --git a/src/storage/ExocoreGatewayStorage.sol b/src/storage/ExocoreGatewayStorage.sol index 75732ca8..3c99f592 100644 --- a/src/storage/ExocoreGatewayStorage.sol +++ b/src/storage/ExocoreGatewayStorage.sol @@ -44,12 +44,19 @@ contract ExocoreGatewayStorage is GatewayStorage { /* --------- asset operations results and staking operations results -------- */ - /// @notice Emitted when reward is withdrawn. - /// @param success Whether the withdrawal was successful. + /// @notice Emitted when a reward operation is executed, submit or claim. + /// @param isSubmitReward Whether the operation is a submit reward or a claim reward. + /// @param success Whether the operation was successful. /// @param token The address of the token. - /// @param withdrawer The address of the withdrawer. - /// @param amount The amount of the token withdrawn. - event ClaimRewardResult(bool indexed success, bytes32 indexed token, bytes32 indexed withdrawer, uint256 amount); + /// @param avsOrWithdrawer The address of the avs or withdrawer, avs for submit reward, withdrawer for claim reward. + /// @param amount The amount of the token submitted or claimed. + event RewardOperation( + bool isSubmitReward, + bool indexed success, + bytes32 indexed token, + bytes32 indexed avsOrWithdrawer, + uint256 amount + ); /// @notice Emitted when a LST transfer happens. /// @param isDeposit Whether the transfer is a deposit or a withdraw. diff --git a/src/storage/GatewayStorage.sol b/src/storage/GatewayStorage.sol index a095a170..d37604fa 100644 --- a/src/storage/GatewayStorage.sol +++ b/src/storage/GatewayStorage.sol @@ -5,19 +5,20 @@ import {Errors} from "../libraries/Errors.sol"; /// @notice Enum representing various actions that can be performed. enum Action { + RESPOND, + REQUEST_ADD_WHITELIST_TOKEN, REQUEST_DEPOSIT_LST, REQUEST_DEPOSIT_NST, REQUEST_WITHDRAW_LST, REQUEST_WITHDRAW_NST, + REQUEST_SUBMIT_REWARD, REQUEST_CLAIM_REWARD, REQUEST_DELEGATE_TO, REQUEST_UNDELEGATE_FROM, REQUEST_DEPOSIT_THEN_DELEGATE_TO, REQUEST_MARK_BOOTSTRAP, - REQUEST_ADD_WHITELIST_TOKEN, REQUEST_ASSOCIATE_OPERATOR, - REQUEST_DISSOCIATE_OPERATOR, - RESPOND + REQUEST_DISSOCIATE_OPERATOR } /// @title GatewayStorage diff --git a/src/storage/RewardVaultStorage.sol b/src/storage/RewardVaultStorage.sol new file mode 100644 index 00000000..cd3b1cba --- /dev/null +++ b/src/storage/RewardVaultStorage.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +contract RewardVaultStorage { + + // Address of the gateway contract + address public gateway; + + // Mapping of token address to staker address to withdrawable balance + mapping(address => mapping(address => uint256)) public withdrawableBalances; + + // Mapping of token address to AVS ID to balance + mapping(address => mapping(address => uint256)) public totalDepositedRewards; + + // Gap for future storage variables + uint256[40] private __gap; + + /** + * @notice Emitted when a reward is deposited. + * @param token The address of the token. + * @param avs The address of the AVS. + * @param amount The amount of the reward deposited. + */ + event RewardDeposited(address indexed token, address indexed avs, uint256 amount); + + /** + * @notice Emitted when a reward is unlocked. + * @param token The address of the token. + * @param staker The address of the staker. + * @param amount The amount of the reward unlocked. + */ + event RewardUnlocked(address indexed token, address indexed staker, uint256 amount); + + /** + * @notice Emitted when a reward is withdrawn. + * @param token The address of the token. + * @param staker The address of the staker. + * @param recipient The address of the recipient. + * @param amount The amount of the reward withdrawn. + */ + event RewardWithdrawn(address indexed token, address indexed staker, address indexed recipient, uint256 amount); + +} diff --git a/src/storage/VaultStorage.sol b/src/storage/VaultStorage.sol index 8e4b67d9..512d7bbe 100644 --- a/src/storage/VaultStorage.sol +++ b/src/storage/VaultStorage.sol @@ -30,22 +30,21 @@ contract VaultStorage { /// @notice Address of the gateway contract. ILSTRestakingController public gateway; - /// @notice Emitted when a user's reward balance is updated. - /// @param user The address of the user. - /// @param amount The new reward balance. - event RewardBalanceUpdated(address user, uint256 amount); - - /// @notice Emmitted when a user's withdrawable balance is updated. - /// @param user The address of the user. - /// @param pAmount The new principal balance. - /// @param rAmount The new reward balance. - event WithdrawableBalanceUpdated(address user, uint256 pAmount, uint256 rAmount); - - /// @notice Emitted upon withdrawal success. + /// @notice Emitted when a user's principal balance is deposited. + /// @param depositor The address of the depositor. + /// @param amount The amount of the principal balance deposited. + event PrincipalDeposited(address indexed depositor, uint256 amount); + + /// @notice Emitted when a user's principal balance is unlocked for withdrawal. + /// @param staker The address of the withdrawer. + /// @param amount The amount of the principal balance unlocked. + event PrincipalUnlocked(address indexed staker, uint256 amount); + + /// @notice Emitted when a user's principal balance is withdrawn. /// @param src The address of the withdrawer. /// @param dst The address of the recipient. - /// @param amount The amount withdrawn. - event WithdrawalSuccess(address src, address dst, uint256 amount); + /// @param amount The amount of the principal balance withdrawn. + event PrincipalWithdrawn(address indexed src, address indexed dst, uint256 amount); /// @notice Emitted upon the TVL limit being updated. /// @param newTvlLimit The new TVL limit. diff --git a/src/utils/CustomProxyAdmin.sol b/src/utils/CustomProxyAdmin.sol index 284d1f92..c5b997a2 100644 --- a/src/utils/CustomProxyAdmin.sol +++ b/src/utils/CustomProxyAdmin.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; +import {ICustomProxyAdmin} from "../interfaces/ICustomProxyAdmin.sol"; import {Errors} from "../libraries/Errors.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; @@ -11,7 +12,7 @@ import {ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transp /// @notice CustomProxyAdmin is a custom implementation of ProxyAdmin that allows a proxy contract to upgrade its own /// implementation. /// @dev This contract is not upgradeable intentionally, since doing so would produce a lot of risk. -contract CustomProxyAdmin is Initializable, ProxyAdmin { +contract CustomProxyAdmin is Initializable, ProxyAdmin, ICustomProxyAdmin { /// @notice The address of the proxy which will upgrade itself. address public bootstrapper; @@ -28,15 +29,18 @@ contract CustomProxyAdmin is Initializable, ProxyAdmin { } /// @notice Changes the implementation of the proxy contract. - /// @param proxy The address of the proxy contract. + /// @param proxy The proxy contract. /// @param implementation The address of the new implementation contract. /// @param data The data to be passed to the new implementation contract. /// @dev This function can only be called by the proxy to upgrade itself, exactly once. - function changeImplementation(address proxy, address implementation, bytes calldata data) public virtual { + function changeImplementation(ITransparentUpgradeableProxy proxy, address implementation, bytes calldata data) + public + virtual + { if (msg.sender != bootstrapper) { revert Errors.CustomProxyAdminOnlyCalledFromBootstrapper(); } - if (msg.sender != proxy) { + if (msg.sender != address(proxy)) { revert Errors.CustomProxyAdminOnlyCalledFromProxy(); } diff --git a/test/foundry/DepositWithdrawPrinciple.t.sol b/test/foundry/DepositWithdrawPrinciple.t.sol index 65d17aba..4a2e6406 100644 --- a/test/foundry/DepositWithdrawPrinciple.t.sol +++ b/test/foundry/DepositWithdrawPrinciple.t.sol @@ -29,6 +29,9 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { event Transfer(address indexed from, address indexed to, uint256 amount); event CapsuleCreated(address owner, address capsule); event StakedWithCapsule(address staker, address capsule); + event PrincipalDeposited(address indexed depositor, uint256 amount); + event PrincipalUnlocked(address indexed staker, uint256 amount); + event PrincipalWithdrawn(address indexed src, address indexed dst, uint256 amount); uint256 constant DEFAULT_ENDPOINT_CALL_GAS_LIMIT = 200_000; uint64 public constant MAX_RESTAKED_BALANCE_GWEI_PER_VALIDATOR = 32e9; @@ -97,6 +100,9 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // depositor should transfer deposited token to vault vm.expectEmit(true, true, false, true, address(restakeToken)); emit Transfer(depositor.addr, address(vault), depositAmount); + vm.expectEmit(true, true, true, true, address(vault)); + emit PrincipalDeposited(depositor.addr, depositAmount); + // client chain layerzero endpoint should emit the message packet including deposit payload. vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); emit NewPacket( @@ -168,9 +174,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { emit MessageSent( Action.REQUEST_WITHDRAW_LST, withdrawRequestId, outboundNonces[clientChainId]++, withdrawRequestNativeFee ); - clientGateway.withdrawPrincipalFromExocore{value: withdrawRequestNativeFee}( - address(restakeToken), withdrawAmount - ); + clientGateway.claimPrincipalFromExocore{value: withdrawRequestNativeFee}(address(restakeToken), withdrawAmount); // second layerzero relayers should watch the request message packet and relay the message to destination // endpoint @@ -218,6 +222,8 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { // endpoint // client chain gateway should execute the response hook and emit RequestFinished event + vm.expectEmit(true, true, true, true, address(vault)); + emit PrincipalUnlocked(withdrawer.addr, withdrawAmount); vm.expectEmit(true, true, true, true, address(clientGateway)); emit ResponseProcessed(Action.REQUEST_WITHDRAW_LST, outboundNonces[clientChainId] - 1, true); vm.expectEmit(address(clientGateway)); @@ -613,7 +619,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { nativeFee = clientGateway.quote(requestPayload); vm.expectEmit(address(clientGateway)); emit MessageSent(Action.REQUEST_WITHDRAW_LST, requestId, outboundNonces[clientChainId]++, nativeFee); - clientGateway.withdrawPrincipalFromExocore{value: nativeFee}(address(restakeToken), withdrawAmount); + clientGateway.claimPrincipalFromExocore{value: nativeFee}(address(restakeToken), withdrawAmount); vm.stopPrank(); principalBalance -= withdrawAmount; @@ -653,7 +659,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { vm.startPrank(addr); vm.expectEmit(address(restakeToken)); emit Transfer(address(vault), addr, withdrawAmount); - clientGateway.claim(address(restakeToken), withdrawAmount, addr); + clientGateway.withdrawPrincipal(address(restakeToken), withdrawAmount, addr); vm.stopPrank(); consumedTvl -= withdrawAmount; @@ -681,7 +687,7 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { nativeFee = clientGateway.quote(requestPayload); vm.expectEmit(address(clientGateway)); emit MessageSent(Action.REQUEST_WITHDRAW_LST, requestId, outboundNonces[clientChainId]++, nativeFee); - clientGateway.withdrawPrincipalFromExocore{value: nativeFee}(address(restakeToken), withdrawAmount); + clientGateway.claimPrincipalFromExocore{value: nativeFee}(address(restakeToken), withdrawAmount); // obtain the response responsePayload = abi.encodePacked(Action.RESPOND, outboundNonces[clientChainId] - 1, true); @@ -719,11 +725,11 @@ contract DepositWithdrawPrincipalTest is ExocoreDeployer { assertTrue(vault.getConsumedTvl() == consumedTvl); assertTrue(vault.getTvlLimit() == newTvlLimit); - // claim now + // withdraw now vm.startPrank(addr); vm.expectEmit(address(restakeToken)); emit Transfer(address(vault), addr, withdrawAmount); - clientGateway.claim(address(restakeToken), withdrawAmount, addr); + clientGateway.withdrawPrincipal(address(restakeToken), withdrawAmount, addr); consumedTvl -= withdrawAmount; vm.stopPrank(); diff --git a/test/foundry/ExocoreDeployer.t.sol b/test/foundry/ExocoreDeployer.t.sol index bca85ea3..0938b916 100644 --- a/test/foundry/ExocoreDeployer.t.sol +++ b/test/foundry/ExocoreDeployer.t.sol @@ -18,19 +18,24 @@ import "../../src/core/ClientChainGateway.sol"; import "../../src/core/ExoCapsule.sol"; import "../../src/core/ExocoreGateway.sol"; + +import {RewardVault} from "../../src/core/RewardVault.sol"; import {Vault} from "../../src/core/Vault.sol"; import {Action, GatewayStorage} from "../../src/storage/GatewayStorage.sol"; +import {IRewardVault} from "../../src/interfaces/IRewardVault.sol"; import {IVault} from "../../src/interfaces/IVault.sol"; import "../../src/interfaces/precompiles/IAssets.sol"; -import "../../src/interfaces/precompiles/IClaimReward.sol"; + import "../../src/interfaces/precompiles/IDelegation.sol"; +import "../../src/interfaces/precompiles/IReward.sol"; import "../mocks/AssetsMock.sol"; -import "../mocks/ClaimRewardMock.sol"; + import "../mocks/DelegationMock.sol"; import {NonShortCircuitEndpointV2Mock} from "../mocks/NonShortCircuitEndpointV2Mock.sol"; +import "../mocks/RewardMock.sol"; import "src/core/ExoCapsule.sol"; import "src/utils/BeaconProxyBytecode.sol"; @@ -52,6 +57,7 @@ contract ExocoreDeployer is Test { ClientChainGateway clientGateway; ClientChainGateway clientGatewayLogic; + IRewardVault rewardVault; Vault vault; ExoCapsule capsule; ExocoreGateway exocoreGateway; @@ -60,8 +66,10 @@ contract ExocoreDeployer is Test { ILayerZeroEndpointV2 exocoreLzEndpoint; IBeaconChainOracle beaconOracle; IVault vaultImplementation; + IRewardVault rewardVaultImplementation; IExoCapsule capsuleImplementation; IBeacon vaultBeacon; + IBeacon rewardVaultBeacon; IBeacon capsuleBeacon; BeaconProxyBytecode beaconProxyBytecode; @@ -134,8 +142,8 @@ contract ExocoreDeployer is Test { bytes memory DelegationMockCode = vm.getDeployedCode("DelegationMock.sol"); vm.etch(DELEGATION_PRECOMPILE_ADDRESS, DelegationMockCode); - bytes memory WithdrawRewardMockCode = vm.getDeployedCode("ClaimRewardMock.sol"); - vm.etch(CLAIM_REWARD_PRECOMPILE_ADDRESS, WithdrawRewardMockCode); + bytes memory RewardMockCode = vm.getDeployedCode("RewardMock.sol"); + vm.etch(REWARD_PRECOMPILE_ADDRESS, RewardMockCode); // load beacon chain validator container and proof from json file string memory validatorInfo = vm.readFile("test/foundry/test-data/validator_container_proof_302913.json"); @@ -325,10 +333,12 @@ contract ExocoreDeployer is Test { // that has logics called by proxy vaultImplementation = new Vault(); capsuleImplementation = new ExoCapsule(); + rewardVaultImplementation = new RewardVault(); // deploy the vault beacon and capsule beacon that store the implementation contract address vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); capsuleBeacon = new UpgradeableBeacon(address(capsuleImplementation)); + rewardVaultBeacon = new UpgradeableBeacon(address(rewardVaultImplementation)); // deploy BeaconProxyBytecode to store BeaconProxyBytecode beaconProxyBytecode = new BeaconProxyBytecode(); @@ -345,6 +355,7 @@ contract ExocoreDeployer is Test { exocoreChainId, address(beaconOracle), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); @@ -362,6 +373,10 @@ contract ExocoreDeployer is Test { ) ); + // get the reward vault address since it would be deployed during initialization + rewardVault = clientGateway.rewardVault(); + require(address(rewardVault) != address(0), "reward vault should not be empty"); + // deploy Exocore network contracts exocoreGatewayLogic = new ExocoreGateway(address(exocoreLzEndpoint)); exocoreGateway = ExocoreGateway( diff --git a/test/foundry/Governance.t.sol b/test/foundry/Governance.t.sol index bb1fee3b..ea7ca693 100644 --- a/test/foundry/Governance.t.sol +++ b/test/foundry/Governance.t.sol @@ -24,7 +24,10 @@ import "src/core/ClientChainGateway.sol"; import "src/storage/ClientChainGatewayStorage.sol"; import "src/core/ExoCapsule.sol"; + +import {RewardVault} from "src/core/RewardVault.sol"; import {Vault} from "src/core/Vault.sol"; +import {IRewardVault} from "src/interfaces/IRewardVault.sol"; import {NonShortCircuitEndpointV2Mock} from "../mocks/NonShortCircuitEndpointV2Mock.sol"; import "src/interfaces/IExoCapsule.sol"; @@ -61,8 +64,10 @@ contract GovernanceTest is Test { ILayerZeroEndpointV2 clientChainLzEndpoint; IBeaconChainOracle beaconOracle; IVault vaultImplementation; + IRewardVault rewardVaultImplementation; IExoCapsule capsuleImplementation; IBeacon vaultBeacon; + IBeacon rewardVaultBeacon; IBeacon capsuleBeacon; BeaconProxyBytecode beaconProxyBytecode; @@ -131,9 +136,11 @@ contract GovernanceTest is Test { beaconOracle = IBeaconChainOracle(_deployBeaconOracle()); vaultImplementation = new Vault(); + rewardVaultImplementation = new RewardVault(); capsuleImplementation = new ExoCapsule(); vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); + rewardVaultBeacon = new UpgradeableBeacon(address(rewardVaultImplementation)); capsuleBeacon = new UpgradeableBeacon(address(capsuleImplementation)); beaconProxyBytecode = new BeaconProxyBytecode(); @@ -147,6 +154,7 @@ contract GovernanceTest is Test { exocoreChainId, address(beaconOracle), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); diff --git a/test/foundry/WithdrawReward.t.sol b/test/foundry/WithdrawReward.t.sol index 973a551f..f064d72d 100644 --- a/test/foundry/WithdrawReward.t.sol +++ b/test/foundry/WithdrawReward.t.sol @@ -1,7 +1,10 @@ pragma solidity ^0.8.19; import "../../src/core/ExocoreGateway.sol"; + +import "../../src/interfaces/precompiles/IReward.sol"; import {Action, GatewayStorage} from "../../src/storage/GatewayStorage.sol"; +import "../mocks/RewardMock.sol"; import "./ExocoreDeployer.t.sol"; import "@layerzero-v2/protocol/contracts/libs/AddressCast.sol"; @@ -13,24 +16,144 @@ contract WithdrawRewardTest is ExocoreDeployer { using AddressCast for address; - event ClaimRewardResult(bool indexed success, bytes32 indexed token, bytes32 indexed withdrawer, uint256 amount); + event RewardOperation( + bool isSubmitReward, + bool indexed success, + bytes32 indexed token, + bytes32 indexed avsOrWithdrawer, + uint256 amount + ); event Transfer(address indexed from, address indexed to, uint256 amount); + event RewardDeposited(address indexed token, address indexed avs, uint256 amount); + event RewardUnlocked(address indexed token, address indexed staker, uint256 amount); + event RewardWithdrawn(address indexed token, address indexed staker, address indexed recipient, uint256 amount); uint256 constant DEFAULT_ENDPOINT_CALL_GAS_LIMIT = 200_000; - function test_WithdrawRewardByLayerZero() public { - Player memory withdrawer = players[0]; - Player memory relayer = players[1]; + function test_SubmitAndClaimAndWithdrawRewardByLayerZero() public { + Player memory avsDepositor = players[0]; + Player memory staker = players[1]; + Player memory relayer = players[2]; + address avs = address(0xaabb); + + // fund the avs depositor some restake token so that it can deposit reward to reward vault + vm.startPrank(exocoreValidatorSet.addr); + restakeToken.transfer(avsDepositor.addr, 1_000_000); + vm.stopPrank(); - deal(withdrawer.addr, 1e22); - deal(address(clientGateway), 1e22); + // fund the depositor, staker, and exocore gateway for gas fee + deal(avsDepositor.addr, 1e22); + deal(staker.addr, 1e22); deal(address(exocoreGateway), 1e22); - uint256 withdrawAmount = 1000; + + // the amount of deposit, distribute, and withdraw + uint256 depositAmount = 1000; + uint256 distributeAmount = 500; + uint256 claimAmount = 100; + uint256 withdrawAmount = 100; // before withdraw we should add whitelist tokens test_AddWhitelistTokens(); - // -- withdraw reward workflow -- + _testSubmitReward(avsDepositor, relayer, staker, avs, depositAmount); + RewardMock(REWARD_PRECOMPILE_ADDRESS).distributeReward( + clientChainId, + _addressToBytes(address(restakeToken)), + _addressToBytes(avs), + _addressToBytes(staker.addr), + distributeAmount + ); + _testClaimReward(staker, relayer, claimAmount); + _testWithdrawReward(staker, withdrawAmount); + } + + function _testSubmitReward( + Player memory depositor, + Player memory relayer, + Player memory staker, + address avs, + uint256 amount + ) internal { + // -- submit reward workflow -- + + // first user call client chain gateway to submit reward on behalf of AVS + + // depositor needs to approve the restake token to the client gateway + vm.startPrank(depositor.addr); + restakeToken.approve(address(rewardVault), amount); + vm.stopPrank(); + + // estimate l0 relay fee that the user should pay + bytes memory submitRewardRequestPayload = abi.encodePacked( + Action.REQUEST_SUBMIT_REWARD, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(avs)), amount + ); + uint256 requestNativeFee = clientGateway.quote(submitRewardRequestPayload); + bytes32 requestId = generateUID(outboundNonces[clientChainId], true); + + // depositor should transfer deposited token to vault + vm.expectEmit(true, true, false, true, address(restakeToken)); + emit Transfer(depositor.addr, address(rewardVault), amount); + vm.expectEmit(true, true, true, true, address(rewardVault)); + emit RewardDeposited(address(restakeToken), avs, amount); + + // client chain layerzero endpoint should emit the message packet including submit reward payload. + vm.expectEmit(true, true, true, true, address(clientChainLzEndpoint)); + emit NewPacket( + exocoreChainId, + address(clientGateway), + address(exocoreGateway).toBytes32(), + outboundNonces[clientChainId], + submitRewardRequestPayload + ); + + // client chain gateway should emit MessageSent event + vm.expectEmit(true, true, true, true, address(clientGateway)); + emit MessageSent(Action.REQUEST_SUBMIT_REWARD, requestId, outboundNonces[clientChainId]++, requestNativeFee); + + vm.startPrank(depositor.addr); + clientGateway.submitReward{value: requestNativeFee}(address(restakeToken), avs, amount); + vm.stopPrank(); + + // assert that withdrawable amount is zero + assertEq(rewardVault.getWithdrawableBalance(address(restakeToken), staker.addr), 0); + assertEq(rewardVault.getWithdrawableBalance(address(restakeToken), depositor.addr), 0); + // assert total deposited amount for the avs is equal to the amount + assertEq(rewardVault.getTotalDepositedRewards(address(restakeToken), avs), amount); + + // second layerzero relayers should watch the request message packet and relay the message to destination + // endpoint + + // exocore gateway should emit RewardOperation event + vm.expectEmit(true, true, true, true, address(exocoreGateway)); + emit RewardOperation(true, true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(avs)), amount); + + vm.expectEmit(address(exocoreGateway)); + emit MessageExecuted(Action.REQUEST_SUBMIT_REWARD, inboundNonces[exocoreChainId]++); + + vm.startPrank(relayer.addr); + exocoreLzEndpoint.lzReceive( + Origin(clientChainId, address(clientGateway).toBytes32(), inboundNonces[exocoreChainId] - 1), + address(exocoreGateway), + requestId, + submitRewardRequestPayload, + bytes("") + ); + vm.stopPrank(); + + // assert that RewardMock has increased the reward amount for the avs + assertEq( + RewardMock(REWARD_PRECOMPILE_ADDRESS).getRewardAmountForAVS( + clientChainId, _addressToBytes(address(restakeToken)), _addressToBytes(avs) + ), + amount + ); + } + + function _testClaimReward(Player memory withdrawer, Player memory relayer, uint256 amount) internal { + // -- claim reward workflow -- + + uint256 withdrawableAmountBeforeClaim = + rewardVault.getWithdrawableBalance(address(restakeToken), withdrawer.addr); // first user call client chain gateway to withdraw @@ -39,7 +162,7 @@ contract WithdrawRewardTest is ExocoreDeployer { Action.REQUEST_CLAIM_REWARD, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(withdrawer.addr)), - withdrawAmount + amount ); uint256 requestNativeFee = clientGateway.quote(withdrawRequestPayload); bytes32 requestId = generateUID(outboundNonces[clientChainId], true); @@ -57,9 +180,14 @@ contract WithdrawRewardTest is ExocoreDeployer { emit MessageSent(Action.REQUEST_CLAIM_REWARD, requestId, outboundNonces[clientChainId]++, requestNativeFee); vm.startPrank(withdrawer.addr); - clientGateway.withdrawRewardFromExocore{value: requestNativeFee}(address(restakeToken), withdrawAmount); + clientGateway.claimRewardFromExocore{value: requestNativeFee}(address(restakeToken), amount); vm.stopPrank(); + // assert that withdrawable amount is not increased before receiving response from exocore + assertEq( + rewardVault.getWithdrawableBalance(address(restakeToken), withdrawer.addr), withdrawableAmountBeforeClaim + ); + // second layerzero relayers should watch the request message packet and relay the message to destination // endpoint @@ -68,10 +196,10 @@ contract WithdrawRewardTest is ExocoreDeployer { uint256 responseNativeFee = exocoreGateway.quote(clientChainId, withdrawResponsePayload); bytes32 responseId = generateUID(outboundNonces[exocoreChainId], false); - // exocore gateway should emit WithdrawRewardResult event + // exocore gateway should emit RewardOperation event vm.expectEmit(true, true, true, true, address(exocoreGateway)); - emit ClaimRewardResult( - true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(withdrawer.addr)), withdrawAmount + emit RewardOperation( + false, true, bytes32(bytes20(address(restakeToken))), bytes32(bytes20(withdrawer.addr)), amount ); vm.expectEmit(true, true, true, true, address(exocoreLzEndpoint)); @@ -103,6 +231,8 @@ contract WithdrawRewardTest is ExocoreDeployer { // endpoint // client chain gateway should execute the response hook and emit RequestFinished event + vm.expectEmit(true, true, true, true, address(rewardVault)); + emit RewardUnlocked(address(restakeToken), withdrawer.addr, amount); vm.expectEmit(true, true, true, true, address(clientGateway)); emit ResponseProcessed(Action.REQUEST_CLAIM_REWARD, outboundNonces[clientChainId] - 1, true); @@ -118,6 +248,34 @@ contract WithdrawRewardTest is ExocoreDeployer { bytes("") ); vm.stopPrank(); + + // assert that the withdrawable amount has been increased by the amount + uint256 withdrawableAmountAfterClaim = + rewardVault.getWithdrawableBalance(address(restakeToken), withdrawer.addr); + assertEq(withdrawableAmountAfterClaim, withdrawableAmountBeforeClaim + amount); + } + + function _testWithdrawReward(Player memory withdrawer, uint256 amount) internal { + // -- withdraw reward workflow -- + + uint256 withdrawableAmountBeforeWithdraw = + rewardVault.getWithdrawableBalance(address(restakeToken), withdrawer.addr); + uint256 balanceBeforeWithdraw = restakeToken.balanceOf(withdrawer.addr); + + vm.expectEmit(true, true, true, true, address(rewardVault)); + emit RewardWithdrawn(address(restakeToken), withdrawer.addr, withdrawer.addr, amount); + + vm.startPrank(withdrawer.addr); + clientGateway.withdrawReward(address(restakeToken), withdrawer.addr, amount); + vm.stopPrank(); + + // assert the withdrawable amount has been decreased by the amount + uint256 withdrawableAmountAfterWithdraw = + rewardVault.getWithdrawableBalance(address(restakeToken), withdrawer.addr); + assertEq(withdrawableAmountAfterWithdraw, withdrawableAmountBeforeWithdraw - amount); + // assert that the balance of the withdrawer has been increased by the amount + uint256 balanceAfterWithdraw = restakeToken.balanceOf(withdrawer.addr); + assertEq(balanceAfterWithdraw, balanceBeforeWithdraw + amount); } } diff --git a/test/foundry/unit/Bootstrap.t.sol b/test/foundry/unit/Bootstrap.t.sol index 9db4f327..a29fc1ab 100644 --- a/test/foundry/unit/Bootstrap.t.sol +++ b/test/foundry/unit/Bootstrap.t.sol @@ -11,7 +11,11 @@ import {IValidatorRegistry} from "src/interfaces/IValidatorRegistry.sol"; import {NonShortCircuitEndpointV2Mock} from "../../mocks/NonShortCircuitEndpointV2Mock.sol"; import {MyToken} from "./MyToken.sol"; + +import {RewardVault} from "src/core/RewardVault.sol"; +import {IRewardVault} from "src/interfaces/IRewardVault.sol"; import {IVault} from "src/interfaces/IVault.sol"; + import {Origin} from "src/lzApp/OAppReceiverUpgradeable.sol"; import {BootstrapStorage} from "src/storage/BootstrapStorage.sol"; import {Action, GatewayStorage} from "src/storage/GatewayStorage.sol"; @@ -61,8 +65,10 @@ contract BootstrapTest is Test { address constant lzActor = address(0x20); IVault vaultImplementation; + IRewardVault rewardVaultImplementation; IExoCapsule capsuleImplementation; IBeacon vaultBeacon; + IBeacon rewardVaultBeacon; IBeacon capsuleBeacon; BeaconProxyBytecode beaconProxyBytecode; @@ -86,9 +92,11 @@ contract BootstrapTest is Test { // deploy vault implementationcontract that has logics called by proxy vaultImplementation = new Vault(); + rewardVaultImplementation = new RewardVault(); // deploy the vault beacon that store the implementation contract address vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); + rewardVaultBeacon = new UpgradeableBeacon(address(rewardVaultImplementation)); // deploy BeaconProxyBytecode to store BeaconProxyBytecode beaconProxyBytecode = new BeaconProxyBytecode(); @@ -110,6 +118,7 @@ contract BootstrapTest is Test { exocoreChainId, address(0x1), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); @@ -394,8 +403,8 @@ contract BootstrapTest is Test { // now attempt to withdraw, which should go through vm.startPrank(addr); - bootstrap.withdrawPrincipalFromExocore(address(myToken), withdrawAmount); - bootstrap.claim(address(myToken), withdrawAmount, addr); + bootstrap.claimPrincipalFromExocore(address(myToken), withdrawAmount); + bootstrap.withdrawPrincipal(address(myToken), withdrawAmount, addr); vm.stopPrank(); assertTrue(vault.getConsumedTvl() == balance - withdrawAmount); @@ -413,8 +422,8 @@ contract BootstrapTest is Test { // withdraw to get just below tvl limit withdrawAmount = vault.getConsumedTvl() - vault.getTvlLimit() + 1; vm.startPrank(addr); - bootstrap.withdrawPrincipalFromExocore(address(myToken), withdrawAmount); - bootstrap.claim(address(myToken), withdrawAmount, addr); + bootstrap.claimPrincipalFromExocore(address(myToken), withdrawAmount); + bootstrap.withdrawPrincipal(address(myToken), withdrawAmount, addr); vm.stopPrank(); assertTrue(vault.getConsumedTvl() == newTvlLimit - 1); @@ -929,7 +938,7 @@ contract BootstrapTest is Test { bootstrap.undelegateFrom("exo13hasr43vvq8v44xpzh0l6yuym4kca98f87j7ac", address(myToken), amounts[0]); } - function test11_WithdrawPrincipalFromExocore() public { + function test11_ClaimPrincipalFromExocore() public { // delegate and then undelegate test10_UndelegateFrom(); // now, withdraw @@ -940,7 +949,7 @@ contract BootstrapTest is Test { uint256 prevTokenDeposit = bootstrap.depositsByToken(address(myToken)); uint256 prevVaultWithdrawable = Vault(address(bootstrap.tokenToVault(address(myToken)))).withdrawableBalances(addrs[i]); - bootstrap.withdrawPrincipalFromExocore(address(myToken), amounts[i]); + bootstrap.claimPrincipalFromExocore(address(myToken), amounts[i]); uint256 postDeposit = bootstrap.totalDepositAmounts(addrs[i], address(myToken)); uint256 postWithdrawable = bootstrap.withdrawableAmounts(addrs[i], address(myToken)); uint256 postTokenDeposit = bootstrap.depositsByToken(address(myToken)); @@ -955,40 +964,40 @@ contract BootstrapTest is Test { } } - function test11_WithdrawPrincipalFromExocore_TokenNotWhitelisted() public { + function test11_ClaimPrincipalFromExocore_TokenNotWhitelisted() public { vm.startPrank(addrs[0]); vm.expectRevert("BootstrapStorage: token is not whitelisted"); - bootstrap.withdrawPrincipalFromExocore(address(0xa), amounts[0]); + bootstrap.claimPrincipalFromExocore(address(0xa), amounts[0]); vm.stopPrank(); } - function test11_WithdrawPrincipalFromExocore_ZeroAmount() public { + function test11_ClaimPrincipalFromExocore_ZeroAmount() public { vm.startPrank(addrs[0]); vm.expectRevert("BootstrapStorage: amount should be greater than zero"); - bootstrap.withdrawPrincipalFromExocore(address(myToken), 0); + bootstrap.claimPrincipalFromExocore(address(myToken), 0); vm.stopPrank(); } - function test11_WithdrawPrincipalFromExocore_NoDeposits() public { + function test11_ClaimPrincipalFromExocore_NoDeposits() public { vm.startPrank(addrs[0]); vm.expectRevert(Errors.BootstrapInsufficientDepositedBalance.selector); - bootstrap.withdrawPrincipalFromExocore(address(myToken), amounts[0]); + bootstrap.claimPrincipalFromExocore(address(myToken), amounts[0]); vm.stopPrank(); } - function test11_WithdrawPrincipalFromExocore_Excess() public { + function test11_ClaimPrincipalFromExocore_Excess() public { test10_UndelegateFrom(); vm.startPrank(addrs[0]); vm.expectRevert(Errors.BootstrapInsufficientDepositedBalance.selector); - bootstrap.withdrawPrincipalFromExocore(address(myToken), amounts[0] + 1); + bootstrap.claimPrincipalFromExocore(address(myToken), amounts[0] + 1); vm.stopPrank(); } - function test11_WithdrawPrincipalFromExocore_ExcessFree() public { + function test11_ClaimPrincipalFromExocore_ExcessFree() public { test09_DelegateTo(); vm.startPrank(addrs[0]); vm.expectRevert(Errors.BootstrapInsufficientWithdrawableBalance.selector); - bootstrap.withdrawPrincipalFromExocore(address(myToken), amounts[0]); + bootstrap.claimPrincipalFromExocore(address(myToken), amounts[0]); vm.stopPrank(); } @@ -1416,15 +1425,15 @@ contract BootstrapTest is Test { function test20_WithdrawRewardFromExocore() public { vm.expectRevert(abi.encodeWithSignature("NotYetSupported()")); - bootstrap.withdrawRewardFromExocore(address(0x0), 1); + bootstrap.claimRewardFromExocore(address(0x0), 1); } - function test22_Claim() public { - test11_WithdrawPrincipalFromExocore(); + function test22_WithdrawPrincipal() public { + test11_ClaimPrincipalFromExocore(); for (uint256 i = 0; i < 6; i++) { vm.startPrank(addrs[i]); uint256 prevBalance = myToken.balanceOf(addrs[i]); - bootstrap.claim(address(myToken), amounts[i], addrs[i]); + bootstrap.withdrawPrincipal(address(myToken), amounts[i], addrs[i]); uint256 postBalance = myToken.balanceOf(addrs[i]); assertTrue(postBalance == prevBalance + amounts[i]); vm.stopPrank(); @@ -1434,20 +1443,20 @@ contract BootstrapTest is Test { function test22_Claim_TokenNotWhitelisted() public { vm.startPrank(addrs[0]); vm.expectRevert("BootstrapStorage: token is not whitelisted"); - bootstrap.claim(address(0xa), amounts[0], addrs[0]); + bootstrap.withdrawPrincipal(address(0xa), amounts[0], addrs[0]); } function test22_Claim_ZeroAmount() public { vm.startPrank(addrs[0]); vm.expectRevert("BootstrapStorage: amount should be greater than zero"); - bootstrap.claim(address(myToken), 0, addrs[0]); + bootstrap.withdrawPrincipal(address(myToken), 0, addrs[0]); } - function test22_Claim_Excess() public { - test11_WithdrawPrincipalFromExocore(); + function test22_WithdrawPrincipal_Excess() public { + test11_ClaimPrincipalFromExocore(); vm.startPrank(addrs[0]); vm.expectRevert(Errors.VaultWithdrawalAmountExceeds.selector); - bootstrap.claim(address(myToken), amounts[0] + 5, addrs[0]); + bootstrap.withdrawPrincipal(address(myToken), amounts[0] + 5, addrs[0]); } function test23_RevertWhen_Deposit_WithEther() public { @@ -1458,11 +1467,11 @@ contract BootstrapTest is Test { vm.stopPrank(); } - function test23_RevertWhen_WithdrawPrincipalFromExocore_WithEther() public { + function test23_RevertWhen_ClaimPrincipalFromExocore_WithEther() public { vm.startPrank(addrs[0]); vm.deal(addrs[0], 1 ether); vm.expectRevert(Errors.NonZeroValue.selector); - bootstrap.withdrawPrincipalFromExocore{value: 0.1 ether}(address(myToken), amounts[0]); + bootstrap.claimPrincipalFromExocore{value: 0.1 ether}(address(myToken), amounts[0]); vm.stopPrank(); } diff --git a/test/foundry/unit/ClientChainGateway.t.sol b/test/foundry/unit/ClientChainGateway.t.sol index 8bf7d3b7..76835a85 100644 --- a/test/foundry/unit/ClientChainGateway.t.sol +++ b/test/foundry/unit/ClientChainGateway.t.sol @@ -28,7 +28,10 @@ import {Vault} from "src/core/Vault.sol"; import {Action, GatewayStorage} from "src/storage/GatewayStorage.sol"; import {NonShortCircuitEndpointV2Mock} from "../../mocks/NonShortCircuitEndpointV2Mock.sol"; + +import {RewardVault} from "src/core/RewardVault.sol"; import "src/interfaces/IExoCapsule.sol"; +import {IRewardVault} from "src/interfaces/IRewardVault.sol"; import "src/interfaces/IVault.sol"; import {Errors} from "src/libraries/Errors.sol"; @@ -57,8 +60,10 @@ contract SetUp is Test { ILayerZeroEndpointV2 exocoreLzEndpoint; IBeaconChainOracle beaconOracle; IVault vaultImplementation; + IRewardVault rewardVaultImplementation; IExoCapsule capsuleImplementation; IBeacon vaultBeacon; + IBeacon rewardVaultBeacon; IBeacon capsuleBeacon; BeaconProxyBytecode beaconProxyBytecode; @@ -100,9 +105,11 @@ contract SetUp is Test { beaconOracle = IBeaconChainOracle(_deployBeaconOracle()); vaultImplementation = new Vault(); + rewardVaultImplementation = new RewardVault(); capsuleImplementation = new ExoCapsule(); vaultBeacon = new UpgradeableBeacon(address(vaultImplementation)); + rewardVaultBeacon = new UpgradeableBeacon(address(rewardVaultImplementation)); capsuleBeacon = new UpgradeableBeacon(address(capsuleImplementation)); beaconProxyBytecode = new BeaconProxyBytecode(); @@ -117,6 +124,7 @@ contract SetUp is Test { exocoreChainId, address(beaconOracle), address(vaultBeacon), + address(rewardVaultBeacon), address(capsuleBeacon), address(beaconProxyBytecode) ); @@ -216,7 +224,7 @@ contract Pausable is SetUp { clientGateway.pause(); vm.expectRevert("Pausable: paused"); - clientGateway.claim(address(restakeToken), uint256(1), deployer.addr); + clientGateway.withdrawPrincipal(address(restakeToken), uint256(1), deployer.addr); vm.expectRevert("Pausable: paused"); clientGateway.delegateTo(operatorAddress, address(restakeToken), uint256(1)); @@ -225,7 +233,7 @@ contract Pausable is SetUp { clientGateway.deposit(address(restakeToken), uint256(1)); vm.expectRevert("Pausable: paused"); - clientGateway.withdrawPrincipalFromExocore(address(restakeToken), uint256(1)); + clientGateway.claimPrincipalFromExocore(address(restakeToken), uint256(1)); vm.expectRevert("Pausable: paused"); clientGateway.undelegateFrom(operatorAddress, address(restakeToken), uint256(1)); @@ -395,7 +403,7 @@ contract WithdrawalPrincipalFromExocore is SetUp { // Try to withdraw VIRTUAL_STAKED_ETH vm.prank(user); vm.expectRevert(Errors.VaultDoesNotExist.selector); - clientGateway.withdrawPrincipalFromExocore(VIRTUAL_STAKED_ETH_ADDRESS, WITHDRAWAL_AMOUNT); + clientGateway.claimPrincipalFromExocore(VIRTUAL_STAKED_ETH_ADDRESS, WITHDRAWAL_AMOUNT); } function test_revert_withdrawNonWhitelistedToken() public { @@ -403,13 +411,13 @@ contract WithdrawalPrincipalFromExocore is SetUp { vm.prank(players[0].addr); vm.expectRevert("BootstrapStorage: token is not whitelisted"); - clientGateway.withdrawPrincipalFromExocore(nonWhitelistedToken, WITHDRAWAL_AMOUNT); + clientGateway.claimPrincipalFromExocore(nonWhitelistedToken, WITHDRAWAL_AMOUNT); } function test_revert_withdrawZeroAmount() public { vm.prank(user); vm.expectRevert("BootstrapStorage: amount should be greater than zero"); - clientGateway.withdrawPrincipalFromExocore(address(restakeToken), 0); + clientGateway.claimPrincipalFromExocore(address(restakeToken), 0); } } diff --git a/test/foundry/unit/CustomProxyAdmin.t.sol b/test/foundry/unit/CustomProxyAdmin.t.sol index c7e57b34..43eaccd7 100644 --- a/test/foundry/unit/CustomProxyAdmin.t.sol +++ b/test/foundry/unit/CustomProxyAdmin.t.sol @@ -141,7 +141,7 @@ contract CustomProxyAdminTest is Test { try proxyAdmin.changeImplementation( // the call is made to the ProxyAdmin from address(0x1) // when instead it should have been made from the TransparentUpgradeableProxy - address(implementationChanger), + ITransparentUpgradeableProxy(address(implementationChanger)), address(new NewImplementation()), abi.encodeCall(NewImplementation.initialize, ()) ) { diff --git a/test/foundry/unit/ExocoreGateway.t.sol b/test/foundry/unit/ExocoreGateway.t.sol index 0cc9c2fd..3ad8eab2 100644 --- a/test/foundry/unit/ExocoreGateway.t.sol +++ b/test/foundry/unit/ExocoreGateway.t.sol @@ -2,13 +2,15 @@ pragma solidity ^0.8.19; import {NonShortCircuitEndpointV2Mock} from "../../mocks/NonShortCircuitEndpointV2Mock.sol"; import "src/interfaces/precompiles/IAssets.sol"; -import "src/interfaces/precompiles/IClaimReward.sol"; + import "src/interfaces/precompiles/IDelegation.sol"; +import "src/interfaces/precompiles/IReward.sol"; import "src/libraries/Errors.sol"; import "test/mocks/AssetsMock.sol"; -import "test/mocks/ClaimRewardMock.sol"; + import "test/mocks/DelegationMock.sol"; +import "test/mocks/RewardMock.sol"; import "@layerzero-v2/protocol/contracts/libs/AddressCast.sol"; import "@layerzerolabs/lz-evm-protocol-v2/contracts/libs/GUID.sol"; @@ -70,8 +72,8 @@ contract SetUp is Test { bytes memory DelegationMockCode = vm.getDeployedCode("DelegationMock.sol"); vm.etch(DELEGATION_PRECOMPILE_ADDRESS, DelegationMockCode); - bytes memory WithdrawRewardMockCode = vm.getDeployedCode("ClaimRewardMock.sol"); - vm.etch(CLAIM_REWARD_PRECOMPILE_ADDRESS, WithdrawRewardMockCode); + bytes memory WithdrawRewardMockCode = vm.getDeployedCode("RewardMock.sol"); + vm.etch(REWARD_PRECOMPILE_ADDRESS, WithdrawRewardMockCode); _deploy(); @@ -195,8 +197,8 @@ contract LzReceive is SetUp { bytes memory DelegationMockCode = vm.getDeployedCode("DelegationMock.sol"); vm.etch(DELEGATION_PRECOMPILE_ADDRESS, DelegationMockCode); - bytes memory WithdrawRewardMockCode = vm.getDeployedCode("ClaimRewardMock.sol"); - vm.etch(CLAIM_REWARD_PRECOMPILE_ADDRESS, WithdrawRewardMockCode); + bytes memory RewardMockCode = vm.getDeployedCode("RewardMock.sol"); + vm.etch(REWARD_PRECOMPILE_ADDRESS, RewardMockCode); } function test_NotRevert_WithdrawalAmountOverflow() public { diff --git a/test/foundry/unit/RewardVault.t.sol b/test/foundry/unit/RewardVault.t.sol new file mode 100644 index 00000000..eac7fa15 --- /dev/null +++ b/test/foundry/unit/RewardVault.t.sol @@ -0,0 +1,158 @@ +pragma solidity ^0.8.19; + +import {RewardVault} from "../../../src/core/RewardVault.sol"; +import {Errors} from "../../../src/libraries/Errors.sol"; + +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "forge-std/Test.sol"; + +contract MockERC20 is ERC20 { + + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 1_000_000 * 10 ** 18); + } + +} + +contract MockGateway {} + +contract RewardVaultTest is Test { + + RewardVault public rewardVaultImplementation; + RewardVault public rewardVault; + MockERC20 public token; + MockGateway public gateway; + address public depositor; + address public withdrawer; + address public avs; + ProxyAdmin public proxyAdmin; + + event RewardDeposited(address indexed token, address indexed avs, uint256 amount); + event RewardWithdrawn(address indexed token, address indexed withdrawer, address indexed recipient, uint256 amount); + event RewardUnlocked(address indexed token, address indexed withdrawer, uint256 amount); + + function setUp() public { + rewardVaultImplementation = new RewardVault(); + proxyAdmin = new ProxyAdmin(); + gateway = new MockGateway(); + + // Deploy the proxy + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(rewardVaultImplementation), + address(proxyAdmin), + abi.encodeWithSelector(RewardVault.initialize.selector, address(gateway)) + ); + + // Cast the proxy to RewardVault + rewardVault = RewardVault(address(proxy)); + + token = new MockERC20("Test Token", "TEST"); + depositor = address(0x1); + withdrawer = address(0x2); + avs = address(0x3); + + token.transfer(depositor, 1000 * 10 ** 18); + } + + function testInitialize() public { + assertEq(rewardVault.gateway(), address(gateway)); + } + + function testDeposit() public { + uint256 amount = 100 * 10 ** 18; + vm.startPrank(depositor); + token.approve(address(rewardVault), amount); + vm.stopPrank(); + + vm.expectEmit(true, true, false, true); + emit RewardDeposited(address(token), avs, amount); + + vm.prank(address(gateway)); + rewardVault.deposit(address(token), depositor, avs, amount); + + assertEq(token.balanceOf(address(rewardVault)), amount); + assertEq(rewardVault.getTotalDepositedRewards(address(token), avs), amount); + } + + function testWithdraw() public { + uint256 amount = 100 * 10 ** 18; + + vm.prank(depositor); + token.approve(address(rewardVault), amount); + + vm.prank(address(gateway)); + rewardVault.deposit(address(token), depositor, avs, amount); + + vm.prank(address(gateway)); + rewardVault.unlockReward(address(token), withdrawer, amount); + + vm.expectEmit(true, true, true, true); + emit RewardWithdrawn(address(token), withdrawer, withdrawer, amount); + + vm.prank(address(gateway)); + rewardVault.withdraw(address(token), withdrawer, withdrawer, amount); + + assertEq(token.balanceOf(withdrawer), amount); + assertEq(rewardVault.getWithdrawableBalance(address(token), withdrawer), 0); + } + + function testUnlockReward() public { + uint256 amount = 100 * 10 ** 18; + + vm.expectEmit(true, true, false, true); + emit RewardUnlocked(address(token), withdrawer, amount); + + vm.prank(address(gateway)); + rewardVault.unlockReward(address(token), withdrawer, amount); + + assertEq(rewardVault.getWithdrawableBalance(address(token), withdrawer), amount); + } + + function testGetWithdrawableBalance() public { + uint256 amount = 100 * 10 ** 18; + vm.prank(address(gateway)); + rewardVault.unlockReward(address(token), withdrawer, amount); + + assertEq(rewardVault.getWithdrawableBalance(address(token), withdrawer), amount); + } + + function testGetTotalDepositedRewards() public { + uint256 amount = 100 * 10 ** 18; + vm.startPrank(depositor); + token.approve(address(rewardVault), amount); + vm.stopPrank(); + + vm.prank(address(gateway)); + rewardVault.deposit(address(token), depositor, avs, amount); + + assertEq(rewardVault.getTotalDepositedRewards(address(token), avs), amount); + } + + function testOnlyGatewayModifier() public { + vm.prank(address(0x4)); + vm.expectRevert(Errors.VaultCallerIsNotGateway.selector); + rewardVault.deposit(address(token), depositor, avs, 100 * 10 ** 18); + vm.expectRevert(Errors.VaultCallerIsNotGateway.selector); + rewardVault.withdraw(address(token), withdrawer, withdrawer, 100 * 10 ** 18); + vm.expectRevert(Errors.VaultCallerIsNotGateway.selector); + rewardVault.unlockReward(address(token), withdrawer, 100 * 10 ** 18); + } + + function testWithdrawInsufficientBalance() public { + vm.prank(address(gateway)); + vm.expectRevert(Errors.InsufficientBalance.selector); + rewardVault.withdraw(address(token), withdrawer, withdrawer, 100 * 10 ** 18); + } + + function testWithdrawVaultInsufficientTokenBalance() public { + uint256 amount = 100 * 10 ** 18; + vm.prank(address(gateway)); + rewardVault.unlockReward(address(token), withdrawer, amount); + vm.expectRevert("ERC20: transfer amount exceeds balance"); + vm.prank(address(gateway)); + rewardVault.withdraw(address(token), withdrawer, withdrawer, amount); + } + +} diff --git a/test/foundry/unit/Vault.t.sol b/test/foundry/unit/Vault.t.sol new file mode 100644 index 00000000..c9290e5e --- /dev/null +++ b/test/foundry/unit/Vault.t.sol @@ -0,0 +1,192 @@ +pragma solidity ^0.8.19; + +import {Vault} from "../../../src/core/Vault.sol"; +import {Errors} from "../../../src/libraries/Errors.sol"; + +import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "forge-std/Test.sol"; + +contract MockERC20 is ERC20 { + + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 1e10 * 10 ** 18); + } + +} + +contract MockGateway {} + +contract VaultTest is Test { + + Vault public vaultImplementation; + Vault public vault; + MockERC20 public token; + MockGateway public gateway; + ProxyAdmin public proxyAdmin; + address public depositor; + address public withdrawer; + uint256 public constant TVL_LIMIT = 1_000_000 * 10 ** 18; + + event ConsumedTvlChanged(uint256 newConsumedTvl); + event PrincipalDeposited(address indexed depositor, uint256 amount); + event PrincipalWithdrawn(address indexed withdrawer, address indexed recipient, uint256 amount); + event PrincipalUnlocked(address indexed user, uint256 amount); + event TvlLimitUpdated(uint256 newTvlLimit); + + function setUp() public { + vaultImplementation = new Vault(); + proxyAdmin = new ProxyAdmin(); + token = new MockERC20("Test Token", "TEST"); + gateway = new MockGateway(); + depositor = address(0x1); + withdrawer = address(0x2); + + // Deploy the proxy + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(vaultImplementation), + address(proxyAdmin), + abi.encodeWithSelector(Vault.initialize.selector, address(token), TVL_LIMIT, address(gateway)) + ); + + // Cast the proxy to Vault + vault = Vault(address(proxy)); + + token.transfer(depositor, TVL_LIMIT + 1000 * 10 ** 18); // Give enough tokens to exceed TVL + } + + function testInitialize() public { + assertEq(vault.getUnderlyingToken(), address(token)); + assertEq(vault.getTvlLimit(), TVL_LIMIT); + assertEq(address(vault.gateway()), address(gateway)); + } + + function testDeposit() public { + uint256 amount = 100 * 10 ** 18; + vm.startPrank(depositor); + token.approve(address(vault), amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vm.expectEmit(false, false, false, true); + emit ConsumedTvlChanged(amount); + vm.expectEmit(true, false, false, true); + emit PrincipalDeposited(depositor, amount); + vault.deposit(depositor, amount); + vm.stopPrank(); + + assertEq(token.balanceOf(address(vault)), amount); + assertEq(vault.totalDepositedPrincipalAmount(depositor), amount); + assertEq(vault.getConsumedTvl(), amount); + } + + function testWithdraw() public { + uint256 amount = 100 * 10 ** 18; + + token.transfer(withdrawer, amount); // Give enough tokens to exceed TVL + + vm.startPrank(withdrawer); + token.approve(address(vault), amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vault.deposit(withdrawer, amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vault.unlockPrincipal(withdrawer, amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vm.expectEmit(false, false, false, true); + emit ConsumedTvlChanged(0); + vm.expectEmit(true, true, false, true); + emit PrincipalWithdrawn(withdrawer, withdrawer, amount); + vault.withdraw(withdrawer, withdrawer, amount); + vm.stopPrank(); + + assertEq(token.balanceOf(withdrawer), amount); + assertEq(vault.getWithdrawableBalance(withdrawer), 0); + assertEq(vault.getConsumedTvl(), 0); + } + + function testUnlockPrincipal() public { + uint256 amount = 100 * 10 ** 18; + vm.startPrank(depositor); + token.approve(address(vault), amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vault.deposit(depositor, amount); + vm.expectEmit(true, false, false, true); + emit PrincipalUnlocked(depositor, amount); + vault.unlockPrincipal(depositor, amount); + vm.stopPrank(); + + assertEq(vault.getWithdrawableBalance(depositor), amount); + } + + function testSetTvlLimit() public { + uint256 newLimit = 2_000_000 * 10 ** 18; + vm.startPrank(address(gateway)); + vm.expectEmit(false, false, false, true); + emit TvlLimitUpdated(newLimit); + vault.setTvlLimit(newLimit); + vm.stopPrank(); + + assertEq(vault.getTvlLimit(), newLimit); + } + + function testOnlyGatewayModifier() public { + vm.expectRevert(Errors.VaultCallerIsNotGateway.selector); + vault.deposit(depositor, 100 * 10 ** 18); + } + + function testDepositExceedsTvlLimit() public { + uint256 amount = TVL_LIMIT + 1; + vm.startPrank(depositor); + token.approve(address(vault), amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vm.expectRevert(Errors.VaultTvlLimitExceeded.selector); + vault.deposit(depositor, amount); + vm.stopPrank(); + } + + function testWithdrawExceedsBalance() public { + vm.startPrank(address(gateway)); + vm.expectRevert(Errors.VaultWithdrawalAmountExceeds.selector); + vault.withdraw(withdrawer, withdrawer, 1); + vm.stopPrank(); + } + + function testUnlockPrincipalExceedsTotalDeposit() public { + uint256 amount = 100 * 10 ** 18; + vm.startPrank(depositor); + token.approve(address(vault), amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vault.deposit(depositor, amount); + vm.expectRevert(Errors.VaultPrincipalExceedsTotalDeposit.selector); + vault.unlockPrincipal(depositor, amount + 1); + vm.stopPrank(); + } + + function testTotalUnlockPrincipalExceedsDeposit() public { + uint256 amount = 100 * 10 ** 18; + vm.startPrank(depositor); + token.approve(address(vault), amount); + vm.stopPrank(); + + vm.startPrank(address(gateway)); + vault.deposit(depositor, amount); + vault.unlockPrincipal(depositor, amount / 2); + vm.expectRevert(Errors.VaultTotalUnlockPrincipalExceedsDeposit.selector); + vault.unlockPrincipal(depositor, (amount / 2) + 1); + vm.stopPrank(); + } + +} diff --git a/test/mocks/ClaimRewardMock.sol b/test/mocks/ClaimRewardMock.sol deleted file mode 100644 index 4f7c94d2..00000000 --- a/test/mocks/ClaimRewardMock.sol +++ /dev/null @@ -1,18 +0,0 @@ -pragma solidity ^0.8.19; - -import {IClaimReward} from "../../src/interfaces/precompiles/IClaimReward.sol"; - -contract ClaimRewardMock is IClaimReward { - - function claimReward( - uint32 clientChainLzId, - bytes calldata assetsAddress, - bytes calldata withdrawer, - uint256 opAmount - ) external returns (bool success, uint256 latestAssetState) { - require(assetsAddress.length == 32, "invalid asset address"); - require(withdrawer.length == 32, "invalid withdrawer address"); - return (true, uint256(1234)); - } - -} diff --git a/test/mocks/ExocoreGatewayMock.sol b/test/mocks/ExocoreGatewayMock.sol index c75f323b..23c91b4f 100644 --- a/test/mocks/ExocoreGatewayMock.sol +++ b/test/mocks/ExocoreGatewayMock.sol @@ -4,8 +4,9 @@ import {IExocoreGateway} from "src/interfaces/IExocoreGateway.sol"; import {Action} from "src/storage/GatewayStorage.sol"; import {IAssets} from "src/interfaces/precompiles/IAssets.sol"; -import {IClaimReward} from "src/interfaces/precompiles/IClaimReward.sol"; + import {IDelegation} from "src/interfaces/precompiles/IDelegation.sol"; +import {IReward} from "src/interfaces/precompiles/IReward.sol"; import { MessagingFee, @@ -39,11 +40,11 @@ contract ExocoreGatewayMock is using OptionsBuilder for bytes; address public immutable ASSETS_PRECOMPILE_ADDRESS; - address public immutable CLAIM_REWARD_PRECOMPILE_ADDRESS; + address public immutable REWARD_PRECOMPILE_ADDRESS; address public immutable DELEGATION_PRECOMPILE_ADDRESS; IAssets internal immutable ASSETS_CONTRACT; - IClaimReward internal immutable CLAIM_REWARD_CONTRACT; + IReward internal immutable REWARD_CONTRACT; IDelegation internal immutable DELEGATION_CONTRACT; modifier onlyCalledFromThis() { @@ -57,20 +58,20 @@ contract ExocoreGatewayMock is constructor( address endpoint_, address assetsPrecompileMock, - address ClaimRewardPrecompileMock, + address RewardPrecompileMock, address delegationPrecompileMock ) OAppUpgradeable(endpoint_) { require(endpoint_ != address(0), "Endpoint address cannot be zero."); require(assetsPrecompileMock != address(0), "Assets precompile address cannot be zero."); - require(ClaimRewardPrecompileMock != address(0), "ClaimReward precompile address cannot be zero."); + require(RewardPrecompileMock != address(0), "Reward precompile address cannot be zero."); require(delegationPrecompileMock != address(0), "Delegation precompile address cannot be zero."); ASSETS_PRECOMPILE_ADDRESS = assetsPrecompileMock; - CLAIM_REWARD_PRECOMPILE_ADDRESS = ClaimRewardPrecompileMock; + REWARD_PRECOMPILE_ADDRESS = RewardPrecompileMock; DELEGATION_PRECOMPILE_ADDRESS = delegationPrecompileMock; ASSETS_CONTRACT = IAssets(ASSETS_PRECOMPILE_ADDRESS); - CLAIM_REWARD_CONTRACT = IClaimReward(CLAIM_REWARD_PRECOMPILE_ADDRESS); + REWARD_CONTRACT = IReward(REWARD_PRECOMPILE_ADDRESS); DELEGATION_CONTRACT = IDelegation(DELEGATION_PRECOMPILE_ADDRESS); _disableInitializers(); @@ -98,6 +99,7 @@ contract ExocoreGatewayMock is _whiteListFunctionSelectors[Action.REQUEST_WITHDRAW_LST] = this.handleLSTTransfer.selector; _whiteListFunctionSelectors[Action.REQUEST_DEPOSIT_NST] = this.handleNSTTransfer.selector; _whiteListFunctionSelectors[Action.REQUEST_WITHDRAW_NST] = this.handleNSTTransfer.selector; + _whiteListFunctionSelectors[Action.REQUEST_SUBMIT_REWARD] = this.handleRewardOperation.selector; _whiteListFunctionSelectors[Action.REQUEST_CLAIM_REWARD] = this.handleRewardOperation.selector; _whiteListFunctionSelectors[Action.REQUEST_DELEGATE_TO] = this.handleDelegation.selector; _whiteListFunctionSelectors[Action.REQUEST_UNDELEGATE_FROM] = this.handleDelegation.selector; @@ -332,18 +334,27 @@ contract ExocoreGatewayMock is revert Errors.RequestOrResponseExecuteFailed(act, _origin.nonce, responseOrReason); } + // decode to get the response, and send it back if it is not empty + bytes memory response = abi.decode(responseOrReason, (bytes)); + if (response.length > 0) { + _sendInterchainMsg(_origin.srcEid, Action.RESPOND, response, true); + } + emit MessageExecuted(act, _origin.nonce); } /// @notice Handles LST transfer from a client chain. /// @dev Can only be called from this contract via low-level call. + /// @dev Returns empty bytes if the action is deposit, otherwise returns the lzNonce and success flag. /// @param srcChainId The source chain id. /// @param lzNonce The layer zero nonce. /// @param act The action type. /// @param payload The request payload. + // slither-disable-next-line unused-return function handleLSTTransfer(uint32 srcChainId, uint64 lzNonce, Action act, bytes calldata payload) public onlyCalledFromThis + returns (bytes memory response) { bytes calldata token = payload[:32]; bytes calldata staker = payload[32:64]; @@ -351,30 +362,31 @@ contract ExocoreGatewayMock is bool isDeposit = act == Action.REQUEST_DEPOSIT_LST; bool success; - uint256 updatedBalance; if (isDeposit) { - (success, updatedBalance) = ASSETS_CONTRACT.depositLST(srcChainId, token, staker, amount); + (success,) = ASSETS_CONTRACT.depositLST(srcChainId, token, staker, amount); } else { - (success, updatedBalance) = ASSETS_CONTRACT.withdrawLST(srcChainId, token, staker, amount); + (success,) = ASSETS_CONTRACT.withdrawLST(srcChainId, token, staker, amount); } if (isDeposit && !success) { revert Errors.DepositRequestShouldNotFail(srcChainId, lzNonce); // we should not let this happen } emit LSTTransfer(isDeposit, success, bytes32(token), bytes32(staker), amount); - bytes memory response = abi.encodePacked(lzNonce, success, updatedBalance); - _sendInterchainMsg(srcChainId, Action.RESPOND, response, true); + response = isDeposit ? bytes("") : abi.encodePacked(lzNonce, success); } /// @notice Handles NST transfer from a client chain. /// @dev Can only be called from this contract via low-level call. + /// @dev Returns empty bytes if the action is deposit, otherwise returns the lzNonce and success flag. /// @param srcChainId The source chain id. /// @param lzNonce The layer zero nonce. /// @param act The action type. /// @param payload The request payload. + // slither-disable-next-line unused-return function handleNSTTransfer(uint32 srcChainId, uint64 lzNonce, Action act, bytes calldata payload) public onlyCalledFromThis + returns (bytes memory response) { bytes calldata validatorPubkey = payload[:32]; bytes calldata staker = payload[32:64]; @@ -382,44 +394,51 @@ contract ExocoreGatewayMock is bool isDeposit = act == Action.REQUEST_DEPOSIT_NST; bool success; - uint256 updatedBalance; if (isDeposit) { - (success, updatedBalance) = ASSETS_CONTRACT.depositNST(srcChainId, validatorPubkey, staker, amount); + (success,) = ASSETS_CONTRACT.depositNST(srcChainId, validatorPubkey, staker, amount); } else { - (success, updatedBalance) = ASSETS_CONTRACT.withdrawNST(srcChainId, validatorPubkey, staker, amount); + (success,) = ASSETS_CONTRACT.withdrawNST(srcChainId, validatorPubkey, staker, amount); } if (isDeposit && !success) { revert Errors.DepositRequestShouldNotFail(srcChainId, lzNonce); // we should not let this happen } emit NSTTransfer(isDeposit, success, bytes32(validatorPubkey), bytes32(staker), amount); - bytes memory response = abi.encodePacked(lzNonce, success, updatedBalance); - _sendInterchainMsg(srcChainId, Action.RESPOND, response, true); + response = isDeposit ? bytes("") : abi.encodePacked(lzNonce, success); } - /// @notice Handles rewards request from a client chain. + /// @notice Handles rewards request from a client chain, submit reward or claim reward. /// @dev Can only be called from this contract via low-level call. + /// @dev Returns the response to client chain including lzNonce and success flag. /// @param srcChainId The source chain id. /// @param lzNonce The layer zero nonce. /// @param payload The request payload. - function handleRewardOperation(uint32 srcChainId, uint64 lzNonce, Action, bytes calldata payload) + // slither-disable-next-line unused-return + function handleRewardOperation(uint32 srcChainId, uint64 lzNonce, Action act, bytes calldata payload) public onlyCalledFromThis + returns (bytes memory response) { bytes calldata token = payload[:32]; - bytes calldata withdrawer = payload[32:64]; + // it could be either avsId or withdrawer, depending on the action + bytes calldata avsOrWithdrawer = payload[32:64]; uint256 amount = uint256(bytes32(payload[64:96])); - (bool success, uint256 updatedBalance) = - CLAIM_REWARD_CONTRACT.claimReward(srcChainId, token, withdrawer, amount); - emit ClaimRewardResult(success, bytes32(token), bytes32(withdrawer), amount); + bool isSubmitReward = act == Action.REQUEST_SUBMIT_REWARD; + bool success; + if (isSubmitReward) { + (success,) = REWARD_CONTRACT.submitReward(srcChainId, token, avsOrWithdrawer, amount); + } else { + (success,) = REWARD_CONTRACT.claimReward(srcChainId, token, avsOrWithdrawer, amount); + } + emit RewardOperation(success, isSubmitReward, bytes32(token), bytes32(avsOrWithdrawer), amount); - bytes memory response = abi.encodePacked(lzNonce, success, updatedBalance); - _sendInterchainMsg(srcChainId, Action.RESPOND, response, true); + response = isSubmitReward ? bytes("") : abi.encodePacked(lzNonce, success); } /// @notice Handles delegation request from a client chain. /// @dev Can only be called from this contract via low-level call. + /// @dev Returns empty response because the client chain should not expect a response. /// @param srcChainId The source chain id. /// @param lzNonce The layer zero nonce. /// @param act The action type. @@ -427,10 +446,12 @@ contract ExocoreGatewayMock is function handleDelegation(uint32 srcChainId, uint64 lzNonce, Action act, bytes calldata payload) public onlyCalledFromThis + returns (bytes memory response) { - bytes calldata token = payload[:32]; - bytes calldata staker = payload[32:64]; - bytes calldata operator = payload[64:106]; + // use memory to avoid stack too deep + bytes memory token = payload[:32]; + bytes memory staker = payload[32:64]; + bytes memory operator = payload[64:106]; uint256 amount = uint256(bytes32(payload[106:138])); bool isDelegate = act == Action.REQUEST_DELEGATE_TO; @@ -441,26 +462,27 @@ contract ExocoreGatewayMock is accepted = DELEGATION_CONTRACT.undelegate(srcChainId, lzNonce, token, staker, operator, amount); } emit DelegationRequest(isDelegate, accepted, bytes32(token), bytes32(staker), string(operator), amount); - - bytes memory response = abi.encodePacked(lzNonce, accepted); - _sendInterchainMsg(srcChainId, Action.RESPOND, response, true); } /// @notice Responds to a deposit-then-delegate request from a client chain. /// @dev Can only be called from this contract via low-level call. + /// @dev Returns empty response because the client chain should not expect a response. /// @param srcChainId The source chain id. /// @param lzNonce The layer zero nonce. /// @param payload The request payload. + // slither-disable-next-line unused-return function handleDepositAndDelegate(uint32 srcChainId, uint64 lzNonce, Action, bytes calldata payload) public onlyCalledFromThis + returns (bytes memory response) { + // use memory to avoid stack too deep bytes memory token = payload[:32]; bytes memory depositor = payload[32:64]; bytes memory operator = payload[64:106]; uint256 amount = uint256(bytes32(payload[106:138])); - (bool success, uint256 updatedBalance) = ASSETS_CONTRACT.depositLST(srcChainId, token, depositor, amount); + (bool success,) = ASSETS_CONTRACT.depositLST(srcChainId, token, depositor, amount); if (!success) { revert Errors.DepositRequestShouldNotFail(srcChainId, lzNonce); // we should not let this happen } @@ -468,20 +490,18 @@ contract ExocoreGatewayMock is bool accepted = DELEGATION_CONTRACT.delegate(srcChainId, lzNonce, token, depositor, operator, amount); emit DelegationRequest(true, accepted, bytes32(token), bytes32(depositor), string(operator), amount); - - bytes memory response = abi.encodePacked(lzNonce, accepted, updatedBalance); - _sendInterchainMsg(srcChainId, Action.RESPOND, response, true); } /// @notice Handles the associating/dissociating operator request, and no response would be returned. /// @dev Can only be called from this contract via low-level call. + /// @dev Returns empty response because the client chain should not expect a response. /// @param srcChainId The source chain id. - /// @param lzNonce The layer zero nonce. /// @param act The action type. /// @param payload The request payload. - function handleOperatorAssociation(uint32 srcChainId, uint64 lzNonce, Action act, bytes calldata payload) + function handleOperatorAssociation(uint32 srcChainId, uint64, Action act, bytes calldata payload) public onlyCalledFromThis + returns (bytes memory response) { bool success; bytes calldata staker = payload[:32]; diff --git a/test/mocks/RewardMock.sol b/test/mocks/RewardMock.sol new file mode 100644 index 00000000..c2b4bddf --- /dev/null +++ b/test/mocks/RewardMock.sol @@ -0,0 +1,65 @@ +pragma solidity ^0.8.19; + +import {IReward} from "../../src/interfaces/precompiles/IReward.sol"; + +contract RewardMock is IReward { + + mapping(uint32 => mapping(bytes => mapping(bytes => uint256))) public rewardsOfAVS; + mapping(uint32 => mapping(bytes => mapping(bytes => uint256))) public rewardsOfStaker; + + function submitReward(uint32 clientChainLzId, bytes calldata assetsAddress, bytes calldata avsId, uint256 amount) + external + returns (bool success, uint256 latestAssetState) + { + require(assetsAddress.length == 32, "invalid asset address"); + require(avsId.length == 32, "invalid avsId"); + rewardsOfAVS[clientChainLzId][assetsAddress][avsId] += amount; + return (true, rewardsOfAVS[clientChainLzId][assetsAddress][avsId]); + } + + function claimReward( + uint32 clientChainLzId, + bytes calldata assetsAddress, + bytes calldata withdrawer, + uint256 opAmount + ) external returns (bool success, uint256 latestAssetState) { + require(assetsAddress.length == 32, "invalid asset address"); + require(withdrawer.length == 32, "invalid withdrawer address"); + require(rewardsOfStaker[clientChainLzId][assetsAddress][withdrawer] >= opAmount, "insufficient reward"); + rewardsOfStaker[clientChainLzId][assetsAddress][withdrawer] -= opAmount; + return (true, rewardsOfStaker[clientChainLzId][assetsAddress][withdrawer]); + } + + function distributeReward( + uint32 clientChainLzId, + bytes calldata assetsAddress, + bytes calldata avsId, + bytes calldata staker, + uint256 amount + ) external returns (bool success, uint256 latestAssetState) { + require(assetsAddress.length == 32, "invalid asset address"); + require(staker.length == 32, "invalid staker address"); + require(avsId.length == 32, "invalid avsId"); + require(rewardsOfAVS[clientChainLzId][assetsAddress][avsId] >= amount, "insufficient reward"); + rewardsOfAVS[clientChainLzId][assetsAddress][avsId] -= amount; + rewardsOfStaker[clientChainLzId][assetsAddress][staker] += amount; + return (true, rewardsOfAVS[clientChainLzId][assetsAddress][avsId]); + } + + function getRewardAmountForAVS(uint32 clientChainLzId, bytes calldata assetsAddress, bytes calldata avsId) + external + view + returns (uint256) + { + return rewardsOfAVS[clientChainLzId][assetsAddress][avsId]; + } + + function getRewardAmountForStaker(uint32 clientChainLzId, bytes calldata assetsAddress, bytes calldata staker) + external + view + returns (uint256) + { + return rewardsOfStaker[clientChainLzId][assetsAddress][staker]; + } + +}