diff --git a/README.md b/README.md index c86aef4..8f40e0b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Valantis Stake Exchange AMM (STEX AMM) -STEX AMM is a novel AMM uniquely designed for redeemable assets such as Liquid Staking Tokens (LSTs), designed in collaboration with [Thunderhead](https://thunderhead.xyz/). +STEX AMM is a novel AMM uniquely designed for yield-bearing assets which are redeemable for their underlying, such as Liquid Staking Tokens (LSTs), designed in collaboration with [Thunderhead](https://thunderhead.xyz/). LSTs are backed 1:1 by an equivalent amount of native asset (in the absence of slashing). However, generic AMMs fail to account for this simple fact, forcing LPs to sell the LST for less than fair value, resulting in significant cumulative losses to arbitrageurs. Moreover, there are times where excess liquidity can be put to earn extra yield on external protocols (e.g. lending markets like AAVE and Euler) and only be brought back into the AMM if needed to absorb incoming swap volume. @@ -17,19 +17,33 @@ STEX AMM solves these two structural inefficiencies by integrating with the LST' Contains STEX AMM's core contracts, Module dependencies and Mock contracts used for testing. -**AaveLendingModule.sol**: This is a dedicated module, compatible with AAVE V3's `supply` and `withdraw` functions. Its owner can deposit and withdraw a portion of Wrapped Native Token's pool reserves, with the goal of optimizing overall yield. +**AaveLendingModule.sol**: Dedicated Lending Module compatible with AAVE V3's `supply` and `withdraw` functions. Its owner can deposit and withdraw a portion of pool's `token1` reserves, with the goal of optimizing overall yield. + +**ERC4626LendingModule.sol**: Dedicated Lending Module compatible with ERC4626 standard vaults. Its owner can deposit and withdraw a portion of pool's `token1` reserves, with the goal of optimizing overall yield. + +**MultiMarketLendingModule.sol**: Lending Module which can deposit and withdraw an asset across multiple Lending Modules, such as `AaveLendingModule` and `ERC4626LendingModule`. **STEXAMM.sol**: The main contract which implements the core mechanisms of STEX AMM, built as a Valantis [Liquidity Module](https://docs.valantis.xyz/sovereign-pool-subpages/modules/liquidity-module). -**STEXLens.sol**: Helper contract that contains read-only functions which are useful to simulate state updates in `STEXAMM`. +**STEXLens.sol**: Helper contract that contains read-only functions which are useful to simulate state updates in `STEXAMM`. WARNING: Only compatible with `stHYPEWithdrawalModule` and `kHYPEWithdrawalModule`. + +**STEXRatioSwapFeeModule.sol**: Contains a dynamic fee mechanism for swaps on STEX AMM, based on the ratio of reserves between the LST and wrapped native token, built as a Valantis [Swap Fee Module](https://docs.valantis.xyz/sovereign-pool-subpages/modules/swap-fee-module). -**STEXRatioSwapFeeModule.sol**: Contains a dynamic fee mechanism for swaps on STEX AMM, built as a Valantis [Swap Fee Module](https://docs.valantis.xyz/sovereign-pool-subpages/modules/swap-fee-module). +**StepwiseFeeModule.sol**: Contains a dynamic fee mechanism for swaps on STEX AMM, using a stepwise function pricing curve whose shape can be determined off-chain using sophisticated models and pricing information, built as a Valantis [Swap Fee Module](https://docs.valantis.xyz/sovereign-pool-subpages/modules/swap-fee-module). **stHYPEWithdrawalModule.sol**: Module that manages all of STEX AMM's interactions with [stakedHYPE](https://www.stakedhype.fi/), a leading LST protocol on HyperEVM developed by [Thunderhead](https://thunderhead.xyz/). -**owner/WithdrawalModuleManager.sol\***: Custom contract that has the `owner` role in `stHYPEWithdrawalModule`. It is controlled by a multi-sig. +**kHYPEWithdrawalModule.sol**: Module that manages all of STEX AMM's interactions with [kHYPE](https://kinetiq.xyz/), a leading LST protocol on HyperEVM developed by [Kinetiq](https://kinetiq.xyz/). + +**owner/stHYPEWithdrawalModuleManager.sol**: Custom contract that has the `owner` role in `stHYPEWithdrawalModule`. It is controlled by a multi-sig. + +**owner/stHYPEWithdrawalModuleKeeper.sol**: A sub-role in `stHYPEWithdrawalModuleManager`, executing smart contract calls that require automation but which are not mission critical. + +**owner/kHYPEWithdrawalModuleManager.sol**: Custom contract that has the `owner` role in `kHYPEWithdrawalModule`. It is controlled by a multi-sig. -**owner/WithdrawalModuleKeeper.sol**: A sub-role in `WithdrawalModuleManager`, executing smart contract calls that require automation but which are not mission critical. +**owner/kHYPEWithdrawalModuleKeeper.sol**: A sub-role in `kHYPEWithdrawalModuleManager`, executing smart contract calls that require automation but which are not mission critical. + +**owner/StepwiseFeeModuleKeeper.sol**: A sub-role in `StepwiseFeeModule`, setting and updating dynamic fee parameters. **interfaces/**: Contains all relevant interfaces. @@ -71,6 +85,50 @@ Copy .env.example file to .env and set the variables. **Note:** All deployment bash scripts are, by default, in simulation mode. Add `--broadcast` flag to trigger deployments, and `--verify` for block explorer contract verification. +### Risks and Trust Assumptions: kHYPEWithdrawalModule + +**Owner role in kHYPEWithdrawalModule** + +Role: + +- Can update the `Lending Module` under 3-7 day timelock. +- Can send any amount of token1 to the `Lending Module`'s deposit function. +- Can withdraw any amount of token1 from the `Lending Module`'s withdraw function and send it to `pool`. +- Can unstake the `pool`'s entire token0 balance to the kHYPE `StakingManager` queueWithdrawal function, queuing for withdrawal with `kHYPEWithdrawalModule` being the recipient. +- Can stake the `pool`'s entire token1 balance to the kHYPE `StakingManager` stake function, with the pool being the recipient. +- Can atomically rebalance the `pool`'s token0 reserves into token1, as long as the cost of rebalance does not exceed the fee which would be paid by unstaking through `StakingManager` +- Can use liquid token1 reserves in the `pool` to net off against pending LP withdrawals, delivering faster LP withdrawals whenever available. +- Can rescue any locked tokens which are not the native token, token0 nor token1. +- Can unstake any surplus balance of token0 to the kHYPE `StakingManager` queueWithdrawal function. This can happen in case of donations or cancelled unstaking operations by `StakingManager`. + +Risks: + +- `owner` can steal token1 reserves with a minimum 3-day timelock by upgrading the `Lending Module` to a malicious contract. Currently the `owner` role is managed by a trusted multi-sig. +- Due to the fact that Kinetiq protocol can update unstaking fees, and unstaking of token0 reserves is managed by `owner` and is separate from the LP's withdrawal initiation, there can be a mismatch between the amount of HYPE which the LP expects to receive vs. what is actually returned after unstaking is processed. `owner` is trusted to quickly unstake after LPs initiate withdrawal requests and to pay attention to any sudden changes in Kinetiq's unstaking fee, in order to minimize such discrepancies. + +**Lending Module in kHYPEWithdrawalModule** + +The integrated lending protocol in `Lending Module` custodies a portion of token1 reserves determined by `owner`. +For example, `AAVELendingModule` contract provides a concrete implementation compatible with AAVE V3 deployments. + +Risks: + +- If the integrated lending protocol becomes insolvent or contains faulty withdrawal logic, funds will be lost. +- If the integrated lending protocol is working correctly but does not have enough liquidity to honor instant withdrawals, then LP withdrawals via `STEX AMM` withdraw function would become temporarily blocked. In this scenario, the user is expected to withdraw at a later stage. +- It is assumed that the lending protocol's deposit function does not allow for partially deposited amounts. If that is the case, `Lending Module` would need to handle refunds of unused token amounts back into the pool. + +**Kinetiq Protocol Risks and Assumptions:** kHYPE AMM integrates with the `StakingManager` contract for token0 (kHYPE) withdrawals, and `StakingAccountant` for reading exchange rates between kHYPE and HYPE. kHYPE AMM allows the external custody of up to 100% of the AMM's token0 reserves as Pending Withdrawals. `StakingManager` and `Staking Accountant` are upgradable, and kHYPE AMM trusts the management of Kinetiq protocol to manage its kHYPE assets for delayed redemption at its true rate. + +This codebase is currently intended to deployed on Hyperliquid's HyperEVM, where slashing is not active. This is why the kHYPEWithdrawalModule contract always assumes that `StakingManager` will honor kHYPE withdrawal requests amount at 1:1 rate against Native Token, excluding the fee paid in kHYPE at the time where unstaking is initiated. + +**Slashing Risk:** + +kHYPE AMM protects against secondary-market depeg arbitrage loss by never selling kHYPE below the true peg (determined by `StakingAccountant` contract). kHYPE reserves in the AMM are exposed to real slashing events in the same way as holding kHYPE directly. The risk is only based on the current kHYPE reserves of the pool at the time of slashing, and does not create further arbitrage loss. Currently, Hyperliquid does not have slashing enabled. + +**Inventory Risk** + +An LP position can consist of HYPE, kHYPE, and pending kHYPE withdrawals. Upon withdrawing the LP position, a user may withdraw their illiquid portion of pending kHYPE Withdrawals instantly for a fee. In the case of pending withdrawals, to be given the full value of their position, the user must wait until maturity of their pending withdrawal. + ### License Stake Exchange AMM is licensed under the Business Source License 1.1 (BUSL-1.1), see [BUSL_LICENSE](licenses/BUSL_LICENSE), and the MIT Licence (MIT), see [MIT_LICENSE](licenses/MIT_LICENSE). Each file in Stake Exhange AMM states the applicable license type in the header. diff --git a/audits/zenith-aug-25-lending-modules.pdf b/audits/zenith-aug-25-lending-modules.pdf new file mode 100644 index 0000000..310e58e Binary files /dev/null and b/audits/zenith-aug-25-lending-modules.pdf differ diff --git a/deploy_khype_mocks.sh b/deploy_khype_mocks.sh new file mode 100644 index 0000000..5875921 --- /dev/null +++ b/deploy_khype_mocks.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPEMocksDeploy.s.sol:kHYPEMocksDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/deploy_khype_multimarket_lending_module.sh b/deploy_khype_multimarket_lending_module.sh new file mode 100644 index 0000000..db3ed7d --- /dev/null +++ b/deploy_khype_multimarket_lending_module.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPESTEXMultiMarketLendingDeploy.s.sol:kHYPESTEXMultiMarketLendingDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/deploy_khype_rebalance_module.sh b/deploy_khype_rebalance_module.sh new file mode 100644 index 0000000..cd6ffb2 --- /dev/null +++ b/deploy_khype_rebalance_module.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPERebalanceModuleDeploy.s.sol:kHYPERebalanceModuleDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/deploy_khype_stex.sh b/deploy_khype_stex.sh new file mode 100644 index 0000000..82cfeaa --- /dev/null +++ b/deploy_khype_stex.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPESTEXDeploy.s.sol:kHYPESTEXDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/deploy_stepwise_fee_module.sh b/deploy_stepwise_fee_module.sh new file mode 100644 index 0000000..42fc54d --- /dev/null +++ b/deploy_stepwise_fee_module.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/StepwiseFeeModuleDeploy.s.sol:StepwiseFeeModuleDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/deploy_stex.sh b/deploy_stex.sh deleted file mode 100644 index 75c54e4..0000000 --- a/deploy_stex.sh +++ /dev/null @@ -1 +0,0 @@ -eval $(grep '^RPC_URL' .env) && forge script scripts/STEXDeploy.s.sol:STEXDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/deploy_sthype_stex.sh b/deploy_sthype_stex.sh new file mode 100644 index 0000000..1b326bd --- /dev/null +++ b/deploy_sthype_stex.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/stHYPESTEXDeploy.s.sol:stHYPESTEXDeployScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/khype_propose_lending_module.sh b/khype_propose_lending_module.sh new file mode 100644 index 0000000..b9ccd87 --- /dev/null +++ b/khype_propose_lending_module.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPESTEXLendingModuleProposal.s.sol:kHYPESTEXLendingModuleProposalScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/khype_stex_lp.sh b/khype_stex_lp.sh new file mode 100644 index 0000000..bc046fa --- /dev/null +++ b/khype_stex_lp.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPESTEXLP.s.sol:kHYPESTEXLPScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/khype_stex_swap.sh b/khype_stex_swap.sh new file mode 100644 index 0000000..4d33bfa --- /dev/null +++ b/khype_stex_swap.sh @@ -0,0 +1 @@ +eval $(grep '^RPC_URL' .env) && forge script scripts/kHYPESTEXSwap.s.sol:kHYPESTEXSwapScript --rpc-url $RPC_URL \ No newline at end of file diff --git a/scripts/LendingModuleProposal.s.sol b/scripts/LendingModuleProposal.s.sol index e7731e0..ed54296 100644 --- a/scripts/LendingModuleProposal.s.sol +++ b/scripts/LendingModuleProposal.s.sol @@ -6,11 +6,12 @@ import {Test} from "forge-std/Test.sol"; import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; -import {AaveLendingModule} from "src/AaveLendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; +import {MultiMarketLendingModule} from "src/lending-modules/MultiMarketLendingModule.sol"; import {STEXAMM} from "src/STEXAMM.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; -import {WithdrawalModuleKeeper} from "src/owner/WithdrawalModuleKeeper.sol"; -import {WithdrawalModuleManager} from "src/owner/WithdrawalModuleManager.sol"; +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; +import {stHYPEWithdrawalModuleKeeper} from "src/owner/stHYPEWithdrawalModuleKeeper.sol"; +import {stHYPEWithdrawalModuleManager} from "src/owner/stHYPEWithdrawalModuleManager.sol"; contract LendingModuleProposalScript is Script, Test { function run() external { @@ -41,12 +42,12 @@ contract LendingModuleProposalScript is Script, Test { console.log("STEX AMM: ", address(stex)); - WithdrawalModuleKeeper keeper = WithdrawalModuleKeeper( + stHYPEWithdrawalModuleKeeper keeper = stHYPEWithdrawalModuleKeeper( 0x0Aef1eAAd539C16292faEB16D3F4AB5842F0aa6c ); assertEq(keeper.owner(), ownerMultisig); - WithdrawalModuleManager manager = WithdrawalModuleManager( + stHYPEWithdrawalModuleManager manager = stHYPEWithdrawalModuleManager( 0x80c7f89398160fCD9E74519f63F437459E5d02E2 ); assertEq(manager.owner(), ownerMultisig); diff --git a/scripts/OverseerInteractions.s.sol b/scripts/OverseerInteractions.s.sol index db081d4..6cacb27 100644 --- a/scripts/OverseerInteractions.s.sol +++ b/scripts/OverseerInteractions.s.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.25; import "forge-std/Script.sol"; import {Test} from "forge-std/Test.sol"; -import {IOverseer} from "src/interfaces/IOverseer.sol"; +import {IOverseer} from "src/interfaces/sthype/IOverseer.sol"; import {IWithdrawalModule} from "src/interfaces/IWithdrawalModule.sol"; import {LPWithdrawalRequest} from "src/structs/WithdrawalModuleStructs.sol"; diff --git a/scripts/STEXLP.s.sol b/scripts/STEXLP.s.sol index fc53e6a..0ec90ef 100644 --- a/scripts/STEXLP.s.sol +++ b/scripts/STEXLP.s.sol @@ -31,7 +31,7 @@ contract STEXLPScript is Script, Test { uint256 shares = stexLens.getSharesForDeposit(address(stex), amount); DepositWrapper depositWrapper = DepositWrapper( - payable(0x644195021278674bd8F7574e17018d32d8E75A98) + payable(0x640b752B6452C7FeE6afE15e0667EBeB058aB0D2) ); uint256 sharesDeposited = depositWrapper.depositFromNative{ value: amount diff --git a/scripts/STEXLensDeploy.s.sol b/scripts/STEXLensDeploy.s.sol index b5911bf..72c09ce 100644 --- a/scripts/STEXLensDeploy.s.sol +++ b/scripts/STEXLensDeploy.s.sol @@ -5,7 +5,6 @@ import "forge-std/Script.sol"; import {Test} from "forge-std/Test.sol"; import {STEXLens} from "src/STEXLens.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; contract STEXLensDeployScript is Script, Test { function run() external { diff --git a/scripts/STEXSwap.s.sol b/scripts/STEXSwap.s.sol index 3032c1e..e63711b 100644 --- a/scripts/STEXSwap.s.sol +++ b/scripts/STEXSwap.s.sol @@ -25,14 +25,14 @@ contract STEXSwapScript is Script, Test { vm.startBroadcast(deployerPrivateKey); STEXAMM stex = STEXAMM( - payable(0x39694eFF3b02248929120c73F90347013Aec834d) + payable(0xd43d4444fc88E1fC205AA303DBd52aCf3493Fd02) ); address token0 = stex.token0(); address token1 = stex.token1(); address tokenIn = token0; - uint256 amount = 0.01 ether; + uint256 amount = 0.005 ether; uint256 amountOut = stex.getAmountOut(tokenIn, amount, false); diff --git a/scripts/StepwiseFeeModuleDeploy.s.sol b/scripts/StepwiseFeeModuleDeploy.s.sol new file mode 100644 index 0000000..0513e22 --- /dev/null +++ b/scripts/StepwiseFeeModuleDeploy.s.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {STEXAMM} from "src/STEXAMM.sol"; +import {StepwiseFeeModule} from "src/swap-fee-modules/StepwiseFeeModule.sol"; +import {StepwiseFeeModuleKeeper} from "src/owner/StepwiseFeeModuleKeeper.sol"; + +contract StepwiseFeeModuleDeployScript is Script, Test { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain ID not Hyper EVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + // Address of owner multi-sig wallet + address ownerMultisig = 0xe26dA5cBf101bDA4028E2B3208c32424f5D09421; + + STEXAMM stex = STEXAMM( + payable(0xbf747D2959F03332dbd25249dB6f00F62c6Cb526) + ); + + // Uncomment to deploy Swap Fee Module + /*StepwiseFeeModule swapFeeModule = new StepwiseFeeModule( + deployerAddress + ); + assertEq(swapFeeModule.owner(), deployerAddress);*/ + StepwiseFeeModule swapFeeModule = StepwiseFeeModule( + 0x14EFe613b8a1fce7142286D0bC70723519bc4485 + ); + + // Uncomment to deploy Keeper + /*StepwiseFeeModuleKeeper keeper = new StepwiseFeeModuleKeeper( + deployerAddress + );*/ + StepwiseFeeModuleKeeper keeper = StepwiseFeeModuleKeeper( + 0x623cD8e6CA491A30BeE10Cb91034B059a162C007 + ); + + // Uncomment to whitelist a keeper address + address keeperEOA = 0x35826836418Ece9D00e4436bb91CA9a9d5136b30; + //keeper.setKeeper(keeperEOA); + assertTrue(keeper.isKeeper(keeperEOA)); + + // Uncomment to remove a keeper address + //keeper.removeKeeper(deployerAddress); + //assertFalse(keeper.isKeeper(deployerAddress)); + + // Uncomment to transfer ownership of Keeper + //keeper.transferOwnership(ownerMultisig); + assertEq(keeper.owner(), ownerMultisig); + + // Uncomment to generate payload to propose Swap Fee Module update under timelock + /*vm.startPrank(ownerMultisig); + bytes memory swapFeeModuleProposalPayload = abi.encodeWithSelector( + STEXAMM.proposeSwapFeeModule.selector, + address(swapFeeModule), + 3 days + ); + console.log("payload for swap fee module proposal: "); + console.logBytes(swapFeeModuleProposalPayload); + (bool success, ) = address(stex).call(swapFeeModuleProposalPayload); + assertTrue(success); + (address swapFeeModuleProposed, uint256 startTimestamp) = stex + .swapFeeModuleProposal(); + assertEq(swapFeeModuleProposed, address(swapFeeModule)); + assertEq(startTimestamp, block.timestamp + 3 days); + + vm.warp(block.timestamp + 3 days); + + stex.setProposedSwapFeeModule(); + + vm.stopPrank();*/ + + // Uncomment to transfer ownership of Swap Fee Module + //swapFeeModule.transferOwnership(address(keeper)); + assertEq(swapFeeModule.owner(), address(keeper)); + + // Uncomment to set Swap Fee Module params + /*{ + uint256 minThresholdToken1 = 10_000 ether; + uint256 maxThresholdToken1 = 200_000 ether; + uint256 numSteps = 5; + uint32[] memory feeStepsInBipsToken0 = new uint32[](numSteps); + feeStepsInBipsToken0[0] = 3; + feeStepsInBipsToken0[1] = 20; + feeStepsInBipsToken0[2] = 50; + feeStepsInBipsToken0[3] = 200; + feeStepsInBipsToken0[4] = 500; + + keeper.setFeeParamsToken0( + address(swapFeeModule), + minThresholdToken1, + maxThresholdToken1, + feeStepsInBipsToken0 + ); + }*/ + /*{ + uint256 minThresholdToken1 = swapFeeModule.minThresholdToken1(); + uint256 maxThresholdToken1 = swapFeeModule.maxThresholdToken1(); + uint32[] memory feeStepsInBips = swapFeeModule.getToken0FeeInBips(); + + assertEq(minThresholdToken1, 10_000 ether); + assertEq(maxThresholdToken1, 200_000 ether); + assertEq(feeStepsInBips[1], 20); + assertEq(feeStepsInBips[2], 50); + assertEq(feeStepsInBips[3], 200); + assertEq(feeStepsInBips[4], 500); + assertEq(swapFeeModule.numStepsToken0FeeCurve(), 5); + }*/ + + // Uncomment to set STEX AMM's pool in Swap Fee Module + address pool = stex.pool(); + //swapFeeModule.setPool(pool); + assertEq(swapFeeModule.pool(), pool); + + vm.stopBroadcast(); + } +} diff --git a/scripts/WithdrawalModuleProposal.s.sol b/scripts/WithdrawalModuleProposal.s.sol index 2046c93..a77170b 100644 --- a/scripts/WithdrawalModuleProposal.s.sol +++ b/scripts/WithdrawalModuleProposal.s.sol @@ -7,9 +7,9 @@ import {Test} from "forge-std/Test.sol"; import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; import {STEXAMM} from "src/STEXAMM.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; -import {WithdrawalModuleManager} from "src/owner/WithdrawalModuleManager.sol"; -import {WithdrawalModuleKeeper} from "src/owner/WithdrawalModuleKeeper.sol"; +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; +import {stHYPEWithdrawalModuleManager} from "src/owner/stHYPEWithdrawalModuleManager.sol"; +import {stHYPEWithdrawalModuleKeeper} from "src/owner/stHYPEWithdrawalModuleKeeper.sol"; contract WithdrawalModuleProposalScript is Script, Test { function run() external { @@ -113,12 +113,12 @@ contract WithdrawalModuleProposalScript is Script, Test { vm.stopPrank(); - WithdrawalModuleKeeper keeper = WithdrawalModuleKeeper( + stHYPEWithdrawalModuleKeeper keeper = stHYPEWithdrawalModuleKeeper( 0x0Aef1eAAd539C16292faEB16D3F4AB5842F0aa6c ); assertEq(keeper.owner(), ownerMultisig); - WithdrawalModuleManager manager = WithdrawalModuleManager( + stHYPEWithdrawalModuleManager manager = stHYPEWithdrawalModuleManager( 0x80c7f89398160fCD9E74519f63F437459E5d02E2 ); assertEq(manager.owner(), ownerMultisig); @@ -140,12 +140,12 @@ contract WithdrawalModuleProposalScript is Script, Test { communityCode ); bytes memory managerPayload = abi.encodeWithSelector( - WithdrawalModuleManager.call.selector, + stHYPEWithdrawalModuleManager.call.selector, address(withdrawalModule), payloadStakeToken1 ); console.log( - "payload to withdrawalModule manager for withdrawalModule.stakeToken1: " + "payload to stHYPEWithdrawalModule manager for stHYPEWithdrawalModule.stakeToken1: " ); console.logBytes(managerPayload); diff --git a/scripts/WithdrawalModuleStaking.s.sol b/scripts/WithdrawalModuleStaking.s.sol index 02e21ed..e04c9c4 100644 --- a/scripts/WithdrawalModuleStaking.s.sol +++ b/scripts/WithdrawalModuleStaking.s.sol @@ -7,9 +7,9 @@ import {Test} from "forge-std/Test.sol"; import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; import {STEXAMM} from "src/STEXAMM.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; -import {WithdrawalModuleManager} from "src/owner/WithdrawalModuleManager.sol"; -import {WithdrawalModuleKeeper} from "src/owner/WithdrawalModuleKeeper.sol"; +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; +import {stHYPEWithdrawalModuleManager} from "src/owner/stHYPEWithdrawalModuleManager.sol"; +import {stHYPEWithdrawalModuleKeeper} from "src/owner/stHYPEWithdrawalModuleKeeper.sol"; contract WithdrawalModuleStakingScript is Script, Test { function run() external { @@ -38,12 +38,12 @@ contract WithdrawalModuleStakingScript is Script, Test { assertEq(stex.withdrawalModule(), address(withdrawalModule)); - WithdrawalModuleKeeper keeper = WithdrawalModuleKeeper( + stHYPEWithdrawalModuleKeeper keeper = stHYPEWithdrawalModuleKeeper( 0x0Aef1eAAd539C16292faEB16D3F4AB5842F0aa6c ); assertEq(keeper.owner(), ownerMultisig); - WithdrawalModuleManager manager = WithdrawalModuleManager( + stHYPEWithdrawalModuleManager manager = stHYPEWithdrawalModuleManager( 0x80c7f89398160fCD9E74519f63F437459E5d02E2 ); assertEq(manager.owner(), ownerMultisig); @@ -62,12 +62,12 @@ contract WithdrawalModuleStakingScript is Script, Test { communityCode ); bytes memory managerPayload = abi.encodeWithSelector( - WithdrawalModuleManager.call.selector, + stHYPEWithdrawalModuleManager.call.selector, address(withdrawalModule), payloadStakeToken1 ); console.log( - "payload to withdrawalModule manager for withdrawalModule.stakeToken1: " + "payload to stHYPEWithdrawalModule manager for withdrawalModule.stakeToken1: " ); console.logBytes(managerPayload); diff --git a/scripts/kHYPEMocksDeploy.s.sol b/scripts/kHYPEMocksDeploy.s.sol new file mode 100644 index 0000000..bb13a8f --- /dev/null +++ b/scripts/kHYPEMocksDeploy.s.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; + +import {MockStakingAccountant} from "src/mocks/kinetiq/MockStakingAccountant.sol"; +import {MockStakingManager} from "src/mocks/kinetiq/MockStakingManager.sol"; + +contract kHYPEMocksDeployScript is Script, Test { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain ID not Hyper EVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy mock kHYPE + //ERC20Mock token0 = new ERC20Mock(); + ERC20Mock token0 = ERC20Mock( + 0x95DD90fDeFfd53631409A1946B96225Ed3bc30B2 + ); + + // Deploy mock Staking Accountant + /*MockStakingAccountant stakingAccountant = new MockStakingAccountant( + address(token0) + );*/ + MockStakingAccountant stakingAccountant = MockStakingAccountant( + 0xD9CDA4e095f74E284FF38B8276E7aeA3ca9a25c2 + ); + // Deploy mock Staking Manager + /*MockStakingManager stakingManager = new MockStakingManager( + address(stakingAccountant), + address(token0) + );*/ + MockStakingManager stakingManager = MockStakingManager( + 0x0245549783cCb543bC98D3557b42512ac8d1cdc4 + ); + + // Set unstaking fee in bips + //stakingManager.setUnstakeFeeRate(10); + assertEq(stakingManager.unstakeFeeRate(), 10); + + // Mint kHYPE + //stakingManager.stake{value: 0.2 ether}(); + + vm.stopBroadcast(); + } +} diff --git a/scripts/kHYPERebalanceModuleDeploy.s.sol b/scripts/kHYPERebalanceModuleDeploy.s.sol new file mode 100644 index 0000000..bca5b53 --- /dev/null +++ b/scripts/kHYPERebalanceModuleDeploy.s.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {kHYPEWithdrawalModule} from "src/withdrawal-modules/kHYPEWithdrawalModule.sol"; +//import {kHYPEWithdrawalModuleKeeper} from "src/owner/kHYPEWithdrawalModuleKeeper.sol"; +//import {kHYPEWithdrawalModuleManager} from "src/owner/kHYPEWithdrawalModuleManager.sol"; +import {RebalanceModule} from "src/RebalanceModule.sol"; + +contract kHYPERebalanceModuleDeployScript is Script, Test { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain ID not Hyper EVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + // Address of owner multi-sig wallet + address ownerMultisig = 0xe26dA5cBf101bDA4028E2B3208c32424f5D09421; + + // kHYPE withdrawal module + kHYPEWithdrawalModule withdrawalModule = kHYPEWithdrawalModule( + payable(0xd939975c3b24f5Cc8F5cd794204378a5A34e55aa) + ); + + address pool = withdrawalModule.pool(); + + /*RebalanceModule rebalanceModule = new RebalanceModule( + address(withdrawalModule), + pool, + ownerMultisig + );*/ + RebalanceModule rebalanceModule = RebalanceModule( + 0xbfc56efbdB55C1bF0A3103Fd04C98dD0D70f13B9 + ); + + assertEq( + address(rebalanceModule.withdrawalModule()), + address(withdrawalModule) + ); + assertEq(address(rebalanceModule.pool()), pool); + assertEq(rebalanceModule.owner(), ownerMultisig); + + vm.stopBroadcast(); + } +} diff --git a/scripts/kHYPESTEXDeploy.s.sol b/scripts/kHYPESTEXDeploy.s.sol new file mode 100644 index 0000000..e3b0605 --- /dev/null +++ b/scripts/kHYPESTEXDeploy.s.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {STEXAMM} from "src/STEXAMM.sol"; +import {StepwiseFeeModule} from "src/swap-fee-modules/StepwiseFeeModule.sol"; +import {kHYPEWithdrawalModule} from "src/withdrawal-modules/kHYPEWithdrawalModule.sol"; +import {DepositWrapper} from "src/DepositWrapper.sol"; +import {kHYPEWithdrawalModuleManager} from "src/owner/kHYPEWithdrawalModuleManager.sol"; +import {kHYPEWithdrawalModuleKeeper} from "src/owner/kHYPEWithdrawalModuleKeeper.sol"; +import {ERC4626LendingModule} from "src/lending-modules/ERC4626LendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; + +contract kHYPESTEXDeployScript is Script, Test { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain ID not Hyper EVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + // Address of owner multi-sig wallet + address ownerMultisig = 0xe26dA5cBf101bDA4028E2B3208c32424f5D09421; + + // kHYPE + address token0 = 0xfD739d4e423301CE9385c1fb8850539D657C296D; + // WHYPE + address token1 = 0x5555555555555555555555555555555555555555; + + // Valantis Protocol Factory + address protocolFactory = 0x7E028ac56cB2AF75292F3D967978189698C24732; + + // Kinetiq Staking Manager + //address stakingManager = 0x393D0B87Ed38fc779FD9611144aE649BA6082109; + // Kinetiq Staking Accountant + //address stakingAccountant = 0x9209648Ec9D448EF57116B73A2f081835643dc7A; + + // Uncomment to deploy Swap Fee Module + /*StepwiseFeeModule swapFeeModule = new StepwiseFeeModule( + deployerAddress + ); + assertEq(swapFeeModule.owner(), deployerAddress);*/ + StepwiseFeeModule swapFeeModule = StepwiseFeeModule( + 0x14EFe613b8a1fce7142286D0bC70723519bc4485 + ); + // Uncomment to generate payload to propose Swap Fee Module update under timelock + /*bytes memory swapFeeModuleProposalPayload = abi.encodeWithSelector( + STEXAMM.proposeSwapFeeModule.selector, + address(swapFeeModule), + 3 days + ); + console.log("payload for swap fee module proposal: "); + console.logBytes(swapFeeModuleProposalPayload);*/ + + // Uncomment to transfer ownership of Swap Fee Module + //swapFeeModule.transferOwnership(ownerMultisig); + //assertEq(swapFeeModule.owner(), ownerMultisig); + + // Uncomment to set Swap Fee Module params + /*{ + uint256 minThresholdToken1 = 10_000 ether; + uint256 maxThresholdToken1 = 200_000 ether; + uint256 numSteps = 5; + uint32[] memory feeStepsInBipsToken0 = new uint32[](numSteps); + feeStepsInBipsToken0[0] = 3; + feeStepsInBipsToken0[1] = 20; + feeStepsInBipsToken0[2] = 50; + feeStepsInBipsToken0[3] = 200; + feeStepsInBipsToken0[4] = 500; + + swapFeeModule.setFeeParamsToken0( + minThresholdToken1, + maxThresholdToken1, + feeStepsInBipsToken0 + ); + }*/ + /*{ + uint256 minThresholdToken1 = swapFeeModule.minThresholdToken1(); + uint256 maxThresholdToken1 = swapFeeModule.maxThresholdToken1(); + uint32[] memory feeStepsInBips = swapFeeModule.getToken0FeeInBips(); + + assertEq(minThresholdToken1, 10_000 ether); + assertEq(maxThresholdToken1, 200_000 ether); + assertEq(feeStepsInBips[1], 20); + assertEq(feeStepsInBips[2], 50); + assertEq(feeStepsInBips[3], 200); + assertEq(feeStepsInBips[4], 500); + assertEq(swapFeeModule.numStepsToken0FeeCurve(), 5); + }*/ + + // Uncomment for deployment of Withdrawal Module + /*kHYPEWithdrawalModule withdrawalModule = new kHYPEWithdrawalModule( + stakingAccountant, + stakingManager, + deployerAddress + ); + assertEq(withdrawalModule.owner(), deployerAddress); + assertEq(withdrawalModule.overseer(), stakingManager); + assertEq(withdrawalModule.stakingAccountant(), stakingAccountant); + assertEq(withdrawalModule.stakingManager(), stakingManager);*/ + kHYPEWithdrawalModule withdrawalModule = kHYPEWithdrawalModule( + payable(0xd939975c3b24f5Cc8F5cd794204378a5A34e55aa) + ); + + // Uncomment for deployment of STEX AMM + /*STEXAMM stex = new STEXAMM( + "kHYPE AMM", + "kHYPE AMM LP", + token0, + token1, + address(swapFeeModule), + protocolFactory, + 0xA2666B4dD1242Def4c3cf5731a85Aa8457fe01C1, // feeRecipient1 + 0xA2666B4dD1242Def4c3cf5731a85Aa8457fe01C1, // feeRecipient2 + deployerAddress, // owner + address(withdrawalModule), + 0 + ); + assertEq(stex.owner(), deployerAddress);*/ + STEXAMM stex = STEXAMM( + payable(0xbf747D2959F03332dbd25249dB6f00F62c6Cb526) + ); + //stex.transferOwnership(ownerMultisig); + assertEq(stex.owner(), ownerMultisig); + + address pool = stex.pool(); + console.log("STEX sovereign pool: ", pool); + + // Uncomment to set STEX's pool manager fees in bips + // 20% + //uint256 managerFeeBips = 2_000; + + /*bytes memory data = abi.encodeWithSelector( + STEXAMM.setPoolManagerFeeBips.selector, + managerFeeBips + );*/ + //console.log("payload for stex.setPoolManagerFeeBips: "); + //console.logBytes(data); + + //stex.setPoolManagerFeeBips(managerFeeBips); + + // Uncomment to set STEX AMM's pool in Swap Fee Module + //swapFeeModule.setPool(pool); + assertEq(swapFeeModule.pool(), pool); + + // Uncomment to set STEX AMM in withdrawal module + //withdrawalModule.setSTEX(address(stex)); + assertEq(withdrawalModule.stex(), address(stex)); + assertEq(withdrawalModule.pool(), pool); + + console.log("STEX AMM: ", address(stex)); + + // Uncomment for deployment of Deposit Wrapper + /*DepositWrapper depositWrapper = new DepositWrapper( + stex.token1(), + address(stex) + ); + DepositWrapper depositWrapper = DepositWrapper( + payable(0xA2918c869e352ADdd5b1f9f12cDe5672B23f139d) + );*/ + + // Uncomment for deployment of withdrawal module's keeper + /*kHYPEWithdrawalModuleKeeper keeper = new kHYPEWithdrawalModuleKeeper( + deployerAddress + ); + assertEq(keeper.owner(), deployerAddress); + console.log("keeper deployed: ", address(keeper));*/ + kHYPEWithdrawalModuleKeeper keeper = kHYPEWithdrawalModuleKeeper( + 0x0A3495d86DbB7dB0f59c54747bc81C321C295e8c + ); + //address keeperEOA = 0xFBFfd0f718E8f3Bb0c5Dcd0678529609ba4A398E; + //keeper.setKeeper(keeperEOA); + //assertTrue(keeper.isKeeper(keeperEOA)); + //keeper.transferOwnership(ownerMultisig); + assertEq(keeper.owner(), ownerMultisig); + + // Uncomment for deployment of withdrawal module's owner + /*kHYPEWithdrawalModuleManager manager = new kHYPEWithdrawalModuleManager( + deployerAddress, + address(keeper) + ); + assertEq(manager.owner(), deployerAddress); + assertEq(manager.keeper(), address(keeper));*/ + kHYPEWithdrawalModuleManager manager = kHYPEWithdrawalModuleManager( + 0xE100cC3B7bCD133381B63351868705b224537765 + ); + //manager.transferOwnership(ownerMultisig); + assertEq(manager.owner(), ownerMultisig); + assertEq(manager.keeper(), address(keeper)); + //withdrawalModule.transferOwnership(address(manager)); + assertEq(withdrawalModule.owner(), address(manager)); + + // Uncomment for deployment of ERC4626 Lending Module + /*{ + ERC4626LendingModule lendingModule = new ERC4626LendingModule( + 0x2900ABd73631b2f60747e687095537B673c06A76, // ERC4626 vault + address(withdrawalModule), // owner + ownerMultisig // tokenSweepManager + ); + assertEq( + address(lendingModule.vault()), + 0x2900ABd73631b2f60747e687095537B673c06A76 + ); + assertEq( + lendingModule.asset(), + stex.token1() // WHYPE + ); + assertEq(lendingModule.owner(), address(withdrawalModule)); + assertEq(lendingModule.tokenSweepManager(), ownerMultisig); + }*/ + + // Uncomment for deployment of Aave Lending Module + /*{ + AaveLendingModule lendingModule = new AaveLendingModule( + 0x00A89d7a5A02160f20150EbEA7a2b5E4879A1A8b, // Aave V3 Pool + 0x0D745EAA9E70bb8B6e2a0317f85F1d536616bD34, // Yield Token + stex.token1(), // WHYPE + address(withdrawalModule), // Owner + ownerMultisig, // Token Sweep Manager + 0 // Referral Code + ); + assertEq(lendingModule.owner(), address(withdrawalModule)); + assertEq(lendingModule.tokenSweepManager(), ownerMultisig); + assertEq( + address(lendingModule.pool()), + 0x00A89d7a5A02160f20150EbEA7a2b5E4879A1A8b + ); + assertEq( + lendingModule.yieldToken(), + 0x0D745EAA9E70bb8B6e2a0317f85F1d536616bD34 + ); + assertEq( + lendingModule.asset(), + 0x5555555555555555555555555555555555555555 + ); + }*/ + + // Uncomment to unpause STEXAMM + /*vm.startPrank(ownerMultisig); + bytes memory payload = abi.encodeWithSelector(STEXAMM.unpause.selector); + console.log("payload to stex manager: "); + console.logBytes(payload); + stex.unpause(); + assertFalse(stex.paused()); + + vm.stopPrank();*/ + + vm.stopBroadcast(); + } +} diff --git a/scripts/kHYPESTEXLP.s.sol b/scripts/kHYPESTEXLP.s.sol new file mode 100644 index 0000000..84201a8 --- /dev/null +++ b/scripts/kHYPESTEXLP.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {STEXLens} from "src/STEXLens.sol"; +import {STEXAMM} from "src/STEXAMM.sol"; +import {DepositWrapper} from "src/DepositWrapper.sol"; + +contract kHYPESTEXLPScript is Script, Test { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain Id not HyperEVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + STEXLens stexLens = STEXLens( + 0x95e88072c3fe908101a13584d7A0ff87DaDd88f3 + ); + STEXAMM stex = STEXAMM( + payable(0xbf747D2959F03332dbd25249dB6f00F62c6Cb526) + ); + + // Uncomment for deposits + /*uint256 amount = 0.01 ether; + uint256 shares = stexLens.getSharesForDeposit(address(stex), amount); + + DepositWrapper depositWrapper = DepositWrapper( + payable(0xA2918c869e352ADdd5b1f9f12cDe5672B23f139d) + ); + uint256 sharesDeposited = depositWrapper.depositFromNative{ + value: amount + }((shares * 9999) / 10_000, block.timestamp + 120, deployerAddress); + + console.log("shares expected: ", shares); + console.log("shares deposited: ", sharesDeposited); + console.log( + "native token balance after deposit: ", + deployerAddress.balance + );*/ + + // Uncomment for withdrawals + /*uint256 amount = stex.balanceOf(deployerAddress); + bool isInstantWithdrawal = true; + + (uint256 amount0, uint256 amount1) = stexLens.getAmountsForWithdraw( + address(stex), + amount, + isInstantWithdrawal + ); + + console.log("amount0 expected: ", amount0); + console.log("amount1 expected: ", amount1); + + (uint256 amount0Withdraw, uint256 amount1Withdraw) = stex.withdraw( + amount, + amount0, + amount1, + block.timestamp + 120, + deployerAddress, + true, + isInstantWithdrawal + ); + + console.log("amount0 withdraw: ", amount0Withdraw); + console.log("amount1 withdraw: ", amount1Withdraw);*/ + + vm.stopBroadcast(); + } +} diff --git a/scripts/kHYPESTEXLendingModuleProposal.s.sol b/scripts/kHYPESTEXLendingModuleProposal.s.sol new file mode 100644 index 0000000..19dc54c --- /dev/null +++ b/scripts/kHYPESTEXLendingModuleProposal.s.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + +import {ERC4626LendingModule} from "src/lending-modules/ERC4626LendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; +import {MultiMarketLendingModule} from "src/lending-modules/MultiMarketLendingModule.sol"; +import {STEXAMM} from "src/STEXAMM.sol"; +import {kHYPEWithdrawalModule} from "src/withdrawal-modules/kHYPEWithdrawalModule.sol"; +import {kHYPEWithdrawalModuleKeeper} from "src/owner/kHYPEWithdrawalModuleKeeper.sol"; +import {kHYPEWithdrawalModuleManager} from "src/owner/kHYPEWithdrawalModuleManager.sol"; + +contract kHYPESTEXLendingModuleProposalScript is Script, Test { + function run() external { + if (block.chainid != 999) revert("Chain ID not Hyper EVM mainnet"); + + // Address of owner multi-sig wallet + address ownerMultisig = 0xe26dA5cBf101bDA4028E2B3208c32424f5D09421; + + // kHYPE + address token0 = 0xfD739d4e423301CE9385c1fb8850539D657C296D; + // WHYPE + address token1 = 0x5555555555555555555555555555555555555555; + + // Valantis Protocol Factory + address protocolFactory = 0x7E028ac56cB2AF75292F3D967978189698C24732; + + kHYPEWithdrawalModule withdrawalModule = kHYPEWithdrawalModule( + payable(0xd939975c3b24f5Cc8F5cd794204378a5A34e55aa) + ); + + STEXAMM stex = STEXAMM( + payable(0xbf747D2959F03332dbd25249dB6f00F62c6Cb526) + ); + assertEq(stex.owner(), ownerMultisig); + + ISovereignPool pool = ISovereignPool(stex.pool()); + console.log("STEX sovereign pool: ", address(pool)); + + console.log("STEX AMM: ", address(stex)); + + kHYPEWithdrawalModuleKeeper keeper = kHYPEWithdrawalModuleKeeper( + 0x0A3495d86DbB7dB0f59c54747bc81C321C295e8c + ); + assertEq(keeper.owner(), ownerMultisig); + + kHYPEWithdrawalModuleManager manager = kHYPEWithdrawalModuleManager( + 0xE100cC3B7bCD133381B63351868705b224537765 + ); + assertEq(manager.owner(), ownerMultisig); + assertEq(manager.keeper(), address(keeper)); + + // HyperLend Lending Module + /*AaveLendingModule lendingModule = AaveLendingModule( + 0x78b68763294B86d451958dc01c8E6b3057645F67 + );*/ + // Multi-market lending module + MultiMarketLendingModule lendingModule = MultiMarketLendingModule( + 0xdFcAeD3ff2C15dd5d95B3191670F21E73f21c22F + ); + + // Simulate proposal + + /*vm.startPrank(address(manager)); + withdrawalModule.proposeLendingModule(address(lendingModule), 3 days); + ( + address lendingModuleProposed, + uint256 startTimestamp + ) = withdrawalModule.lendingModuleProposal(); + assertEq(lendingModuleProposed, address(lendingModule)); + assertEq(startTimestamp, block.timestamp + 3 days); + + vm.warp(block.timestamp + 3 days); + + withdrawalModule.setProposedLendingModule(); + assertEq( + address(withdrawalModule.lendingModule()), + address(lendingModule) + ); + vm.stopPrank();*/ + + // Simulate lending module deposit + /*vm.startPrank(address(manager)); + withdrawalModule.supplyToken1ToLendingPool(100 ether); + console.log( + "asset balance in lending protocol: ", + lendingModule.assetBalance() + ); + vm.stopPrank();*/ + + // Simulate lending module withdraw + /*vm.startPrank(address(manager)); + withdrawalModule.withdrawToken1FromLendingPool(0.1 ether, address(0)); + console.log( + "asset balance in lending protocol: ", + lendingModule.assetBalance() + ); + vm.stopPrank();*/ + + // Generate payload for `proposeLendingModule` + /*vm.startPrank(ownerMultisig); + + bytes memory payload = abi.encodeWithSelector( + kHYPEWithdrawalModule.proposeLendingModule.selector, + address(lendingModule), + 3 days + ); + bytes memory managerPayload = abi.encodeWithSelector( + kHYPEWithdrawalModuleManager.call.selector, + address(withdrawalModule), + payload + ); + console.log("payload to withdrawalModule manager: "); + console.logBytes(managerPayload); + + (bool success, ) = address(manager).call(managerPayload); + assertTrue(success); + ( + address lendingModuleProposed, + uint256 startTimestamp + ) = withdrawalModule.lendingModuleProposal(); + assertEq(lendingModuleProposed, address(lendingModule)); + assertEq(startTimestamp, block.timestamp + 3 days); + + vm.stopPrank();*/ + + // Generate payload for `setProposedLendingModule` + /*vm.startPrank(ownerMultisig); + + bytes memory payload = abi.encodeWithSelector( + kHYPEWithdrawalModule.setProposedLendingModule.selector + ); + bytes memory managerPayload = abi.encodeWithSelector( + kHYPEWithdrawalModuleManager.call.selector, + address(withdrawalModule), + payload + ); + console.log("payload to withdrawalModule manager: "); + console.logBytes(managerPayload); + + (bool success, ) = address(manager).call(managerPayload); + assertTrue(success); + + assertEq( + address(withdrawalModule.lendingModule()), + address(lendingModule) + ); + + vm.stopPrank();*/ + } +} diff --git a/scripts/kHYPESTEXMultiMarketLendingDeploy.s.sol b/scripts/kHYPESTEXMultiMarketLendingDeploy.s.sol new file mode 100644 index 0000000..bf28ab5 --- /dev/null +++ b/scripts/kHYPESTEXMultiMarketLendingDeploy.s.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; + +import {MultiMarketLendingModule} from "src/lending-modules/MultiMarketLendingModule.sol"; +import {MultiMarketLendingModuleManager} from "src/owner/MultiMarketLendingModuleManager.sol"; +import {MultiMarketLendingModuleKeeper} from "src/owner/MultiMarketLendingModuleKeeper.sol"; +import {ERC4626LendingModule} from "src/lending-modules/ERC4626LendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; +import {kHYPEWithdrawalModule} from "src/withdrawal-modules/kHYPEWithdrawalModule.sol"; + +contract kHYPESTEXMultiMarketLendingDeployScript is Script, Test { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain ID not Hyper EVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + //vm.startBroadcast(deployerPrivateKey); + + // Address of owner multi-sig wallet + address ownerMultisig = 0xe26dA5cBf101bDA4028E2B3208c32424f5D09421; + + // WHYPE + address token1 = 0x5555555555555555555555555555555555555555; + + kHYPEWithdrawalModule withdrawalModule = kHYPEWithdrawalModule( + payable(0xd939975c3b24f5Cc8F5cd794204378a5A34e55aa) + ); + + // Uncomment to deploy MultiMarketLendingModule + /*MultiMarketLendingModule multiLendingModule = new MultiMarketLendingModule( + token1, // asset + deployerAddress, // owner role + address(withdrawalModule), // manager role (withdrawal module) + ownerMultisig, // token sweep manager role + 2_000 // 20% fee on net yield + );*/ + MultiMarketLendingModule multiLendingModule = MultiMarketLendingModule( + 0xdFcAeD3ff2C15dd5d95B3191670F21E73f21c22F + ); + assertEq(multiLendingModule.asset(), token1); + assertEq(multiLendingModule.manager(), address(withdrawalModule)); + assertEq(multiLendingModule.tokenSweepManager(), ownerMultisig); + assertEq(multiLendingModule.managerFeeBips(), 2_000); + + // Uncomment for deployment of HypurrFi Aave Lending Module + /*AaveLendingModule lendingModule = new AaveLendingModule( + 0xceCcE0EB9DD2Ef7996e01e25DD70e461F918A14b, // AAVE V3 pool + 0x7C97cd7B57b736c6AD74fAE97C0e21e856251dcf, // aWHYPE + token1, // WHYPE + address(multiLendingModule), // owner + ownerMultisig, // tokenSweepManager + 2 + );*/ + AaveLendingModule hypurrFiLendingModule = AaveLendingModule( + 0x4f4Da3703ecf88a885fe20A3B5c4C2581Ff4ED28 + ); + assertEq( + address(hypurrFiLendingModule.pool()), + 0xceCcE0EB9DD2Ef7996e01e25DD70e461F918A14b + ); + assertEq( + hypurrFiLendingModule.yieldToken(), + 0x7C97cd7B57b736c6AD74fAE97C0e21e856251dcf + ); + assertEq(hypurrFiLendingModule.owner(), address(multiLendingModule)); + assertEq(hypurrFiLendingModule.tokenSweepManager(), ownerMultisig); + assertEq(hypurrFiLendingModule.referralCode(), 2); + + // Uncomment for deployment of HyperLend Aave Lending Module + /*AaveLendingModule hyperLendLendingModule = new AaveLendingModule( + 0x00A89d7a5A02160f20150EbEA7a2b5E4879A1A8b, // Aave V3 Pool + 0x0D745EAA9E70bb8B6e2a0317f85F1d536616bD34, // Yield Token + token1, // WHYPE + address(multiLendingModule), // owner + ownerMultisig, // tokenSweepManager + 0 + );*/ + AaveLendingModule hyperLendLendingModule = AaveLendingModule( + 0x1D79F0e8F7B03071957ebC677148CFBb99dEe357 + ); + assertEq( + address(hyperLendLendingModule.pool()), + 0x00A89d7a5A02160f20150EbEA7a2b5E4879A1A8b + ); + assertEq( + hyperLendLendingModule.yieldToken(), + 0x0D745EAA9E70bb8B6e2a0317f85F1d536616bD34 + ); + assertEq(hyperLendLendingModule.owner(), address(multiLendingModule)); + assertEq(hyperLendLendingModule.tokenSweepManager(), ownerMultisig); + assertEq(hyperLendLendingModule.referralCode(), 0); + + // Uncomment for deployment of Felix Lending Module + /*ERC4626LendingModule felixLendingModule = new ERC4626LendingModule( + 0x2900ABd73631b2f60747e687095537B673c06A76, // ERC4626 vault + address(multiLendingModule), // owner + ownerMultisig // tokenSweepManager + );*/ + ERC4626LendingModule felixLendingModule = ERC4626LendingModule( + 0x33d60CAF7C70Fc4cB5AfF3Ae7f31fCdE74398D13 + ); + assertEq( + address(felixLendingModule.vault()), + 0x2900ABd73631b2f60747e687095537B673c06A76 + ); + assertEq( + felixLendingModule.asset(), + token1 // WHYPE + ); + assertEq(felixLendingModule.owner(), address(multiLendingModule)); + assertEq(felixLendingModule.tokenSweepManager(), ownerMultisig); + + // Uncomment for deployment of Hyperbeat Morpho Lending Module + /*ERC4626LendingModule hyperbeatLendingModule = new ERC4626LendingModule( + 0xd19e3d00f8547f7d108abFD4bbb015486437B487, // ERC4626 vault + address(multiLendingModule), // owner + ownerMultisig // tokenSweepManager + );*/ + ERC4626LendingModule hyperbeatLendingModule = ERC4626LendingModule( + 0x269B88Fa6545e117171106439CcDF38d706DF2d8 + ); + /*assertEq( + address(hyperbeatLendingModule.vault()), + 0xd19e3d00f8547f7d108abFD4bbb015486437B487 + ); + assertEq( + hyperbeatLendingModule.asset(), + token1 // WHYPE + ); + assertEq(hyperbeatLendingModule.owner(), address(multiLendingModule)); + assertEq(hyperbeatLendingModule.tokenSweepManager(), ownerMultisig);*/ + + // Initialize multi-market lending module + /*address[] memory lendingModuleArray = new address[](4); + lendingModuleArray[0] = address(hypurrFiLendingModule); + lendingModuleArray[1] = address(hyperLendLendingModule); + lendingModuleArray[2] = address(felixLendingModule); + lendingModuleArray[3] = address(hyperbeatLendingModule); + + MultiMarketLendingModule.LendingModuleConfig[] + memory lendingModuleConfigArray = new MultiMarketLendingModule.LendingModuleConfig[]( + 4 + ); + lendingModuleConfigArray[0] = MultiMarketLendingModule + .LendingModuleConfig({ + depositWeightBips: 3_000, + withdrawWeightBips: 3_000 + }); + lendingModuleConfigArray[1] = MultiMarketLendingModule + .LendingModuleConfig({ + depositWeightBips: 3_700, + withdrawWeightBips: 3_700 + }); + lendingModuleConfigArray[2] = MultiMarketLendingModule + .LendingModuleConfig({ + depositWeightBips: 3_000, + withdrawWeightBips: 3_000 + }); + lendingModuleConfigArray[3] = MultiMarketLendingModule + .LendingModuleConfig({ + depositWeightBips: 300, + withdrawWeightBips: 300 + }); + + multiLendingModule.initialize( + lendingModuleArray, + lendingModuleConfigArray + );*/ + + // Set deposit weights + /*uint16[] memory depositWeights = new uint16[](4); + depositWeights[0] = 3_000; + depositWeights[1] = 3_700; + depositWeights[2] = 3_300; + depositWeights[3] = 0; + + multiLendingModule.setDepositWeights(depositWeights);*/ + + // Set withdraw weights + /*uint16[] memory withdrawWeights = new uint16[](4); + withdrawWeights[0] = 3_000; + withdrawWeights[1] = 3_700; + withdrawWeights[2] = 3_300; + withdrawWeights[3] = 0; + + multiLendingModule.setWithdrawWeights(withdrawWeights);*/ + + // Deploy MultiMarketLendingModuleKeeper + /*MultiMarketLendingModuleKeeper keeper = new MultiMarketLendingModuleKeeper( + ownerMultisig + );*/ + MultiMarketLendingModuleKeeper keeper = MultiMarketLendingModuleKeeper( + 0xeA31F3f2A024298A1f35290b6876c5993ee01cE3 + ); + assertEq(keeper.owner(), ownerMultisig); + assertFalse(keeper.isKeeper(deployerAddress)); + + // Deploy MultiMarketLendingModuleManager + /*MultiMarketLendingModuleManager manager = new MultiMarketLendingModuleManager( + ownerMultisig, + address(keeper) + );*/ + MultiMarketLendingModuleManager manager = MultiMarketLendingModuleManager( + 0x81211b64628b7e1dECf352D175a260448DA3B089 + ); + assertEq(manager.owner(), ownerMultisig); + assertEq(manager.keeper(), address(keeper)); + + // Initiate transfer of ownership from deployerAddress to manager + //multiLendingModule.transferOwnership(address(manager)); + + // Generate payload for `manager.acceptOwnership` + /*vm.startPrank(ownerMultisig); + + bytes memory payload = abi.encodeWithSelector( + MultiMarketLendingModuleManager.acceptOwnership.selector, + address(multiLendingModule) + ); + + // Call `manager.acceptOwnership` + (bool success, ) = address(manager).call(payload); + assertTrue(success); + assertEq(multiLendingModule.owner(), address(manager)); + + console.log( + "Payload for MultiMarketLendingModuleManager.acceptOwnership: " + ); + console.logBytes(payload); + + vm.stopPrank();*/ + assertEq(multiLendingModule.owner(), address(manager)); + + //vm.stopBroadcast(); + } +} diff --git a/scripts/kHYPESTEXSwap.s.sol b/scripts/kHYPESTEXSwap.s.sol new file mode 100644 index 0000000..14993eb --- /dev/null +++ b/scripts/kHYPESTEXSwap.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import "forge-std/Script.sol"; +import {Test} from "forge-std/Test.sol"; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; +import {SovereignPoolSwapParams} from "@valantis-core/pools/structs/SovereignPoolStructs.sol"; + +import {STEXAMM} from "src/STEXAMM.sol"; + +contract kHYPESTEXSwapScript is Script, Test { + using SafeERC20 for ERC20; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployerAddress = vm.addr(deployerPrivateKey); + + if (block.chainid != 999) revert("Chain Id not HyperEVM mainnet"); + + console.log("Deployer address: ", deployerAddress); + + vm.startBroadcast(deployerPrivateKey); + + STEXAMM stex = STEXAMM( + payable(0x38257bbEc97bBFd605d8c9770e66CFA0E7A47242) + ); + + address token0 = stex.token0(); + address token1 = stex.token1(); + + address tokenIn = token0; + uint256 amount = 0.01 ether; + + uint256 amountOut = stex.getAmountOut(tokenIn, amount, false); + + console.log("amountOut expected: ", amountOut); + + SovereignPoolSwapParams memory params; + params.isZeroToOne = tokenIn == token0; + params.amountIn = amount; + params.deadline = block.timestamp + 120; + params.swapTokenOut = params.isZeroToOne ? token1 : token0; + params.recipient = deployerAddress; + + ISovereignPool pool = ISovereignPool(stex.pool()); + + console.log("pool: ", address(pool)); + + ERC20(tokenIn).forceApprove(address(pool), params.amountIn); + + (uint256 amountInSwap, uint256 amountOutSwap) = pool.swap(params); + + console.log("amountIn: ", amountInSwap); + console.log("amountOut: ", amountOutSwap); + + vm.stopBroadcast(); + } +} diff --git a/scripts/STEXDeploy.s.sol b/scripts/stHYPESTEXDeploy.s.sol similarity index 90% rename from scripts/STEXDeploy.s.sol rename to scripts/stHYPESTEXDeploy.s.sol index 430cd02..58082c4 100644 --- a/scripts/STEXDeploy.s.sol +++ b/scripts/stHYPESTEXDeploy.s.sol @@ -4,15 +4,15 @@ pragma solidity ^0.8.25; import "forge-std/Script.sol"; import {Test} from "forge-std/Test.sol"; -import {AaveLendingModule} from "src/AaveLendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; import {STEXAMM} from "src/STEXAMM.sol"; -import {STEXRatioSwapFeeModule} from "src/STEXRatioSwapFeeModule.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; +import {STEXRatioSwapFeeModule} from "src/swap-fee-modules/STEXRatioSwapFeeModule.sol"; +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; import {DepositWrapper} from "src/DepositWrapper.sol"; -import {WithdrawalModuleManager} from "src/owner/WithdrawalModuleManager.sol"; -import {WithdrawalModuleKeeper} from "src/owner/WithdrawalModuleKeeper.sol"; +import {stHYPEWithdrawalModuleManager} from "src/owner/stHYPEWithdrawalModuleManager.sol"; +import {stHYPEWithdrawalModuleKeeper} from "src/owner/stHYPEWithdrawalModuleKeeper.sol"; -contract STEXDeployScript is Script, Test { +contract stHYPESTEXDeployScript is Script, Test { function run() external { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); address deployerAddress = vm.addr(deployerPrivateKey); @@ -162,16 +162,16 @@ contract STEXDeployScript is Script, Test { address(stex) );*/ DepositWrapper depositWrapper = DepositWrapper( - payable(0x644195021278674bd8F7574e17018d32d8E75A98) + payable(0x640b752B6452C7FeE6afE15e0667EBeB058aB0D2) ); // Uncomment for deployment of withdrawal module's keeper - /*WithdrawalModuleKeeper keeper = new WithdrawalModuleKeeper( + /*stHYPEWithdrawalModuleKeeper keeper = new stHYPEWithdrawalModuleKeeper( deployerAddress ); assertEq(keeper.owner(), deployerAddress); console.log("keeper deployed: ", address(keeper));*/ - WithdrawalModuleKeeper keeper = WithdrawalModuleKeeper( + stHYPEWithdrawalModuleKeeper keeper = stHYPEWithdrawalModuleKeeper( 0x0Aef1eAAd539C16292faEB16D3F4AB5842F0aa6c ); /*address keeperEOA = 0x6Fa0b094b71EF7fcA715177242682bdf1954e2e8; @@ -181,13 +181,13 @@ contract STEXDeployScript is Script, Test { assertEq(keeper.owner(), ownerMultisig); // Uncomment for deployment of withdrawal module's owner - /*WithdrawalModuleManager manager = new WithdrawalModuleManager( + /*stHYPEWithdrawalModuleManager manager = new stHYPEWithdrawalModuleManager( deployerAddress, address(keeper) );*/ //assertEq(manager.owner(), deployerAddress); //assertEq(manager.keeper(), address(keeper)); - WithdrawalModuleManager manager = WithdrawalModuleManager( + stHYPEWithdrawalModuleManager manager = stHYPEWithdrawalModuleManager( 0x80c7f89398160fCD9E74519f63F437459E5d02E2 ); //manager.transferOwnership(ownerMultisig); diff --git a/src/DepositWrapper.sol b/src/DepositWrapper.sol index 8500542..fbc2fa4 100644 --- a/src/DepositWrapper.sol +++ b/src/DepositWrapper.sol @@ -17,6 +17,14 @@ contract DepositWrapper is ReentrancyGuardTransient { using SafeERC20 for ERC20; using SafeERC20 for IWETH9; + /** + * + * EVENTS + * + */ + event DepositFromNativeWithCode(uint256 nativeTokenAmount, uint256 shares, address recipient, bytes32 code); + event DepositFromToken0WithCode(uint256 amountToken0, uint256 shares, address recipient, bytes32 code); + /** * * CUSTOM ERRORS @@ -74,14 +82,21 @@ contract DepositWrapper is ReentrancyGuardTransient { nonReentrant returns (uint256 shares) { - if (_recipient == address(0)) revert DepositWrapper__ZeroAddress(); - - uint256 amount = msg.value; - if (amount == 0) return 0; + shares = _depositFromNative(_minShares, _deadline, _recipient); + } - _wrapAndApprove(amount, address(stex)); + /** + * @notice Same as `depositFromNative`, but emits an event with `_code`. + */ + function depositFromNativeWithCode(uint256 _minShares, uint256 _deadline, address _recipient, bytes32 _code) + external + payable + nonReentrant + returns (uint256 shares) + { + shares = _depositFromNative(_minShares, _deadline, _recipient); - shares = stex.deposit(amount, _minShares, _deadline, _recipient); + emit DepositFromNativeWithCode(msg.value, shares, _recipient, _code); } /** @@ -100,6 +115,51 @@ contract DepositWrapper is ReentrancyGuardTransient { uint256 _deadline, address _recipient ) external nonReentrant returns (uint256 shares) { + shares = _depositFromToken0(_amountToken0, _amountToken1Min, _minShares, _deadline, _recipient); + } + + /** + * @notice Same as `depositFromToken0WithCode`, but emits an event with `_code`. + */ + function depositFromToken0WithCode( + uint256 _amountToken0, + uint256 _amountToken1Min, + uint256 _minShares, + uint256 _deadline, + address _recipient, + bytes32 _code + ) external nonReentrant returns (uint256 shares) { + shares = _depositFromToken0(_amountToken0, _amountToken1Min, _minShares, _deadline, _recipient); + + emit DepositFromToken0WithCode(_amountToken0, shares, _recipient, _code); + } + + /** + * + * PRIVATE FUNCTIONS + * + */ + function _depositFromNative(uint256 _minShares, uint256 _deadline, address _recipient) + private + returns (uint256 shares) + { + if (_recipient == address(0)) revert DepositWrapper__ZeroAddress(); + + uint256 amount = msg.value; + if (amount == 0) return 0; + + _wrapAndApprove(amount, address(stex)); + + shares = stex.deposit(amount, _minShares, _deadline, _recipient); + } + + function _depositFromToken0( + uint256 _amountToken0, + uint256 _amountToken1Min, + uint256 _minShares, + uint256 _deadline, + address _recipient + ) private returns (uint256 shares) { if (_recipient == address(0)) revert DepositWrapper__ZeroAddress(); if (_amountToken0 == 0) return 0; @@ -126,11 +186,6 @@ contract DepositWrapper is ReentrancyGuardTransient { shares = stex.deposit(amountToken1, _minShares, _deadline, _recipient); } - /** - * - * PRIVATE FUNCTIONS - * - */ function _wrapAndApprove(uint256 amount, address to) private { weth.deposit{value: amount}(); weth.forceApprove(to, amount); diff --git a/src/RebalanceModule.sol b/src/RebalanceModule.sol new file mode 100644 index 0000000..abb8c15 --- /dev/null +++ b/src/RebalanceModule.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {IRebalanceModule} from "./interfaces/IRebalanceModule.sol"; + +/** + * @title RebalanceModule + * @notice This contract is used to rebalance the STEX AMM pool by swapping token0 for token1. + * @dev Only callable by the withdrawal module. + */ +contract RebalanceModule is IRebalanceModule, Ownable { + using SafeERC20 for IERC20; + + /** + * + * CUSTOM ERRORS + * + */ + error RebalanceModule__ZeroAddress(); + error RebalanceModule__onlyWithdrawalModule(); + error RebalanceModule__rebalance_InsufficientToken1Received(); + error RebalanceModule__rebalance_TargetCallFailed(); + error RebalanceModule__rebalance_InvalidAmountToken1Min(); + error RebalanceModule__rebalance_InvalidAmountToken0(); + error RebalanceModule__rebalance_InsufficientToken0Received(); + error RebalanceModule__rebalance_InvalidPayloadArrayLength(); + + /** + * + * EVENTS + * + */ + event Sweep(address indexed token, address indexed recipient, uint256 amount); + + /** + * + * IMMUTABLES + * + */ + address public immutable withdrawalModule; + address public immutable pool; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _withdrawalModule, address _pool, address _owner) Ownable(_owner) { + if (_withdrawalModule == address(0) || _pool == address(0)) { + revert RebalanceModule__ZeroAddress(); + } + + withdrawalModule = _withdrawalModule; + pool = _pool; + } + + /** + * + * MODIFIERS + * + */ + modifier onlyWithdrawalModule() { + if (msg.sender != withdrawalModule) { + revert RebalanceModule__onlyWithdrawalModule(); + } + _; + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Sweep token balances which have been locked into this contract. + * @dev Only callable by `owner`. + * @param _token Token address to claim balances for. + * @param _recipient Recipient of `_token` balance. + */ + function sweep(address _token, address _recipient) external onlyOwner { + if (_token == address(0)) revert RebalanceModule__ZeroAddress(); + if (_recipient == address(0)) { + revert RebalanceModule__ZeroAddress(); + } + + uint256 balance = IERC20(_token).balanceOf(address(this)); + if (balance > 0) { + IERC20(_token).safeTransfer(_recipient, balance); + + emit Sweep(_token, _recipient, balance); + } + } + + /** + * @notice Rebalance the STEX AMM pool by swapping token0 for token1. + * @dev Only callable by the withdrawal module. + * @param _amountToken1Min Minimum amount of token1 to receive. + * @param _payload Payload containing all required data for rebalancing. + */ + function rebalance(uint256 _amountToken1Min, bytes memory _payload) + external + override + onlyWithdrawalModule + returns (bytes4) + { + ( + address token0, + address token1, + uint256[] memory amountToken0Array, + address[] memory targetAddresses, + bytes[] memory payloads + ) = abi.decode(_payload, (address, address, uint256[], address[], bytes[])); + + _validateInputs(_amountToken1Min, token0, token1, amountToken0Array, targetAddresses, payloads); + + uint256 amountToken1PreBalance = IERC20(token1).balanceOf(address(this)); + + for (uint256 i = 0; i < amountToken0Array.length; i++) { + // Approve the target contract to spend the token0 amount + IERC20(token0).forceApprove(targetAddresses[i], amountToken0Array[i]); + + // Call the target contract to swap token0 for token1 + (bool success,) = targetAddresses[i].call(payloads[i]); + if (!success) { + revert RebalanceModule__rebalance_TargetCallFailed(); + } + } + + // Ensure that enough token1 amount has been received + uint256 amountToken1 = IERC20(token1).balanceOf(address(this)) - amountToken1PreBalance; + if (amountToken1 < _amountToken1Min) { + revert RebalanceModule__rebalance_InsufficientToken1Received(); + } + + // Return token1 amount received to withdrawal module + IERC20(token1).safeTransfer(withdrawalModule, amountToken1); + + // Return any remaining token0 to the pool + uint256 balanceToken0 = IERC20(token0).balanceOf(address(this)); + if (balanceToken0 != 0) { + IERC20(token0).safeTransfer(pool, balanceToken0); + } + + return IRebalanceModule.rebalance.selector; + } + + function _validateInputs( + uint256 _amountToken1Min, + address token0, + address token1, + uint256[] memory amountToken0Array, + address[] memory targetAddresses, + bytes[] memory payloads + ) internal view { + // Zero value checks + if (_amountToken1Min == 0) { + revert RebalanceModule__rebalance_InvalidAmountToken1Min(); + } + + if (token0 == address(0) || token1 == address(0)) { + revert RebalanceModule__ZeroAddress(); + } + + for (uint256 i; i < targetAddresses.length; i++) { + if (targetAddresses[i] == address(0)) { + revert RebalanceModule__ZeroAddress(); + } + } + + // Array length checks + if (amountToken0Array.length != targetAddresses.length || amountToken0Array.length != payloads.length) { + revert RebalanceModule__rebalance_InvalidPayloadArrayLength(); + } + + // token0 balance and amounts consistency checks + uint256 amountToken0 = IERC20(token0).balanceOf(address(this)); + uint256 amountToken0Total; + for (uint256 i = 0; i < amountToken0Array.length; i++) { + uint256 amount = amountToken0Array[i]; + if (amount == 0) { + revert RebalanceModule__rebalance_InvalidAmountToken0(); + } + + amountToken0Total += amount; + } + if (amountToken0Total != amountToken0) { + revert RebalanceModule__rebalance_InsufficientToken0Received(); + } + } +} diff --git a/src/STEXLens.sol b/src/STEXLens.sol index c8492a7..e074fa0 100644 --- a/src/STEXLens.sol +++ b/src/STEXLens.sol @@ -32,8 +32,8 @@ contract STEXLens { * CONSTANTS * */ - uint256 private constant MINIMUM_LIQUIDITY = 1e3; - uint256 private constant BIPS = 1e4; + uint256 private constant MINIMUM_LIQUIDITY = 1_000; + uint256 private constant BIPS = 10_000; /** * @@ -63,6 +63,56 @@ contract STEXLens { amount1PendingLPWithdrawal = withdrawalModule.amountToken1PendingLPWithdrawal(); } + /** + * @notice Calculates total value of STEX AMM's LP token, in token1. + * @dev Once this is calculated, an exchange rate can be derived by dividing + * `totalValueToken1` by the LP token's `totalSupply`. + * @param stex Address of STEX AMM deployment to query for. + * @return totalValueToken1 Total value of STEX AMM's LP token, + * denominated in token1 (wrapped native token). + */ + function getTotalValueToken1(address stex) external view returns (uint256 totalValueToken1) { + ISTEXAMM stexAmm = ISTEXAMM(stex); + + ISovereignPool pool = ISovereignPool(stexAmm.pool()); + IWithdrawalModule withdrawalModule = IWithdrawalModule(stexAmm.withdrawalModule()); + require(!pool.isLocked(), "Sovereign Pool reentrancy guard active"); + require(!stexAmm.isLocked(), "STEX AMM reentrancy guard active"); + require(!withdrawalModule.isLocked(), "Withdrawal Module reentrancy guard active"); + + (uint256 reserve0Pool, uint256 reserve1Pool) = pool.getReserves(); + uint256 reserve1Lending = withdrawalModule.amountToken1LendingPool(); + // This value already acounts for the effect of calling `withdrawalModule::update()` + uint256 amountToken0PendingUnstaking = withdrawalModule.amountToken0PendingUnstaking(); + // This value already acounts for the effect of calling `withdrawalModule::update()` + uint256 amountToken1PendingLPWithdrawal = withdrawalModule.amountToken1PendingLPWithdrawal(); + + uint256 withdrawalModuleBalance = address(withdrawalModule).balance; + uint256 amountToken1ClaimableLPWithdrawal = withdrawalModule.amountToken1ClaimableLPWithdrawal(); + uint256 withdrawalModuleExcessBalance = withdrawalModuleBalance > amountToken1ClaimableLPWithdrawal + ? withdrawalModuleBalance - amountToken1ClaimableLPWithdrawal + : 0; + + // If this is true, a call to `withdrawalModule::update()` + // needs to be made to account for the newly received native token balance fully. + if (withdrawalModuleExcessBalance > 0) { + uint256 amountToken1PendingLPWithdrawalBeforeUpdate = + withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); + + // Add extra balance to `reserve1Pool`, + // to be transferred after `withdrawalModule::update()` is called + if (withdrawalModuleExcessBalance > amountToken1PendingLPWithdrawalBeforeUpdate) { + uint256 amountToken1Surplus = + withdrawalModuleExcessBalance - amountToken1PendingLPWithdrawalBeforeUpdate; + reserve1Pool += amountToken1Surplus; + } + } + + totalValueToken1 = reserve1Pool + reserve1Lending + + withdrawalModule.convertToToken1(reserve0Pool + amountToken0PendingUnstaking) + - amountToken1PendingLPWithdrawal; + } + function getSharesForDeposit(address stex, uint256 amount) external view returns (uint256) { ISTEXAMM stexInterface = ISTEXAMM(stex); (uint256 reserve0Pool, uint256 reserve1Pool) = ISovereignPool(stexInterface.pool()).getReserves(); @@ -183,7 +233,7 @@ contract STEXLens { return false; } - // Check if there is enough ETH available to fulfill this request + // Check if there is enough native token available to fulfill this request if (withdrawalModule.amountToken1ClaimableLPWithdrawal() < request.amountToken1) { return false; } diff --git a/src/interfaces/IRebalanceModule.sol b/src/interfaces/IRebalanceModule.sol new file mode 100644 index 0000000..871048d --- /dev/null +++ b/src/interfaces/IRebalanceModule.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +interface IRebalanceModule { + function rebalance(uint256 amountToken1Min, bytes calldata _data) external returns (bytes4); +} diff --git a/src/interfaces/IStepwiseFeeModule.sol b/src/interfaces/IStepwiseFeeModule.sol new file mode 100644 index 0000000..50a3731 --- /dev/null +++ b/src/interfaces/IStepwiseFeeModule.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {ISwapFeeModuleMinimal} from "@valantis-core/swap-fee-modules/interfaces/ISwapFeeModule.sol"; + +interface IStepwiseFeeModule is ISwapFeeModuleMinimal { + event PoolSet(address pool); + + event FeeParamsSetToken0(uint256 maxThresholdToken1, uint256 minThresholdToken1, uint256 numStepsToken1); +} diff --git a/src/interfaces/kinetiq/IStakingAccountant.sol b/src/interfaces/kinetiq/IStakingAccountant.sol new file mode 100644 index 0000000..dc4db4d --- /dev/null +++ b/src/interfaces/kinetiq/IStakingAccountant.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IStakingAccountant { + // View functions + function totalStaked() external view returns (uint256); + function totalClaimed() external view returns (uint256); + function totalRewards() external view returns (uint256); + function totalSlashing() external view returns (uint256); + + // Exchange ratio functions + function kHYPEToHYPE(uint256 kHYPEAmount) external view returns (uint256); + function HYPEToKHYPE(uint256 HYPEAmount) external view returns (uint256); + + // State changing functions + function recordStake(uint256 amount) external; + function recordClaim(uint256 amount) external; +} diff --git a/src/interfaces/kinetiq/IStakingManager.sol b/src/interfaces/kinetiq/IStakingManager.sol new file mode 100644 index 0000000..ff3ac2d --- /dev/null +++ b/src/interfaces/kinetiq/IStakingManager.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IStakingManager { + /* ========== STRUCTS ========== */ + + struct WithdrawalRequest { + uint256 hypeAmount; // Amount in HYPE to withdraw + uint256 kHYPEAmount; // Amount in kHYPE to burn (excluding fee) + uint256 kHYPEFee; // Fee amount in kHYPE tokens + uint256 timestamp; // Request timestamp + } + + /* ========== VIEW FUNCTIONS ========== */ + + /// @notice Gets withdrawal request details for a user + function withdrawalRequests(address user, uint256 id) external view returns (WithdrawalRequest memory); + + /// @notice Gets the next withdrawal ID for a user + function nextWithdrawalId(address user) external view returns (uint256); + + /// @notice Gets the current unstake fee rate + function unstakeFeeRate() external view returns (uint256); + + /// @notice Gets the withdrawal delay period + function withdrawalDelay() external view returns (uint256); + + /* ========== MUTATIVE FUNCTIONS ========== */ + + /// @notice Stakes HYPE tokens + function stake() external payable; + + /// @notice Queues a withdrawal request + function queueWithdrawal(uint256 amount) external; + + /// @notice Confirms a withdrawal request + function confirmWithdrawal(uint256 withdrawalId) external; +} diff --git a/src/interfaces/IOverseer.sol b/src/interfaces/sthype/IOverseer.sol similarity index 100% rename from src/interfaces/IOverseer.sol rename to src/interfaces/sthype/IOverseer.sol diff --git a/src/interfaces/IstHYPE.sol b/src/interfaces/sthype/IstHYPE.sol similarity index 100% rename from src/interfaces/IstHYPE.sol rename to src/interfaces/sthype/IstHYPE.sol diff --git a/src/AaveLendingModule.sol b/src/lending-modules/AaveLendingModule.sol similarity index 97% rename from src/AaveLendingModule.sol rename to src/lending-modules/AaveLendingModule.sol index f29d094..da62ed1 100644 --- a/src/AaveLendingModule.sol +++ b/src/lending-modules/AaveLendingModule.sol @@ -5,8 +5,8 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {ILendingModule} from "./interfaces/ILendingModule.sol"; -import {IPool} from "./interfaces/aavev3/IPool.sol"; +import {ILendingModule} from "../interfaces/ILendingModule.sol"; +import {IPool} from "../interfaces/aavev3/IPool.sol"; /** * @notice Wrapper contract that allows its owner to lend an underlying token on AAVE V3 pools. diff --git a/src/lending-modules/ERC4626LendingModule.sol b/src/lending-modules/ERC4626LendingModule.sol new file mode 100644 index 0000000..7c6d418 --- /dev/null +++ b/src/lending-modules/ERC4626LendingModule.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import {ILendingModule} from "../interfaces/ILendingModule.sol"; + +/** + * @notice Wrapper contract that allows its owner to lend an underlying token on ERC4626 vaults. + */ +contract ERC4626LendingModule is ILendingModule, Ownable { + using SafeERC20 for IERC20; + + /** + * + * EVENTS + * + */ + event Sweep(address indexed token, address indexed recipient, uint256 balance); + event TokenSweepManagerUpdated(address tokenSweepManager); + + /** + * + * CUSTOM ERRORS + * + */ + error ERC4626LendingModule__OnlyTokenSweepManager(); + error ERC4626LendingModule__ZeroAddress(); + error ERC4626LendingModule__sweep_VaultTokenCannotBeSweeped(); + + /** + * + * IMMUTABLES + * + */ + + /** + * @notice ERC-20 token to be supplied. + */ + address public immutable asset; + + /** + * @notice ERC4626 compatible lending market vault. + */ + IERC4626 public immutable vault; + + /** + * + * STORAGE + * + */ + + /** + * @notice Role which is able to call `sweep`. + * @dev It can sweep any stuck token balances, except `vault`. + */ + address public tokenSweepManager; + + /** + * + * MODIFIERS + * + */ + modifier onlyTokenSweepManager() { + if (msg.sender != tokenSweepManager) { + revert ERC4626LendingModule__OnlyTokenSweepManager(); + } + _; + } + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _vault, address _owner, address _tokenSweepManager) Ownable(_owner) { + if (_vault == address(0) || _tokenSweepManager == address(0)) { + revert ERC4626LendingModule__ZeroAddress(); + } + + vault = IERC4626(_vault); + asset = vault.asset(); + tokenSweepManager = _tokenSweepManager; + } + + /** + * + * VIEW FUNCTIONS + * + */ + + /** + * @notice Returns amount of asset token owned by this contract's position in the vault. + * @dev WARNING: This must NOT decrease as utilization/borrowing increases. + * This represents the total amount of asset token, plus any accrued interest, + * that is owed to this contract, + * even when the vault has temporarily insufficient liquidity to fulfill the withdrawal. + */ + function assetBalance() external view returns (uint256) { + uint256 shares = vault.balanceOf(address(this)); + return vault.convertToAssets(shares); + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Sets the address of the token sweep manager. + * @param _tokenSweepManager Address of the token sweep manager. + * @dev Only callable by the current `tokenSweepManager`. + */ + function setTokenSweepManager(address _tokenSweepManager) external onlyTokenSweepManager { + if (_tokenSweepManager == address(0)) { + revert ERC4626LendingModule__ZeroAddress(); + } + + tokenSweepManager = _tokenSweepManager; + + emit TokenSweepManagerUpdated(_tokenSweepManager); + } + + /** + * @notice Deposits asset token into the lending vault. + * @param _amount Amount of asset token to deposit. + * @dev Only the owner can deposit asset token into the lending vault. + */ + function deposit(uint256 _amount) external onlyOwner { + IERC20(asset).safeTransferFrom(msg.sender, address(this), _amount); + IERC20(asset).forceApprove(address(vault), _amount); + // WARNING: Partial deposits are not allowed + vault.deposit(_amount, address(this)); + } + + /** + * @notice Withdraws asset token from the lending vault. + * @param _amount Amount of asset token to withdraw. + * @param _recipient Address to receive the withdrawn asset vault. + * @dev Only the owner can withdraw asset token from the lending vault. + */ + function withdraw(uint256 _amount, address _recipient) external onlyOwner { + vault.withdraw(_amount, _recipient, address(this)); + } + + /** + * @notice Sweep token balances which have been locked into this contract. + * @dev Only callable by `tokenSweepManager`. + * @param _token Token address to claim balances for. + * @param _recipient Recipient of `_token` balance. + */ + function sweep(address _token, address _recipient) external onlyTokenSweepManager { + if (_token == address(0)) revert ERC4626LendingModule__ZeroAddress(); + if (_recipient == address(0)) { + revert ERC4626LendingModule__ZeroAddress(); + } + + if (_token == address(vault)) { + revert ERC4626LendingModule__sweep_VaultTokenCannotBeSweeped(); + } + + IERC20 token = IERC20(_token); + + uint256 balance = token.balanceOf(address(this)); + if (balance > 0) { + token.safeTransfer(_recipient, balance); + + emit Sweep(_token, _recipient, balance); + } + } +} diff --git a/src/lending-modules/MultiMarketLendingModule.sol b/src/lending-modules/MultiMarketLendingModule.sol new file mode 100644 index 0000000..5c23b89 --- /dev/null +++ b/src/lending-modules/MultiMarketLendingModule.sol @@ -0,0 +1,599 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {ILendingModule} from "../interfaces/ILendingModule.sol"; + +/** + * @notice Wrapper contract that allows its `manager` to lend an underlying `asset` + * on multiple lending markets, defined by `ILendingModule`. + */ +contract MultiMarketLendingModule is ILendingModule, Ownable2Step { + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + /** + * + * EVENTS + * + */ + event DepositWeightsSet(uint16[] depositWeightBipsArray); + event Initialized(address[] lendingModuleArray, LendingModuleConfig[] lendingModuleConfigArray); + event ManagerFeeClaimed(address indexed recipient, uint256 amount); + event Sweep(address indexed token, address indexed recipient, uint256 balance); + event TokenSweepManagerUpdated(address tokenSweepManager); + event WithdrawWeightsSet(uint16[] withdrawWeightBipsArray); + + /** + * + * CUSTOM ERRORS + * + */ + error MultiMarketLendingModule__InvalidManagerFeeBips(); + error MultiMarketLendingModule__NotInitialized(); + error MultiMarketLendingModule__OnlyManager(); + error MultiMarketLendingModule__OnlyTokenSweepManager(); + error MultiMarketLendingModule__ZeroAddress(); + error MultiMarketLendingModule__deposit_InvalidAmount(); + error MultiMarketLendingModule__deposit_PartialDepositNotAllowed(); + error MultiMarketLendingModule__initialize_AlreadyInitialized(); + error MultiMarketLendingModule__initialize_DuplicateLendingModule(); + error MultiMarketLendingModule__initialize_ExceededMaxLendingModules(); + error MultiMarketLendingModule__initialize_InvalidArrayLength(); + error MultiMarketLendingModule__initialize_InconsistentArrayLength(); + error MultiMarketLendingModule__initialize_InvalidOwner(); + error MultiMarketLendingModule__initialize_InvalidAsset(); + error MultiMarketLendingModule__initialize_InvalidDepositWeights(); + error MultiMarketLendingModule__initialize_InvalidWithdrawWeights(); + error MultiMarketLendingModule__setDepositWeights_InvalidArrayLength(); + error MultiMarketLendingModule__setDepositWeights_InvalidWeights(); + error MultiMarketLendingModule__setWithdrawWeights_InvalidArrayLength(); + error MultiMarketLendingModule__setWithdrawWeights_InvalidWeights(); + error MultiMarketLendingModule__withdraw_InvalidAmount(); + error MultiMarketLendingModule__withdraw_InsufficientAmountReceived(); + error MultiMarketLendingModule__withdraw_InsufficientBalance(); + error MultiMarketLendingModule__withdraw_ExcessiveAmountReceived(); + + /** + * + * CONSTANTS + * + */ + uint256 private constant BIPS = 10_000; + + /** + * @notice Maximum number of lending modules that can be initialized. + */ + uint256 private constant MAX_LENDING_MODULES = 10; + + /** + * + * STRUCTS + * + */ + + /** + * @notice Configuration for a lending module. + * @param depositWeightBips Weight of the lending module in the `deposit` allocation. + * @param withdrawWeightBips Weight of the lending module in the `withdraw` allocation. + */ + struct LendingModuleConfig { + uint16 depositWeightBips; + uint16 withdrawWeightBips; + } + + /** + * + * IMMUTABLES + * + */ + + /** + * @notice ERC-20 token to be supplied. + */ + address public immutable asset; + + /** + * @notice Address of the manager, which can deposit and withdraw `asset`. + */ + address public immutable manager; + + /** + * @notice Manager fee, in bips. + * @dev Must be less than or equal to 50%. + */ + uint256 public immutable managerFeeBips; + + /** + * + * STORAGE + * + */ + + /** + * @notice Total principal amount of `asset` token deposited into the set of lending modules, + * excluding any accrued yield and manager fees. + */ + uint256 public totalPrincipal; + + /** + * @notice Accounts for due yield when withdrawals exceed the total principal. + * @dev This is used to ensure that the manager can claim the due yield when + * withdrawals exceed the total principal. + */ + uint256 public virtualYield; + + /** + * @notice Total amount of `asset` token manager fees already claimed by the manager. + */ + uint256 public totalManagerClaimed; + + /** + * @notice Role which is able to call `sweep`. + * @dev It can sweep any stuck token balances. + */ + address public tokenSweepManager; + + /** + * @notice Set of Lending Module addresses. + */ + EnumerableSet.AddressSet private _lendingModules; + + /** + * @notice Configuration for each lending module in `_lendingModules`. + */ + mapping(address => LendingModuleConfig) private _lendingModuleConfigs; + + /** + * + * MODIFIERS + * + */ + modifier onlyTokenSweepManager() { + if (msg.sender != tokenSweepManager) { + revert MultiMarketLendingModule__OnlyTokenSweepManager(); + } + _; + } + + modifier onlyManager() { + if (msg.sender != manager) { + revert MultiMarketLendingModule__OnlyManager(); + } + _; + } + + modifier onlyWhenInitialized() { + if (_lendingModules.length() == 0) { + revert MultiMarketLendingModule__NotInitialized(); + } + _; + } + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _asset, address _owner, address _manager, address _tokenSweepManager, uint256 _managerFeeBips) + Ownable(_owner) + { + if (_asset == address(0) || _tokenSweepManager == address(0) || _manager == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + + if (_managerFeeBips > BIPS / 2) { + revert MultiMarketLendingModule__InvalidManagerFeeBips(); + } + + tokenSweepManager = _tokenSweepManager; + asset = _asset; + manager = _manager; + managerFeeBips = _managerFeeBips; + } + + /** + * + * VIEW FUNCTIONS + * + */ + + /** + * @notice Returns amount of asset token owned by this contract's position + * across all lending modules. + * @dev WARNING: This must NOT decrease as utilization/borrowing increases. + * This represents the total amount of asset token, plus any accrued yield, + * minus manager fees, that is owed to this contract, + * even when the lending modules have temporarily insufficient liquidity. + */ + function assetBalance() public view override returns (uint256) { + uint256 totalBalance = _getTotalBalance(); + + uint256 managerFee = _getManagerFee(totalBalance); + + return totalBalance > managerFee ? totalBalance - managerFee : 0; + } + + /** + * @notice Returns the amount of yield claimable by the manager. + */ + function managerFeeClaimable() public view returns (uint256) { + uint256 totalBalance = _getTotalBalance(); + + return _getManagerFee(totalBalance); + } + + /** + * @notice Returns the configuration for a lending module. + * @param _lendingModule Address of the lending module. + */ + function getLendingModuleConfig(address _lendingModule) external view returns (LendingModuleConfig memory) { + return _lendingModuleConfigs[_lendingModule]; + } + + /** + * @notice Returns the set of lending modules. + */ + function lendingModules() external view returns (address[] memory) { + return _lendingModules.values(); + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Initializes the multi-market lending module. + * @param _lendingModuleArray Array of lending module addresses. + * @param _lendingModuleConfigArray Array of lending module config parameters. + * @dev Only callable by the current `owner`. + */ + function initialize(address[] memory _lendingModuleArray, LendingModuleConfig[] memory _lendingModuleConfigArray) + external + onlyOwner + { + if (_lendingModules.length() > 0) { + revert MultiMarketLendingModule__initialize_AlreadyInitialized(); + } + + if (_lendingModuleArray.length > MAX_LENDING_MODULES) { + revert MultiMarketLendingModule__initialize_ExceededMaxLendingModules(); + } + + if (_lendingModuleArray.length == 0) { + revert MultiMarketLendingModule__initialize_InvalidArrayLength(); + } + + if (_lendingModuleConfigArray.length != _lendingModuleArray.length) { + revert MultiMarketLendingModule__initialize_InconsistentArrayLength(); + } + + uint256 totalDepositWeightBips; + uint256 totalWithdrawWeightBips; + for (uint256 i = 0; i < _lendingModuleArray.length; i++) { + address lendingModule = _lendingModuleArray[i]; + + if (lendingModule == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + + if (Ownable(lendingModule).owner() != address(this)) { + revert MultiMarketLendingModule__initialize_InvalidOwner(); + } + + // Sanity check that it is possible to query `assetBalance` + ILendingModule(lendingModule).assetBalance(); + + bool success = _lendingModules.add(lendingModule); + if (!success) { + revert MultiMarketLendingModule__initialize_DuplicateLendingModule(); + } + + LendingModuleConfig memory lendingModuleConfig = _lendingModuleConfigArray[i]; + + _lendingModuleConfigs[lendingModule] = lendingModuleConfig; + + totalDepositWeightBips += lendingModuleConfig.depositWeightBips; + totalWithdrawWeightBips += lendingModuleConfig.withdrawWeightBips; + } + + // Weights must sum to one, in bips + if (totalDepositWeightBips != BIPS) { + revert MultiMarketLendingModule__initialize_InvalidDepositWeights(); + } + + if (totalWithdrawWeightBips != BIPS) { + revert MultiMarketLendingModule__initialize_InvalidWithdrawWeights(); + } + + emit Initialized(_lendingModuleArray, _lendingModuleConfigArray); + } + + /** + * @notice Sets the weights for each lending module in the `deposit` allocation. + * @param _depositWeightBipsArray Array of weights, in bips, for each lending module. + * @dev Only callable by the current `owner`. + */ + function setDepositWeights(uint16[] memory _depositWeightBipsArray) external onlyOwner onlyWhenInitialized { + if (_depositWeightBipsArray.length != _lendingModules.length()) { + revert MultiMarketLendingModule__setDepositWeights_InvalidArrayLength(); + } + + uint256 totalDepositWeightBips; + for (uint256 i = 0; i < _lendingModules.length(); i++) { + address lendingModule = _lendingModules.at(i); + uint16 weight = _depositWeightBipsArray[i]; + + _lendingModuleConfigs[lendingModule].depositWeightBips = weight; + + totalDepositWeightBips += weight; + } + // Weights must sum to one, in bips + if (totalDepositWeightBips != BIPS) { + revert MultiMarketLendingModule__setDepositWeights_InvalidWeights(); + } + + emit DepositWeightsSet(_depositWeightBipsArray); + } + + /** + * @notice Sets the weights for each lending module in the `withdraw` allocation. + * @param _withdrawWeightBipsArray Array of weights, in bips, for each lending module. + * @dev Only callable by the current `owner`. + */ + function setWithdrawWeights(uint16[] memory _withdrawWeightBipsArray) external onlyOwner onlyWhenInitialized { + if (_withdrawWeightBipsArray.length != _lendingModules.length()) { + revert MultiMarketLendingModule__setWithdrawWeights_InvalidArrayLength(); + } + + uint256 totalWithdrawWeightBips; + for (uint256 i = 0; i < _lendingModules.length(); i++) { + address lendingModule = _lendingModules.at(i); + uint16 weight = _withdrawWeightBipsArray[i]; + + _lendingModuleConfigs[lendingModule].withdrawWeightBips = weight; + + totalWithdrawWeightBips += weight; + } + + // Weights must sum to one, in bips + if (totalWithdrawWeightBips != BIPS) { + revert MultiMarketLendingModule__setWithdrawWeights_InvalidWeights(); + } + + emit WithdrawWeightsSet(_withdrawWeightBipsArray); + } + + /** + * @notice Claims the manager fee on the yield accrued across all lending modules. + * @param _recipient Address to receive the manager fee. + * @dev Only callable by the current `owner`. + */ + function claimManagerFee(address _recipient) external onlyOwner { + if (_recipient == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + + uint256 claimable = managerFeeClaimable(); + if (claimable > 0) { + // Cannot withdraw more than due manager fees + uint256 amountClaimed = _withdraw(claimable, claimable, assetBalance(), _recipient); + + totalManagerClaimed += amountClaimed; + + emit ManagerFeeClaimed(_recipient, amountClaimed); + } + } + + /** + * @notice Sets the address of the token sweep manager. + * @param _tokenSweepManager Address of the token sweep manager. + * @dev Only callable by the current `tokenSweepManager`. + */ + function setTokenSweepManager(address _tokenSweepManager) external onlyTokenSweepManager { + if (_tokenSweepManager == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + + tokenSweepManager = _tokenSweepManager; + + emit TokenSweepManagerUpdated(_tokenSweepManager); + } + + /** + * @notice Deposits `asset` into the set of lending modules. + * @param _amount Amount of `asset` token to deposit. + * @dev Only the `manager` can deposit `asset` into the set of lending modules. + */ + function deposit(uint256 _amount) external override onlyManager onlyWhenInitialized { + if (_amount == 0) { + revert MultiMarketLendingModule__deposit_InvalidAmount(); + } + + IERC20 token = IERC20(asset); + token.safeTransferFrom(msg.sender, address(this), _amount); + + uint256 amountRemaining = _amount; + uint256 preBalance = token.balanceOf(address(this)); + for (uint256 i = 0; i < _lendingModules.length(); i++) { + address lendingModule = _lendingModules.at(i); + LendingModuleConfig memory lendingModuleConfig = _lendingModuleConfigs[lendingModule]; + + uint256 amountToDeposit = Math.min( + Math.mulDiv( + _amount, + lendingModuleConfig.depositWeightBips, + BIPS, + Math.Rounding.Ceil // rounding up to avoid dust left-over + ), + amountRemaining + ); + + if (amountToDeposit == 0) { + continue; + } + + amountRemaining -= amountToDeposit; + + token.forceApprove(lendingModule, amountToDeposit); + ILendingModule(lendingModule).deposit(amountToDeposit); + } + + uint256 amountDeposited = preBalance - token.balanceOf(address(this)); + if (amountDeposited != _amount || amountRemaining > 0) { + revert MultiMarketLendingModule__deposit_PartialDepositNotAllowed(); + } + + // Update total principal + totalPrincipal += _amount; + } + + /** + * @notice Withdraws `asset` from the set of lending modules. + * @param _amount Amount of `asset` token to withdraw. + * @param _recipient Address to receive the withdrawn `asset` token. + * @dev Only the `manager` can withdraw `asset` from the set of lending modules. + */ + function withdraw(uint256 _amount, address _recipient) external override onlyManager onlyWhenInitialized { + if (_amount == 0) { + revert MultiMarketLendingModule__withdraw_InvalidAmount(); + } + + if (_recipient == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + + // Cannot withdraw more than the total principal, plus earned yield, + // minus due manager fees + uint256 maxWithdraw = assetBalance(); + _withdraw(_amount, maxWithdraw, maxWithdraw, _recipient); + } + + /** + * @notice Sweep token balances which have been locked into this contract. + * @dev Only callable by `tokenSweepManager`. + * @param _token Token address to claim balances for. + * @param _recipient Recipient of `_token` balance. + */ + function sweep(address _token, address _recipient) external onlyTokenSweepManager { + if (_token == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + if (_recipient == address(0)) { + revert MultiMarketLendingModule__ZeroAddress(); + } + + IERC20 token = IERC20(_token); + + uint256 balance = token.balanceOf(address(this)); + if (balance > 0) { + token.safeTransfer(_recipient, balance); + + emit Sweep(_token, _recipient, balance); + } + } + + /** + * + * PRIVATE FUNCTIONS + * + */ + function _getTotalBalance() private view returns (uint256 totalBalance) { + for (uint256 i = 0; i < _lendingModules.length(); i++) { + totalBalance += ILendingModule(_lendingModules.at(i)).assetBalance(); + } + } + + function _getManagerFee(uint256 totalBalance) private view returns (uint256) { + uint256 totalNetYield = totalBalance > totalPrincipal ? totalBalance - totalPrincipal : 0; + + totalNetYield += virtualYield; + + uint256 totalManagerClaimable = Math.mulDiv(totalNetYield, managerFeeBips, BIPS); + + return totalManagerClaimable > totalManagerClaimed ? totalManagerClaimable - totalManagerClaimed : 0; + } + + function _withdraw(uint256 amount, uint256 maxWithdraw, uint256 totalBalance, address recipient) + private + returns (uint256 amountReceived) + { + // Cannot withdraw in excess + if (amount > maxWithdraw) { + revert MultiMarketLendingModule__withdraw_InsufficientBalance(); + } + + bool isFullWithdraw = amount == totalBalance; + + IERC20 token = IERC20(asset); + + uint256 amountRemaining = amount; + uint256 recipientPreBalance = token.balanceOf(recipient); + for (uint256 i = 0; i < _lendingModules.length(); i++) { + address lendingModule = _lendingModules.at(i); + LendingModuleConfig memory lendingModuleConfig = _lendingModuleConfigs[lendingModule]; + + uint256 amountToWithdraw; + + if (isFullWithdraw) { + // If full withdraw, withdraw the full balance of the lending module, + // without relying on the withdrawal weight. + amountToWithdraw = ILendingModule(lendingModule).assetBalance(); + } else { + amountToWithdraw = Math.min( + Math.mulDiv( + amount, + lendingModuleConfig.withdrawWeightBips, + BIPS, + Math.Rounding.Ceil // rounding up to avoid dust left-over + ), + amountRemaining + ); + + // Avoid potential reverts by ensuring that the amount to withdraw + // does not exceed the total withdrawable amount (assetBalance) + amountToWithdraw = Math.min(amountToWithdraw, ILendingModule(lendingModule).assetBalance()); + } + + if (amountToWithdraw == 0) { + continue; + } + + amountRemaining -= amountToWithdraw; + + // WARNING: This might temporarily revert if: + // 1) Lending Module has insufficient liqudity due to high utilization + // 2) `amountWithdraw` is greater than the Lending Module's available liquidity. + // If 2), `owner` is required to adjust withdrawal weights proportionally. + // If 1), `manager` needs to wait for the Lending Module to have sufficient liquidity. + ILendingModule(lendingModule).withdraw(amountToWithdraw, recipient); + } + + uint256 recipientPostBalance = token.balanceOf(recipient); + if (recipientPostBalance < recipientPreBalance + amount) { + revert MultiMarketLendingModule__withdraw_InsufficientAmountReceived(); + } + + amountReceived = recipientPostBalance - recipientPreBalance; + + // Cannot withdraw in excess + if (amountReceived > maxWithdraw) { + revert MultiMarketLendingModule__withdraw_ExcessiveAmountReceived(); + } + + // Update total principal and virtual yield + if (totalPrincipal > amountReceived) { + totalPrincipal -= amountReceived; + } else { + virtualYield += (amountReceived - totalPrincipal); + totalPrincipal = 0; + } + } +} diff --git a/src/mocks/MockERC4626LendingPool.sol b/src/mocks/MockERC4626LendingPool.sol new file mode 100644 index 0000000..05ddc92 --- /dev/null +++ b/src/mocks/MockERC4626LendingPool.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MockERC4626LendingPool { + using SafeERC20 for ERC20Mock; + + address public immutable underlyingAsset; + + bool public isCompromised; + bool public isPartialDeposit; + bool public isExcessTransfer; + + address public partialDepositRecipient; + + mapping(address account => uint256) private _shares; + + uint256 private _totalSupply; + + uint256 private _totalAssets; + + constructor(address _underlyingAsset) { + underlyingAsset = _underlyingAsset; + } + + function totalAssets() external view returns (uint256) { + return _totalAssets; + } + + function asset() external view returns (address) { + return underlyingAsset; + } + + function balanceOf(address account) public view returns (uint256) { + return _shares[account]; + } + + function convertToAssets(uint256 shares) external view returns (uint256) { + uint256 balance = ERC20Mock(underlyingAsset).balanceOf(address(this)); + uint256 maxAssets = balance > _totalAssets ? balance : _totalAssets; + + return _totalSupply == 0 ? 0 : (shares * maxAssets) / _totalSupply; + } + + function maxWithdraw(address account) external view returns (uint256) { + return _totalSupply == 0 + ? 0 + : (_shares[account] * ERC20Mock(underlyingAsset).balanceOf(address(this))) / _totalSupply; + } + + function setIsCompromised(bool value) public { + isCompromised = value; + } + + function setIsPartialDeposit(bool value) public { + isPartialDeposit = value; + } + + function setIsExcessTransfer(bool value) public { + isExcessTransfer = value; + } + + function setPartialDepositRecipient(address value) public { + partialDepositRecipient = value; + } + + function deposit(uint256 amount, address recipient) external returns (uint256 shares) { + require(amount != 0, "amount cannot be zero"); + require(recipient != address(0), "recipient cannot be zero"); + + uint256 amountDeposited = amount; + + ERC20Mock(underlyingAsset).safeTransferFrom(msg.sender, address(this), amountDeposited); + + if (isPartialDeposit) { + amountDeposited = amount / 2; + // Return the remaining amount to the sender + ERC20Mock(underlyingAsset).safeTransfer(partialDepositRecipient, amount - amountDeposited); + } + + uint256 underlyingBalance = ERC20Mock(underlyingAsset).balanceOf(address(this)); + shares = _totalSupply == 0 ? amountDeposited : (amountDeposited * _totalSupply) / underlyingBalance; + + _shares[recipient] += shares; + _totalSupply += shares; + _totalAssets += amountDeposited; + } + + function withdraw(uint256 assets, address receiver, address /*owner*/ ) external returns (uint256 shares) { + require(receiver != address(0), "receiver cannot be zero"); + + if (assets == 0) { + return 0; + } + + uint256 maxShares = _shares[msg.sender]; + uint256 underlyingBalance = ERC20Mock(underlyingAsset).balanceOf(address(this)); + shares = (assets * _totalSupply) / underlyingBalance; + + require(shares <= maxShares, "excessive withdrawal amount"); + + _shares[msg.sender] -= shares; + _totalSupply -= shares; + _totalAssets -= assets; + + uint256 amountToTransfer = isCompromised ? 0 : assets; + if (isExcessTransfer) { + ERC20Mock(underlyingAsset).mint(receiver, 10 ether); + } + + ERC20Mock(underlyingAsset).safeTransfer(receiver, amountToTransfer); + } + + function removeToken(uint256 amount, address recipient) external { + ERC20Mock(underlyingAsset).safeTransfer(recipient, amount); + } +} diff --git a/src/mocks/MockLendingPool.sol b/src/mocks/MockLendingPool.sol index abb75be..8e416bb 100644 --- a/src/mocks/MockLendingPool.sol +++ b/src/mocks/MockLendingPool.sol @@ -15,6 +15,8 @@ contract MockLendingPool is IPool { bool public isCompromised; + bool public isExcessTransfer; + mapping(address account => uint256) private _shares; uint256 private _totalSupply; @@ -33,6 +35,10 @@ contract MockLendingPool is IPool { isCompromised = value; } + function setIsExcessTransfer(bool value) public { + isExcessTransfer = value; + } + function supply(address asset, uint256 amount, address onBehalfOf, uint16 /*referralCode*/ ) external override { require(asset == underlyingAsset, "unexpected underlying asset"); require(amount != 0, "amount cannot be zero"); @@ -64,7 +70,12 @@ contract MockLendingPool is IPool { _shares[msg.sender] -= shares; _totalSupply -= shares; - ERC20(asset).safeTransfer(to, isCompromised ? 0 : amount); + uint256 amountToTransfer = isCompromised ? 0 : amount; + if (isExcessTransfer) { + amountToTransfer += 1; + } + + ERC20(asset).safeTransfer(to, amountToTransfer); return amount; } diff --git a/src/mocks/kinetiq/MockStakingAccountant.sol b/src/mocks/kinetiq/MockStakingAccountant.sol new file mode 100644 index 0000000..a189d16 --- /dev/null +++ b/src/mocks/kinetiq/MockStakingAccountant.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {IStakingAccountant} from "../../interfaces/kinetiq/IStakingAccountant.sol"; + +/** + * @notice Mock contract for Kinetiq Protocol's Staking Accountant. + */ +contract MockStakingAccountant is IStakingAccountant { + address public immutable kHYPE; + + uint256 public totalSlashing; + + uint256 public totalRewards; + + uint256 public totalStaked; + + uint256 public totalClaimed; + + constructor(address _kHYPE) { + kHYPE = _kHYPE; + } + + function kHYPEToHYPE(uint256 kHYPEAmount) public view override returns (uint256) { + return Math.mulDiv(kHYPEAmount, _getExchangeRatio(), 1e18); + } + + function HYPEToKHYPE(uint256 HYPEAmount) public view override returns (uint256) { + uint256 exchangeRatio = _getExchangeRatio(); + require(exchangeRatio > 0, "Invalid exchange ratio"); + return Math.mulDiv(HYPEAmount, 1e18, exchangeRatio); + } + + function setTotalSlashing(uint256 _totalSlashing) external { + totalSlashing = _totalSlashing; + } + + function setTotalRewards(uint256 _totalRewards) external { + totalRewards = _totalRewards; + } + + function recordStake(uint256 amount) external override { + totalStaked += amount; + } + + function recordClaim(uint256 amount) external override { + totalClaimed += amount; + } + + function _getExchangeRatio() private view returns (uint256) { + // Calculate total kHYPE supply across all unique tokens + uint256 totalKHYPESupply = ERC20(kHYPE).totalSupply(); + + // Return 1:1 ratio when no kHYPE has been minted yet + if (totalKHYPESupply == 0) { + return 1e18; // 1:1 ratio with 18 decimals precision + } + + // Calculate total HYPE + uint256 totalHYPE = totalStaked + totalRewards - totalClaimed - totalSlashing; + + // Calculate ratio with 18 decimals precision + return Math.mulDiv(totalHYPE, 1e18, totalKHYPESupply); + } +} diff --git a/src/mocks/kinetiq/MockStakingManager.sol b/src/mocks/kinetiq/MockStakingManager.sol new file mode 100644 index 0000000..d9c7a61 --- /dev/null +++ b/src/mocks/kinetiq/MockStakingManager.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {IStakingAccountant} from "../../interfaces/kinetiq/IStakingAccountant.sol"; +import {IStakingManager} from "../../interfaces/kinetiq/IStakingManager.sol"; + +/** + * @notice Mock contract for Kinetiq Protocol's Staking Manager. + */ +contract MockStakingManager is IStakingManager { + address public constant TREASURY = address(2); + + address public immutable kHYPE; + + IStakingAccountant public immutable stakingAccountant; + + uint256 public totalClaimed; + + uint256 public totalStaked; + + uint256 public totalQueuedWithdrawals; // Total amount of all pending withdrawal requests + + uint256 public unstakeFeeRate; // Fee rate in basis points (10 = 0.1%) + + // User tracking + mapping(address => uint256) public nextWithdrawalId; + + uint256 private _cancelledWithdrawalAmount; + + mapping(address => mapping(uint256 => WithdrawalRequest)) private _withdrawalRequests; + + constructor(address _stakingAccountant, address _kHYPE) { + stakingAccountant = IStakingAccountant(_stakingAccountant); + kHYPE = _kHYPE; + } + + function withdrawalDelay() external pure override returns (uint256) { + return 7 days; + } + + function withdrawalRequests(address user, uint256 id) external view override returns (WithdrawalRequest memory) { + return _withdrawalRequests[user][id]; + } + + function setUnstakeFeeRate(uint256 newRate) external { + require(newRate <= 1_000, "Fee rate too high"); // Max 10% + unstakeFeeRate = newRate; + } + + function stake() external payable override { + require(msg.value > 0, "invalid amount staked"); + + totalStaked += msg.value; + + // Convert HYPE to kHYPE amount using exchange ratio + uint256 kHYPEAmount = stakingAccountant.HYPEToKHYPE(msg.value); + + // Mint kHYPE tokens based on the conversion + ERC20Mock(kHYPE).mint(msg.sender, kHYPEAmount); + + stakingAccountant.recordStake(msg.value); + } + + function queueWithdrawal(uint256 kHYPEAmount) external override { + require(kHYPEAmount > 0, "Invalid amount"); + require(ERC20Mock(kHYPE).balanceOf(msg.sender) >= kHYPEAmount, "Insufficient kHYPE balance"); + + uint256 withdrawalId = nextWithdrawalId[msg.sender]; + + uint256 kHYPEFee = Math.mulDiv(kHYPEAmount, unstakeFeeRate, 10_000); + uint256 postFeeKHYPE = kHYPEAmount - kHYPEFee; + + uint256 hypeAmount = stakingAccountant.kHYPEToHYPE(postFeeKHYPE); + + // Lock kHYPE tokens + ERC20Mock(kHYPE).transferFrom(msg.sender, address(this), kHYPEAmount); + + // Create withdrawal request + _withdrawalRequests[msg.sender][withdrawalId] = WithdrawalRequest({ + hypeAmount: hypeAmount, + kHYPEAmount: postFeeKHYPE, + kHYPEFee: kHYPEFee, + timestamp: block.timestamp + }); + + nextWithdrawalId[msg.sender]++; + totalQueuedWithdrawals += hypeAmount; + } + + function confirmWithdrawal(uint256 withdrawalId) external override { + uint256 amount = _processConfirmation(msg.sender, withdrawalId); + require(amount > 0, "No valid withdrawal request"); + require(address(this).balance >= amount, "Insufficient contract balance"); + + stakingAccountant.recordClaim(amount); + + // Process withdrawal using call instead of transfer + (bool success,) = payable(msg.sender).call{value: amount}(""); + require(success, "Transfer failed"); + } + + function cancelWithdrawal(address user, uint256 withdrawalId) external { + WithdrawalRequest storage request = _withdrawalRequests[user][withdrawalId]; + require(request.hypeAmount > 0, "No such withdrawal request"); + + uint256 hypeAmount = request.hypeAmount; + uint256 kHYPEAmount = request.kHYPEAmount; + uint256 kHYPEFee = request.kHYPEFee; + + // Check kHYPE balances + require(ERC20Mock(kHYPE).balanceOf(address(this)) >= kHYPEAmount + kHYPEFee, "Insufficient kHYPE balance"); + + // Clear the withdrawal request + delete _withdrawalRequests[user][withdrawalId]; + totalQueuedWithdrawals -= hypeAmount; + + // Return kHYPE tokens to user (including fees) + ERC20Mock(kHYPE).transfer(user, kHYPEAmount + kHYPEFee); + + // Track cancelled amount for future redelegation + _cancelledWithdrawalAmount += hypeAmount; + } + + function _processConfirmation(address user, uint256 withdrawalId) internal returns (uint256) { + WithdrawalRequest memory request = _withdrawalRequests[user][withdrawalId]; + + // Skip if request doesn't exist or delay period not met + if (request.hypeAmount == 0 || block.timestamp < request.timestamp + 7 days) { + return 0; + } + + uint256 hypeAmount = request.hypeAmount; + uint256 kHYPEAmount = request.kHYPEAmount; + uint256 kHYPEFee = request.kHYPEFee; + + // Check kHYPE balances + require(ERC20Mock(kHYPE).balanceOf(address(this)) >= kHYPEAmount + kHYPEFee, "Insufficient kHYPE balance"); + + // Update state + totalQueuedWithdrawals -= hypeAmount; + totalClaimed += hypeAmount; + delete _withdrawalRequests[user][withdrawalId]; + + // Burn kHYPE tokens (excluding fee) + ERC20Mock(kHYPE).burn(address(this), kHYPEAmount); + + // Transfer fee to treasury + ERC20Mock(kHYPE).transfer(TREASURY, kHYPEFee); + + return hypeAmount; + } +} diff --git a/src/mocks/MockOverseer.sol b/src/mocks/sthype/MockOverseer.sol similarity index 96% rename from src/mocks/MockOverseer.sol rename to src/mocks/sthype/MockOverseer.sol index c1257df..3e82f0b 100644 --- a/src/mocks/MockOverseer.sol +++ b/src/mocks/sthype/MockOverseer.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.25; -import {IOverseer} from "../interfaces/IOverseer.sol"; +import {IOverseer} from "../../interfaces/sthype/IOverseer.sol"; import {MockStHype} from "./MockStHype.sol"; diff --git a/src/mocks/MockStHype.sol b/src/mocks/sthype/MockStHype.sol similarity index 97% rename from src/mocks/MockStHype.sol rename to src/mocks/sthype/MockStHype.sol index 8365168..d1e9ff5 100644 --- a/src/mocks/MockStHype.sol +++ b/src/mocks/sthype/MockStHype.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import {IstHYPE} from "../interfaces/IstHYPE.sol"; +import {IstHYPE} from "../../interfaces/sthype/IstHYPE.sol"; /** * @notice Mock contract for stHYPE. diff --git a/src/owner/MultiMarketLendingModuleKeeper.sol b/src/owner/MultiMarketLendingModuleKeeper.sol new file mode 100644 index 0000000..3faa0dc --- /dev/null +++ b/src/owner/MultiMarketLendingModuleKeeper.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @notice Keeper contract to automate routine function calls in `MultiMarketLendingModule`. + * @dev This contract is supposed to route its calls via the `owner` of `MultiMarketLendingModule`. + */ +contract MultiMarketLendingModuleKeeper is Ownable { + /** + * + * CUSTOM ERRORS + * + */ + error MultiMarketLendingModuleKeeper__ZeroAddress(); + error MultiMarketLendingModuleKeeper__call_onlyKeeper(); + error MultiMarketLendingModuleKeeper__call_callFailed(); + + /** + * + * STORAGE + * + */ + + /** + * @dev Tracks whitelisted addresses which have the keeper role. + */ + mapping(address => bool) public isKeeper; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _owner) Ownable(_owner) {} + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Assigns keeper role to `_keeper`. + * @dev Only callable by `owner`. + * @param _keeper Address to grant the keeper role to. + */ + function setKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert MultiMarketLendingModuleKeeper__ZeroAddress(); + } + isKeeper[_keeper] = true; + } + + /** + * @notice Revokes the keeper role from `_keeper`. + * @dev Only callable by `owner`. + * @param _keeper Address to revoke the keeper role from. + */ + function removeKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert MultiMarketLendingModuleKeeper__ZeroAddress(); + } + isKeeper[_keeper] = false; + } + + /** + * @notice Allows an address with keeper role to execute an arbitrary external call. + * @dev Only callable by an address with keeper role. + * @param _lendingModuleManager Address of `MultiMarketLendingModule`'s owner, + * which should validate this call. + * @param _payload Payload to execute. + */ + function call(address _lendingModuleManager, bytes calldata _payload) external { + if (!isKeeper[msg.sender]) { + revert MultiMarketLendingModuleKeeper__call_onlyKeeper(); + } + + (bool success,) = _lendingModuleManager.call(_payload); + if (!success) revert MultiMarketLendingModuleKeeper__call_callFailed(); + } +} diff --git a/src/owner/MultiMarketLendingModuleManager.sol b/src/owner/MultiMarketLendingModuleManager.sol new file mode 100644 index 0000000..0bd1ac8 --- /dev/null +++ b/src/owner/MultiMarketLendingModuleManager.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {MultiMarketLendingModule} from "../lending-modules/MultiMarketLendingModule.sol"; + +contract MultiMarketLendingModuleManager is Ownable { + /** + * + * CUSTOM ERRORS + * + */ + error MultiMarketLendingModuleManager__OnlyKeeper(); + error MultiMarketLendingModuleManager__ZeroAddress(); + error MultiMarketLendingModuleManager__call_callFailed(); + + /** + * + * STORAGE + * + */ + + /** + * @dev Address of keeper role. + */ + address public keeper; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _owner, address _keeper) Ownable(_owner) { + if (_keeper == address(0)) { + revert MultiMarketLendingModuleManager__ZeroAddress(); + } + + keeper = _keeper; + } + + /** + * + * MODIFIERS + * + */ + modifier onlyKeeper() { + if (msg.sender != keeper) { + revert MultiMarketLendingModuleManager__OnlyKeeper(); + } + _; + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Accepts ownership of `_lendingModule`. + * @dev Only callable by `owner`. + */ + function acceptOwnership(address _lendingModule) external onlyOwner { + MultiMarketLendingModule(_lendingModule).acceptOwnership(); + } + + /** + * @notice Set `_keeper` as the keeper role. + * @dev Only callable by `owner`. + */ + function setKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert MultiMarketLendingModuleManager__ZeroAddress(); + } + + keeper = _keeper; + } + + /** + * @notice Allows `owner` to execute an arbitrary external call. + * @dev Only callable by `owner`. + * @dev `owner` can call any function in `_lendingModule`, including keeper functions. + * @param _lendingModule Address of `MultiMarketLendingModule`, + * which should have this contract as its `owner`. + * @param _payload Payload to execute. + */ + function call(address _lendingModule, bytes calldata _payload) external onlyOwner { + (bool success,) = _lendingModule.call(_payload); + if (!success) revert MultiMarketLendingModuleManager__call_callFailed(); + } + + /** + * @notice Sets the deposit weights for each market in `_lendingModule`. + * @dev Only callable by `keeper`. + * @param _lendingModule Address of `MultiMarketLendingModule`. + * @param _depositWeightBipsArray Array of deposit weights for each market. + */ + function setDepositWeights(address _lendingModule, uint16[] memory _depositWeightBipsArray) external onlyKeeper { + MultiMarketLendingModule(_lendingModule).setDepositWeights(_depositWeightBipsArray); + } + + /** + * @notice Sets the withdrawal weights for each market in `_lendingModule`. + * @dev Only callable by `keeper`. + * @param _lendingModule Address of `MultiMarketLendingModule`. + * @param _withdrawWeightBipsArray Array of withdrawal weights for each market. + */ + function setWithdrawWeights(address _lendingModule, uint16[] memory _withdrawWeightBipsArray) external onlyKeeper { + MultiMarketLendingModule(_lendingModule).setWithdrawWeights(_withdrawWeightBipsArray); + } + + /** + * @notice Claims manager fee from `_lendingModule` and sends it to `_recipient`. + * @dev Only callable by `keeper`. + * @param _lendingModule Address of `MultiMarketLendingModule`. + * @param _recipient Address to send the manager fee to. + */ + function claimManagerFee(address _lendingModule, address _recipient) external onlyKeeper { + MultiMarketLendingModule(_lendingModule).claimManagerFee(_recipient); + } +} diff --git a/src/owner/StepwiseFeeModuleKeeper.sol b/src/owner/StepwiseFeeModuleKeeper.sol new file mode 100644 index 0000000..cd52b3a --- /dev/null +++ b/src/owner/StepwiseFeeModuleKeeper.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {StepwiseFeeModule} from "../swap-fee-modules/StepwiseFeeModule.sol"; + +/** + * @notice Keeper contract to automate params setting in StepwiseFeeModule contract. + * @dev The keeper role is assignable and revokable by an owner. + */ +contract StepwiseFeeModuleKeeper is Ownable { + /** + * + * CUSTOM ERRORS + * + */ + error StepwiseFeeModuleKeeper__ZeroAddress(); + error StepwiseFeeModuleKeeper__setFeeParamsToken0_OnlyKeeper(); + + /** + * + * STORAGE + * + */ + + /** + * @dev Tracks whitelisted addresses which have the keeper role. + */ + mapping(address => bool) public isKeeper; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _owner) Ownable(_owner) {} + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Assigns keeper role to `_keeper`. + * @dev Only callable by `owner`. + * @param _keeper Address to grant the keeper role to. + */ + function setKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert StepwiseFeeModuleKeeper__ZeroAddress(); + } + isKeeper[_keeper] = true; + } + + /** + * @notice Revokes the keeper role from `_keeper`. + * @dev Only callable by `owner`. + * @param _keeper Address to revoke the keeper role from. + */ + function removeKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert StepwiseFeeModuleKeeper__ZeroAddress(); + } + isKeeper[_keeper] = false; + } + + /** + * @notice Allows an address with keeper role to update token0 related parameters + * in `_swapFeeModule`. + * @dev Only callable by an address with keeper role. + * @param _swapFeeModule Address of StepwiseFeeModule contract to call. + * @param _minThresholdToken1 New parameter value for StepwiseFeeModule + * @param _maxThresholdToken1 New parameter value for StepwiseFeeModule. + * @param _feeStepsInBips New parameter value for StepwiseFeeModule. + */ + function setFeeParamsToken0( + address _swapFeeModule, + uint256 _minThresholdToken1, + uint256 _maxThresholdToken1, + uint32[] calldata _feeStepsInBips + ) external { + if (!isKeeper[msg.sender]) { + revert StepwiseFeeModuleKeeper__setFeeParamsToken0_OnlyKeeper(); + } + + if (_swapFeeModule == address(0)) { + revert StepwiseFeeModuleKeeper__ZeroAddress(); + } + + StepwiseFeeModule(_swapFeeModule).setFeeParamsToken0(_minThresholdToken1, _maxThresholdToken1, _feeStepsInBips); + } +} diff --git a/src/owner/kHYPEWithdrawalModuleKeeper.sol b/src/owner/kHYPEWithdrawalModuleKeeper.sol new file mode 100644 index 0000000..7f45bfc --- /dev/null +++ b/src/owner/kHYPEWithdrawalModuleKeeper.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {kHYPEWithdrawalModule} from "../withdrawal-modules/kHYPEWithdrawalModule.sol"; + +/** + * @notice Keeper contract to automate routine function calls in `kHYPEWithdrawalModule`. + * @dev This contract is supposed to route its calls via the `owner` of `kHYPEWithdrawalModule`. + */ +contract kHYPEWithdrawalModuleKeeper is Ownable { + /** + * + * CUSTOM ERRORS + * + */ + error kHYPEWithdrawalModuleKeeper__ZeroAddress(); + error kHYPEWithdrawalModuleKeeper__call_onlyKeeper(); + error kHYPEWithdrawalModuleKeeper__call_callFailed(); + + /** + * + * STORAGE + * + */ + + /** + * @dev Tracks whitelisted addresses which have the keeper role. + */ + mapping(address => bool) public isKeeper; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _owner) Ownable(_owner) {} + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Assigns keeper role to `_keeper`. + * @dev Only callable by `owner`. + * @param _keeper Address to grant the keeper role to. + */ + function setKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert kHYPEWithdrawalModuleKeeper__ZeroAddress(); + } + isKeeper[_keeper] = true; + } + + /** + * @notice Revokes the keeper role from `_keeper`. + * @dev Only callable by `owner`. + * @param _keeper Address to revoke the keeper role from. + */ + function removeKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert kHYPEWithdrawalModuleKeeper__ZeroAddress(); + } + isKeeper[_keeper] = false; + } + + /** + * @notice Allows an address with keeper role to execute an arbitrary external call. + * @dev Only callable by an address with keeper role. + * @param _withdrawalModuleManager Address of `kHYPEWithdrawalModule`'s owner, + * which should validate this call. + * @param _payload Payload to execute. + */ + function call(address _withdrawalModuleManager, bytes calldata _payload) external { + if (!isKeeper[msg.sender]) { + revert kHYPEWithdrawalModuleKeeper__call_onlyKeeper(); + } + + (bool success,) = _withdrawalModuleManager.call(_payload); + if (!success) revert kHYPEWithdrawalModuleKeeper__call_callFailed(); + } + + /** + * @notice Allows anyone to claim an array of LST protocol withdrawals and call Withdrawal Module's update function. + * @param _burnIds Ids of LST protocol withdrawals in `_overseer` to claim. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @dev Returns a boolean array of same size as `_burnIds` to flag the ones which have been successfully claimed. + */ + function redeemBurnsAndUpdate(uint256[] calldata _burnIds, address _withdrawalModule) + external + returns (bool[] memory) + { + bool[] memory burnIdsProcessed = new bool[](_burnIds.length); + + if (_burnIds.length > 0) { + for (uint256 i; i < _burnIds.length; i++) { + burnIdsProcessed[i] = kHYPEWithdrawalModule(payable(_withdrawalModule)).confirmWithdrawal(_burnIds[i]); + } + + kHYPEWithdrawalModule(payable(_withdrawalModule)).update(); + } + + return burnIdsProcessed; + } +} diff --git a/src/owner/kHYPEWithdrawalModuleManager.sol b/src/owner/kHYPEWithdrawalModuleManager.sol new file mode 100644 index 0000000..4a1b657 --- /dev/null +++ b/src/owner/kHYPEWithdrawalModuleManager.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; +import {kHYPEWithdrawalModule} from "../withdrawal-modules/kHYPEWithdrawalModule.sol"; + +/** + * @notice Owner contract for `kHYPEWithdrawalModule`. + * @dev It separates the keeper role from more critical `owner` controlled functionality. + * Keeper role can automate non-timelocked functions in `kHYPEWithdrawalModule`. + */ +contract kHYPEWithdrawalModuleManager is Ownable { + /** + * + * CUSTOM ERRORS + * + */ + error kHYPEWithdrawalModuleManager__OnlyKeeper(); + error kHYPEWithdrawalModuleManager__ZeroAddress(); + error kHYPEWithdrawalModuleManager__unstakeToken0Reserves_onlyKeeper(); + error kHYPEWithdrawalModuleManager__call_callFailed(); + + /** + * + * STORAGE + * + */ + + /** + * @dev Address of keeper role. + */ + address public keeper; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _owner, address _keeper) Ownable(_owner) { + if (_keeper == address(0)) { + revert kHYPEWithdrawalModuleManager__ZeroAddress(); + } + keeper = _keeper; + } + + /** + * + * ONLY KEEPER + * + */ + modifier onlyKeeper() { + if (msg.sender != keeper) { + revert kHYPEWithdrawalModuleManager__OnlyKeeper(); + } + _; + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Set `_keeper` as the keeper role. + * @dev Only callable by `owner`. + */ + function setKeeper(address _keeper) external onlyOwner { + if (_keeper == address(0)) { + revert kHYPEWithdrawalModuleManager__ZeroAddress(); + } + keeper = _keeper; + } + + /** + * @notice Allows `owner` to execute an arbitrary external call. + * @dev Only callable by `owner`. + * @dev `owner` can call any function in `_withdrawalModule`, including keeper functions. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`, + * which should have this contract as its `owner`. + * @param _payload Payload to execute. + */ + function call(address _withdrawalModule, bytes calldata _payload) external onlyOwner { + (bool success,) = _withdrawalModule.call(_payload); + if (!success) revert kHYPEWithdrawalModuleManager__call_callFailed(); + } + + /** + * @notice Withdraws a portion of pool's token1 reserves, + * and stakes into `stakingManager` for an equivalent amount of `token0`. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @param _amountToken1 Amount of pool's token1 reserves to stake into token0. + */ + function stakeToken1(address _withdrawalModule, uint256 _amountToken1) external onlyKeeper { + kHYPEWithdrawalModule(payable(_withdrawalModule)).stakeToken1(_amountToken1); + } + + /** + * @notice Unstake `amount` of token0 reserves from pool via `_withdrawalModule`. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @param _amount Amount of token0 reserves to withdraw from STEX pool. + */ + function unstakeToken0Reserves(address _withdrawalModule, uint256 _amount) external onlyKeeper { + IWithdrawalModule(_withdrawalModule).unstakeToken0Reserves(_amount); + } + + /** + * @notice Withdraw `_amountToken1` of token1 reserves from pool via `_withdrawalModule`, + * and supply to its respective lending pool integration. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @param _amountToken1 Amount of token1 reserves to withdraw from STEX pool. + */ + function supplyToken1ToLendingPool(address _withdrawalModule, uint256 _amountToken1) external onlyKeeper { + IWithdrawalModule(_withdrawalModule).supplyToken1ToLendingPool(_amountToken1); + } + + /** + * @notice Withdraw `_amountToken1` of token1 from lending pool integration, + * and transfer it back to the respective STEX pool. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @param _amountToken1 Amount of token1 reserves to withdraw from lending pool. + */ + function withdrawToken1FromLendingPool(address _withdrawalModule, uint256 _amountToken1) external onlyKeeper { + IWithdrawalModule(_withdrawalModule).withdrawToken1FromLendingPool( + _amountToken1, + address(0) // _recipient is unused, since it must the STEX pool + ); + } + + /** + * @notice Withdraw `_amountToken1` of token1 from STEX pool, + * and use it to net against pending LP withdrawals. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @param _amountToken1 Amount of token1 reserves to withdraw from STEX pool. + */ + function settlePendingWithdrawalsWithPoolReserves(address _withdrawalModule, uint256 _amountToken1) + external + onlyKeeper + { + kHYPEWithdrawalModule(payable(_withdrawalModule)).settlePendingWithdrawalsWithPoolReserves(_amountToken1); + } + + /** + * @notice Atomically rebalances excess token0 STEX pool reserves into token1, + * at a price no worse than using kHYPE's StakingManager withdrawal queue. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + * @param _amountToken0 Amount of token0 reserves to rebalance into token1. + * @param _recipient Address to receive `_amountToken0` of token0. + * @param _rebalanceModule Address which should execute the rebalance. + * @param _payload payload to `_rebalanceModule`. + */ + function rebalanceToken0Reserves( + address _withdrawalModule, + uint256 _amountToken0, + address _recipient, + address _rebalanceModule, + bytes calldata _payload + ) external onlyKeeper { + kHYPEWithdrawalModule(payable(_withdrawalModule)).rebalanceToken0Reserves( + _amountToken0, _recipient, _rebalanceModule, _payload + ); + } + + /** + * @notice Unstake excess token0 balance through kHYPE's StakingManager. + * @dev Only callable by keeper role. + * @param _withdrawalModule Address of `kHYPEWithdrawalModule`. + */ + function unstakeExcessToken0(address _withdrawalModule) external onlyKeeper { + kHYPEWithdrawalModule(payable(_withdrawalModule)).unstakeExcessToken0(); + } +} diff --git a/src/owner/WithdrawalModuleKeeper.sol b/src/owner/stHYPEWithdrawalModuleKeeper.sol similarity index 78% rename from src/owner/WithdrawalModuleKeeper.sol rename to src/owner/stHYPEWithdrawalModuleKeeper.sol index 7a7e639..336f45f 100644 --- a/src/owner/WithdrawalModuleKeeper.sol +++ b/src/owner/stHYPEWithdrawalModuleKeeper.sol @@ -3,22 +3,22 @@ pragma solidity ^0.8.25; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {IOverseer} from "../interfaces/IOverseer.sol"; +import {IOverseer} from "../interfaces/sthype/IOverseer.sol"; import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; /** - * @notice Keeper contract to automate routine function calls in Withdrawal Module. - * @dev This contract is supposed to route its calls via the `owner` of Withdrawal Module. + * @notice Keeper contract to automate routine function calls in `stHYPEWithdrawalModule`. + * @dev This contract is supposed to route its calls via the `owner` of `stHYPEWithdrawalModule`. */ -contract WithdrawalModuleKeeper is Ownable { +contract stHYPEWithdrawalModuleKeeper is Ownable { /** * * CUSTOM ERRORS * */ - error WithdrawalModuleKeeper__ZeroAddress(); - error WithdrawalModuleKeeper__call_onlyKeeper(); - error WithdrawalModuleKeeper__call_callFailed(); + error stHYPEWithdrawalModuleKeeper__ZeroAddress(); + error stHYPEWithdrawalModuleKeeper__call_onlyKeeper(); + error stHYPEWithdrawalModuleKeeper__call_callFailed(); /** * @@ -65,7 +65,9 @@ contract WithdrawalModuleKeeper is Ownable { * @param _keeper Address to grant the keeper role to. */ function setKeeper(address _keeper) external onlyOwner { - if (_keeper == address(0)) revert WithdrawalModuleKeeper__ZeroAddress(); + if (_keeper == address(0)) { + revert stHYPEWithdrawalModuleKeeper__ZeroAddress(); + } isKeeper[_keeper] = true; } @@ -75,30 +77,33 @@ contract WithdrawalModuleKeeper is Ownable { * @param _keeper Address to revoke the keeper role from. */ function removeKeeper(address _keeper) external onlyOwner { - if (_keeper == address(0)) revert WithdrawalModuleKeeper__ZeroAddress(); + if (_keeper == address(0)) { + revert stHYPEWithdrawalModuleKeeper__ZeroAddress(); + } isKeeper[_keeper] = false; } /** * @notice Allows an address with keeper role to execute an arbitrary external call. * @dev Only callable by an address with keeper role. - * @param _withdrawalModuleManager Address of Withdrawal Module's owner, which should validate this call. + * @param _withdrawalModuleManager Address of `stHYPEWithdrawalModule`'s owner, + * which should validate this call. * @param _payload Payload to execute. */ function call(address _withdrawalModuleManager, bytes calldata _payload) external { if (!isKeeper[msg.sender]) { - revert WithdrawalModuleKeeper__call_onlyKeeper(); + revert stHYPEWithdrawalModuleKeeper__call_onlyKeeper(); } (bool success,) = _withdrawalModuleManager.call(_payload); - if (!success) revert WithdrawalModuleKeeper__call_callFailed(); + if (!success) revert stHYPEWithdrawalModuleKeeper__call_callFailed(); } /** * @notice Allows anyone to claim an array of LST protocol withdrawals and call Withdrawal Module's update function. * @param _burnIds Ids of LST protocol withdrawals in `_overseer` to claim. * @param _overseer Address of LST protocol's withdrawal queue entrypoint. - * @param _withdrawalModule Address of STEX Withdrawal Module. + * @param _withdrawalModule Address of `stHYPEWithdrawalModule`. * @dev Returns a boolean array of same size as `_burnIds` to flag the ones which have been successfully claimed. */ function redeemBurnsAndUpdate(uint256[] calldata _burnIds, address _overseer, address _withdrawalModule) diff --git a/src/owner/WithdrawalModuleManager.sol b/src/owner/stHYPEWithdrawalModuleManager.sol similarity index 72% rename from src/owner/WithdrawalModuleManager.sol rename to src/owner/stHYPEWithdrawalModuleManager.sol index bd15d9a..a6a7655 100644 --- a/src/owner/WithdrawalModuleManager.sol +++ b/src/owner/stHYPEWithdrawalModuleManager.sol @@ -3,24 +3,23 @@ pragma solidity ^0.8.25; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {IOverseer} from "../interfaces/IOverseer.sol"; import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; /** - * @notice Owner contract for STEX Withdrawal Module. - * @dev It separates the keeper role from more critical owner controlled functionality. - * Keeper role can automate non-timelocked functions in Withdrawal Module. + * @notice Owner contract for `stHYPEWithdrawalModule`. + * @dev It separates the keeper role from more critical `owner` controlled functionality. + * Keeper role can automate non-timelocked functions in `stHYPEWithdrawalModule`. */ -contract WithdrawalModuleManager is Ownable { +contract stHYPEWithdrawalModuleManager is Ownable { /** * * CUSTOM ERRORS * */ - error WithdrawalModuleManager__OnlyKeeper(); - error WithdrawalModuleManager__ZeroAddress(); - error WithdrawalModuleManager__unstakeToken0Reserves_onlyKeeper(); - error WithdrawalModuleManager__call_callFailed(); + error stHYPEWithdrawalModuleManager__OnlyKeeper(); + error stHYPEWithdrawalModuleManager__ZeroAddress(); + error stHYPEWithdrawalModuleManager__unstakeToken0Reserves_onlyKeeper(); + error stHYPEWithdrawalModuleManager__call_callFailed(); /** * @@ -40,7 +39,7 @@ contract WithdrawalModuleManager is Ownable { */ constructor(address _owner, address _keeper) Ownable(_owner) { if (_keeper == address(0)) { - revert WithdrawalModuleManager__ZeroAddress(); + revert stHYPEWithdrawalModuleManager__ZeroAddress(); } keeper = _keeper; } @@ -52,7 +51,7 @@ contract WithdrawalModuleManager is Ownable { */ modifier onlyKeeper() { if (msg.sender != keeper) { - revert WithdrawalModuleManager__OnlyKeeper(); + revert stHYPEWithdrawalModuleManager__OnlyKeeper(); } _; } @@ -69,7 +68,7 @@ contract WithdrawalModuleManager is Ownable { */ function setKeeper(address _keeper) external onlyOwner { if (_keeper == address(0)) { - revert WithdrawalModuleManager__ZeroAddress(); + revert stHYPEWithdrawalModuleManager__ZeroAddress(); } keeper = _keeper; } @@ -78,18 +77,19 @@ contract WithdrawalModuleManager is Ownable { * @notice Allows `owner` to execute an arbitrary external call. * @dev Only callable by `owner`. * @dev `owner` can call any function in `_withdrawalModule`, including keeper functions. - * @param _withdrawalModule Address of Withdrawal Module, which should have this contract as its `owner`. + * @param _withdrawalModule Address of `stHYPEWithdrawalModule`, + * which should have this contract as its `owner`. * @param _payload Payload to execute. */ function call(address _withdrawalModule, bytes calldata _payload) external onlyOwner { (bool success,) = _withdrawalModule.call(_payload); - if (!success) revert WithdrawalModuleManager__call_callFailed(); + if (!success) revert stHYPEWithdrawalModuleManager__call_callFailed(); } /** * @notice Unstake `amount` of token0 reserves from pool via `_withdrawalModule`. * @dev Only callable by keeper role. - * @param _withdrawalModule Address of STEX Withdrawal Module. + * @param _withdrawalModule Address of `stHYPEWithdrawalModule`. * @param _amount Amount of token0 reserves to withdraw from STEX pool. */ function unstakeToken0Reserves(address _withdrawalModule, uint256 _amount) external onlyKeeper { @@ -100,7 +100,7 @@ contract WithdrawalModuleManager is Ownable { * @notice Withdraw `_amountToken1` of token1 reserves from pool via `_withdrawalModule`, * and supply to its respective lending pool integration. * @dev Only callable by keeper role. - * @param _withdrawalModule Address of STEX Withdrawal Module. + * @param _withdrawalModule Address of `stHYPEWithdrawalModule`. * @param _amountToken1 Amount of token1 reserves to withdraw from STEX pool. */ function supplyToken1ToLendingPool(address _withdrawalModule, uint256 _amountToken1) external onlyKeeper { @@ -111,7 +111,7 @@ contract WithdrawalModuleManager is Ownable { * @notice Withdraw `_amountToken1` of token1 from lending pool integration, * and transfer it back to the respective STEX pool. * @dev Only callable by keeper role. - * @param _withdrawalModule Address of STEX Withdrawal Module. + * @param _withdrawalModule Address of `stHYPEWithdrawalModule`. * @param _amountToken1 Amount of token1 reserves to withdraw from lending pool. */ function withdrawToken1FromLendingPool(address _withdrawalModule, uint256 _amountToken1) external onlyKeeper { diff --git a/src/STEXRatioSwapFeeModule.sol b/src/swap-fee-modules/STEXRatioSwapFeeModule.sol similarity index 95% rename from src/STEXRatioSwapFeeModule.sol rename to src/swap-fee-modules/STEXRatioSwapFeeModule.sol index 9ce102d..4ba1d59 100644 --- a/src/STEXRatioSwapFeeModule.sol +++ b/src/swap-fee-modules/STEXRatioSwapFeeModule.sol @@ -5,10 +5,10 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {SwapFeeModuleData} from "@valantis-core/swap-fee-modules/interfaces/ISwapFeeModule.sol"; import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; -import {FeeParams} from "./structs/STEXRatioSwapFeeModuleStructs.sol"; -import {ISTEXAMM} from "./interfaces/ISTEXAMM.sol"; -import {ISTEXRatioSwapFeeModule} from "./interfaces/ISTEXRatioSwapFeeModule.sol"; -import {IWithdrawalModule} from "./interfaces/IWithdrawalModule.sol"; +import {FeeParams} from "../structs/STEXRatioSwapFeeModuleStructs.sol"; +import {ISTEXAMM} from "../interfaces/ISTEXAMM.sol"; +import {ISTEXRatioSwapFeeModule} from "../interfaces/ISTEXRatioSwapFeeModule.sol"; +import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; /** * @title Stake Exchange: Reserves Ratio based Swap Fee Module. diff --git a/src/swap-fee-modules/StepwiseFeeModule.sol b/src/swap-fee-modules/StepwiseFeeModule.sol new file mode 100644 index 0000000..5ebe5ce --- /dev/null +++ b/src/swap-fee-modules/StepwiseFeeModule.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SwapFeeModuleData} from "@valantis-core/swap-fee-modules/interfaces/ISwapFeeModule.sol"; +import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; +import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; +import {ISTEXAMM} from "../interfaces/ISTEXAMM.sol"; + +import {IStepwiseFeeModule} from "../interfaces/IStepwiseFeeModule.sol"; + +contract StepwiseFeeModule is IStepwiseFeeModule, Ownable { + /** + * + * CUSTOM ERRORS + * + */ + error StepwiseFeeModule__ZeroAddress(); + error StepwiseFeeModule__getSwapFeeInBips_InvalidMinToken1Threshold(); + error StepwiseFeeModule__getSwapFeeInBips_InvalidMaxToken1Threshold(); + error StepwiseFeeModule__getSwapFeeInBips_InvalidToken1Threshold(); + error StepwiseFeeModule__setPool_AlreadySet(); + error StepwiseFeeModule__setFeeParamsToken0__FeeTooHigh(); + error StepwiseFeeModule__setFeeParamsToken0__FeeTooLow(); + error StepwiseFeeModule__setFeeParamsToken0__FeeStepsNotMonotonicallyNonDecreasing(); + error StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdZero(); + error StepwiseFeeModule__setFeeParamsToken0__MaxToken1ThresholdZero(); + error StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold(); + error StepwiseFeeModule__setFeeParamsToken0__ZeroSteps(); + + /** + * + * CONSTANTS + * + */ + uint256 private constant BIPS = 10_000; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _owner) Ownable(_owner) {} + + /** + * + * STORAGE + * + */ + + /** + * @notice Supply of token1 where maximum fee is charged. + */ + uint256 public minThresholdToken1; + + /** + * @notice Supply of token1 where minimum fee is charged. + */ + uint256 public maxThresholdToken1; + + /** + * @notice Number of steps in the Stepwise Fee curve. + */ + uint256 public numStepsToken0FeeCurve; + + /** + * @notice Address of Valantis Sovereign Pool. + */ + address public pool; + + /** + * @notice Stepwise Fee curve parameters. + */ + uint32[] private _feeStepwiseInBips; + + /** + * + * VIEW FUNCTIONS + * + */ + function getToken0FeeInBips() external view returns (uint32[] memory) { + return _feeStepwiseInBips; + } + + function getSwapFeeInBips( + address _tokenIn, + address, /*_tokenOut*/ + uint256 _amountIn, + address, /*_user*/ + bytes memory /*_swapFeeModuleContext*/ + ) external view override returns (SwapFeeModuleData memory swapFeeModuleData) { + ISovereignPool poolInterface = ISovereignPool(pool); + // Fee is only applied on token0 -> token1 swaps + if (_tokenIn == poolInterface.token0()) { + uint256 minThresholdToken1Cache = minThresholdToken1; + uint256 maxThresholdToken1Cache = maxThresholdToken1; + uint256 numStepsToken0FeeCurveCache = numStepsToken0FeeCurve; + + if (minThresholdToken1Cache == 0) { + revert StepwiseFeeModule__getSwapFeeInBips_InvalidMinToken1Threshold(); + } + if (maxThresholdToken1Cache == 0) { + revert StepwiseFeeModule__getSwapFeeInBips_InvalidMaxToken1Threshold(); + } + + if (maxThresholdToken1Cache <= minThresholdToken1Cache) { + revert StepwiseFeeModule__getSwapFeeInBips_InvalidToken1Threshold(); + } + + ISTEXAMM stexInterface = ISTEXAMM(poolInterface.alm()); + IWithdrawalModule withdrawalModuleInterface = IWithdrawalModule(stexInterface.withdrawalModule()); + (, uint256 reserve1) = poolInterface.getReserves(); + uint256 reserve1Total = reserve1 + withdrawalModuleInterface.amountToken1LendingPool(); + uint256 expectedToken1Out = withdrawalModuleInterface.convertToToken1(_amountIn); + + uint256 amount1AfterSwap; + if (reserve1Total > expectedToken1Out) { + amount1AfterSwap = reserve1Total - expectedToken1Out; + } else { + // Do not revert in the swap fee module for low liquidity. + // amount1AfterSwap is an overestimate, large values should charge the maxFee. + amount1AfterSwap = 0; + } + + uint256 feeInBips; + + if (amount1AfterSwap > maxThresholdToken1Cache) { + // If amount1AfterSwap is greater than the maxThresholdToken1 charge the minimum fee. + feeInBips = _feeStepwiseInBips[0]; + } else { + // If amount1AfterSwap is below the threshold, find the corresponding tickNumber + uint256 tickNumberNumerator = (maxThresholdToken1Cache - amount1AfterSwap) * numStepsToken0FeeCurveCache; + uint256 tickNumberDenominator = (maxThresholdToken1Cache - minThresholdToken1Cache); + uint256 tickNumber = tickNumberNumerator / tickNumberDenominator; + if (tickNumber >= numStepsToken0FeeCurveCache) { + feeInBips = _feeStepwiseInBips[numStepsToken0FeeCurveCache - 1]; + } else { + feeInBips = _feeStepwiseInBips[tickNumber]; + } + } + + // Swap fee in `SovereignPool::swap` is applied as: + // amountIn * BIPS / (BIPS + swapFeeModuleData.feeInBips), + // but our parametrization assumes the form: amountIn * (BIPS - feeInBips) / BIPS + // Hence we need to equate both and solve for `swapFeeModuleData.feeInBips`, + // with the constraint that feeInBips <= 5_000 + swapFeeModuleData.feeInBips = (BIPS * feeInBips) / (BIPS - feeInBips); + } + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Sets address of Valantis Sovereign Pool. + * @param _pool Address of Valantis Sovereign Pool to set. + * @dev Callable by `owner` only once. + */ + function setPool(address _pool) external onlyOwner { + if (_pool == address(0)) revert StepwiseFeeModule__ZeroAddress(); + // Pool can only be set once + if (pool != address(0)) { + revert StepwiseFeeModule__setPool_AlreadySet(); + } + pool = _pool; + + emit PoolSet(_pool); + } + + /** + * @notice Update AMM's dynamic swap fee parameters for token0->token1 swaps. + * @dev Only callable by `owner`. + * @param _minThresholdToken1 Threshold value of token1 reserves below which + * the last fee in `feeStepsInBips` will be applied. + * @param _maxThresholdToken1 Threshold value of token1 reserves above which + * the first fee in `feeStepsInBips` will be applied. + * @param _feeStepsInBips Array of fee steps in bips. The fee will be linearly + * interpolated as a function of the token1 reserves below `maxToken1Threshold`. + * Items moving between first and last indicies must be strictly increasing in value. + */ + function setFeeParamsToken0( + uint256 _minThresholdToken1, + uint256 _maxThresholdToken1, + uint32[] calldata _feeStepsInBips + ) external onlyOwner { + if (_feeStepsInBips.length == 0) { + revert StepwiseFeeModule__setFeeParamsToken0__ZeroSteps(); + } + + if (_minThresholdToken1 == 0) { + revert StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdZero(); + } + + if (_maxThresholdToken1 == 0) { + revert StepwiseFeeModule__setFeeParamsToken0__MaxToken1ThresholdZero(); + } + + if (_minThresholdToken1 >= _maxThresholdToken1) { + revert StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold(); + } + + for (uint32 i = 0; i < _feeStepsInBips.length; i++) { + if (_feeStepsInBips[i] >= BIPS / 2) { + revert StepwiseFeeModule__setFeeParamsToken0__FeeTooHigh(); + } + + if (_feeStepsInBips[i] == 0) { + revert StepwiseFeeModule__setFeeParamsToken0__FeeTooLow(); + } + + if (i > 0 && _feeStepsInBips[i] < _feeStepsInBips[i - 1]) { + revert StepwiseFeeModule__setFeeParamsToken0__FeeStepsNotMonotonicallyNonDecreasing(); + } + } + + // Clear current array's elements in storage + delete _feeStepwiseInBips; + + // Set new params in storage + + minThresholdToken1 = _minThresholdToken1; + maxThresholdToken1 = _maxThresholdToken1; + _feeStepwiseInBips = _feeStepsInBips; + numStepsToken0FeeCurve = _feeStepsInBips.length; + + emit FeeParamsSetToken0(_minThresholdToken1, _maxThresholdToken1, _feeStepsInBips.length); + } +} diff --git a/src/withdrawal-modules/kHYPEWithdrawalModule.sol b/src/withdrawal-modules/kHYPEWithdrawalModule.sol new file mode 100644 index 0000000..831ee16 --- /dev/null +++ b/src/withdrawal-modules/kHYPEWithdrawalModule.sol @@ -0,0 +1,873 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.25; + +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; + +import {ILendingModule} from "../interfaces/ILendingModule.sol"; +import {IRebalanceModule} from "../interfaces/IRebalanceModule.sol"; +import {ISTEXAMM} from "../interfaces/ISTEXAMM.sol"; +import {IWETH9} from "../interfaces/IWETH9.sol"; +import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; +import {IStakingAccountant} from "../interfaces/kinetiq/IStakingAccountant.sol"; +import {IStakingManager} from "../interfaces/kinetiq/IStakingManager.sol"; +import {LPWithdrawalRequest, LendingModuleProposal} from "../structs/WithdrawalModuleStructs.sol"; + +/** + * @notice Withdrawal Module for integration between STEX AMM and Kinetiq Liquid Staking Protocol, + * and modular, upgradeable integration with a lending protocol via the Lending Module Interface. + */ +contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, Ownable { + using SafeCast for uint256; + using SafeERC20 for IWETH9; + using SafeERC20 for ERC20; + + /** + * + * EVENTS + * + */ + event STEXSet(address stex); + event LPWithdrawalRequestCreated(uint256 id, uint256 amountToken1, address recipient); + event LPWithdrawalRequestClaimed(uint256 id); + event LendingModuleProposed(address lendingModule, uint256 startTimestamp); + event LendingModuleProposalCancelled(); + event LendingModuleSet(address lendingModule); + event AmountToken1Staked(uint256 amount); + event AmountToken0Unstaked(uint256 amount); + event AmountSuppliedToLendingModule(uint256 amount); + event AmountWithdrawnFromLendingModule(uint256 amount); + event Update(); + event WithdrawalRequestConfirmed(uint256 id, uint256 amount, bool isConfirmed); + event Sweep(address indexed token, address indexed recipient, uint256 balance); + + /** + * + * CUSTOM ERRORS + * + */ + error kHYPEWithdrawalModule__InvalidMaxNoBatchWithdrawals(); + error kHYPEWithdrawalModule__OnlySTEX(); + error kHYPEWithdrawalModule__OnlySTEXOrOwner(); + error kHYPEWithdrawalModule__PoolNonReentrant(); + error kHYPEWithdrawalModule__ZeroAddress(); + error kHYPEWithdrawalModule__claim_AlreadyClaimed(); + error kHYPEWithdrawalModule__claim_CannotYetClaim(); + error kHYPEWithdrawalModule__claim_InsufficientAmountToClaim(); + error kHYPEWithdrawalModule__proposeLendingModule_ProposalAlreadyActive(); + error kHYPEWithdrawalModule__rebalanceToken0Reserves_InsufficientToken1Received(); + error kHYPEWithdrawalModule__rebalanceToken0Reserves_InvalidRecipient(); + error kHYPEWithdrawalModule__rebalanceToken0Reserves_PoolToken0ReservesDecreased(); + error kHYPEWithdrawalModule__rebalanceToken0Reserves_PoolToken1ReservesDecreased(); + error kHYPEWithdrawalModule__rebalanceToken0Reserves_RebalanceModuleCallFailed(); + error kHYPEWithdrawalModule__setProposedLendingModule_InactiveProposal(); + error kHYPEWithdrawalModule__setProposedLendingModule_ProposalNotActive(); + error kHYPEWithdrawalModule__setSTEX_AlreadySet(); + error kHYPEWithdrawalModule__sweep_Token0CannotBeSweeped(); + error kHYPEWithdrawalModule__sweep_Token1CannotBeSweeped(); + error kHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn(); + error kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow(); + error kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh(); + + /** + * + * CONSTANTS + * + */ + uint256 private constant BIPS = 10_000; + + uint256 private constant MIN_TIMELOCK_DELAY = 3 days; + uint256 private constant MAX_TIMELOCK_DELAY = 7 days; + + /** + * + * IMMUTABLES + * + */ + + /** + * @notice Staking Accountant contract from Kinetiq Liquid Staking Protocol. + */ + address public immutable stakingAccountant; + + /** + * @notice Staking Manager contract from Kinetiq Liquid Staking Protocol. + */ + address public immutable stakingManager; + + /** + * + * STORAGE + * + */ + + /** + * @notice Address of Stake Exchange AMM (STEX AMM) deployment. + */ + address public stex; + + /** + * @notice Address of `stex` Sovereign Pool deployment. + */ + address public pool; + + /** + * @notice Amount of native `token1` which is ready for eligible STEX AMM LPs to claim. + */ + uint256 public amountToken1ClaimableLPWithdrawal; + + /** + * @notice Cumulative amount of native `token1` owed to LP withdrawals. + */ + uint256 public cumulativeAmountToken1LPWithdrawal; + + /** + * @notice Cumulative amount of native `token1` claimable by LP withdrawals. + */ + uint256 public cumulativeAmountToken1ClaimableLPWithdrawal; + + /** + * @notice Unique identifier for each LP Withdrawal Request. + */ + uint256 public idLPWithdrawal; + + /** + * @notice mapping from `idLPWithdrawal` to its respective `LPWithdrawalRequest` data. + */ + mapping(uint256 => LPWithdrawalRequest) public LPWithdrawals; + + /** + * @notice Address of proposed lending module to interact with lending protocol. + * @dev WARNING: This is a critical dependency which can affect the solvency of the pool and this contract. + * Updates to lending module happen under a 3-7 days timelock and assumes that `owner` + * implements sufficient internal security checks. + */ + LendingModuleProposal public lendingModuleProposal; + + /** + * @notice Address of lending module to interact with lending protocol. + */ + ILendingModule public lendingModule; + + /** + * @notice Amount of `token0` pending unstaking in the `stakingManager` withdrawal queue. + * @dev This might get updated after calling to `update`. + */ + uint256 private _amountToken0PendingUnstaking; + + /** + * @notice Amount of native `token1` which is owed to STEX AMM LPs who have burnt their LP tokens. + * @dev This might get updated after calling to `update`. + */ + uint256 private _amountToken1PendingLPWithdrawal; + + /** + * + * CONSTRUCTOR + * + */ + constructor(address _stakingAccountant, address _stakingManager, address _owner) Ownable(_owner) { + if (_stakingAccountant == address(0) || _stakingManager == address(0) || _owner == address(0)) { + revert kHYPEWithdrawalModule__ZeroAddress(); + } + + stakingAccountant = _stakingAccountant; + stakingManager = _stakingManager; + } + + /** + * + * MODIFIERS + * + */ + modifier onlySTEX() { + if (msg.sender != stex) { + revert kHYPEWithdrawalModule__OnlySTEX(); + } + _; + } + + modifier onlySTEXOrOwner() { + if (msg.sender != stex && msg.sender != owner()) { + revert kHYPEWithdrawalModule__OnlySTEXOrOwner(); + } + _; + } + + modifier whenPoolNotLocked() { + if (ISovereignPool(pool).isLocked()) { + revert kHYPEWithdrawalModule__PoolNonReentrant(); + } + _; + } + + /** + * + * VIEW FUNCTIONS + * + */ + + /** + * @notice Address of Kinetiq's protocol staking and unstaking contract. + */ + function overseer() external view override returns (address) { + return stakingManager; + } + + /** + * @notice Returns `true` if ReentrancyGuard lock is active, `false` otherwise. + */ + function isLocked() external view override returns (bool) { + return _reentrancyGuardEntered(); + } + + function convertToToken0(uint256 _amountToken1) public view override returns (uint256) { + return IStakingAccountant(stakingAccountant).HYPEToKHYPE(_amountToken1); + } + + function convertToToken1(uint256 _amountToken0) public view override returns (uint256) { + return IStakingAccountant(stakingAccountant).kHYPEToHYPE(_amountToken0); + } + + function token0SharesToBalance(uint256 _shares) public view override returns (uint256) { + return convertToToken1(_shares); + } + + function token0BalanceToShares(uint256 _balance) public view override returns (uint256) { + return convertToToken0(_balance); + } + + function token0SharesOf(address _account) public view override returns (uint256) { + // token0 balances already represent shares + return _getToken(true).balanceOf(_account); + } + + /** + * @notice Returns the LP withdrawal request for the given `_idLPWithdrawal`. + * @param _idLPWithdrawal The ID of the LP withdrawal request to retrieve. + * @return The LP withdrawal request for the given ID. + */ + function getLPWithdrawals(uint256 _idLPWithdrawal) public view override returns (LPWithdrawalRequest memory) { + return LPWithdrawals[_idLPWithdrawal]; + } + + /** + * @notice Tracks amount of token0 which is pending unstaking through `stakingManager`. + * @dev This needs to be tracked as a function of surplus native token balance in this contract, + * in order to maintain consistent accounting before `update` gets called + * and unaccounted native token balance gets transferred. + */ + function amountToken0PendingUnstaking() public view override returns (uint256) { + uint256 excessToken1 = _getExcessNativeBalance(); + uint256 excessToken0 = convertToToken0(excessToken1); + + uint256 amountToken0PendingUnstakingCache = _amountToken0PendingUnstaking; + if (amountToken0PendingUnstakingCache > excessToken0) { + return amountToken0PendingUnstakingCache - excessToken0; + } else { + return 0; + } + } + + /** + * @notice Similar to `amountToken0PendingUnstaking()`, + * but returns the value in storage prior to calling `update`. + */ + function amountToken0PendingUnstakingBeforeUpdate() external view override returns (uint256) { + return _amountToken0PendingUnstaking; + } + + /** + * @notice Tracks amount of token1 which is owed to LP withdrawal requests. + * @dev This needs to be tracked as a function of surplus native token balance in this contract, + * in order to maintain consistent accounting before `update` gets called. + */ + function amountToken1PendingLPWithdrawal() public view override returns (uint256) { + uint256 excessNativeBalance = _getExcessNativeBalance(); + + uint256 amountToken1PendingLPWithdrawalCache = _amountToken1PendingLPWithdrawal; + if (amountToken1PendingLPWithdrawalCache > excessNativeBalance) { + return amountToken1PendingLPWithdrawalCache - excessNativeBalance; + } else { + return 0; + } + } + + /** + * @notice Similar to `amountToken1PendingLPWithdrawal()`, + * but returns the value in storage prior to calling `update`. + */ + function amountToken1PendingLPWithdrawalBeforeUpdate() external view override returns (uint256) { + return _amountToken1PendingLPWithdrawal; + } + + /** + * @notice Returns amount of token1 owned in the lending module. + */ + function amountToken1LendingPool() public view override returns (uint256) { + if (address(lendingModule) != address(0)) { + // Returns balance of underlying token (token1) in the Lending Module's lending protocol position + return lendingModule.assetBalance(); + } else { + return 0; + } + } + + /** + * + * EXTERNAL FUNCTIONS + * + */ + + /** + * @notice Sets the STEX AMM address and respective Sovereign Pool deployment. + * @dev Callable by `owner` only once. + * @param _stex Stake Exchange AMM address to set. + */ + function setSTEX(address _stex) external onlyOwner { + if (_stex == address(0)) revert kHYPEWithdrawalModule__ZeroAddress(); + // Can only be set once + if (stex != address(0)) { + revert kHYPEWithdrawalModule__setSTEX_AlreadySet(); + } + + stex = _stex; + pool = ISTEXAMM(_stex).pool(); + + emit STEXSet(_stex); + } + + /** + * @notice Sweep token balances which have been locked into this contract. + * @dev Only callable by `owner`. + * @param _token Token address to claim balances for. + * @param _recipient Recipient of `_token` balance. + */ + function sweep(address _token, address _recipient) external onlyOwner { + if (_token == address(0)) revert kHYPEWithdrawalModule__ZeroAddress(); + if (_recipient == address(0)) { + revert kHYPEWithdrawalModule__ZeroAddress(); + } + + if (_token == ISTEXAMM(stex).token0()) { + revert kHYPEWithdrawalModule__sweep_Token0CannotBeSweeped(); + } + if (_token == ISTEXAMM(stex).token1()) { + revert kHYPEWithdrawalModule__sweep_Token1CannotBeSweeped(); + } + + uint256 balance = ERC20(_token).balanceOf(address(this)); + if (balance > 0) { + ERC20(_token).safeTransfer(_recipient, balance); + + emit Sweep(_token, _recipient, balance); + } + } + + /** + * @notice Propose an update to Lending Module. + * @dev Only callable by `owner`. + * @dev WARNING: This is a critical dependency which affects the solvency of the pool and this contract, + * hence `owner` should have sufficient internal checks and protections. + * @param _lendingModule Address of new Lending Module to set. + * @param _timelockDelay 3-7 days timelock delay. + */ + function proposeLendingModule(address _lendingModule, uint256 _timelockDelay) external onlyOwner { + _verifyTimelockDelay(_timelockDelay); + + if (lendingModuleProposal.startTimestamp > 0) { + revert kHYPEWithdrawalModule__proposeLendingModule_ProposalAlreadyActive(); + } + + lendingModuleProposal = + LendingModuleProposal({lendingModule: _lendingModule, startTimestamp: block.timestamp + _timelockDelay}); + + emit LendingModuleProposed(_lendingModule, block.timestamp + _timelockDelay); + } + + /** + * @notice Cancel a pending update proposal to Lending Module. + * @dev Only callable by `owner`. + */ + function cancelLendingModuleProposal() external onlyOwner { + emit LendingModuleProposalCancelled(); + + delete lendingModuleProposal; + } + + /** + * @notice Set the proposed Lending Module after timelock has passed. + * @dev Only callable by `owner`. + */ + function setProposedLendingModule() external onlyOwner whenPoolNotLocked { + if (lendingModuleProposal.startTimestamp > block.timestamp) { + revert kHYPEWithdrawalModule__setProposedLendingModule_ProposalNotActive(); + } + + if (lendingModuleProposal.startTimestamp == 0) { + revert kHYPEWithdrawalModule__setProposedLendingModule_InactiveProposal(); + } + + // Withdraw all token1 amount from lending module back into pool + if (address(lendingModule) != address(0)) { + uint256 amountToken1LendingModule = lendingModule.assetBalance(); + + if (amountToken1LendingModule > 0) { + lendingModule.withdraw(amountToken1LendingModule, pool); + } + } + + // Set new lending module + lendingModule = ILendingModule(lendingModuleProposal.lendingModule); + + // Sanity check that it is possible to query `assetBalance` from the new lending module + lendingModule.assetBalance(); + + delete lendingModuleProposal; + + emit LendingModuleSet(address(lendingModule)); + } + + /** + * @dev This contract will receive token1 in native form, + * as pending unstaking requests are settled. + */ + receive() external payable {} + + /** + * @notice This function gets called after an LP burns its LP tokens, + * in order to create a pending request. + * @dev Only callable by the AMM. + * @param _amountToken0 Amount of token0 which would be due to `_recipient`. + * @param _recipient Address which should receive the amounts from this withdrawal's request once fulfilled. + */ + function burnToken0AfterWithdraw(uint256 _amountToken0, address _recipient) + external + override + onlySTEX + nonReentrant + { + IStakingManager stakingManagerInterface = IStakingManager(stakingManager); + + uint256 feeToken0Bips = stakingManagerInterface.unstakeFeeRate(); + + // `stakingManager` charges an unstaking fee in token0 + uint256 feeToken0 = Math.mulDiv(_amountToken0, feeToken0Bips, BIPS); + + // Amount of token1 which the LP expects to receive after unstaking, + // excluding token0 fee and assuming no slashing + uint256 amountToken1 = convertToToken1(_amountToken0 - feeToken0); + + _amountToken1PendingLPWithdrawal += amountToken1; + + emit LPWithdrawalRequestCreated(idLPWithdrawal, amountToken1, _recipient); + + LPWithdrawals[idLPWithdrawal] = LPWithdrawalRequest({ + recipient: _recipient, + amountToken1: amountToken1.toUint96(), + cumulativeAmountToken1LPWithdrawalCheckpoint: cumulativeAmountToken1LPWithdrawal + }); + idLPWithdrawal++; + + cumulativeAmountToken1LPWithdrawal += amountToken1; + } + + /** + * @notice This function gets called by either: + * - AMM, after an LP burns its LP tokens, + * in order to withdraw `token1` amounts from the lending protocol. + * - `owner`, to withdraw `token1` from lending protocol back into pool. + * @dev Only callable by the AMM or `owner`. + * @param _amountToken1 Amount of token1 which is due to `_recipient` or pool. + * @param _recipient Address which should receive `_amountToken1` of `token1`, + * only relevant if msg.sender == AMM. + */ + function withdrawToken1FromLendingPool(uint256 _amountToken1, address _recipient) + external + override + onlySTEXOrOwner + nonReentrant + whenPoolNotLocked + { + if (address(lendingModule) == address(0)) return; + if (_amountToken1 == 0) return; + + address recipient = msg.sender == stex ? _recipient : pool; + ERC20 token1 = _getToken(false); + + uint256 preBalance = token1.balanceOf(recipient); + lendingModule.withdraw(_amountToken1, recipient); + uint256 postBalance = token1.balanceOf(recipient); + // Ensure that recipient gets at least `_amountToken1` worth of token1 + if (postBalance - preBalance < _amountToken1) { + revert kHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn(); + } + + emit AmountWithdrawnFromLendingModule(_amountToken1); + } + + /** + * @notice Withdraws a portion of pool's token1 reserves and stakes into `stakingManager` for an equivalent amount of `token0`. + * @dev Only callable by `owner`. + * @param _amountToken1 Amount of pool's token1 reserves to stake into token0. + */ + function stakeToken1(uint256 _amountToken1) external onlyOwner whenPoolNotLocked nonReentrant { + if (_amountToken1 == 0) return; + + ISTEXAMM stexInterface = ISTEXAMM(stex); + stexInterface.supplyToken1Reserves(_amountToken1); + + // Unwrap into native token + _getWrappedNativeToken().withdraw(_amountToken1); + + // Stake `_amounToken1` of token1 into token0 + ERC20 token0 = _getToken(true); + uint256 token0PreBalance = token0.balanceOf(address(this)); + IStakingManager(stakingManager).stake{value: _amountToken1}(); + uint256 amountToken0 = token0.balanceOf(address(this)) - token0PreBalance; + + // Transfer minted LST balance to STEX AMM's pool + token0.safeTransfer(pool, amountToken0); + + emit AmountToken1Staked(_amountToken1); + } + + /** + * @notice Withdraws a portion of pool's token1 reserves, + * unwraps into native token to net against pending LP withdrawals, + * and transfers any left-over back into the pool. + * @dev Only callable by `owner`. + * @param _amountToken1 Amount of pool's token1 reserves to stake into token0. + */ + function settlePendingWithdrawalsWithPoolReserves(uint256 _amountToken1) + external + onlyOwner + nonReentrant + whenPoolNotLocked + { + if (_amountToken1 == 0) return; + + // Ensure that net new native token balance is properly accounted for + _update(false); + + ISTEXAMM(stex).supplyToken1Reserves(_amountToken1); + + // Unwrap into native token + _getWrappedNativeToken().withdraw(_amountToken1); + + // Use native token balance to net against pending LP withdrawals, + // and transfer left-over amount as token1 back into pool + _update(true); + } + + /** + * @notice Withdraws a portion of pool's token1 reserves and supplies to `lendingPool` to earn extra yield. + * @dev Only callable by `owner`. + * @param _amountToken1 Amount of token1 reserves to supply. + */ + function supplyToken1ToLendingPool(uint256 _amountToken1) external override onlyOwner nonReentrant { + if (address(lendingModule) == address(0)) return; + if (_amountToken1 == 0) return; + + ISTEXAMM(stex).supplyToken1Reserves(_amountToken1); + + ERC20 token1 = _getToken(false); + + token1.forceApprove(address(lendingModule), _amountToken1); + // WARNING: Assumes that lending module deposits the total `_amountToken1` (no partial deposits) + lendingModule.deposit(_amountToken1); + + emit AmountSuppliedToLendingModule(_amountToken1); + } + + /** + * @notice Claims pool's accummulated token0 reserves and executes an unstaking request (burn) via `stakingManager`. + * @dev Only callable by `owner`. + * @param _unstakeAmountToken0 Amount of `token0` reserves to unstake. + */ + function unstakeToken0Reserves(uint256 _unstakeAmountToken0) external override nonReentrant onlyOwner { + _unstakeToken0Reserves(_unstakeAmountToken0); + } + + /** + * @notice Atomically rebalances excess token0 reserves into token1, + * at a price no worse than using `stakingManager` withdrawal queue. + * @dev Only callable by `owner`. + * @param _amountToken0 Amount of token0 reserves to rebalance into token1. + * @param _recipient Address to receive `_amountToken0` of token0. + * @param _rebalanceModule Address which should execute the rebalance. + * If zero, it assumes that `owner` will provide the token1 amount. + * @param _payload payload to `_rebalanceModule`. + */ + function rebalanceToken0Reserves( + uint256 _amountToken0, + address _recipient, + address _rebalanceModule, + bytes calldata _payload + ) external nonReentrant onlyOwner whenPoolNotLocked { + if (_recipient == address(0)) { + revert kHYPEWithdrawalModule__ZeroAddress(); + } + // If `_rebalanceModule` is specified, + // it must also be the recipient of token0 + if (_rebalanceModule != address(0) && _recipient != _rebalanceModule) { + revert kHYPEWithdrawalModule__rebalanceToken0Reserves_InvalidRecipient(); + } + + if (_amountToken0 == 0) return; + + ISTEXAMM(stex).unstakeToken0Reserves(_amountToken0); + + // Kinetiq charges an unstaking fee + uint256 feeToken0 = Math.mulDiv(_amountToken0, IStakingManager(stakingManager).unstakeFeeRate(), BIPS); + // The rebalance must return at least the amount of token1 + // which would be received by going through `stakingManager` + uint256 amountToken1Min = convertToToken1(_amountToken0 - feeToken0); + + ERC20 token0 = _getToken(true); + ERC20 token1 = _getToken(false); + + // Transfer token0 amount to `_recipient` + token0.safeTransfer(_recipient, _amountToken0); + + (uint256 preReserve0, uint256 preReserve1) = ISovereignPool(pool).getReserves(); + uint256 preToken1Balance = token1.balanceOf(address(this)); + if (_rebalanceModule == address(0)) { + // msg.sender should have approved this contract to transfer + // `amountToken1Min` worth of token1 + token1.safeTransferFrom(msg.sender, address(this), amountToken1Min); + } else { + // General callback for `_rebalanceModule` to swap token0 into token1 + bytes4 selector = IRebalanceModule(_rebalanceModule).rebalance(amountToken1Min, _payload); + if (selector != IRebalanceModule.rebalance.selector) { + revert kHYPEWithdrawalModule__rebalanceToken0Reserves_RebalanceModuleCallFailed(); + } + } + (uint256 postReserve0, uint256 postReserve1) = ISovereignPool(pool).getReserves(); + + // Ensure that pool reserves have not decreased after rebalancing + if (postReserve0 < preReserve0) { + revert kHYPEWithdrawalModule__rebalanceToken0Reserves_PoolToken0ReservesDecreased(); + } + + if (postReserve1 < preReserve1) { + revert kHYPEWithdrawalModule__rebalanceToken0Reserves_PoolToken1ReservesDecreased(); + } + + uint256 amountToken1Received = token1.balanceOf(address(this)) - preToken1Balance; + // Ensure that enough token1 amount has been received + if (amountToken1Received < amountToken1Min) { + revert kHYPEWithdrawalModule__rebalanceToken0Reserves_InsufficientToken1Received(); + } + + // Transfer received token1 amount to pool + token1.safeTransfer(pool, amountToken1Received); + } + + /** + * @notice Unstakes left-over token0 balance in this contract. + * @dev This can happen in case of token0 donations, or Kinetiq withdrawal cancellations. + * @dev Only callable by `owner`. + */ + function unstakeExcessToken0() external nonReentrant onlyOwner { + ERC20 token0 = _getToken(true); + uint256 token0Balance = token0.balanceOf(address(this)); + + if (token0Balance == 0) return; + + _unstakeToken0(token0Balance); + } + + /** + * @notice Allows anyone to claim a processed withdrawal id from `stakingManager`. + * @param _id Id of withdrawal to confirm in `stakingManager`. + * @return isConfirmed Boolean that indicates if the request got processed by this function call. + */ + function confirmWithdrawal(uint256 _id) external nonReentrant whenPoolNotLocked returns (bool isConfirmed) { + isConfirmed = _confirmWithdrawal(_id); + + // Update accounting state immediately after confirmation + if (isConfirmed) { + _update(false); + } + } + + /** + * @notice Checks current balance of native token and updates state. + * @dev Pending LP withdrawals are prioritized, + * and any remaining native token is wrapped and transfered to + * the AMM's Sovereign Pool. + */ + function update() external override nonReentrant whenPoolNotLocked { + _update(false); + } + + /** + * @notice Claims a LP withdrawal request which has already been fulfilled. + * @dev Anyone can claim on behalf of its recipient. + * @param _idLPQueue Id of LP's withdrawal request to claim. + */ + function claim(uint256 _idLPQueue) external override nonReentrant whenPoolNotLocked { + // WARNING: This implementation assumes that there is no slashing enabled in the LST protocol + + LPWithdrawalRequest memory request = LPWithdrawals[_idLPQueue]; + + // Check if LP withdrawal has already been claimed + if (request.amountToken1 == 0) { + revert kHYPEWithdrawalModule__claim_AlreadyClaimed(); + } + + // Check if there is enough native token available to fulfill the rest of this request + if (amountToken1ClaimableLPWithdrawal < request.amountToken1) { + revert kHYPEWithdrawalModule__claim_InsufficientAmountToClaim(); + } + + // Check if it is the right time to claim (according to queue priority) + if ( + cumulativeAmountToken1ClaimableLPWithdrawal + < request.cumulativeAmountToken1LPWithdrawalCheckpoint + request.amountToken1 + ) { + revert kHYPEWithdrawalModule__claim_CannotYetClaim(); + } + + amountToken1ClaimableLPWithdrawal -= request.amountToken1; + + emit LPWithdrawalRequestClaimed(_idLPQueue); + + delete LPWithdrawals[_idLPQueue]; + + // Send equivalent amount of native token to recipient + Address.sendValue(payable(request.recipient), request.amountToken1); + } + + /** + * + * PRIVATE FUNCTIONS + * + */ + function _update(bool isPoolRebalance) private { + // WARNING: This implementation assumes that there is no slashing enabled in the LST protocol + + // `confirmWithdrawal` should be called in order to process confirmed withdrawals + // and accrue net new native token balance to this contract + + // Need to ensure that enough native token is reserved for settled LP withdrawals + uint256 excessNativeBalance = _getExcessNativeBalance(); + if (excessNativeBalance == 0) return; + + if (!isPoolRebalance) { + uint256 amountToken0PendingUnstakingCache = _amountToken0PendingUnstaking; + uint256 excessToken0Balance = convertToToken0(excessNativeBalance); + if (amountToken0PendingUnstakingCache > excessToken0Balance) { + _amountToken0PendingUnstaking = amountToken0PendingUnstakingCache - excessToken0Balance; + } else { + _amountToken0PendingUnstaking = 0; + } + } + + // Allocate native token balance to pending LP withdrawal requests + uint256 amountToken1PendingLPWithdrawalCache = _amountToken1PendingLPWithdrawal; + if (excessNativeBalance > amountToken1PendingLPWithdrawalCache) { + excessNativeBalance -= amountToken1PendingLPWithdrawalCache; + amountToken1ClaimableLPWithdrawal += amountToken1PendingLPWithdrawalCache; + cumulativeAmountToken1ClaimableLPWithdrawal += amountToken1PendingLPWithdrawalCache; + _amountToken1PendingLPWithdrawal = 0; + } else { + _amountToken1PendingLPWithdrawal -= excessNativeBalance; + amountToken1ClaimableLPWithdrawal += excessNativeBalance; + cumulativeAmountToken1ClaimableLPWithdrawal += excessNativeBalance; + excessNativeBalance = 0; + + emit Update(); + + return; + } + + // Wrap left-over native token into token1 and re-deposit into the pool + IWETH9 token1 = _getWrappedNativeToken(); + + token1.deposit{value: excessNativeBalance}(); + // Pool reserves are measured as balances, hence we can replenish it with token1 + // by transfering directly + token1.safeTransfer(pool, excessNativeBalance); + + emit Update(); + } + + function _confirmWithdrawal(uint256 id) private returns (bool isConfirmed) { + IStakingManager.WithdrawalRequest memory request = + IStakingManager(stakingManager).withdrawalRequests(address(this), id); + + // Request does not exist, has been cancelled, + // or has already been confirmed + if (request.hypeAmount == 0) return false; + + // Request is not yet ready to claim + if (block.timestamp < request.timestamp + IStakingManager(stakingManager).withdrawalDelay()) { + return false; + } + + uint256 preBalance = address(this).balance; + + IStakingManager(stakingManager).confirmWithdrawal(id); + + isConfirmed = address(this).balance >= preBalance + request.hypeAmount; + + emit WithdrawalRequestConfirmed(id, request.hypeAmount, isConfirmed); + } + + function _unstakeToken0Reserves(uint256 amountToken0) private { + ISTEXAMM(stex).unstakeToken0Reserves(amountToken0); + + // Kinetiq charges an unstaking fee + uint256 feeToken0 = Math.mulDiv(amountToken0, IStakingManager(stakingManager).unstakeFeeRate(), BIPS); + + _amountToken0PendingUnstaking += (amountToken0 - feeToken0); + + _unstakeToken0(amountToken0); + } + + function _unstakeToken0(uint256 amountToken0) private { + // Burn `amountToken0` worth of token0 through `stakingManager` withdrawal queue. + // WARNING: This implementation assumes that there is no slashing enabled in the LST protocol + _getToken(true).forceApprove(stakingManager, amountToken0); + IStakingManager(stakingManager).queueWithdrawal(amountToken0); + + emit AmountToken0Unstaked(amountToken0); + } + + function _getToken(bool isToken0) private view returns (ERC20 token) { + return isToken0 ? ERC20(ISTEXAMM(stex).token0()) : ERC20(ISTEXAMM(stex).token1()); + } + + function _getWrappedNativeToken() private view returns (IWETH9 token) { + return IWETH9(ISTEXAMM(stex).token1()); + } + + function _getExcessNativeBalance() private view returns (uint256) { + // Calculates native token balance in excess of the balance already claimable by processed LP withdrawals + // This will be used to net against pending LP withdrawals, and any leftover can be transferred + // to STEX AMM's pool via `update` + uint256 balanceNative = address(this).balance; + uint256 excessBalanceNative = + balanceNative > amountToken1ClaimableLPWithdrawal ? balanceNative - amountToken1ClaimableLPWithdrawal : 0; + + return excessBalanceNative; + } + + function _verifyTimelockDelay(uint256 _timelockDelay) private pure { + if (_timelockDelay < MIN_TIMELOCK_DELAY) { + revert kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow(); + } + + if (_timelockDelay > MAX_TIMELOCK_DELAY) { + revert kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh(); + } + } +} diff --git a/src/stHYPEWithdrawalModule.sol b/src/withdrawal-modules/stHYPEWithdrawalModule.sol similarity index 89% rename from src/stHYPEWithdrawalModule.sol rename to src/withdrawal-modules/stHYPEWithdrawalModule.sol index d0bb59a..cfb0faa 100644 --- a/src/stHYPEWithdrawalModule.sol +++ b/src/withdrawal-modules/stHYPEWithdrawalModule.sol @@ -10,13 +10,13 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; -import {IOverseer} from "./interfaces/IOverseer.sol"; -import {IstHYPE} from "./interfaces/IstHYPE.sol"; -import {IWithdrawalModule} from "./interfaces/IWithdrawalModule.sol"; -import {IWETH9} from "./interfaces/IWETH9.sol"; -import {ISTEXAMM} from "./interfaces/ISTEXAMM.sol"; -import {ILendingModule} from "./interfaces/ILendingModule.sol"; -import {LPWithdrawalRequest, LendingModuleProposal} from "./structs/WithdrawalModuleStructs.sol"; +import {IOverseer} from "../interfaces/sthype/IOverseer.sol"; +import {IstHYPE} from "../interfaces/sthype/IstHYPE.sol"; +import {IWithdrawalModule} from "../interfaces/IWithdrawalModule.sol"; +import {IWETH9} from "../interfaces/IWETH9.sol"; +import {ISTEXAMM} from "../interfaces/ISTEXAMM.sol"; +import {ILendingModule} from "../interfaces/ILendingModule.sol"; +import {LPWithdrawalRequest, LendingModuleProposal} from "../structs/WithdrawalModuleStructs.sol"; /** * @notice Withdrawal Module for integration between STEX AMM and Thunderheads' Staked Hype, @@ -32,16 +32,17 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, * EVENTS * */ - event STEXSet(address stex); - event LPWithdrawalRequestCreated(uint256 id, uint256 amountToken1, address recipient); - event LPWithdrawalRequestClaimed(uint256 id); - event LendingModuleProposed(address lendingModule, uint256 startTimestamp); - event LendingModuleProposalCancelled(); - event LendingModuleSet(address lendingModule); - event AmountToken1Staked(uint256 amount); - event AmountToken0Unstaked(uint256 amount); event AmountSuppliedToLendingModule(uint256 amount); + event AmountToken0Unstaked(uint256 amount); + event AmountToken1Staked(uint256 amount); event AmountWithdrawnFromLendingModule(uint256 amount); + event LendingModuleProposalCancelled(); + event LendingModuleProposed(address lendingModule, uint256 startTimestamp); + event LendingModuleSet(address lendingModule); + event LPWithdrawalRequestClaimed(uint256 id); + event LPWithdrawalRequestCreated(uint256 id, uint256 amountToken1, address recipient); + event STEXSet(address stex); + event Sweep(address indexed token, address indexed recipient, uint256 balance); event Update(); /** @@ -49,20 +50,22 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, * CUSTOM ERRORS * */ - error stHYPEWithdrawalModule__ZeroAddress(); error stHYPEWithdrawalModule__OnlySTEX(); error stHYPEWithdrawalModule__OnlySTEXOrOwner(); error stHYPEWithdrawalModule__PoolNonReentrant(); - error stHYPEWithdrawalModule__claim_alreadyClaimed(); - error stHYPEWithdrawalModule__claim_cannotYetClaim(); - error stHYPEWithdrawalModule__claim_insufficientAmountToClaim(); - error stHYPEWithdrawalModule__setSTEX_AlreadySet(); - error stHYPEWithdrawalModule__withdrawToken1FromLendingPool_insufficientAmountWithdrawn(); - error stHYPEWithdrawalModule___verifyTimelockDelay_timelockTooLow(); - error stHYPEWithdrawalModule___verifyTimelockDelay_timelockTooHigh(); + error stHYPEWithdrawalModule__ZeroAddress(); + error stHYPEWithdrawalModule__claim_AlreadyClaimed(); + error stHYPEWithdrawalModule__claim_CannotYetClaim(); + error stHYPEWithdrawalModule__claim_InsufficientAmountToClaim(); error stHYPEWithdrawalModule__proposeLendingModule_ProposalAlreadyActive(); error stHYPEWithdrawalModule__setProposedLendingModule_ProposalNotActive(); error stHYPEWithdrawalModule__setProposedLendingModule_InactiveProposal(); + error stHYPEWithdrawalModule__setSTEX_AlreadySet(); + error stHYPEWithdrawalModule__sweep_Token0CannotBeSweeped(); + error stHYPEWithdrawalModule__sweep_Token1CannotBeSweeped(); + error stHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn(); + error stHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow(); + error stHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh(); /** * @@ -83,6 +86,11 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, */ address public immutable overseer; + /** + * @notice wstHYPE token address. + */ + address public immutable wstHYPE; + /** * * STORAGE @@ -154,13 +162,14 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, * CONSTRUCTOR * */ - constructor(address _overseer, address _owner) Ownable(_owner) { + constructor(address _overseer, address _wstHYPE, address _owner) Ownable(_owner) { // _lendingPool can be zero address, in case it is not set - if (_overseer == address(0) || _owner == address(0)) { + if (_overseer == address(0) || _wstHYPE == address(0) || _owner == address(0)) { revert stHYPEWithdrawalModule__ZeroAddress(); } overseer = _overseer; + wstHYPE = _wstHYPE; } /** @@ -329,6 +338,34 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, emit STEXSet(_stex); } + /** + * @notice Sweep token balances which have been locked into this contract. + * @dev Only callable by `owner`. + * @param _token Token address to claim balances for. + * @param _recipient Recipient of `_token` balance. + */ + function sweep(address _token, address _recipient) external onlyOwner { + if (_token == address(0)) revert stHYPEWithdrawalModule__ZeroAddress(); + if (_recipient == address(0)) { + revert stHYPEWithdrawalModule__ZeroAddress(); + } + + // stHYPE and wstHYPE are not sweepable + if (_token == ISTEXAMM(stex).token0() || _token == wstHYPE) { + revert stHYPEWithdrawalModule__sweep_Token0CannotBeSweeped(); + } + if (_token == ISTEXAMM(stex).token1()) { + revert stHYPEWithdrawalModule__sweep_Token1CannotBeSweeped(); + } + + uint256 balance = ERC20(_token).balanceOf(address(this)); + if (balance > 0) { + ERC20(_token).safeTransfer(_recipient, balance); + + emit Sweep(_token, _recipient, balance); + } + } + /** * @notice Propose an update to Lending Module. * @dev Only callable by `owner`. @@ -385,6 +422,9 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, // Set new lending module lendingModule = ILendingModule(lendingModuleProposal.lendingModule); + // Sanity check that it is possible to query `assetBalance` from the new lending module + lendingModule.assetBalance(); + delete lendingModuleProposal; emit LendingModuleSet(address(lendingModule)); @@ -454,7 +494,7 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, uint256 postBalance = ERC20(token1).balanceOf(recipient); // Ensure that recipient gets at least `_amountToken1` worth of token1 if (postBalance - preBalance < _amountToken1) { - revert stHYPEWithdrawalModule__withdrawToken1FromLendingPool_insufficientAmountWithdrawn(); + revert stHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn(); } emit AmountWithdrawnFromLendingModule(_amountToken1); @@ -561,6 +601,9 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, amountToken1ClaimableLPWithdrawal += excessNativeBalance; cumulativeAmountToken1ClaimableLPWithdrawal += excessNativeBalance; excessNativeBalance = 0; + + emit Update(); + return; } @@ -587,12 +630,12 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, LPWithdrawalRequest memory request = LPWithdrawals[_idLPQueue]; if (request.amountToken1 == 0) { - revert stHYPEWithdrawalModule__claim_alreadyClaimed(); + revert stHYPEWithdrawalModule__claim_AlreadyClaimed(); } // Check if there is enough ETH available to fulfill this request if (amountToken1ClaimableLPWithdrawal < request.amountToken1) { - revert stHYPEWithdrawalModule__claim_insufficientAmountToClaim(); + revert stHYPEWithdrawalModule__claim_InsufficientAmountToClaim(); } // Check if it is the right time to claim (according to queue priority) @@ -600,7 +643,7 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, cumulativeAmountToken1ClaimableLPWithdrawal < request.cumulativeAmountToken1LPWithdrawalCheckpoint + request.amountToken1 ) { - revert stHYPEWithdrawalModule__claim_cannotYetClaim(); + revert stHYPEWithdrawalModule__claim_CannotYetClaim(); } amountToken1ClaimableLPWithdrawal -= request.amountToken1; @@ -631,11 +674,11 @@ contract stHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, function _verifyTimelockDelay(uint256 _timelockDelay) private pure { if (_timelockDelay < MIN_TIMELOCK_DELAY) { - revert stHYPEWithdrawalModule___verifyTimelockDelay_timelockTooLow(); + revert stHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow(); } if (_timelockDelay > MAX_TIMELOCK_DELAY) { - revert stHYPEWithdrawalModule___verifyTimelockDelay_timelockTooHigh(); + revert stHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh(); } } } diff --git a/test/AaveLendingModule.t.sol b/test/AaveLendingModule.t.sol index e2f3d94..9f2335a 100644 --- a/test/AaveLendingModule.t.sol +++ b/test/AaveLendingModule.t.sol @@ -6,7 +6,7 @@ import {Test} from "forge-std/Test.sol"; import {WETH} from "@solmate/tokens/WETH.sol"; import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; -import {AaveLendingModule} from "src/AaveLendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; import {MockLendingPool} from "src/mocks/MockLendingPool.sol"; contract AaveLendingModuleTest is Test { diff --git a/test/ERC4626LendingModule.t.sol b/test/ERC4626LendingModule.t.sol new file mode 100644 index 0000000..f577ca3 --- /dev/null +++ b/test/ERC4626LendingModule.t.sol @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {ERC4626LendingModule} from "src/lending-modules/ERC4626LendingModule.sol"; +import {MockERC4626LendingPool} from "src/mocks/MockERC4626LendingPool.sol"; + +contract ERC4626LendingModuleTest is Test { + ERC4626LendingModule lendingModule; + MockERC4626LendingPool vault; + ERC20Mock assetToken; + + address owner = makeAddr("OWNER"); + address tokenSweepManager = makeAddr("TOKEN_SWEEP_MANAGER"); + address recipient = makeAddr("RECIPIENT"); + address user = makeAddr("USER"); + + uint256 constant DEPOSIT_AMOUNT = 1000 ether; + uint256 constant WITHDRAW_AMOUNT = 500 ether; + + event TokenSweepManagerUpdated(address tokenSweepManager); + event Sweep(address indexed token, address indexed recipient, uint256 balance); + + function setUp() public { + // Deploy mock asset token + assetToken = new ERC20Mock(); + assetToken.mint(owner, 10000 ether); + + // Deploy mock vault + vault = new MockERC4626LendingPool(address(assetToken)); + + // Deploy lending module + lendingModule = new ERC4626LendingModule(address(vault), owner, tokenSweepManager); + + // Verify initial state + assertEq(address(lendingModule.vault()), address(vault)); + assertEq(lendingModule.asset(), address(assetToken)); + assertEq(lendingModule.owner(), owner); + assertEq(lendingModule.tokenSweepManager(), tokenSweepManager); + } + + function testDeploy() public { + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__ZeroAddress.selector); + new ERC4626LendingModule(address(0), owner, tokenSweepManager); + + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__ZeroAddress.selector); + new ERC4626LendingModule(address(vault), owner, address(0)); + + vm.expectRevert(); + new ERC4626LendingModule(address(vault), address(0), tokenSweepManager); + + // Test with valid parameters + ERC4626LendingModule newModule = new ERC4626LendingModule(address(vault), owner, tokenSweepManager); + + assertEq(address(newModule.vault()), address(vault)); + assertEq(newModule.asset(), address(assetToken)); + assertEq(newModule.owner(), owner); + assertEq(newModule.tokenSweepManager(), tokenSweepManager); + } + + function testAssetBalance() public { + // Initially should be 0 + assertEq(lendingModule.assetBalance(), 0); + + vm.startPrank(owner); + assetToken.approve(address(lendingModule), DEPOSIT_AMOUNT * 3); + + // First deposit + lendingModule.deposit(DEPOSIT_AMOUNT); + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT); + + // Second deposit + lendingModule.deposit(DEPOSIT_AMOUNT); + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT * 2); + + // Third deposit + lendingModule.deposit(DEPOSIT_AMOUNT); + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT * 3); + + // Simulate borrow through the vault + vault.removeToken(DEPOSIT_AMOUNT, owner); + + // Asset balance remains the same, + // since it should not decrease as utilization/borrowing increases. + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT * 3); + assertEq(vault.totalAssets(), DEPOSIT_AMOUNT * 3); + assertEq(assetToken.balanceOf(address(vault)), 2 * DEPOSIT_AMOUNT); + + vm.stopPrank(); + } + + function testDeposit() public { + vm.startPrank(user); + + assetToken.approve(address(lendingModule), DEPOSIT_AMOUNT); + + vm.expectRevert(); // Should revert due to access control + lendingModule.deposit(DEPOSIT_AMOUNT); + + vm.stopPrank(); + + vm.startPrank(owner); + + assetToken.approve(address(lendingModule), 0); + + vm.expectRevert(); // Should revert due to zero amount + lendingModule.deposit(0); + + // Approve tokens + assetToken.approve(address(lendingModule), DEPOSIT_AMOUNT); + + // Record initial balances + uint256 initialOwnerBalance = assetToken.balanceOf(owner); + uint256 initialVaultBalance = assetToken.balanceOf(address(vault)); + uint256 initialModuleBalance = assetToken.balanceOf(address(lendingModule)); + + // Deposit + lendingModule.deposit(DEPOSIT_AMOUNT); + + // Verify balances + assertEq(assetToken.balanceOf(owner), initialOwnerBalance - DEPOSIT_AMOUNT); + assertEq(assetToken.balanceOf(address(vault)), initialVaultBalance + DEPOSIT_AMOUNT); + assertEq(assetToken.balanceOf(address(lendingModule)), initialModuleBalance); + + // Verify vault shares were minted to the module + assertGt(vault.balanceOf(address(lendingModule)), 0); + + vm.stopPrank(); + } + + function testWithdraw() public { + // First deposit some tokens + vm.startPrank(owner); + assetToken.approve(address(lendingModule), DEPOSIT_AMOUNT); + lendingModule.deposit(DEPOSIT_AMOUNT); + vm.stopPrank(); + + // Record initial balances + uint256 initialRecipientBalance = assetToken.balanceOf(recipient); + uint256 initialVaultBalance = assetToken.balanceOf(address(vault)); + uint256 initialModuleBalance = assetToken.balanceOf(address(lendingModule)); + + vm.startPrank(user); + + vm.expectRevert(); // Should revert due to access control + lendingModule.withdraw(WITHDRAW_AMOUNT, recipient); + + vm.expectRevert(); // Should revert due to zero address + lendingModule.withdraw(WITHDRAW_AMOUNT, address(0)); + + // Try to withdraw more than deposited + vm.expectRevert(); // Should revert due to insufficient balance + lendingModule.withdraw(DEPOSIT_AMOUNT + 1, recipient); + + vm.stopPrank(); + + // Withdraw + vm.prank(owner); + lendingModule.withdraw(WITHDRAW_AMOUNT, recipient); + + // Verify balances + assertEq(assetToken.balanceOf(recipient), initialRecipientBalance + WITHDRAW_AMOUNT); + assertEq(assetToken.balanceOf(address(vault)), initialVaultBalance - WITHDRAW_AMOUNT); + assertEq(assetToken.balanceOf(address(lendingModule)), initialModuleBalance); + + // Verify vault shares decreased + assertLt(vault.balanceOf(address(lendingModule)), vault.balanceOf(address(lendingModule)) + WITHDRAW_AMOUNT); + } + + function testSetTokenSweepManager() public { + address newTokenSweepManager = makeAddr("NEW_TOKEN_SWEEP_MANAGER"); + + // Should revert if called by non-tokenSweepManager + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__OnlyTokenSweepManager.selector); + lendingModule.setTokenSweepManager(newTokenSweepManager); + + // Should revert if new address is zero + vm.startPrank(tokenSweepManager); + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__ZeroAddress.selector); + lendingModule.setTokenSweepManager(address(0)); + + // Should succeed with valid parameters + vm.expectEmit(true, false, false, true); + emit TokenSweepManagerUpdated(newTokenSweepManager); + lendingModule.setTokenSweepManager(newTokenSweepManager); + + assertEq(lendingModule.tokenSweepManager(), newTokenSweepManager); + + vm.stopPrank(); + } + + function testSweep() public { + ERC20Mock mockToken = new ERC20Mock(); + + // Should revert if called by non-tokenSweepManager + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__OnlyTokenSweepManager.selector); + lendingModule.sweep(address(mockToken), recipient); + + vm.startPrank(tokenSweepManager); + + // Should revert if token address is zero + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__ZeroAddress.selector); + lendingModule.sweep(address(0), recipient); + + // Should revert if recipient address is zero + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__ZeroAddress.selector); + lendingModule.sweep(address(mockToken), address(0)); + + // Should revert if trying to sweep vault token + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__sweep_VaultTokenCannotBeSweeped.selector); + lendingModule.sweep(address(vault), recipient); + + // Should not revert but also not transfer anything + uint256 initialRecipientBalance = mockToken.balanceOf(recipient); + lendingModule.sweep(address(mockToken), recipient); + + assertEq(mockToken.balanceOf(recipient), initialRecipientBalance); + + mockToken.mint(address(lendingModule), 100 ether); + + // Should succeed with valid parameters + uint256 moduleBalance = mockToken.balanceOf(address(lendingModule)); + + vm.expectEmit(true, true, false, true); + emit Sweep(address(mockToken), recipient, moduleBalance); + lendingModule.sweep(address(mockToken), recipient); + + assertEq(mockToken.balanceOf(recipient), initialRecipientBalance + moduleBalance); + assertEq(mockToken.balanceOf(address(lendingModule)), 0); + + vm.stopPrank(); + } + + function testIntegrationDepositAndWithdraw() public { + vm.startPrank(owner); + + // Initial state + assertEq(lendingModule.assetBalance(), 0); + + // Deposit + assetToken.approve(address(lendingModule), DEPOSIT_AMOUNT); + lendingModule.deposit(DEPOSIT_AMOUNT); + + // Verify deposit + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT); + + // Withdraw partial amount + lendingModule.withdraw(WITHDRAW_AMOUNT, recipient); + + // Verify withdrawal + assertEq(assetToken.balanceOf(recipient), WITHDRAW_AMOUNT); + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT - WITHDRAW_AMOUNT); + + // Withdraw remaining amount + lendingModule.withdraw(DEPOSIT_AMOUNT - WITHDRAW_AMOUNT, recipient); + + // Verify final state + assertEq(assetToken.balanceOf(recipient), DEPOSIT_AMOUNT); + assertEq(lendingModule.assetBalance(), 0); + + vm.stopPrank(); + } + + function testMultipleWithdrawals() public { + vm.startPrank(owner); + + // Deposit initial amount + assetToken.approve(address(lendingModule), DEPOSIT_AMOUNT); + lendingModule.deposit(DEPOSIT_AMOUNT); + + // Multiple withdrawals to different recipients + address recipient1 = makeAddr("RECIPIENT1"); + address recipient2 = makeAddr("RECIPIENT2"); + + lendingModule.withdraw(WITHDRAW_AMOUNT / 2, recipient1); + lendingModule.withdraw(WITHDRAW_AMOUNT / 2, recipient2); + + assertEq(assetToken.balanceOf(recipient1), WITHDRAW_AMOUNT / 2); + assertEq(assetToken.balanceOf(recipient2), WITHDRAW_AMOUNT / 2); + assertEq(lendingModule.assetBalance(), DEPOSIT_AMOUNT - WITHDRAW_AMOUNT); + + vm.stopPrank(); + } + + function testRevertWithdrawMoreThanDeposited() public { + vm.startPrank(owner); + + // Deposit small amount + assetToken.approve(address(lendingModule), 100 ether); + lendingModule.deposit(100 ether); + + // Try to withdraw more + vm.expectRevert(); + lendingModule.withdraw(100 ether + 1, recipient); + + vm.stopPrank(); + } + + function testMultipleTokenSweepManagerUpdates() public { + address newTokenSweepManager1 = makeAddr("NEW_TOKEN_SWEEP_MANAGER_1"); + address newTokenSweepManager2 = makeAddr("NEW_TOKEN_SWEEP_MANAGER_2"); + + // Initial manager can update + vm.startPrank(tokenSweepManager); + lendingModule.setTokenSweepManager(newTokenSweepManager1); + vm.stopPrank(); + assertEq(lendingModule.tokenSweepManager(), newTokenSweepManager1); + + // Old manager should now revert + vm.startPrank(tokenSweepManager); + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__OnlyTokenSweepManager.selector); + lendingModule.setTokenSweepManager(newTokenSweepManager2); + vm.stopPrank(); + + // New manager can update + vm.startPrank(newTokenSweepManager1); + lendingModule.setTokenSweepManager(newTokenSweepManager2); + vm.stopPrank(); + assertEq(lendingModule.tokenSweepManager(), newTokenSweepManager2); + + // Both previous managers should now revert + vm.startPrank(tokenSweepManager); + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__OnlyTokenSweepManager.selector); + lendingModule.setTokenSweepManager(tokenSweepManager); + vm.stopPrank(); + + vm.startPrank(newTokenSweepManager1); + vm.expectRevert(ERC4626LendingModule.ERC4626LendingModule__OnlyTokenSweepManager.selector); + lendingModule.setTokenSweepManager(tokenSweepManager); + vm.stopPrank(); + + // Only the latest manager can update + vm.startPrank(newTokenSweepManager2); + lendingModule.setTokenSweepManager(tokenSweepManager); + vm.stopPrank(); + assertEq(lendingModule.tokenSweepManager(), tokenSweepManager); + } + + function testSweepMultipleTokens() public { + ERC20Mock mockToken1 = new ERC20Mock(); + ERC20Mock mockToken2 = new ERC20Mock(); + ERC20Mock mockToken3 = new ERC20Mock(); + + mockToken1.mint(address(lendingModule), 100 ether); + mockToken2.mint(address(lendingModule), 200 ether); + mockToken3.mint(address(lendingModule), 300 ether); + + vm.startPrank(tokenSweepManager); + + // Sweep all tokens + lendingModule.sweep(address(mockToken1), recipient); + lendingModule.sweep(address(mockToken2), recipient); + lendingModule.sweep(address(mockToken3), recipient); + + assertEq(mockToken1.balanceOf(recipient), 100 ether); + assertEq(mockToken2.balanceOf(recipient), 200 ether); + assertEq(mockToken3.balanceOf(recipient), 300 ether); + + assertEq(mockToken1.balanceOf(address(lendingModule)), 0); + assertEq(mockToken2.balanceOf(address(lendingModule)), 0); + assertEq(mockToken3.balanceOf(address(lendingModule)), 0); + + vm.stopPrank(); + } +} diff --git a/test/MultiMarketLendingModule.t.sol b/test/MultiMarketLendingModule.t.sol new file mode 100644 index 0000000..183411c --- /dev/null +++ b/test/MultiMarketLendingModule.t.sol @@ -0,0 +1,1220 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; + +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +import {MultiMarketLendingModule} from "src/lending-modules/MultiMarketLendingModule.sol"; +import {MockERC4626LendingPool} from "src/mocks/MockERC4626LendingPool.sol"; +import {ERC4626LendingModule} from "src/lending-modules/ERC4626LendingModule.sol"; +import {ILendingModule} from "src/interfaces/ILendingModule.sol"; + +contract MultiMarketLendingModuleTest is Test { + MultiMarketLendingModule public multiLendingModule; + ERC20Mock public asset; + + // Mock lending modules + ERC4626LendingModule public lendingModule1; + ERC4626LendingModule public lendingModule2; + ERC4626LendingModule public lendingModule3; + + // Mock ERC4626 pools + MockERC4626LendingPool public mockPool1; + MockERC4626LendingPool public mockPool2; + MockERC4626LendingPool public mockPool3; + + // Test actors + address public owner = makeAddr("OWNER"); + address public manager = makeAddr("MANAGER"); + address public tokenSweepManager = makeAddr("TOKEN_SWEEP_MANAGER"); + address public user = makeAddr("USER"); + address public recipient = makeAddr("RECIPIENT"); + + // Constants + uint256 public constant BIPS = 10_000; + uint256 public constant INITIAL_BALANCE = 1_000_000e18; + + // Events for testing + event Initialized( + address[] lendingModuleArray, MultiMarketLendingModule.LendingModuleConfig[] lendingModuleConfigArray + ); + event DepositWeightsSet(uint16[] depositWeightBipsArray); + event WithdrawWeightsSet(uint16[] withdrawWeightBipsArray); + event TokenSweepManagerUpdated(address tokenSweepManager); + event Sweep(address indexed token, address indexed recipient, uint256 balance); + event ManagerFeeClaimed(address indexed recipient, uint256 amount); + + function setUp() public { + // Deploy asset token + asset = new ERC20Mock(); + + // Deploy MultiMarketLendingModule + vm.prank(owner); + multiLendingModule = new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 0); + + // Deploy mock ERC4626 pools + mockPool1 = new MockERC4626LendingPool(address(asset)); + mockPool2 = new MockERC4626LendingPool(address(asset)); + mockPool3 = new MockERC4626LendingPool(address(asset)); + + // Deploy ERC4626 lending modules + lendingModule1 = new ERC4626LendingModule(address(mockPool1), address(multiLendingModule), tokenSweepManager); + + lendingModule2 = new ERC4626LendingModule(address(mockPool2), address(multiLendingModule), tokenSweepManager); + + lendingModule3 = new ERC4626LendingModule(address(mockPool3), address(multiLendingModule), tokenSweepManager); + + // Fund test accounts + asset.mint(manager, INITIAL_BALANCE); + asset.mint(user, INITIAL_BALANCE); + + // Approve multiLendingModule to spend manager's tokens + vm.prank(manager); + asset.approve(address(multiLendingModule), type(uint256).max); + } + + /** + * INITIALIZATION TESTS + */ + function testConstructor() public { + // Test zero asset address + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + new MultiMarketLendingModule(address(0), owner, manager, tokenSweepManager, 0); + + // Test zero manager address + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + new MultiMarketLendingModule(address(asset), owner, address(0), tokenSweepManager, 0); + + // Test zero token sweep manager address + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + new MultiMarketLendingModule(address(asset), owner, manager, address(0), 0); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__InvalidManagerFeeBips.selector); + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 5_001); + + // Test constructor + MultiMarketLendingModule multiLendingModuleDeployment = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 10); + + assertEq(multiLendingModuleDeployment.asset(), address(asset)); + assertEq(multiLendingModuleDeployment.owner(), owner); + assertEq(multiLendingModuleDeployment.manager(), manager); + assertEq(multiLendingModuleDeployment.tokenSweepManager(), tokenSweepManager); + assertEq(multiLendingModuleDeployment.managerFeeBips(), 10); + } + + function testInitializeSuccess() public { + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule2); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 6000, withdrawWeightBips: 4000}); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 4000, withdrawWeightBips: 6000}); + + // Test zero lending module address + lendingModules[0] = address(0); + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + + lendingModules[0] = address(lendingModule1); + + vm.expectEmit(true, true, true, true); + emit Initialized(lendingModules, configs); + + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + + // Verify state + address[] memory storedModules = multiLendingModule.lendingModules(); + assertEq(storedModules.length, 2); + assertEq(storedModules[0], address(lendingModule1)); + assertEq(storedModules[1], address(lendingModule2)); + + // Verify configs + MultiMarketLendingModule.LendingModuleConfig memory config1 = + multiLendingModule.getLendingModuleConfig(address(lendingModule1)); + assertEq(config1.depositWeightBips, 6000); + assertEq(config1.withdrawWeightBips, 4000); + + // Test already initialized + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_AlreadyInitialized.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + function testInitializeInvalidArrayLengths() public { + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule2); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](1); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 10000, withdrawWeightBips: 10000}); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_InconsistentArrayLength.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + function testInitializeEmptyArray() public { + address[] memory lendingModules = new address[](0); + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](0); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_InvalidArrayLength.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + function testInitializeInvalidWeights() public { + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule2); + + // Invalid deposit weights (don't sum to 10000) + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 5000, withdrawWeightBips: 5000}); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 4000, // Total = 9000, not 10000 + withdrawWeightBips: 5000 + }); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_InvalidDepositWeights.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + + // Invalid withdraw weights (don't sum to 10000) + configs = new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 6000, withdrawWeightBips: 6000}); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 4000, + withdrawWeightBips: 5000 // Total = 11000, not 10000 + }); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_InvalidWithdrawWeights.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + function testInitializeInvalidOwner() public { + // Deploy lending module with wrong owner + ERC4626LendingModule wrongOwnerModule = new ERC4626LendingModule( + address(mockPool1), + address(this), // Wrong owner (should be multiLendingModule) + tokenSweepManager + ); + + address[] memory lendingModules = new address[](1); + lendingModules[0] = address(wrongOwnerModule); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](1); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 10000, withdrawWeightBips: 10000}); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_InvalidOwner.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + function testInitializeDuplicateLendingModule() public { + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule1); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 5000, withdrawWeightBips: 6000}); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({depositWeightBips: 5000, withdrawWeightBips: 4000}); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__initialize_DuplicateLendingModule.selector); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + /** + * WITHDRAW AND DEPOSIT WEIGHTS TESTS + */ + function testSetDepositWeights() public { + _initializeDefaultSetup(); + + // Test invalid array length + uint16[] memory newWeights = new uint16[](3); + newWeights[0] = 3000; + newWeights[1] = 4000; + newWeights[2] = 3000; + + vm.expectRevert( + MultiMarketLendingModule.MultiMarketLendingModule__setDepositWeights_InvalidArrayLength.selector + ); + vm.prank(owner); + multiLendingModule.setDepositWeights(newWeights); + + // Test invalid sum of weights + newWeights = new uint16[](2); + newWeights[0] = 3000; + newWeights[1] = 6000; // Sum = 9000, not 10000 + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__setDepositWeights_InvalidWeights.selector); + vm.prank(owner); + multiLendingModule.setDepositWeights(newWeights); + + // Test valid weights + + newWeights = new uint16[](2); + newWeights[0] = 3000; + newWeights[1] = 7000; + + vm.expectEmit(true, true, true, true); + emit DepositWeightsSet(newWeights); + + vm.prank(owner); + multiLendingModule.setDepositWeights(newWeights); + + // Verify updated weights + MultiMarketLendingModule.LendingModuleConfig memory config1 = + multiLendingModule.getLendingModuleConfig(address(lendingModule1)); + assertEq(config1.depositWeightBips, 3000); + + MultiMarketLendingModule.LendingModuleConfig memory config2 = + multiLendingModule.getLendingModuleConfig(address(lendingModule2)); + assertEq(config2.depositWeightBips, 7000); + } + + function testSetWithdrawWeights() public { + _initializeDefaultSetup(); + + // Invalid array length + uint16[] memory newWeights = new uint16[](3); + newWeights[0] = 3000; + newWeights[1] = 4000; + newWeights[2] = 3000; + + vm.expectRevert( + MultiMarketLendingModule.MultiMarketLendingModule__setWithdrawWeights_InvalidArrayLength.selector + ); + vm.prank(owner); + multiLendingModule.setWithdrawWeights(newWeights); + + // Invalid withdraw weights (don't sum to 10000) + newWeights = new uint16[](2); + newWeights[0] = 3000; + newWeights[1] = 6000; // Sum = 9000, not 10000 + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__setWithdrawWeights_InvalidWeights.selector); + vm.prank(owner); + multiLendingModule.setWithdrawWeights(newWeights); + + // Valid withdraw weights + + newWeights[0] = 8000; + newWeights[1] = 2000; + + vm.expectEmit(true, true, true, true); + emit WithdrawWeightsSet(newWeights); + + vm.prank(owner); + multiLendingModule.setWithdrawWeights(newWeights); + + // Verify updated weights + MultiMarketLendingModule.LendingModuleConfig memory config1 = + multiLendingModule.getLendingModuleConfig(address(lendingModule1)); + assertEq(config1.withdrawWeightBips, 8000); + } + + /** + * TOKEN SWEEP MANAGER TESTS + */ + function testSetTokenSweepManager() public { + // Test zero address + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + vm.prank(tokenSweepManager); + multiLendingModule.setTokenSweepManager(address(0)); + + address newManager = makeAddr("NEW_MANAGER"); + + // Test only token sweep manager + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__OnlyTokenSweepManager.selector); + vm.prank(owner); + multiLendingModule.setTokenSweepManager(newManager); + + vm.expectEmit(true, true, true, true); + emit TokenSweepManagerUpdated(newManager); + + vm.prank(tokenSweepManager); + multiLendingModule.setTokenSweepManager(newManager); + + assertEq(multiLendingModule.tokenSweepManager(), newManager); + } + + /** + * DEPOSIT TESTS + */ + function testDeposit() public { + // Test not initialized + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__NotInitialized.selector); + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + _initializeDefaultSetup(); + + // Test zero amount + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__deposit_InvalidAmount.selector); + vm.prank(manager); + multiLendingModule.deposit(0); + + // Test only manager + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__OnlyManager.selector); + vm.prank(user); + multiLendingModule.deposit(1000e18); + + // Test deposit + + uint256 depositAmount = 1000e18; + uint256 managerBalanceBefore = asset.balanceOf(manager); + + vm.prank(manager); + multiLendingModule.deposit(depositAmount); + + // Check manager balance decreased + assertEq(asset.balanceOf(manager), managerBalanceBefore - depositAmount); + + // Check total asset balance in lending modules + assertEq(multiLendingModule.assetBalance(), depositAmount); + + // Check distribution according to weights (60% to module1, 40% to module2) + assertEq(lendingModule1.assetBalance(), 600e18); + assertEq(lendingModule2.assetBalance(), 400e18); + } + + /** + * WITHDRAW TESTS + */ + function testWithdraw() public { + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__NotInitialized.selector); + vm.prank(manager); + multiLendingModule.withdraw(1000e18, recipient); + + _initializeDefaultSetup(); + + // Test only manager + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__OnlyManager.selector); + vm.prank(user); + multiLendingModule.withdraw(1000e18, recipient); + + // Test zero amount + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__withdraw_InvalidAmount.selector); + vm.prank(manager); + multiLendingModule.withdraw(0, recipient); + + // Test zero recipient + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + vm.prank(manager); + multiLendingModule.withdraw(1000e18, address(0)); + + // First deposit + uint256 depositAmount = 1000e18; + vm.prank(manager); + multiLendingModule.deposit(depositAmount); + + // Then withdraw + uint256 withdrawAmount = 600e18; + uint256 recipientBalanceBefore = asset.balanceOf(recipient); + + uint256 snapshot = vm.snapshotState(); + + vm.prank(manager); + multiLendingModule.withdraw(withdrawAmount, recipient); + + // Check recipient received tokens + assertEq(asset.balanceOf(recipient), recipientBalanceBefore + withdrawAmount); + + // Check remaining balance in lending modules + assertEq(multiLendingModule.assetBalance(), depositAmount - withdrawAmount); + + vm.revertToState(snapshot); + + mockPool1.setIsCompromised(true); + + vm.prank(manager); + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__withdraw_InsufficientAmountReceived.selector); + multiLendingModule.withdraw(withdrawAmount, recipient); + } + + /** + * SWEEP TESTS + */ + function testSweep() public { + // Send some random tokens to the contract + ERC20Mock randomToken = new ERC20Mock(); + randomToken.mint(address(multiLendingModule), 500e18); + + // Test only token sweep manager + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__OnlyTokenSweepManager.selector); + vm.prank(owner); + multiLendingModule.sweep(address(randomToken), recipient); + + // Test zero address + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + vm.prank(tokenSweepManager); + multiLendingModule.sweep(address(0), recipient); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + vm.prank(tokenSweepManager); + multiLendingModule.sweep(address(randomToken), address(0)); + + // Test sweep + + uint256 recipientBalanceBefore = randomToken.balanceOf(recipient); + + vm.expectEmit(true, true, true, true); + emit Sweep(address(randomToken), recipient, 500e18); + + vm.prank(tokenSweepManager); + multiLendingModule.sweep(address(randomToken), recipient); + + assertEq(randomToken.balanceOf(recipient), recipientBalanceBefore + 500e18); + assertEq(randomToken.balanceOf(address(multiLendingModule)), 0); + } + + /** + * MANAGER FEE TESTS + */ + function testManagerFeeClaimable() public { + _initializeDefaultSetup(); + + // Initially no fees claimable + assertEq(multiLendingModule.managerFeeClaimable(), 0); + + // Deposit some amount + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + // Still no fees since no yield generated + assertEq(multiLendingModule.managerFeeClaimable(), 0); + + // Simulate yield generation + asset.mint(address(mockPool1), 100e18); + asset.mint(address(mockPool2), 50e18); + + // Now there should be claimable fees + uint256 expectedFee = 0; // managerFeeBips is 0 in default setup + assertEq(multiLendingModule.managerFeeClaimable(), expectedFee); + } + + function testManagerFeeClaimableWithFees() public { + // Deploy with manager fees + vm.prank(owner); + MultiMarketLendingModule multiLendingModuleWithFees = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 1000); // 10% fee + + _initializeLendingModule(multiLendingModuleWithFees); + + // Deposit + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(multiLendingModuleWithFees), type(uint256).max); + + vm.prank(manager); + multiLendingModuleWithFees.deposit(1000e18); + + // Simulate yield generation + asset.mint(address(mockPool1), 100e18); + asset.mint(address(mockPool2), 50e18); + + // Calculate expected fee: 10% of 150e18 yield = 15e18 + uint256 expectedFee = (150e18 * 1000) / 10000; + assertEq(multiLendingModuleWithFees.managerFeeClaimable(), expectedFee); + } + + function testClaimManagerFee() public { + // Deploy with manager fees + vm.prank(owner); + MultiMarketLendingModule multiLendingModuleWithFees = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 1000); // 10% fee + + _initializeLendingModule(multiLendingModuleWithFees); + + // Setup balances and approvals + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(multiLendingModuleWithFees), type(uint256).max); + + // Deposit + vm.prank(manager); + multiLendingModuleWithFees.deposit(1000e18); + + // Simulate yield + asset.mint(address(mockPool1), 100e18); + asset.mint(address(mockPool2), 50e18); + + uint256 recipientBalanceBefore = asset.balanceOf(recipient); + uint256 expectedFee = (150e18 * 1000) / 10000; // 15e18 + + // Test zero address recipient + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__ZeroAddress.selector); + vm.prank(owner); + multiLendingModuleWithFees.claimManagerFee(address(0)); + + // Test only owner + vm.expectRevert(); + vm.prank(manager); + multiLendingModuleWithFees.claimManagerFee(recipient); + + // Claim fees + vm.expectEmit(true, false, false, true); + emit ManagerFeeClaimed(recipient, expectedFee); + + vm.prank(owner); + multiLendingModuleWithFees.claimManagerFee(recipient); + + assertEq(asset.balanceOf(recipient), recipientBalanceBefore + expectedFee); + assertEq(multiLendingModuleWithFees.totalManagerClaimed(), expectedFee); + assertEq(multiLendingModuleWithFees.managerFeeClaimable(), 0); + } + + function testClaimManagerFeeNoFeesAvailable() public { + _initializeDefaultSetup(); + + vm.prank(owner); + multiLendingModule.claimManagerFee(recipient); + + // Should not revert but also not transfer anything + assertEq(asset.balanceOf(recipient), 0); + assertEq(multiLendingModule.totalManagerClaimed(), 0); + } + + function testAssetBalanceAfterManagerFeeClaim() public { + // Deploy with manager fees + vm.prank(owner); + MultiMarketLendingModule multiLendingModuleWithFees = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 2000); // 20% fee + + _initializeLendingModule(multiLendingModuleWithFees); + + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(multiLendingModuleWithFees), type(uint256).max); + + vm.prank(manager); + multiLendingModuleWithFees.deposit(1000e18); + + // Initial asset balance should be 1000e18 + assertEq(multiLendingModuleWithFees.assetBalance(), 1000e18); + + // Generate yield + asset.mint(address(mockPool1), 200e18); + asset.mint(address(mockPool2), 100e18); + + // Asset balance should be principal + yield - manager fee + // Total balance: 1000 + 300 = 1300 + // Manager fee: 300 * 20% = 60 + // Asset balance: 1300 - 60 = 1240 + uint256 expectedAssetBalance = 1000e18 + 300e18 - 60e18; + assertEq(multiLendingModuleWithFees.assetBalance(), expectedAssetBalance); + + // Claim fees + vm.prank(owner); + multiLendingModuleWithFees.claimManagerFee(recipient); + + // After claiming fees, the 60e18 was actually withdrawn from the pools + // So total balance in pools is now: 1300e18 - 60e18 = 1240e18 + // Asset balance = total balance - remaining manager fees = 1240e18 - 0 = 1240e18 + assertEq(multiLendingModuleWithFees.assetBalance(), 1240e18); + } + + /** + * COMPREHENSIVE ERROR CONDITION TESTS + */ + function testWithdrawInsufficientBalance() public { + _initializeDefaultSetup(); + + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + // Try to withdraw more than available + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__withdraw_InsufficientBalance.selector); + vm.prank(manager); + multiLendingModule.withdraw(1001e18, recipient); + } + + function testWithdrawExcessiveAmountReceived() public { + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule2); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 5000, // 50% + withdrawWeightBips: 5000 // 50% + }); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 5000, // 50% + withdrawWeightBips: 5000 // 50% + }); + + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + mockPool1.setIsExcessTransfer(true); + + // recipient cannot receive more than max withdrawable amount + vm.prank(manager); + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__withdraw_ExcessiveAmountReceived.selector); + multiLendingModule.withdraw(1000e18, recipient); + } + + function testDepositPartialDepositNotAllowed() public { + _initializeDefaultSetup(); + + mockPool1.setIsPartialDeposit(true); + mockPool1.setPartialDepositRecipient(address(multiLendingModule)); + + vm.prank(manager); + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__deposit_PartialDepositNotAllowed.selector); + multiLendingModule.deposit(1000e18); + } + + /** + * BOUNDARY CONDITIONS AND PRECISION TESTS + */ + function testManagerFeeMaximum() public { + // Test with maximum allowed manager fee (50%) + vm.prank(owner); + MultiMarketLendingModule multiLendingModuleMaxFee = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 5000); + + _initializeLendingModule(multiLendingModuleMaxFee); + + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(multiLendingModuleMaxFee), type(uint256).max); + + vm.prank(manager); + multiLendingModuleMaxFee.deposit(1000e18); + + // Generate yield + asset.mint(address(mockPool1), 100e18); + + // Manager fee should be 50% of yield + uint256 expectedFee = (100e18 * 5000) / 10000; + assertEq(multiLendingModuleMaxFee.managerFeeClaimable(), expectedFee); + + vm.prank(owner); + multiLendingModuleMaxFee.claimManagerFee(recipient); + + assertEq(asset.balanceOf(recipient), 100e18 - expectedFee); + assertEq(multiLendingModuleMaxFee.totalManagerClaimed(), expectedFee); + assertEq(multiLendingModuleMaxFee.managerFeeClaimable(), 0); + } + + function testPrecisionInWeightedDistribution() public { + // Test with weights that might cause precision issues + address[] memory lendingModules = new address[](3); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule2); + lendingModules[2] = address(lendingModule3); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](3); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 3333, // 33.33% + withdrawWeightBips: 3333 + }); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 3333, // 33.33% + withdrawWeightBips: 3333 + }); + configs[2] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 3334, // 33.34% (to sum to 10000) + withdrawWeightBips: 3334 + }); + + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + + // Test deposit with amount that doesn't divide evenly + vm.prank(manager); + multiLendingModule.deposit(100e18); + + // Verify total balance is maintained + assertEq(multiLendingModule.assetBalance(), 100e18); + } + + function testSweepAssetToken() public { + _initializeDefaultSetup(); + + // Send asset tokens directly to the contract (simulating stuck tokens) + asset.mint(address(multiLendingModule), 500e18); + + uint256 recipientBalanceBefore = asset.balanceOf(recipient); + + vm.prank(tokenSweepManager); + multiLendingModule.sweep(address(asset), recipient); + + assertEq(asset.balanceOf(recipient), recipientBalanceBefore + 500e18); + assertEq(asset.balanceOf(address(multiLendingModule)), 0); + } + + function testSweepNonOwnerRevert() public { + ERC20Mock randomToken = new ERC20Mock(); + randomToken.mint(address(multiLendingModule), 100e18); + + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__OnlyTokenSweepManager.selector); + vm.prank(owner); + multiLendingModule.sweep(address(randomToken), recipient); + } + + /** + * INTEGRATION AND COMPLEX SCENARIO TESTS + */ + function testComplexMultipleOperationsWithFees() public { + // Deploy with fees + vm.prank(owner); + MultiMarketLendingModule multiLendingModuleWithFees = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 1500); // 15% fee + + _initializeLendingModule(multiLendingModuleWithFees); + + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(multiLendingModuleWithFees), type(uint256).max); + + // Multiple deposits + vm.startPrank(manager); + multiLendingModuleWithFees.deposit(1000e18); + multiLendingModuleWithFees.deposit(500e18); + vm.stopPrank(); + + // Generate yield + asset.mint(address(mockPool1), 150e18); + asset.mint(address(mockPool2), 75e18); + + // Partial withdrawal + vm.prank(manager); + multiLendingModuleWithFees.withdraw(200e18, recipient); + + // Claim fees + vm.prank(owner); + multiLendingModuleWithFees.claimManagerFee(recipient); + + // More yield + asset.mint(address(mockPool1), 50e18); + + // Final calculations should be consistent + uint256 finalBalance = multiLendingModuleWithFees.assetBalance(); + uint256 finalClaimable = multiLendingModuleWithFees.managerFeeClaimable(); + + // Verify total tracking is correct + assertTrue(finalBalance > 0); + assertTrue(finalClaimable >= 0); + } + + function testWeightRebalancing() public { + _initializeDefaultSetup(); + + // Initial deposit + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + // Change weights + uint16[] memory newDepositWeights = new uint16[](2); + newDepositWeights[0] = 8000; // 80% + newDepositWeights[1] = 2000; // 20% + + vm.prank(owner); + multiLendingModule.setDepositWeights(newDepositWeights); + + // New deposit should use new weights + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + // Verify distribution + uint256 module1Balance = lendingModule1.assetBalance(); + uint256 module2Balance = lendingModule2.assetBalance(); + + // First deposit: 600 + 400, Second deposit: 800 + 200 + assertEq(module1Balance, 600e18 + 800e18); + assertEq(module2Balance, 400e18 + 200e18); + + // Change weights to 100% for module1 and 0% for module2 + newDepositWeights[0] = 10000; // 100% + newDepositWeights[1] = 0; // 0% + + vm.prank(owner); + multiLendingModule.setDepositWeights(newDepositWeights); + + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + module1Balance = lendingModule1.assetBalance(); + module2Balance = lendingModule2.assetBalance(); + + // Third deposit: 1000 + 0 + assertEq(module1Balance, 600e18 + 800e18 + 1000e18); + assertEq(module2Balance, 400e18 + 200e18); + + // Change weights to 0% for module1 and 100% for module2 + newDepositWeights[0] = 0; // 0% + newDepositWeights[1] = 10000; // 100% + + vm.prank(owner); + multiLendingModule.setDepositWeights(newDepositWeights); + + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + module1Balance = lendingModule1.assetBalance(); + module2Balance = lendingModule2.assetBalance(); + + // Fourth deposit: 0 + 1000 + assertEq(module1Balance, 600e18 + 800e18 + 1000e18); + assertEq(module2Balance, 400e18 + 200e18 + 1000e18); + } + + /** + * EDGE CASES AND INTEGRATION TESTS + */ + function testMultipleDepositsAndWithdrawals() public { + _initializeDefaultSetup(); + + // Multiple deposits + vm.startPrank(manager); + multiLendingModule.deposit(1000e18); + multiLendingModule.deposit(500e18); + multiLendingModule.deposit(300e18); + vm.stopPrank(); + + assertEq(multiLendingModule.assetBalance(), 1800e18); + + // Multiple withdrawals + vm.startPrank(manager); + multiLendingModule.withdraw(200e18, recipient); + multiLendingModule.withdraw(100e18, recipient); + vm.stopPrank(); + + assertEq(multiLendingModule.assetBalance(), 1500e18); + assertEq(asset.balanceOf(recipient), 300e18); + } + + function testAssetBalanceWithYield() public { + _initializeDefaultSetup(); + + // Deposit + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + // Simulate yield generation in underlying pools + asset.mint(address(mockPool1), 100e18); + asset.mint(address(mockPool2), 50e18); + + // Asset balance should include yield + uint256 expectedBalance = 1000e18 + 100e18 + 50e18; + assertEq(multiLendingModule.assetBalance(), expectedBalance); + } + + function testMaxLendingModules() public { + // Deploy maximum number of lending modules + address[] memory lendingModules = new address[](10); + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](10); + + for (uint256 i = 0; i < 10; i++) { + MockERC4626LendingPool pool = new MockERC4626LendingPool(address(asset)); + + ERC4626LendingModule module = + new ERC4626LendingModule(address(pool), address(multiLendingModule), tokenSweepManager); + + lendingModules[i] = address(module); + configs[i] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 1000, // 10% each + withdrawWeightBips: 1000 + }); + } + + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + + assertEq(multiLendingModule.lendingModules().length, 10); + } + + function testExceedMaxLendingModules() public { + address[] memory lendingModules = new address[](11); // Exceed limit + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](11); + + vm.expectRevert( + MultiMarketLendingModule.MultiMarketLendingModule__initialize_ExceededMaxLendingModules.selector + ); + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } + + /** + * ADDITIONAL FUZZ TESTS (constrained for reliability) + */ + function testFuzzSimpleManagerFee(uint8 feeBipsPercent) public { + vm.assume(feeBipsPercent <= 50); // Max 5% for simplicity + uint256 feeBips = uint256(feeBipsPercent) * 100; // Convert to actual bips + + // Deploy with custom fee + vm.prank(owner); + MultiMarketLendingModule fuzzModule = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, feeBips); + + _initializeLendingModule(fuzzModule); + + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(fuzzModule), type(uint256).max); + + // Simple fixed amounts + uint256 principal = 1000e18; + uint256 yield = 100e18; + + // Deposit principal + vm.prank(manager); + fuzzModule.deposit(principal); + + // Generate yield by minting to the first pool + asset.mint(address(mockPool1), yield); + + // Calculate expected fee + uint256 expectedFee = Math.mulDiv(yield, feeBips, BIPS); + assertEq(fuzzModule.managerFeeClaimable(), expectedFee); + + // Asset balance should be principal + yield - fee + uint256 expectedAssetBalance = principal + yield - expectedFee; + assertEq(fuzzModule.assetBalance(), expectedAssetBalance); + + // Claim manager fees + vm.prank(owner); + fuzzModule.claimManagerFee(recipient); + + assertEq(asset.balanceOf(recipient), expectedFee); + assertEq(fuzzModule.totalManagerClaimed(), expectedFee); + assertEq(fuzzModule.managerFeeClaimable(), 0); + // Asset balance should be principal + yield - expectedFee + assertEq(fuzzModule.assetBalance(), principal + yield - expectedFee); + } + + /** + * ADDITIONAL EDGE CASE TESTS + */ + function testZeroAmountOperations() public { + _initializeDefaultSetup(); + + // Zero deposit should revert + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__deposit_InvalidAmount.selector); + vm.prank(manager); + multiLendingModule.deposit(0); + + // Zero withdrawal should revert + vm.expectRevert(MultiMarketLendingModule.MultiMarketLendingModule__withdraw_InvalidAmount.selector); + vm.prank(manager); + multiLendingModule.withdraw(0, recipient); + } + + function testManagerFeeEdgeCases() public { + // Test with zero fee + _initializeDefaultSetup(); + + vm.prank(manager); + multiLendingModule.deposit(1000e18); + + // Generate yield + asset.mint(address(mockPool1), 100e18); + + // Should have zero claimable fees + assertEq(multiLendingModule.managerFeeClaimable(), 0); + + // Asset balance should include all yield + assertEq(multiLendingModule.assetBalance(), 1100e18); + } + + function testTotalPrincipalTracking() public { + _initializeDefaultSetup(); + + // Initially zero + assertEq(multiLendingModule.totalPrincipal(), 0); + + // After deposit + vm.prank(manager); + multiLendingModule.deposit(1000e18); + assertEq(multiLendingModule.totalPrincipal(), 1000e18); + + // After withdrawal + vm.prank(manager); + multiLendingModule.withdraw(200e18, recipient); + assertEq(multiLendingModule.totalPrincipal(), 800e18); + + // Multiple operations + vm.startPrank(manager); + multiLendingModule.deposit(500e18); + assertEq(multiLendingModule.totalPrincipal(), 1300e18); + + multiLendingModule.withdraw(300e18, recipient); + assertEq(multiLendingModule.totalPrincipal(), 1000e18); + vm.stopPrank(); + } + + function testLendingModulesView() public { + _initializeDefaultSetup(); + + address[] memory modules = multiLendingModule.lendingModules(); + assertEq(modules.length, 2); + assertEq(modules[0], address(lendingModule1)); + assertEq(modules[1], address(lendingModule2)); + } + + function testGetLendingModuleConfig() public { + _initializeDefaultSetup(); + + MultiMarketLendingModule.LendingModuleConfig memory config1 = + multiLendingModule.getLendingModuleConfig(address(lendingModule1)); + assertEq(config1.depositWeightBips, 6000); + assertEq(config1.withdrawWeightBips, 5000); + + MultiMarketLendingModule.LendingModuleConfig memory config2 = + multiLendingModule.getLendingModuleConfig(address(lendingModule2)); + assertEq(config2.depositWeightBips, 4000); + assertEq(config2.withdrawWeightBips, 5000); + } + + function testEmptyBalanceOperations() public { + _initializeDefaultSetup(); + + // Test assetBalance with no deposits + assertEq(multiLendingModule.assetBalance(), 0); + assertEq(multiLendingModule.managerFeeClaimable(), 0); + assertEq(multiLendingModule.totalPrincipal(), 0); + assertEq(multiLendingModule.totalManagerClaimed(), 0); + } + + function testAssetBalanceEdgeCases() public { + // Deploy with fees + vm.prank(owner); + MultiMarketLendingModule moduleWithFees = + new MultiMarketLendingModule(address(asset), owner, manager, tokenSweepManager, 1000); + + _initializeLendingModule(moduleWithFees); + + asset.mint(manager, INITIAL_BALANCE); + vm.prank(manager); + asset.approve(address(moduleWithFees), type(uint256).max); + + // Test edge case when there's minimal principal + vm.prank(manager); + moduleWithFees.deposit(1e18); // Very small deposit + + // Generate tiny yield + asset.mint(address(mockPool1), 1000); + + // With very small amounts, precision should still work + uint256 assetBalance = moduleWithFees.assetBalance(); + assertTrue(assetBalance > 0); + + uint256 managerFee = moduleWithFees.managerFeeClaimable(); + assertTrue(managerFee < assetBalance); + } + + /** + * FUZZ TESTS + */ + function testFuzzDeposit(uint256 amount) public { + vm.assume(amount > 0 && amount <= INITIAL_BALANCE); + _initializeDefaultSetup(); + + vm.prank(manager); + multiLendingModule.deposit(amount); + + assertEq(multiLendingModule.assetBalance(), amount); + + // Check proportional distribution + uint256 expectedModule1 = Math.mulDiv(amount, 6000, BIPS, Math.Rounding.Ceil); + uint256 expectedModule2 = amount - expectedModule1; // Remaining goes to module2 + + // There can be rounding errors + assertEq(lendingModule1.assetBalance(), expectedModule1); + assertEq(lendingModule2.assetBalance(), expectedModule2); + } + + function testFuzzWithdraw(uint256 depositAmount, uint256 withdrawAmount) public { + vm.assume(depositAmount > 1000 && depositAmount <= INITIAL_BALANCE); + vm.assume(withdrawAmount > 0 && withdrawAmount <= (depositAmount * 4000) / 10000); + + _initializeDefaultSetup(); + + // Deposit first + vm.prank(manager); + multiLendingModule.deposit(depositAmount); + + // Then withdraw + vm.prank(manager); + multiLendingModule.withdraw(withdrawAmount, recipient); + + assertEq(asset.balanceOf(recipient), withdrawAmount); + assertEq(multiLendingModule.assetBalance(), depositAmount - withdrawAmount); + } + + /** + * HELPER FUNCTIONS + */ + function _initializeLendingModule(MultiMarketLendingModule _module) internal { + // Deploy new lending modules with the correct owner (the _module itself) + ERC4626LendingModule newLendingModule1 = + new ERC4626LendingModule(address(mockPool1), address(_module), tokenSweepManager); + ERC4626LendingModule newLendingModule2 = + new ERC4626LendingModule(address(mockPool2), address(_module), tokenSweepManager); + + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(newLendingModule1); + lendingModules[1] = address(newLendingModule2); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 6000, // 60% + withdrawWeightBips: 5000 // 50% + }); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 4000, // 40% + withdrawWeightBips: 5000 // 50% + }); + + vm.prank(owner); + _module.initialize(lendingModules, configs); + } + + function _initializeDefaultSetup() internal { + address[] memory lendingModules = new address[](2); + lendingModules[0] = address(lendingModule1); + lendingModules[1] = address(lendingModule2); + + MultiMarketLendingModule.LendingModuleConfig[] memory configs = + new MultiMarketLendingModule.LendingModuleConfig[](2); + configs[0] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 6000, // 60% + withdrawWeightBips: 5000 // 50% + }); + configs[1] = MultiMarketLendingModule.LendingModuleConfig({ + depositWeightBips: 4000, // 40% + withdrawWeightBips: 5000 // 50% + }); + + vm.prank(owner); + multiLendingModule.initialize(lendingModules, configs); + } +} diff --git a/test/MultiMarketLendingModuleKeeper.t.sol b/test/MultiMarketLendingModuleKeeper.t.sol new file mode 100644 index 0000000..15df422 --- /dev/null +++ b/test/MultiMarketLendingModuleKeeper.t.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {MultiMarketLendingModuleKeeper} from "src/owner/MultiMarketLendingModuleKeeper.sol"; +import {MultiMarketLendingModuleManager} from "src/owner/MultiMarketLendingModuleManager.sol"; + +contract MultiMarketLendingModuleKeeperTest is Test { + MultiMarketLendingModuleKeeper keeper; + MultiMarketLendingModuleManager manager; + + address public keeperAccount1 = makeAddr("KEEPER_ACCOUNT_1"); + address public keeperAccount2 = makeAddr("KEEPER_ACCOUNT_2"); + + function setUp() public { + keeper = new MultiMarketLendingModuleKeeper(address(this)); + assertEq(keeper.owner(), address(this)); + + manager = new MultiMarketLendingModuleManager(address(this), address(keeper)); + assertEq(manager.owner(), address(this)); + assertEq(manager.keeper(), address(keeper)); + + keeper.setKeeper(keeperAccount1); + assertTrue(keeper.isKeeper(keeperAccount1)); + assertFalse(keeper.isKeeper(keeperAccount2)); + } + + /// MultiMarketLendingModule mock functions /// + + function acceptOwnership() external {} + + function setDepositWeights(uint16[] memory _depositWeightBipsArray) external {} + + function setWithdrawWeights(uint16[] memory _withdrawWeightBipsArray) external {} + + function claimManagerFee(address _recipient) external {} + + /// End of MultiMarketLendingModule mock functions /// + + function testDeployments() public { + vm.expectRevert(); + new MultiMarketLendingModuleKeeper(address(0)); + + MultiMarketLendingModuleKeeper keeperDeployment = new MultiMarketLendingModuleKeeper(address(this)); + assertEq(keeperDeployment.owner(), address(this)); + + vm.expectRevert(); + new MultiMarketLendingModuleManager(address(0), address(keeperDeployment)); + + vm.expectRevert(MultiMarketLendingModuleManager.MultiMarketLendingModuleManager__ZeroAddress.selector); + new MultiMarketLendingModuleManager(address(this), address(0)); + + MultiMarketLendingModuleManager managerDeployment = + new MultiMarketLendingModuleManager(address(this), address(keeperDeployment)); + assertEq(managerDeployment.owner(), address(this)); + assertEq(managerDeployment.keeper(), address(keeperDeployment)); + } + + function testKeeperWhitelist() public { + vm.expectRevert(MultiMarketLendingModuleKeeper.MultiMarketLendingModuleKeeper__ZeroAddress.selector); + keeper.setKeeper(address(0)); + + keeper.setKeeper(keeperAccount2); + assertTrue(keeper.isKeeper(keeperAccount2)); + + vm.expectRevert(MultiMarketLendingModuleKeeper.MultiMarketLendingModuleKeeper__ZeroAddress.selector); + keeper.removeKeeper(address(0)); + + keeper.removeKeeper(keeperAccount2); + assertFalse(keeper.isKeeper(keeperAccount2)); + + vm.expectRevert(MultiMarketLendingModuleManager.MultiMarketLendingModuleManager__ZeroAddress.selector); + manager.setKeeper(address(0)); + + manager.setKeeper(keeperAccount2); + assertEq(manager.keeper(), keeperAccount2); + } + + function testManagerContract__KeeperFunctions() public { + address lendingModule = address(this); + + // Only keeper can call the following functions + + vm.expectRevert(MultiMarketLendingModuleManager.MultiMarketLendingModuleManager__OnlyKeeper.selector); + manager.setDepositWeights(lendingModule, new uint16[](0)); + + vm.prank(address(keeper)); + manager.setDepositWeights(lendingModule, new uint16[](0)); + + vm.expectRevert(MultiMarketLendingModuleManager.MultiMarketLendingModuleManager__OnlyKeeper.selector); + manager.setWithdrawWeights(lendingModule, new uint16[](0)); + + vm.prank(address(keeper)); + manager.setWithdrawWeights(lendingModule, new uint16[](0)); + + vm.expectRevert(MultiMarketLendingModuleManager.MultiMarketLendingModuleManager__OnlyKeeper.selector); + manager.claimManagerFee(lendingModule, address(this)); + + vm.prank(address(keeper)); + manager.claimManagerFee(lendingModule, address(this)); + + // Keeper cannot call owner restricted function + vm.prank(address(keeper)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(keeper))); + manager.call(lendingModule, new bytes(0)); + } + + function testManagerContract__OwnerFunctions() public { + address lendingModule = address(this); + + vm.prank(address(keeper)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(keeper))); + manager.call(lendingModule, new bytes(0)); + + manager.acceptOwnership(lendingModule); + + // owner can call the same functions as keeper's via `call`, + // given its increased access privilleges + + manager.call(lendingModule, abi.encodeWithSelector(this.setDepositWeights.selector, new uint16[](0))); + + manager.call(lendingModule, abi.encodeWithSelector(this.setWithdrawWeights.selector, new uint16[](0))); + + manager.call(lendingModule, abi.encodeWithSelector(this.claimManagerFee.selector, address(this))); + } + + function testKeeperContract__call() public { + address lendingModule = address(this); + + vm.expectRevert(MultiMarketLendingModuleKeeper.MultiMarketLendingModuleKeeper__call_onlyKeeper.selector); + vm.prank(keeperAccount2); + keeper.call(address(manager), new bytes(0)); + + vm.startPrank(keeperAccount1); + + // The following functions can be called by a whitelisted keeper role + + keeper.call( + address(manager), + abi.encodeWithSelector( + MultiMarketLendingModuleManager.setDepositWeights.selector, lendingModule, new uint16[](0) + ) + ); + + keeper.call( + address(manager), + abi.encodeWithSelector( + MultiMarketLendingModuleManager.setWithdrawWeights.selector, lendingModule, new uint16[](0) + ) + ); + + keeper.call( + address(manager), + abi.encodeWithSelector( + MultiMarketLendingModuleManager.claimManagerFee.selector, lendingModule, address(this) + ) + ); + + // `call` from WithdrawalModuleManager cannot be called by keeper contract + + vm.expectRevert(MultiMarketLendingModuleKeeper.MultiMarketLendingModuleKeeper__call_callFailed.selector); + keeper.call( + address(manager), + abi.encodeWithSelector(MultiMarketLendingModuleManager.call.selector, lendingModule, new bytes(0)) + ); + } +} diff --git a/test/RebalanceModule.t.sol b/test/RebalanceModule.t.sol new file mode 100644 index 0000000..7c94b3a --- /dev/null +++ b/test/RebalanceModule.t.sol @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {RebalanceModule} from "src/RebalanceModule.sol"; +import {IRebalanceModule} from "src/interfaces/IRebalanceModule.sol"; + +contract MockTargetContract { + ERC20Mock public token0; + ERC20Mock public token1; + + bool public partialFill; + + constructor(address _token0, address _token1) { + token0 = ERC20Mock(_token0); + token1 = ERC20Mock(_token1); + } + + function setPartialFill(bool _partialFill) external { + partialFill = _partialFill; + } + + function swapToken0ForToken1(uint256 amountToken0) external returns (uint256 amountToken1) { + if (partialFill) { + amountToken0 = amountToken0 / 2; + } + + // Simulate a swap: transfer token0 from caller and mint token1 to caller + token0.transferFrom(msg.sender, address(this), amountToken0); + + // Simulate getting token1 in return (1:1 ratio for simplicity) + amountToken1 = amountToken0; + token1.mint(msg.sender, amountToken1); + + return amountToken1; + } + + function swapToken0ForToken1WithSlippage(uint256 amountToken0, uint256 slippageBps) + external + returns (uint256 amountToken1) + { + // Simulate a swap with slippage + token0.transferFrom(msg.sender, address(this), amountToken0); + + // Calculate amount with slippage (slippageBps is in basis points) + amountToken1 = (amountToken0 * (10000 - slippageBps)) / 10000; + token1.mint(msg.sender, amountToken1); + + return amountToken1; + } + + function failSwap() external pure { + revert("Swap failed"); + } +} + +contract RebalanceModuleTest is Test { + RebalanceModule rebalanceModule; + + ERC20Mock token0; + ERC20Mock token1; + + MockTargetContract mockTargetContract; + MockTargetContract mockTargetContract2; + + address owner = makeAddr("OWNER"); + address withdrawalModule = makeAddr("WITHDRAWAL_MODULE"); + address pool = makeAddr("POOL"); + address recipient = makeAddr("RECIPIENT"); + address unauthorized = makeAddr("UNAUTHORIZED"); + + uint256 constant INITIAL_BALANCE = 10000 ether; + uint256 constant SWAP_AMOUNT = 1000 ether; + uint256 constant MIN_TOKEN1_AMOUNT = 950 ether; // 5% slippage tolerance + + event Sweep(address indexed token, address indexed recipient, uint256 amount); + + function setUp() public { + // Deploy mock tokens + token0 = new ERC20Mock(); + token1 = new ERC20Mock(); + + // Mint initial balances + token0.mint(withdrawalModule, INITIAL_BALANCE); + token1.mint(withdrawalModule, INITIAL_BALANCE); + + // Deploy mock target contracts + mockTargetContract = new MockTargetContract(address(token0), address(token1)); + mockTargetContract2 = new MockTargetContract(address(token0), address(token1)); + + // Deploy rebalance module + rebalanceModule = new RebalanceModule(withdrawalModule, pool, owner); + + // Transfer tokens to rebalance module for testing + vm.prank(withdrawalModule); + token0.transfer(address(rebalanceModule), SWAP_AMOUNT); + } + + function testConstructor() public { + // Test valid deployment + RebalanceModule newModule = new RebalanceModule(withdrawalModule, pool, owner); + assertEq(newModule.withdrawalModule(), withdrawalModule); + assertEq(newModule.pool(), pool); + assertEq(newModule.owner(), owner); + + // Test zero address validation + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + new RebalanceModule(address(0), pool, owner); + + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + new RebalanceModule(withdrawalModule, address(0), owner); + + vm.expectRevert(); + new RebalanceModule(withdrawalModule, pool, address(0)); + } + + function testRebalance_Success() public { + // Prepare rebalance payload + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = SWAP_AMOUNT; + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(mockTargetContract); + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, SWAP_AMOUNT); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + // Record initial balances + uint256 initialToken0Balance = token0.balanceOf(withdrawalModule); + uint256 initialToken1Balance = token1.balanceOf(withdrawalModule); + uint256 initialPoolToken0Balance = token0.balanceOf(pool); + + // Execute rebalance + vm.prank(withdrawalModule); + bytes4 result = rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + + // Verify return value + assertEq(result, IRebalanceModule.rebalance.selector); + + // Verify token1 was transferred to withdrawal module + assertEq(token1.balanceOf(withdrawalModule), initialToken1Balance + SWAP_AMOUNT); + + // Verify no remaining token0 in rebalance module + assertEq(token0.balanceOf(address(rebalanceModule)), 0); + + // Verify no token0 was sent to pool (since all was swapped) + assertEq(token0.balanceOf(pool), initialPoolToken0Balance); + } + + function testRebalance_MultipleTargets() public { + // Prepare rebalance payload with multiple targets + uint256[] memory amountToken0Array = new uint256[](2); + amountToken0Array[0] = SWAP_AMOUNT / 2; + amountToken0Array[1] = SWAP_AMOUNT / 2; + + address[] memory targetAddresses = new address[](2); + targetAddresses[0] = address(mockTargetContract); + targetAddresses[1] = address(mockTargetContract2); + + bytes[] memory payloads = new bytes[](2); + payloads[0] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, SWAP_AMOUNT / 2); + payloads[1] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, SWAP_AMOUNT / 2); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + // Execute rebalance + vm.prank(withdrawalModule); + bytes4 result = rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + + // Verify return value + assertEq(result, IRebalanceModule.rebalance.selector); + + // Verify token1 was received + assertEq(token1.balanceOf(withdrawalModule), INITIAL_BALANCE + SWAP_AMOUNT); + } + + function testRebalance_WithRemainingToken0() public { + token0.burn(address(rebalanceModule), token0.balanceOf(address(rebalanceModule))); + + uint256 swapAmount = 2 ether; + vm.prank(withdrawalModule); + token0.transfer(address(rebalanceModule), swapAmount); + + mockTargetContract.setPartialFill(true); + + // Prepare rebalance payload for only half the balance + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = swapAmount; + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(mockTargetContract); + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, swapAmount); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + uint256 initialPoolToken0Balance = token0.balanceOf(pool); + + // Execute rebalance + vm.prank(withdrawalModule); + rebalanceModule.rebalance(swapAmount / 2, payload); + + // Verify remaining token0 was sent to pool + assertEq(token0.balanceOf(pool), initialPoolToken0Balance + swapAmount / 2); + assertEq(token0.balanceOf(address(rebalanceModule)), 0); + } + + function testRebalance_AccessControl() public { + bytes memory payload = + abi.encode(address(token0), address(token1), new uint256[](0), new address[](0), new bytes[](0)); + + // Test unauthorized access + vm.expectRevert(RebalanceModule.RebalanceModule__onlyWithdrawalModule.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + + vm.prank(unauthorized); + vm.expectRevert(RebalanceModule.RebalanceModule__onlyWithdrawalModule.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_InvalidAmountToken1Min() public { + bytes memory payload = + abi.encode(address(token0), address(token1), new uint256[](0), new address[](0), new bytes[](0)); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__rebalance_InvalidAmountToken1Min.selector); + rebalanceModule.rebalance(0, payload); + } + + function testRebalance_ZeroAddresses() public { + bytes memory payload = abi.encode( + address(0), // token0 + address(token1), + new uint256[](0), + new address[](0), + new bytes[](0) + ); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + + payload = abi.encode( + address(token0), + address(0), // token1 + new uint256[](0), + new address[](0), + new bytes[](0) + ); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_InvalidArrayLengths() public { + uint256[] memory amountToken0Array = new uint256[](1); + address[] memory targetAddresses = new address[](2); // Different length + targetAddresses[0] = address(mockTargetContract); + targetAddresses[1] = address(mockTargetContract2); + + bytes[] memory payloads = new bytes[](1); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__rebalance_InvalidPayloadArrayLength.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_ZeroTargetAddress() public { + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = SWAP_AMOUNT; + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(0); // Zero address + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, SWAP_AMOUNT); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_InvalidAmountToken0() public { + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = 0; // Zero amount + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(mockTargetContract); + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, 0); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__rebalance_InvalidAmountToken0.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_InsufficientToken0Balance() public { + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = SWAP_AMOUNT * 2; // More than available + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(mockTargetContract); + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(MockTargetContract.swapToken0ForToken1.selector, SWAP_AMOUNT * 2); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__rebalance_InsufficientToken0Received.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_TargetCallFailed() public { + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = SWAP_AMOUNT; + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(mockTargetContract); + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector(MockTargetContract.failSwap.selector); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__rebalance_TargetCallFailed.selector); + rebalanceModule.rebalance(MIN_TOKEN1_AMOUNT, payload); + } + + function testRebalance_InsufficientToken1Received() public { + // Create a target that returns less token1 than expected + uint256[] memory amountToken0Array = new uint256[](1); + amountToken0Array[0] = SWAP_AMOUNT; + + address[] memory targetAddresses = new address[](1); + targetAddresses[0] = address(mockTargetContract); + + bytes[] memory payloads = new bytes[](1); + payloads[0] = abi.encodeWithSelector( + MockTargetContract.swapToken0ForToken1WithSlippage.selector, + SWAP_AMOUNT, + 1000 // 10% slippage + ); + + bytes memory payload = + abi.encode(address(token0), address(token1), amountToken0Array, targetAddresses, payloads); + + // Set minimum token1 amount higher than what we'll receive + uint256 highMinAmount = SWAP_AMOUNT; // We'll only get 90% of this + + vm.prank(withdrawalModule); + vm.expectRevert(RebalanceModule.RebalanceModule__rebalance_InsufficientToken1Received.selector); + rebalanceModule.rebalance(highMinAmount, payload); + } + + function testSweep() public { + vm.prank(unauthorized); + vm.expectRevert(); // Ownable revert + rebalanceModule.sweep(address(token0), recipient); + + vm.prank(owner); + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + rebalanceModule.sweep(address(0), recipient); + + vm.prank(owner); + vm.expectRevert(RebalanceModule.RebalanceModule__ZeroAddress.selector); + rebalanceModule.sweep(address(token0), address(0)); + + token0.burn(address(rebalanceModule), 1000 ether); + + // Try to sweep when balance is 0 + vm.prank(owner); + rebalanceModule.sweep(address(token0), recipient); + + vm.prank(owner); + rebalanceModule.sweep(address(token1), recipient); + + // No balance updates + assertEq(token0.balanceOf(recipient), 0); + assertEq(token1.balanceOf(recipient), 0); + + // Transfer some tokens to rebalance module + vm.prank(withdrawalModule); + token0.transfer(address(rebalanceModule), 100 ether); + + vm.prank(withdrawalModule); + token1.transfer(address(rebalanceModule), 50 ether); + + // Sweep token0 + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Sweep(address(token0), recipient, 100 ether); + rebalanceModule.sweep(address(token0), recipient); + + assertEq(token1.balanceOf(recipient), 0); + assertEq(token0.balanceOf(address(recipient)), 100 ether); + + // Sweep token1 + vm.prank(owner); + vm.expectEmit(true, true, false, true); + emit Sweep(address(token1), recipient, 50 ether); + rebalanceModule.sweep(address(token1), recipient); + + assertEq(token1.balanceOf(recipient), 50 ether); + assertEq(token1.balanceOf(address(rebalanceModule)), 0); + } +} diff --git a/test/STEXAMMStepwiseFeeModule.t.sol b/test/STEXAMMStepwiseFeeModule.t.sol new file mode 100644 index 0000000..22e590c --- /dev/null +++ b/test/STEXAMMStepwiseFeeModule.t.sol @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test, console} from "forge-std/Test.sol"; + +import {ProtocolFactory} from "@valantis-core/protocol-factory/ProtocolFactory.sol"; +import {SwapFeeModuleData} from "@valantis-core/swap-fee-modules/interfaces/ISwapFeeModule.sol"; +import {SovereignPoolFactory} from "@valantis-core/pools/factories/SovereignPoolFactory.sol"; +import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; +import {ALMLiquidityQuoteInput, ALMLiquidityQuote} from "@valantis-core/ALM/structs/SovereignALMStructs.sol"; +import {SovereignPoolSwapParams} from "@valantis-core/pools/structs/SovereignPoolStructs.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {WETH} from "@solmate/tokens/WETH.sol"; + +import {STEXAMM} from "src/STEXAMM.sol"; +import {StepwiseFeeModule} from "src/swap-fee-modules/StepwiseFeeModule.sol"; +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; +import {MockOverseer} from "src/mocks/sthype/MockOverseer.sol"; +import {MockStHype} from "src/mocks/sthype/MockStHype.sol"; + +contract STEXAMMStepwiseFeeModuleTest is Test { + STEXAMM stex; + + StepwiseFeeModule stepwiseFeeModule; + stHYPEWithdrawalModule withdrawalModule; + + ProtocolFactory protocolFactory; + + WETH weth; + MockStHype token0; + + MockOverseer overseer; + + address public poolFeeRecipient1 = makeAddr("POOL_FEE_RECIPIENT_1"); + address public poolFeeRecipient2 = makeAddr("POOL_FEE_RECIPIENT_2"); + + address public owner = makeAddr("OWNER"); + uint256 public constant BIPS = 10_000; + + ISovereignPool pool; + + function setUp() public { + token0 = new MockStHype(); + weth = new WETH(); + + overseer = new MockOverseer(address(token0)); + + protocolFactory = new ProtocolFactory(address(this)); + + address sovereignPoolFactory = address(new SovereignPoolFactory()); + protocolFactory.setSovereignPoolFactory(sovereignPoolFactory); + + withdrawalModule = new stHYPEWithdrawalModule(address(overseer), makeAddr("wstHYPE"), address(this)); + + stepwiseFeeModule = new StepwiseFeeModule(owner); + assertEq(stepwiseFeeModule.owner(), owner); + + stex = new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(stepwiseFeeModule), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModule), + 0 + ); + withdrawalModule.setSTEX(address(stex)); + assertEq(withdrawalModule.stex(), address(stex)); + + vm.startPrank(owner); + stepwiseFeeModule.setPool(stex.pool()); + + vm.expectRevert(StepwiseFeeModule.StepwiseFeeModule__setPool_AlreadySet.selector); + stepwiseFeeModule.setPool(makeAddr("MOCK_SOVEREIGN_POOL")); + + vm.stopPrank(); + + pool = ISovereignPool(stex.pool()); + + vm.deal(address(this), 300 ether); + weth.deposit{value: 100 ether}(); + uint256 shares = token0.mint{value: 100 ether}(address(this)); + assertEq(shares, 100 ether); + assertEq(token0.totalSupply(), shares); + assertEq(token0.balanceOf(address(this)), shares); + assertEq(address(token0).balance, 100 ether); + + token0.approve(address(pool), 100 ether); + weth.approve(address(pool), type(uint256).max); + } + + function testSetStepwiseSwapFeeParams_revertsWhenNotOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stepwiseFeeModule.setFeeParamsToken0(10 ether, 100 ether, new uint32[](0)); + } + + function testSetStepwiseSwapFeeParams_revertsWhenZeroSteps() public { + vm.expectRevert( + abi.encodeWithSelector(StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__ZeroSteps.selector) + ); + _setStepwiseSwapFeeParams(10 ether, 100 ether, new uint32[](0)); + } + + function testSetStepwiseSwapFeeParams_revertsWhenFeeTooHigh() public { + vm.expectRevert( + abi.encodeWithSelector(StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__FeeTooHigh.selector) + ); + uint256 minThreshold = 10 ether; + uint256 maxThreshold = 100 ether; + uint32[] memory feeStepsInBips = new uint32[](2); + feeStepsInBips[0] = 5; + feeStepsInBips[1] = 5001; + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + } + + function testSetStepwiseSwapFeeParams_revertsWhenFeeTooLow() public { + vm.expectRevert( + abi.encodeWithSelector(StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__FeeTooLow.selector) + ); + uint256 minThreshold = 10 ether; + uint256 maxThreshold = 100 ether; + uint32[] memory feeStepsInBips = new uint32[](2); + feeStepsInBips[0] = 0; + feeStepsInBips[1] = 5; + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + } + + function testSetStepwiseSwapFeeParams_revertsWhenThresholdFlipped() public { + vm.expectRevert( + abi.encodeWithSelector( + StepwiseFeeModule + .StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold + .selector + ) + ); + uint256 minThreshold = 100 ether; + uint256 maxThreshold = 10 ether; + uint32[] memory feeStepsInBips = new uint32[](2); + feeStepsInBips[0] = 1; + feeStepsInBips[1] = 5; + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + } + + function testSetStepwiseSwapFeeParams_revertsWhenThresholdEqual() public { + vm.expectRevert( + abi.encodeWithSelector( + StepwiseFeeModule + .StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold + .selector + ) + ); + uint256 minThreshold = 100 ether; + uint256 maxThreshold = 100 ether; + uint32[] memory feeStepsInBips = new uint32[](2); + feeStepsInBips[0] = 1; + feeStepsInBips[1] = 5; + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + } + + function testSetStepwiseSwapFeeParams_revertsWhenThresholdZero() public { + vm.expectRevert( + abi.encodeWithSelector( + StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdZero.selector + ) + ); + uint256 minThreshold = 0 ether; + uint256 maxThreshold = 100 ether; + uint32[] memory feeStepsInBips = new uint32[](2); + feeStepsInBips[0] = 1; + feeStepsInBips[1] = 5; + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + + minThreshold = 10 ether; + maxThreshold = 0; + vm.expectRevert(StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__MaxToken1ThresholdZero.selector); + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + } + + function testSetStepwiseSwapFeeParams_revertsWhenFeeStepsNotMonotonicallyNonDecreasing() public { + vm.expectRevert( + abi.encodeWithSelector( + StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__FeeStepsNotMonotonicallyNonDecreasing.selector + ) + ); + uint256 maxThreshold = 100 ether; + uint256 minThreshold = 10 ether; + uint32[] memory feeStepsInBips = new uint32[](2); + feeStepsInBips[0] = 5; + feeStepsInBips[1] = 4; + _setStepwiseSwapFeeParams(minThreshold, maxThreshold, feeStepsInBips); + } + + function testSetStepwiseSwapFeeParams_deletesOldFeeBipsToken0Array() public { + uint32[] memory feeBipsToken0 = new uint32[](5); + feeBipsToken0[0] = 1; + feeBipsToken0[1] = 2; + feeBipsToken0[2] = 3; + feeBipsToken0[3] = 4; + feeBipsToken0[4] = 5; + + _setStepwiseSwapFeeParams(10 ether, 100 ether, feeBipsToken0); + assertEq(stepwiseFeeModule.minThresholdToken1(), 10 ether); + assertEq(stepwiseFeeModule.maxThresholdToken1(), 100 ether); + assertEq(stepwiseFeeModule.numStepsToken0FeeCurve(), 5); + uint32[] memory feeBipsToken0Set = stepwiseFeeModule.getToken0FeeInBips(); + assertEq(feeBipsToken0Set.length, 5); + assertEq(feeBipsToken0Set[0], 1); + assertEq(feeBipsToken0Set[1], 2); + assertEq(feeBipsToken0Set[2], 3); + assertEq(feeBipsToken0Set[3], 4); + assertEq(feeBipsToken0Set[4], 5); + + uint32[] memory feeBipsToken0New = new uint32[](2); + feeBipsToken0New[0] = 12; + feeBipsToken0New[1] = 23; + + _setStepwiseSwapFeeParams(5 ether, 25 ether, feeBipsToken0New); + assertEq(stepwiseFeeModule.minThresholdToken1(), 5 ether); + assertEq(stepwiseFeeModule.maxThresholdToken1(), 25 ether); + assertEq(stepwiseFeeModule.numStepsToken0FeeCurve(), 2); + feeBipsToken0Set = stepwiseFeeModule.getToken0FeeInBips(); + assertEq(feeBipsToken0Set.length, 2); + assertEq(feeBipsToken0Set[0], 12); + assertEq(feeBipsToken0Set[1], 23); + } + + function testSwapTooMuchLiquidity() public { + assertFalse(stex.isLocked()); + address recipient = makeAddr("RECIPIENT"); + _addPoolReserves(0 ether, 50 ether); + // Reserve0 = 0 ether, Reserve1 = 50 ether + uint256 maxToken1Threshold = 50 ether; + uint256 minToken1Threshold = 10 ether; + uint32[] memory steps = new uint32[](2); + steps[0] = 5; // Fee is 5 bips <= token1 = 50 ether + steps[1] = 10; // Fee is 10 bips <= token1 = 30 ether; or token0 swap size < 20. + _setStepwiseSwapFeeParams(minToken1Threshold, maxToken1Threshold, steps); + assertEq(stepwiseFeeModule.numStepsToken0FeeCurve(), 2); + assertEq(stepwiseFeeModule.minThresholdToken1(), minToken1Threshold); + assertEq(stepwiseFeeModule.maxThresholdToken1(), maxToken1Threshold); + uint32[] memory feeBipsToken0 = stepwiseFeeModule.getToken0FeeInBips(); + assertEq(feeBipsToken0.length, 2); + assertEq(feeBipsToken0[0], 5); + assertEq(feeBipsToken0[1], 10); + + SovereignPoolSwapParams memory params; + params.isZeroToOne = true; + params.amountIn = 0.4 ether; + params.deadline = block.timestamp; + params.swapTokenOut = address(weth); + params.recipient = recipient; + + // Swap some more up to the first tick, fee should stay 5 bips + params.amountIn = 49 ether; + SwapFeeModuleData memory swapFeeDataSameTick = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeDataSameTick.feeInBips, _transformFeeInBips(10)); + + // At the tick, the next fee should be used + params.amountIn = 50 ether; + SwapFeeModuleData memory swapFeeDataNextTick = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeDataNextTick.feeInBips, _transformFeeInBips(10)); + + params.amountIn = 500 ether; + SwapFeeModuleData memory swapFeeDataLarge = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeDataLarge.feeInBips, _transformFeeInBips(10)); + } + + function testSimpleSwap() public { + assertFalse(stex.isLocked()); + address recipient = makeAddr("RECIPIENT"); + _addPoolReserves(0 ether, 50 ether); + // Reserve0 = 0 ether, Reserve1 = 50 ether + uint256 maxToken1Threshold = 50 ether; + uint256 minToken1Threshold = 10 ether; + uint32[] memory steps = new uint32[](2); + steps[0] = 5; // Fee is 5 bips <= token1 = 50 ether + steps[1] = 10; // Fee is 10 bips <= token1 = 30 ether; or token0 swap size < 20. + _setStepwiseSwapFeeParams(minToken1Threshold, maxToken1Threshold, steps); + assertEq(stepwiseFeeModule.numStepsToken0FeeCurve(), 2); + assertEq(stepwiseFeeModule.minThresholdToken1(), minToken1Threshold); + assertEq(stepwiseFeeModule.maxThresholdToken1(), maxToken1Threshold); + uint32[] memory feeBipsToken0 = stepwiseFeeModule.getToken0FeeInBips(); + assertEq(feeBipsToken0.length, 2); + assertEq(feeBipsToken0[0], 5); + assertEq(feeBipsToken0[1], 10); + + SovereignPoolSwapParams memory params; + params.isZeroToOne = true; + params.amountIn = 0.4 ether; + params.deadline = block.timestamp; + params.swapTokenOut = address(weth); + params.recipient = recipient; + + // Small swap amount (total token0 = 0.4 ether ; token1 = 50 - 0.4) + SwapFeeModuleData memory swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(5)); + + // Swap some more up to the first tick, fee should stay 5 bips + params.amountIn = 19.99999 ether; + SwapFeeModuleData memory swapFeeDataSameTick = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeDataSameTick.feeInBips, _transformFeeInBips(5)); + + // At the tick, the next fee should be used + params.amountIn = 30 ether; + SwapFeeModuleData memory swapFeeDataNextTick = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeDataNextTick.feeInBips, _transformFeeInBips(10)); + + params.amountIn = 50 ether; + SwapFeeModuleData memory swapFeeDataLarge = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeDataLarge.feeInBips, _transformFeeInBips(10)); + } + + function testMoreComplexFeeParams() public { + assertFalse(stex.isLocked()); + address recipient = makeAddr("RECIPIENT"); + _addPoolReserves(100 ether, 0); + uint32[] memory steps = new uint32[](4); + uint256 minToken1Threshold = 10 ether; + uint256 maxToken1Threshold = 70 ether; + steps[0] = 5; + steps[1] = 10; + steps[2] = 15; + steps[3] = 200; + + SovereignPoolSwapParams memory params; + params.isZeroToOne = true; + params.deadline = block.timestamp; + params.swapTokenOut = address(weth); + params.recipient = recipient; + + // Small size means we stay at the 0th tick + params.amountIn = 0.4 ether; + // revert before setting thresholds + vm.expectRevert(StepwiseFeeModule.StepwiseFeeModule__getSwapFeeInBips_InvalidMinToken1Threshold.selector); + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + + _setStepwiseSwapFeeParams(minToken1Threshold, maxToken1Threshold, steps); + assertEq(stepwiseFeeModule.numStepsToken0FeeCurve(), 4); + assertEq(stepwiseFeeModule.minThresholdToken1(), 10 ether); + assertEq(stepwiseFeeModule.maxThresholdToken1(), 70 ether); + uint32[] memory feeBipsToken0 = stepwiseFeeModule.getToken0FeeInBips(); + assertEq(feeBipsToken0.length, 4); + assertEq(feeBipsToken0[0], 5); + assertEq(feeBipsToken0[1], 10); + assertEq(feeBipsToken0[2], 15); + assertEq(feeBipsToken0[3], 200); + + SwapFeeModuleData memory swapFeeData; + + // Seed Token1 Liquidity + // The steps are (70-10) / 4 = 15 ether wide. Starting at token1 reserves = 70. + _addPoolReserves(0, 70 ether); // Token0 = 70 + + // Zero Tick + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + + assertEq(swapFeeData.feeInBips, _transformFeeInBips(5)); + + // Charge minFee + params.amountIn = 0.5 ether; // Charge Min Fee + // First Tick + console.log("first step"); + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(5)); + // Pass the first bucket with maxThreshold - stepSize + params.amountIn = 15 ether; + // Past Threshold, first tick. + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(10)); + + params.amountIn = 18 ether; + // Between first and second tick + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(10)); + + // Increase Token1 Liquidity. New Deposit lowers slippage. + + _addPoolReserves(0, 30 ether); // Token1 = 100 + // Back to first bucket with new deposit. + params.amountIn = 1 ether; + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(5)); + + params.amountIn = 44 ether; // Between first and second with new deposit + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(5)); + + params.amountIn = 45 ether; // Between second and third with new deposit + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(10)); + + params.amountIn = 60 ether; // Third tick + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(15)); + + params.amountIn = 75 ether; // Max Fee, Fourth Tick + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(200)); + + // The fee after reaching max reserves is the same as the fee in the tick before + params.amountIn = 91 ether; // Max Fee, reach threshold + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(200)); + + params.amountIn = 101 ether; // Max Fee for unrealistically large swap sizes + swapFeeData = + stepwiseFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertEq(swapFeeData.feeInBips, _transformFeeInBips(200)); + } + + function _setStepwiseSwapFeeParams( + uint256 minToken0Threshold, + uint256 maxToken0Threshold, + uint32[] memory feeStepsInBips + ) private { + vm.startPrank(owner); + stepwiseFeeModule.setFeeParamsToken0(minToken0Threshold, maxToken0Threshold, feeStepsInBips); + vm.stopPrank(); + } + + function _addPoolReserves(uint256 amount0, uint256 amount1) private { + (, uint256 preReserve1) = pool.getReserves(); + if (amount0 > 0) { + token0.mint{value: amount0}(address(pool)); + } + + if (amount1 > 0) { + weth.transfer(address(pool), amount1); + (, uint256 postReserve1) = pool.getReserves(); + assertEq(postReserve1, preReserve1 + amount1); + } + } + + function _transformFeeInBips(uint256 feeInBips) private pure returns (uint256) { + return (BIPS * feeInBips) / (BIPS - feeInBips); + } +} diff --git a/test/StepwiseFeeModuleKeeper.t.sol b/test/StepwiseFeeModuleKeeper.t.sol new file mode 100644 index 0000000..b08e700 --- /dev/null +++ b/test/StepwiseFeeModuleKeeper.t.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {StepwiseFeeModuleKeeper} from "src/owner/StepwiseFeeModuleKeeper.sol"; + +contract StepwiseFeeModuleKeeperTest is Test { + StepwiseFeeModuleKeeper keeper; + + address public keeperAccount1 = makeAddr("KEEPER_ACCOUNT_1"); + address public keeperAccount2 = makeAddr("KEEPER_ACCOUNT_2"); + + function setUp() public { + keeper = new StepwiseFeeModuleKeeper(address(this)); + assertEq(keeper.owner(), address(this)); + + keeper.setKeeper(keeperAccount1); + assertTrue(keeper.isKeeper(keeperAccount1)); + assertFalse(keeper.isKeeper(keeperAccount2)); + } + + /// Stepwise Fee Module mock functions /// + + function setFeeParamsToken0( + uint256 _minThresholdToken1, + uint256 _maxThresholdToken1, + uint32[] calldata _feeStepsInBips + ) external {} + + /// End of Stepwise Fee Module mock functions /// + + function testDeployments() public { + StepwiseFeeModuleKeeper keeperDeployment = new StepwiseFeeModuleKeeper(address(this)); + assertEq(keeperDeployment.owner(), address(this)); + } + + function testKeeperWhitelist() public { + vm.expectRevert(StepwiseFeeModuleKeeper.StepwiseFeeModuleKeeper__ZeroAddress.selector); + keeper.setKeeper(address(0)); + + keeper.setKeeper(keeperAccount2); + assertTrue(keeper.isKeeper(keeperAccount2)); + + vm.expectRevert(StepwiseFeeModuleKeeper.StepwiseFeeModuleKeeper__ZeroAddress.selector); + keeper.removeKeeper(address(0)); + + keeper.removeKeeper(keeperAccount2); + assertFalse(keeper.isKeeper(keeperAccount2)); + } + + function test_setToken0FeeParams() public { + uint32[] memory feeStepsInBipsToken0 = new uint32[](3); + feeStepsInBipsToken0[0] = 1; + feeStepsInBipsToken0[1] = 2; + feeStepsInBipsToken0[2] = 3; + + vm.expectRevert(StepwiseFeeModuleKeeper.StepwiseFeeModuleKeeper__setFeeParamsToken0_OnlyKeeper.selector); + keeper.setFeeParamsToken0(address(0), 10 ether, 100 ether, feeStepsInBipsToken0); + + vm.startPrank(keeperAccount1); + + vm.expectRevert(StepwiseFeeModuleKeeper.StepwiseFeeModuleKeeper__ZeroAddress.selector); + keeper.setFeeParamsToken0(address(0), 10 ether, 100 ether, feeStepsInBipsToken0); + + keeper.setFeeParamsToken0(address(this), 10 ether, 100 ether, feeStepsInBipsToken0); + + vm.stopPrank(); + } + + /*function testKeeperContract__call() public { + address withdrawalModule = address(this); + + vm.expectRevert( + WithdrawalModuleKeeper + .WithdrawalModuleKeeper__call_onlyKeeper + .selector + ); + vm.prank(keeperAccount2); + keeper.call(address(manager), new bytes(0)); + + vm.startPrank(keeperAccount1); + + // The following functions can be called by a whitelisted keeper role + + keeper.call( + address(manager), + abi.encodeWithSelector( + WithdrawalModuleManager.unstakeToken0Reserves.selector, + withdrawalModule, + 1 ether + ) + ); + + keeper.call( + address(manager), + abi.encodeWithSelector( + WithdrawalModuleManager.supplyToken1ToLendingPool.selector, + withdrawalModule, + 1 ether + ) + ); + + keeper.call( + address(manager), + abi.encodeWithSelector( + WithdrawalModuleManager.withdrawToken1FromLendingPool.selector, + withdrawalModule, + 1 ether + ) + ); + + // `call` from WithdrawalModuleManager cannot be called by keeper contract + + vm.expectRevert( + WithdrawalModuleKeeper + .WithdrawalModuleKeeper__call_callFailed + .selector + ); + keeper.call( + address(manager), + abi.encodeWithSelector( + WithdrawalModuleManager.call.selector, + withdrawalModule, + new bytes(0) + ) + ); + }*/ +} diff --git a/test/kHYPESTEXAMM.t.sol b/test/kHYPESTEXAMM.t.sol new file mode 100644 index 0000000..a744590 --- /dev/null +++ b/test/kHYPESTEXAMM.t.sol @@ -0,0 +1,1541 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {console} from "forge-std/console.sol"; +import {Test} from "forge-std/Test.sol"; + +import {ProtocolFactory} from "@valantis-core/protocol-factory/ProtocolFactory.sol"; +import {SwapFeeModuleData} from "@valantis-core/swap-fee-modules/interfaces/ISwapFeeModule.sol"; +import {SovereignPoolFactory} from "@valantis-core/pools/factories/SovereignPoolFactory.sol"; +import {ISovereignPool} from "@valantis-core/pools/interfaces/ISovereignPool.sol"; +import {ALMLiquidityQuoteInput, ALMLiquidityQuote} from "@valantis-core/ALM/structs/SovereignALMStructs.sol"; +import {SovereignPoolSwapParams} from "@valantis-core/pools/structs/SovereignPoolStructs.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {WETH} from "@solmate/tokens/WETH.sol"; + +import {STEXAMM} from "src/STEXAMM.sol"; +import {STEXLens} from "src/STEXLens.sol"; +import {STEXRatioSwapFeeModule} from "src/swap-fee-modules/STEXRatioSwapFeeModule.sol"; +import {kHYPEWithdrawalModule} from "src/withdrawal-modules/kHYPEWithdrawalModule.sol"; +import {MockStakingAccountant} from "src/mocks/kinetiq/MockStakingAccountant.sol"; +import {MockStakingManager} from "src/mocks/kinetiq/MockStakingManager.sol"; +import {MockLendingPool} from "src/mocks/MockLendingPool.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; +import {DepositWrapper} from "src/DepositWrapper.sol"; +import {FeeParams} from "src/structs/STEXRatioSwapFeeModuleStructs.sol"; +import {LPWithdrawalRequest} from "src/structs/WithdrawalModuleStructs.sol"; + +contract stHYPESTEXAMMTest is Test { + uint256 private constant BIPS = 10_000; + + STEXAMM stex; + STEXLens stexLens; + + STEXRatioSwapFeeModule swapFeeModule; + + kHYPEWithdrawalModule withdrawalModule; + + DepositWrapper nativeWrapper; + + ProtocolFactory protocolFactory; + + WETH weth; + // kHYPE + ERC20Mock token0; + + MockStakingAccountant stakingAccountant; + MockStakingManager stakingManager; + + MockLendingPool lendingPool; + AaveLendingModule lendingModule; + + address public poolFeeRecipient1 = makeAddr("POOL_FEE_RECIPIENT_1"); + address public poolFeeRecipient2 = makeAddr("POOL_FEE_RECIPIENT_2"); + + address public owner = makeAddr("OWNER"); + + ISovereignPool pool; + + function setUp() public { + token0 = new ERC20Mock(); + weth = new WETH(); + + stakingAccountant = new MockStakingAccountant(address(token0)); + stakingManager = new MockStakingManager(address(stakingAccountant), address(token0)); + + // 10 bips unstaking fee + stakingManager.setUnstakeFeeRate(10); + + protocolFactory = new ProtocolFactory(address(this)); + + address sovereignPoolFactory = address(new SovereignPoolFactory()); + protocolFactory.setSovereignPoolFactory(sovereignPoolFactory); + + stexLens = new STEXLens(); + + lendingPool = new MockLendingPool(address(weth)); + + withdrawalModule = new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(this)); + + swapFeeModule = new STEXRatioSwapFeeModule(owner); + assertEq(swapFeeModule.owner(), owner); + + stex = new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModule), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModule), + 0 + ); + withdrawalModule.setSTEX(address(stex)); + assertEq(withdrawalModule.stex(), address(stex)); + + vm.startPrank(owner); + swapFeeModule.setPool(stex.pool()); + vm.stopPrank(); + + lendingModule = new AaveLendingModule( + address(lendingPool), + lendingPool.lendingPoolYieldToken(), + address(weth), + address(withdrawalModule), + address(0x123), + 2 + ); + assertEq(lendingModule.yieldToken(), lendingPool.lendingPoolYieldToken()); + assertEq(lendingModule.asset(), address(weth)); + assertEq(lendingModule.tokenSweepManager(), address(0x123)); + assertEq(lendingModule.owner(), address(withdrawalModule)); + assertEq(lendingModule.referralCode(), 2); + + withdrawalModule.proposeLendingModule(address(lendingModule), 3 days); + vm.warp(block.timestamp + 3 days); + withdrawalModule.setProposedLendingModule(); + + vm.expectRevert(DepositWrapper.DepositWrapper__ZeroAddress.selector); + new DepositWrapper(address(0), address(stex)); + + vm.expectRevert(DepositWrapper.DepositWrapper__ZeroAddress.selector); + new DepositWrapper(address(weth), address(0)); + + nativeWrapper = new DepositWrapper(address(weth), address(stex)); + + pool = ISovereignPool(stex.pool()); + + vm.deal(address(this), 300 ether); + weth.deposit{value: 100 ether}(); + + stakingManager.stake{value: 100 ether}(); + + assertEq(token0.totalSupply(), 100 ether); + assertEq(token0.balanceOf(address(this)), 100 ether); + + token0.approve(address(pool), 100 ether); + weth.approve(address(pool), type(uint256).max); + } + + function testDeploy() public { + kHYPEWithdrawalModule withdrawalModuleDeployment = + new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(this)); + assertEq(withdrawalModuleDeployment.stakingAccountant(), address(stakingAccountant)); + assertEq(withdrawalModuleDeployment.stakingManager(), address(stakingManager)); + assertEq(withdrawalModuleDeployment.stex(), address(0)); + assertEq(withdrawalModuleDeployment.owner(), address(this)); + + STEXRatioSwapFeeModule swapFeeModuleDeployment = new STEXRatioSwapFeeModule(owner); + assertEq(swapFeeModuleDeployment.owner(), owner); + + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(0), + address(weth), + address(swapFeeModuleDeployment), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(0), + address(swapFeeModuleDeployment), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(0), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModuleDeployment), + address(0), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModuleDeployment), + address(protocolFactory), + address(0), + poolFeeRecipient2, + owner, + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModuleDeployment), + address(protocolFactory), + poolFeeRecipient1, + address(0), + owner, + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModuleDeployment), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + address(0), + address(withdrawalModuleDeployment), + 0 + ); + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModuleDeployment), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(0), + 0 + ); + + STEXAMM stexDeployment = new STEXAMM( + "Stake Exchange LP", + "STEX LP", + address(token0), + address(weth), + address(swapFeeModuleDeployment), + address(protocolFactory), + poolFeeRecipient1, + poolFeeRecipient2, + owner, + address(withdrawalModuleDeployment), + 0 + ); + assertEq(stexDeployment.token0(), address(token0)); + assertEq(stexDeployment.token1(), address(weth)); + assertEq(stexDeployment.poolFeeRecipient1(), poolFeeRecipient1); + assertEq(stexDeployment.poolFeeRecipient2(), poolFeeRecipient2); + assertEq(stexDeployment.owner(), owner); + assertEq(stexDeployment.withdrawalModule(), address(withdrawalModuleDeployment)); + + ISovereignPool poolDeployment = ISovereignPool(stexDeployment.pool()); + assertEq(poolDeployment.token0(), address(token0)); + assertEq(poolDeployment.token1(), address(weth)); + assertEq(poolDeployment.alm(), address(stexDeployment)); + assertEq(poolDeployment.swapFeeModule(), address(swapFeeModuleDeployment)); + assertEq(poolDeployment.poolManager(), address(stexDeployment)); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + swapFeeModuleDeployment.setPool(address(poolDeployment)); + + vm.startPrank(owner); + swapFeeModuleDeployment.setPool(stexDeployment.pool()); + assertEq(swapFeeModuleDeployment.pool(), stexDeployment.pool()); + vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setPool_alreadySet.selector); + swapFeeModuleDeployment.setPool(makeAddr("MOCK_POOL")); + vm.stopPrank(); + + vm.expectRevert(DepositWrapper.DepositWrapper__ZeroAddress.selector); + new DepositWrapper(address(0), address(stexDeployment)); + + vm.expectRevert(DepositWrapper.DepositWrapper__ZeroAddress.selector); + new DepositWrapper(address(weth), address(0)); + + DepositWrapper nativeWrapperDeployment = new DepositWrapper(address(weth), address(stexDeployment)); + assertEq(address(nativeWrapperDeployment.stex()), address(stexDeployment)); + assertEq(address(nativeWrapperDeployment.weth()), address(weth)); + } + + function testReceive() public { + vm.expectRevert(STEXAMM.STEXAMM__receive_onlyWETH9.selector); + (bool success,) = address(stex).call{value: 1 ether}(""); + assertTrue(success); + + vm.prank(address(weth)); + (success,) = address(stex).call{value: 1 ether}(""); + assertTrue(success); + assertEq(address(stex).balance, 1 ether); + } + + function testPause() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.pause(); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.unpause(); + + vm.startPrank(owner); + + stex.pause(); + + vm.expectRevert(Pausable.EnforcedPause.selector); + stex.pause(); + + stex.unpause(); + + vm.expectRevert(Pausable.ExpectedPause.selector); + stex.unpause(); + + vm.stopPrank(); + } + + function testSwapFeeModuleProposal() public { + address swapFeeModuleMock = makeAddr("MOCK_SWAP_FEE_MODULE"); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.proposeSwapFeeModule(swapFeeModuleMock, 3 days); + + vm.startPrank(owner); + + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + stex.proposeSwapFeeModule(address(0), 3 days); + + vm.expectRevert(STEXAMM.STEXAMM___verifyTimelockDelay_timelockTooLow.selector); + stex.proposeSwapFeeModule(swapFeeModuleMock, 3 days - 1); + vm.expectRevert(STEXAMM.STEXAMM___verifyTimelockDelay_timelockTooHigh.selector); + stex.proposeSwapFeeModule(swapFeeModuleMock, 7 days + 1); + + stex.proposeSwapFeeModule(swapFeeModuleMock, 3 days); + (address swapFeeModuleProposed, uint256 startTimestamp) = stex.swapFeeModuleProposal(); + assertEq(swapFeeModuleProposed, swapFeeModuleMock); + assertEq(startTimestamp, block.timestamp + 3 days); + + vm.expectRevert(STEXAMM.STEXAMM__proposeSwapFeeModule_ProposalAlreadyActive.selector); + stex.proposeSwapFeeModule(swapFeeModuleMock, 3 days); + + vm.stopPrank(); + + uint256 snapshot = vm.snapshotState(); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.cancelSwapFeeModuleProposal(); + + vm.startPrank(owner); + + stex.cancelSwapFeeModuleProposal(); + (swapFeeModuleProposed, startTimestamp) = stex.swapFeeModuleProposal(); + assertEq(swapFeeModuleProposed, address(0)); + assertEq(startTimestamp, 0); + + vm.stopPrank(); + + vm.revertToState(snapshot); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.setProposedSwapFeeModule(); + + vm.startPrank(owner); + + vm.expectRevert(STEXAMM.STEXAMM__setProposedSwapFeeModule_Timelock.selector); + stex.setProposedSwapFeeModule(); + + vm.warp(block.timestamp + 3 days); + + stex.setProposedSwapFeeModule(); + assertEq(pool.swapFeeModule(), swapFeeModuleMock); + + (swapFeeModuleProposed, startTimestamp) = stex.swapFeeModuleProposal(); + assertEq(swapFeeModuleProposed, address(0)); + assertEq(startTimestamp, 0); + + vm.expectRevert(STEXAMM.STEXAMM__setProposedSwapFeeModule_InactiveProposal.selector); + stex.setProposedSwapFeeModule(); + + vm.stopPrank(); + } + + function testWithdrawalModuleProposal() public { + address withdrawalModuleMock = makeAddr("MOCK_WITHDRAWAL_MODULE"); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.proposeWithdrawalModule(withdrawalModuleMock); + + vm.startPrank(owner); + + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + stex.proposeWithdrawalModule(address(0)); + + stex.proposeWithdrawalModule(withdrawalModuleMock); + (address withdrawalModuleProposed, uint256 startTimestamp) = stex.withdrawalModuleProposal(); + assertEq(withdrawalModuleProposed, withdrawalModuleMock); + assertEq(startTimestamp, block.timestamp + 7 days); + + vm.expectRevert(STEXAMM.STEXAMM__proposeWithdrawalModule_ProposalAlreadyActive.selector); + stex.proposeWithdrawalModule(withdrawalModuleMock); + + vm.stopPrank(); + + uint256 snapshot = vm.snapshotState(); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.cancelWithdrawalModuleProposal(); + + vm.startPrank(owner); + + stex.cancelWithdrawalModuleProposal(); + (withdrawalModuleProposed, startTimestamp) = stex.withdrawalModuleProposal(); + assertEq(withdrawalModuleProposed, address(0)); + assertEq(startTimestamp, 0); + + vm.stopPrank(); + + vm.revertToState(snapshot); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.setProposedWithdrawalModule(); + + vm.startPrank(owner); + + vm.expectRevert(STEXAMM.STEXAMM__setProposedWithdrawalModule_Timelock.selector); + stex.setProposedWithdrawalModule(); + + vm.warp(block.timestamp + 7 days); + + stex.setProposedWithdrawalModule(); + assertEq(stex.withdrawalModule(), withdrawalModuleMock); + + (withdrawalModuleProposed, startTimestamp) = stex.withdrawalModuleProposal(); + assertEq(withdrawalModuleProposed, address(0)); + assertEq(startTimestamp, 0); + + vm.expectRevert(STEXAMM.STEXAMM__setProposedWithdrawalModule_InactiveProposal.selector); + stex.setProposedWithdrawalModule(); + + vm.stopPrank(); + } + + function testSetSwapFeeParams() public { + _setSwapFeeParams(1000, 7000, 1, 20); + _setSwapFeeParams(11_000, 200_000, 1, 4999); + } + + function _setSwapFeeParams( + uint32 minThresholdRatioBips, + uint32 maxThresholdRatioBips, + uint32 feeMinBips, + uint32 feeMaxBips + ) private { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, feeMinBips, feeMaxBips); + + vm.startPrank(owner); + + vm.expectRevert( + STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setSwapFeeParams_inconsistentThresholdRatioParams.selector + ); + swapFeeModule.setSwapFeeParams(maxThresholdRatioBips, maxThresholdRatioBips, feeMinBips, feeMaxBips); + + vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMin.selector); + swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, 5_000, feeMaxBips); + + vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMax.selector); + swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, feeMinBips, 5_000); + + vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setSwapFeeParams_inconsistentFeeParams.selector); + swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, 2, 1); + + swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, feeMinBips, feeMaxBips); + + (uint32 minThresholdRatio, uint32 maxThresholdRatio, uint32 feeMin, uint32 feeMax) = swapFeeModule.feeParams(); + assertEq(minThresholdRatio, minThresholdRatioBips); + assertEq(maxThresholdRatio, maxThresholdRatioBips); + assertEq(feeMin, feeMinBips); + assertEq(feeMax, feeMaxBips); + + vm.stopPrank(); + } + + function testSetPoolManagerFeeBips() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + stex.setPoolManagerFeeBips(1); + + vm.startPrank(owner); + + stex.setPoolManagerFeeBips(1); + assertEq(pool.poolManagerFeeBips(), 1); + + vm.stopPrank(); + } + + function testDeposit() public { + address recipient = makeAddr("RECIPIENT"); + + _deposit(1e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 1e18 + 1e3 + 1); + } + + function _deposit(uint256 amount, address recipient) private { + assertFalse(stex.isLocked()); + + vm.prank(owner); + stex.pause(); + + vm.expectRevert(Pausable.EnforcedPause.selector); + stex.deposit(1e18, 0, block.timestamp - 1, recipient); + + vm.prank(owner); + stex.unpause(); + + vm.expectRevert(STEXAMM.STEXAMM___checkDeadline_expired.selector); + stex.deposit(1e18, 0, block.timestamp - 1, recipient); + + // Test first deposit + + vm.expectRevert(); + stex.deposit(1e3 - 1, 0, block.timestamp, recipient); + + vm.expectRevert(STEXAMM.STEXAMM__deposit_lessThanMinShares.selector); + stex.deposit(1e10, 1e10, block.timestamp, recipient); + + vm.expectRevert(STEXAMM.STEXAMM__deposit_zeroShares.selector); + stex.deposit(1e3, 0, block.timestamp, recipient); + + weth.approve(address(stex), type(uint256).max); + + uint256 sharesSimulation = stexLens.getSharesForDeposit(address(stex), 1e3 + 1); + uint256 shares = stex.deposit(1e3 + 1, 1, block.timestamp, recipient); + assertEq(shares, sharesSimulation); + assertEq(shares, 1); + assertEq(stex.balanceOf(address(1)), 1e3); + assertEq(stex.balanceOf(recipient), 1); + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertEq(reserve0, 0); + assertEq(reserve1, 1e3 + 1); + + // Test normal deposit + + sharesSimulation = stexLens.getSharesForDeposit(address(stex), amount); + uint256 sharesSimulation2 = + stexLens.getSharesForDepositAndPoolReserves(address(stex), amount, reserve0, reserve1); + assertEq(sharesSimulation, sharesSimulation2); + shares = stex.deposit(amount, 0, block.timestamp, recipient); + assertEq(shares, sharesSimulation); + assertEq(stex.balanceOf(address(1)), 1e3); + assertEq(stex.balanceOf(recipient), shares + 1); + (reserve0, reserve1) = pool.getReserves(); + assertEq(reserve0, 0); + assertEq(reserve1, amount + 1e3 + 1); + + { + ( + uint256 reserve0Pool, + uint256 reserve0Unstaking, + uint256 reserve1Pool, + uint256 reserve1Lending, + uint256 amount1PendingLPWithdrawal + ) = stexLens.getAllReserves(address(stex)); + assertEq(reserve0Pool + reserve0Unstaking, reserve0); + assertEq(reserve1Pool, reserve1); + assertEq(reserve1Lending, 0); + assertEq(amount1PendingLPWithdrawal, 0); + } + } + + function testDeposit__FromNativeToken() public { + testDeposit(); + + address recipient = makeAddr("NATIVE_TOKEN_RECIPIENT"); + + uint256 snapshot = vm.snapshotState(); + + uint256 shares = nativeWrapper.depositFromNative(0, block.timestamp, recipient); + // No native token has been sent + assertEq(shares, 0); + + uint256 amount = 2 ether; + (uint256 preReserve0, uint256 preReserve1) = pool.getReserves(); + shares = nativeWrapper.depositFromNative{value: amount}(0, block.timestamp, recipient); + assertGt(shares, 0); + assertEq(weth.allowance(address(nativeWrapper), address(stex)), 0); + assertEq(stex.balanceOf(recipient), shares); + (uint256 postReserve0, uint256 postReserve1) = pool.getReserves(); + assertEq(preReserve0, postReserve0); + assertEq(preReserve1 + amount, postReserve1); + + vm.revertToState(snapshot); + + shares = nativeWrapper.depositFromNativeWithCode(0, block.timestamp, recipient, keccak256("valantis")); + // No native token has been sent + assertEq(shares, 0); + + amount = 2 ether; + (preReserve0, preReserve1) = pool.getReserves(); + shares = nativeWrapper.depositFromNative{value: amount}(0, block.timestamp, recipient); + assertGt(shares, 0); + assertEq(weth.allowance(address(nativeWrapper), address(stex)), 0); + assertEq(stex.balanceOf(recipient), shares); + (postReserve0, postReserve1) = pool.getReserves(); + assertEq(preReserve0, postReserve0); + assertEq(preReserve1 + amount, postReserve1); + } + + function testDeposit__FromToken0() public { + testDeposit(); + + // AMM swap fee as 1 bips + _setSwapFeeParams(3000, 5000, 1, 1); + + address recipient = makeAddr("MOCK_RECIPIENT_FROM_TOKEN0"); + + token0.mint(recipient, 1 ether); + + vm.startPrank(recipient); + + uint256 amountToken0 = 1 ether; + token0.approve(address(nativeWrapper), amountToken0); + + vm.expectRevert(DepositWrapper.DepositWrapper__ZeroAddress.selector); + nativeWrapper.depositFromToken0(amountToken0, amountToken0, 0, block.timestamp, address(0)); + + // No state updates + nativeWrapper.depositFromToken0(0, 0, 0, block.timestamp, recipient); + assertEq(stex.balanceOf(recipient), 0); + + vm.expectRevert(bytes("Excessive swap amount")); + stexLens.getMinAmountsForToken0Deposit(address(stex), 2 * amountToken0, 0, 0); + + (uint256 amountToken1Min, uint256 minShares) = + stexLens.getMinAmountsForToken0Deposit(address(stex), amountToken0, 0, 0); + + uint256 snapshot = vm.snapshotState(); + + uint256 shares = + nativeWrapper.depositFromToken0(amountToken0, amountToken1Min, minShares, block.timestamp, recipient); + assertEq(token0.balanceOf(address(nativeWrapper)), 0); + assertEq(weth.balanceOf(address(nativeWrapper)), 0); + assertEq(stex.balanceOf(recipient), minShares); + assertEq(stex.balanceOf(recipient), shares); + + vm.revertToState(snapshot); + + shares = nativeWrapper.depositFromToken0WithCode( + amountToken0, amountToken1Min, minShares, block.timestamp, recipient, keccak256("valantis") + ); + assertEq(token0.balanceOf(address(nativeWrapper)), 0); + assertEq(weth.balanceOf(address(nativeWrapper)), 0); + assertEq(stex.balanceOf(recipient), minShares); + assertEq(stex.balanceOf(recipient), shares); + + vm.stopPrank(); + } + + function testDeposit__WithUpdate() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("RECIPIENT"); + + // Mocks a completed unstake operation which needs to be sent back into pool through `update` + vm.deal(address(withdrawalModule), 0.123 ether); + assertEq(address(withdrawalModule).balance, 0.123 ether); + + weth.approve(address(stex), type(uint256).max); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 0.123 ether); + + stex.deposit(10e18, 0, block.timestamp, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 0.123 ether); + + // `update` must have been called + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertEq(reserve0, 0); + assertEq(reserve1, 10e18 + 0.123 ether); + assertEq(address(withdrawalModule).balance, 0); + assertEq(weth.balanceOf(address(withdrawalModule)), 0); + } + + function testOnDepositLiquidityCallback() public { + vm.expectRevert(STEXAMM.STEXAMM__OnlyPool.selector); + stex.onDepositLiquidityCallback(0, 0, new bytes(0)); + + uint256 amount1 = 1e18; + bytes memory data = abi.encode(address(this)); + weth.approve(address(stex), amount1); + + vm.startPrank(address(pool)); + + stex.onDepositLiquidityCallback(0, amount1, data); + + assertEq(weth.balanceOf(address(pool)), amount1); + + vm.stopPrank(); + } + + function testWithdraw() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("RECIPIENT"); + { + (uint256 amount0, uint256 amount1) = stexLens.getAmountsForWithdraw(address(stex), 0, false); + assertEq(amount0, 0); + assertEq(amount1, 0); + } + _deposit(1e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 1e18 + 1000 + 1); + + uint256 shares = stex.balanceOf(recipient); + + vm.expectRevert(STEXAMM.STEXAMM___checkDeadline_expired.selector); + stex.withdraw(1e18, 0, 0, block.timestamp - 1, recipient, false, false); + + vm.expectRevert(STEXAMM.STEXAMM__withdraw_zeroShares.selector); + stex.withdraw(0, 0, 0, block.timestamp, recipient, false, false); + + vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); + stex.withdraw(shares, 0, 0, block.timestamp, address(0), false, false); + + vm.expectRevert(STEXAMM.STEXAMM__withdraw_insufficientToken0Withdrawn.selector); + stex.withdraw(shares, 1, 0, block.timestamp, recipient, false, false); + + vm.expectRevert(STEXAMM.STEXAMM__withdraw_insufficientToken1Withdrawn.selector); + stex.withdraw(shares, 0, 1e19, block.timestamp, recipient, false, false); + + vm.startPrank(recipient); + + uint256 snapshot1 = vm.snapshotState(); + + // Test regular withdrawal in liquid token1 + (uint256 preReserve0, uint256 preReserve1) = pool.getReserves(); + { + (uint256 amount0Simulation, uint256 amount1Simulation) = + stexLens.getAmountsForWithdraw(address(stex), shares, false); + (uint256 amount0, uint256 amount1) = stex.withdraw(shares, 0, 0, block.timestamp, recipient, false, false); + assertEq(amount0Simulation, amount0); + assertEq(amount1Simulation, amount1); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 1e18 + 1000 + 1 - amount0 - amount1); + } + assertEq(stex.balanceOf(recipient), 0); + (uint256 postReserve0, uint256 postReserve1) = pool.getReserves(); + assertEq(preReserve0, postReserve0); + assertLt(postReserve1, preReserve1); + + // Test regular withdrawal in liquid native token (unwrapped token1) + vm.revertToState(snapshot1); + + uint256 preBalance = recipient.balance; + stex.withdraw(shares, 0, 0, block.timestamp, recipient, true, false); + assertEq(stex.balanceOf(recipient), 0); + uint256 postBalance = recipient.balance; + assertGt(postBalance, preBalance); + vm.stopPrank(); + } + + function testWithdraw__WithdrawalModule() public { + assertFalse(stex.isLocked()); + + // Tests withdrawal where token0 is sent to unstake via withdrawal module + address recipient = makeAddr("RECIPIENT"); + + _deposit(10e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1); + + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertEq(reserve0, 0); + assertEq(reserve1, 10e18 + 1e3 + 1); + + token0.mint(address(pool), 10e18); + + (reserve0, reserve1) = pool.getReserves(); + assertEq(reserve1, 10e18 + 1e3 + 1); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), withdrawalModule.convertToToken1(10e18) + 10e18 + 1000 + 1 + ); + + uint256 shares = stex.balanceOf(recipient); + assertGt(shares, 0); + + vm.startPrank(recipient); + + (uint256 amount0, uint256 amount1) = stex.withdraw(shares, 0, 0, block.timestamp, recipient, false, false); + assertEq( + stexLens.getTotalValueToken1(address(stex)), + withdrawalModule.convertToToken1(10e18) + 10e18 + 1000 + 1 - amount1 + - withdrawalModule.amountToken1PendingLPWithdrawal() + ); + assertEq(stex.balanceOf(recipient), 0); + assertEq(weth.balanceOf(recipient), amount1); + assertEq(token0.balanceOf(recipient), 0); + assertEq( + withdrawalModule.amountToken1PendingLPWithdrawal(), + withdrawalModule.convertToToken1((amount0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(withdrawalModule.idLPWithdrawal(), 1); + (address to, uint96 amountToken1, uint256 cumulativeAmount) = withdrawalModule.LPWithdrawals(0); + assertEq( + amountToken1, withdrawalModule.convertToToken1((amount0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(cumulativeAmount, 0); + assertEq(to, recipient); + + (reserve0, reserve1) = pool.getReserves(); + + vm.stopPrank(); + + // Mocks the processing of unstaking token0 by direct transfer of ETH + vm.deal(address(withdrawalModule), 20e18); + // The following quantities are immediately updated after the ETH balance increase, + // even before `update` is called + assertEq(withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + + uint256 preUpdateTVL = 20e18 + withdrawalModule.convertToToken1(10e18) + 10e18 + 1000 + 1 - amount1 + - withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); + assertEq(stexLens.getTotalValueToken1(address(stex)), preUpdateTVL); + + // Fulfills pending withdrawals and re-deposits remaining token1 amount into pool + withdrawalModule.update(); + // Update does not affect TVL + assertEq(stexLens.getTotalValueToken1(address(stex)), preUpdateTVL); + // token1 amount which was previously pending unstaking can now be claimed + assertEq(withdrawalModule.amountToken1ClaimableLPWithdrawal(), amountToken1); + // These values have already been updated after the ETH transfer + assertEq(withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq(withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(withdrawalModule.cumulativeAmountToken1ClaimableLPWithdrawal(), amountToken1); + // Surplus token1 amount was sent to pool + { + (uint256 reserve0Post, uint256 reserve1Post) = pool.getReserves(); + assertEq(reserve1Post, reserve1 + 20e18 - amountToken1); + assertEq(reserve0Post, reserve0); + } + // amountToken1 of native token remains in the contract, + // to be claimed by previously pending LP withdrawal + assertEq(withdrawalModule.amountToken1ClaimableLPWithdrawal(), amountToken1); + + // Claim LP's withdrawal request + + withdrawalModule.claim(0); + // claim does not affect TVL + assertEq(stexLens.getTotalValueToken1(address(stex)), preUpdateTVL); + assertEq(recipient.balance, amountToken1); + assertEq(withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + (to, amountToken1, cumulativeAmount) = withdrawalModule.LPWithdrawals(0); + assertEq(to, address(0)); + assertEq(amountToken1, 0); + assertEq(cumulativeAmount, 0); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_AlreadyClaimed.selector); + withdrawalModule.claim(0); + } + + function testWithdraw__FromLendingPool() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("RECIPIENT"); + + _setSwapFeeParams(3000, 5000, 1, 30); + + _deposit(10e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1); + + token0.mint(address(pool), 1e16); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e16)); + + // transfer wrapped native token reserves to pool, and then into lending protocol + weth.transfer(address(pool), 2 ether); + withdrawalModule.supplyToken1ToLendingPool(2 ether); + assertEq(withdrawalModule.amountToken1LendingPool(), 2 ether); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e16) + 2 ether + ); + + uint256 shares = stex.balanceOf(recipient) / 2; + assertGt(shares, 0); + + vm.startPrank(recipient); + + address withdrawRecipient = makeAddr("WITHDRAW_RECIPIENT"); + + uint256 snapshot = vm.snapshotState(); + + (uint256 amount0, uint256 amount1) = + stex.withdraw(shares, 0, 0, block.timestamp, withdrawRecipient, false, true); + assertEq(amount0, 0); + assertEq(weth.balanceOf(withdrawRecipient), amount1); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e16) + 2 ether - amount1 + ); + + vm.revertToState(snapshot); + + (amount0, amount1) = stex.withdraw(shares, 0, 0, block.timestamp, withdrawRecipient, true, true); + assertEq(amount0, 0); + assertEq(withdrawRecipient.balance, amount1); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e16) + 2 ether - amount1 + ); + } + + function testWithdraw__InstantWithdrawal() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("RECIPIENT"); + + _setSwapFeeParams(3000, 5000, 1, 30); + + _deposit(10e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1); + + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertEq(reserve0, 0); + assertEq(reserve1, 10e18 + 1e3 + 1); + + token0.mint(address(pool), 1e16); + + (reserve0, reserve1) = pool.getReserves(); + assertEq(reserve1, 10e18 + 1e3 + 1); + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e16)); + + uint256 shares = stex.balanceOf(recipient) / 2; + assertGt(shares, 0); + + vm.startPrank(recipient); + + // Instant withdrawals are entirely in token1, hence amount min of token0 must be 0 + vm.expectRevert(STEXAMM.STEXAMM__withdraw_insufficientToken0Withdrawn.selector); + (uint256 amount0, uint256 amount1) = stex.withdraw(shares, 1, 0, block.timestamp, recipient, false, true); + + (uint256 amount0Simulation, uint256 amount1Simulation) = + stexLens.getAmountsForWithdraw(address(stex), shares, true); + (amount0, amount1) = stex.withdraw(shares, 0, 0, block.timestamp, recipient, false, true); + assertEq(amount0Simulation, amount0); + assertEq(amount1Simulation, amount1); + assertEq(amount0, 0); + assertGt(amount1, 0); + assertEq(weth.balanceOf(recipient), amount1); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e16) - amount1 + ); + + vm.stopPrank(); + } + + function testWithdraw__InstantWithdrawal__FromLendingPool() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("RECIPIENT"); + + _setSwapFeeParams(3000, 5000, 1, 30); + + _deposit(10e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1); + + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertEq(reserve0, 0); + assertEq(reserve1, 10e18 + 1e3 + 1); + + token0.mint(address(pool), 10e18); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(10e18) + ); + + (reserve0, reserve1) = pool.getReserves(); + assertEq(reserve1, 10e18 + 1e3 + 1); + + uint256 shares = stex.balanceOf(recipient) / 2; + assertGt(shares, 0); + + vm.startPrank(recipient); + + // Instant withdrawals are entirely in token1, hence amount min of token0 must be 0 + vm.expectRevert(STEXAMM.STEXAMM__withdraw_insufficientToken0Withdrawn.selector); + (uint256 amount0, uint256 amount1) = stex.withdraw(shares, 1, 0, block.timestamp, recipient, false, true); + vm.stopPrank(); + + // Supply a large fraction of token1 reserves into lending pool + withdrawalModule.supplyToken1ToLendingPool(9e18); + assertEq(lendingModule.assetBalance(), 9e18); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(10e18) + ); + + vm.startPrank(recipient); + + (uint256 amount0Simulation, uint256 amount1Simulation) = + stexLens.getAmountsForWithdraw(address(stex), shares, true); + (amount0, amount1) = stex.withdraw(shares, 0, 0, block.timestamp, recipient, false, true); + assertEq(amount0Simulation, amount0); + assertEq(amount1Simulation, amount1); + assertEq(amount0, 0); + assertGt(amount1, 0); + assertEq(weth.balanceOf(recipient), amount1); + // All token1 reserves have been withdrawn from pool + assertEq(weth.balanceOf(address(pool)), 0); + assertLt(lendingModule.assetBalance(), 9e18); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10e18 + 1000 + 1 + withdrawalModule.convertToToken1(10e18) - amount1 + ); + + vm.stopPrank(); + } + + function testWithdraw__WithUpdate() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("MOCK_RECIPIENT"); + + _deposit(1e18, recipient); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 1e18 + 1000 + 1); + + uint256 shares = stex.balanceOf(recipient); + assertGt(shares, 0); + + token0.mint(address(pool), 1e18); + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertGt(reserve0, 0); + assertEq(reserve1, 1e18 + 1e3 + 1); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 1e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e18)); + + vm.startPrank(recipient); + + // Mocks the processing of unstaking token0 by direct transfer of native token + vm.deal(address(withdrawalModule), 0.123 ether); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 1e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e18) + 0.123 ether + ); + + stex.withdraw(shares / 2, 0, 0, block.timestamp, recipient, false, false); + // wrapped native token was transferred to the pool and recipient + assertGt(weth.balanceOf(recipient), 0); + assertEq(token0.balanceOf(recipient), 0); + assertEq(weth.balanceOf(address(pool)), reserve1 + 0.123 ether - weth.balanceOf(recipient)); + assertEq(address(withdrawalModule).balance, 0); + assertEq(weth.balanceOf(address(withdrawalModule)), 0); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 1e18 + 1000 + 1 + withdrawalModule.convertToToken1(1e18) + 0.123 ether - weth.balanceOf(recipient) + - withdrawalModule.amountToken1PendingLPWithdrawal() + ); + + vm.stopPrank(); + } + + function testWithdraw__NoClaimOnToken0() public { + address recipient1 = makeAddr("RECIPIENT_1"); + address recipient2 = makeAddr("RECIPIENT_2"); + + _setSwapFeeParams(3000, 5000, 1, 30); + + // user 1 deposits + _deposit(10 ether, recipient1); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10 ether + 1000 + 1); + + uint256 shares1 = stex.balanceOf(recipient1); + assertGt(shares1, 0); + + // user 2 deposits + stex.deposit(1 ether, 0, block.timestamp, recipient2); + uint256 shares2 = stex.balanceOf(recipient2); + assertGt(shares2, 0); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10 ether + 1000 + 1 + 1 ether); + + // Replace 5e18 wrapped native with 5e18 LST (same effect as swaps with 0 fee) + token0.mint(address(pool), 5 ether); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10 ether + 1000 + 1 + 1 ether + withdrawalModule.convertToToken1(5 ether) + ); + + vm.prank(address(stex)); + pool.withdrawLiquidity(0, 5 ether, address(this), address(1), new bytes(0)); + (uint256 reserve0, uint256 reserve1) = pool.getReserves(); + assertEq(reserve0, 5 ether); + assertEq(reserve1, 5 ether + 1 ether + 1e3 + 1); + + // user 1 withdraws (delayed withdraw) + { + vm.startPrank(recipient1); + (uint256 amount0Simulation, uint256 amount1Simulation) = + stexLens.getAmountsForWithdraw(address(stex), shares1, false); + (uint256 amount0, uint256 amount1) = stex.withdraw(shares1, 0, 0, block.timestamp, recipient1, false, false); + vm.stopPrank(); + assertEq(amount0Simulation, amount0); + assertEq(amount1Simulation, amount1); + + // Kinetiq charges an unstaking fee + uint256 feeToken0 = (amount0 * stakingManager.unstakeFeeRate()) / BIPS; + + assertGt(withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + + assertEq( + withdrawalModule.amountToken1PendingLPWithdrawal(), + withdrawalModule.convertToToken1(amount0 - feeToken0) + ); + + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10 ether + 1000 + 1 + 1 ether + withdrawalModule.convertToToken1(5 ether) - 5 ether - amount1 + - withdrawalModule.convertToToken1(amount0 - feeToken0) + ); + } + // No unstaking has happened + assertEq(withdrawalModule.amountToken0PendingUnstaking(), 0); + + (reserve0, reserve1) = pool.getReserves(); + assertEq(reserve0, 5 ether); + assertEq(reserve1, 5 ether + 1 ether + 1e3 + 1 - weth.balanceOf(recipient1)); + + // Replace 1e18 LST with 1e18 wrapped native token (same effect as swaps with 0 fee) + { + uint256 shares = withdrawalModule.token0BalanceToShares(1e18); + token0.burn(address(pool), shares); + stakingAccountant.recordClaim(1e18); + } + + weth.transfer(address(pool), 1e18); + + // TVL remains constant + assertEq( + stexLens.getTotalValueToken1(address(stex)), + 10 ether + 1000 + 1 + 1 ether + withdrawalModule.convertToToken1(5 ether) - 5 ether + - weth.balanceOf(recipient1) - withdrawalModule.amountToken1PendingLPWithdrawal() + ); + + (reserve0, reserve1) = pool.getReserves(); + // There is a higher amount of token1 owed to recipient1 than pool's token0 reserves + assertGt(withdrawalModule.amountToken1PendingLPWithdrawal(), reserve0); + + uint256 snapshot = vm.snapshotState(); + + // user 2 withdraws (delayed withdraw) + { + vm.startPrank(recipient2); + (uint256 amount0Simulation, uint256 amount1Simulation) = + stexLens.getAmountsForWithdraw(address(stex), shares2, false); + (uint256 amount0Withdraw, uint256 amount1Withdraw) = + stex.withdraw(shares2, 0, 0, block.timestamp, recipient2, false, false); + assertEq(amount0Simulation, amount0Withdraw); + assertEq(amount1Simulation, amount1Withdraw); + (reserve0, reserve1) = pool.getReserves(); + + // user 2 has no claim on token0 reserves, + // but can still withdraw its due token1 portion + assertEq(amount0Withdraw, 0); + assertLt(amount1Withdraw, 1e18); + assertEq(weth.balanceOf(recipient2), amount1Withdraw); + } + + vm.revertToState(snapshot); + + // user 2 withdraws (instant withdraw) + { + vm.startPrank(recipient2); + (uint256 amount0Simulation, uint256 amount1Simulation) = + stexLens.getAmountsForWithdraw(address(stex), shares2, true); + (uint256 amount0Withdraw, uint256 amount1Withdraw) = + stex.withdraw(shares2, 0, 0, block.timestamp, recipient2, false, true); + assertEq(amount0Simulation, amount0Withdraw); + assertEq(amount1Simulation, amount1Withdraw); + (reserve0, reserve1) = pool.getReserves(); + + // user 2 has no claim on token0 reserves, + // but can still withdraw its due token1 portion + assertEq(amount0Withdraw, 0); + assertLt(amount1Withdraw, 1e18); + assertEq(weth.balanceOf(recipient2), amount1Withdraw); + } + } + + function testSwap() public { + assertFalse(stex.isLocked()); + + address recipient = makeAddr("RECIPIENT"); + _setSwapFeeParams(3000, 5000, 1, 30); + + { + uint256 amountOutSimulation = stex.getAmountOut(address(token0), 0, false); + assertEq(amountOutSimulation, 0); + amountOutSimulation = stex.getAmountOut(recipient, 1 ether, false); + assertEq(amountOutSimulation, 0); + } + + // Test token0 -> token1 swap (low price impact) + SovereignPoolSwapParams memory params; + params.isZeroToOne = true; + params.amountIn = 0.4 ether; + params.deadline = block.timestamp; + params.swapTokenOut = address(weth); + params.recipient = recipient; + + // zero token1 liquidity + vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__getSwapFeeInBips_ZeroReserveToken1.selector); + stex.getAmountOut(address(token0), params.amountIn, false); + + _addPoolReserves(0, 30 ether); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 30 ether); + + uint256 amountOutEstimate = stex.getAmountOut(address(token0), params.amountIn, false); + (uint256 amountInUsed, uint256 amountOut) = pool.swap(params); + assertLt(amountOut, withdrawalModule.convertToToken1(amountInUsed)); + assertLt(withdrawalModule.convertToToken0(amountOut), amountInUsed); + assertEq(amountInUsed, 0.4 ether); + assertEq(amountOut, amountOutEstimate); + SwapFeeModuleData memory swapFeeData = + swapFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + // price impact was low, so fee is still at the minimum + assertEq(swapFeeData.feeInBips, 1); + assertEq(weth.balanceOf(recipient), amountOut); + + // swaps with non-zero fee always increase TVL + uint256 TVL = stexLens.getTotalValueToken1(address(stex)); + assertGt(TVL, 30 ether); + + // Test token0 -> token1 swap (medium price impact) + params.amountIn = 5 ether; + (amountInUsed, amountOut) = pool.swap(params); + assertEq(amountInUsed, 5 ether); + swapFeeData = + swapFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + assertGt(swapFeeData.feeInBips, 1); + assertLt(swapFeeData.feeInBips, 30); + + // swaps with non-zero fee always increase TVL + assertGt(stexLens.getTotalValueToken1(address(stex)), TVL); + TVL = stexLens.getTotalValueToken1(address(stex)); + + // Test token0 -> token1 swap (large price impact) + params.amountIn = 10 ether; + amountOutEstimate = stex.getAmountOut(address(token0), params.amountIn, false); + (amountInUsed, amountOut) = pool.swap(params); + assertLt(amountOut, withdrawalModule.convertToToken1(amountInUsed)); + assertLt(withdrawalModule.convertToToken0(amountOut), amountInUsed); + assertEq(amountOut, amountOutEstimate); + swapFeeData = swapFeeModule.getSwapFeeInBips(address(token0), address(0), 0, address(0), new bytes(0)); + // This swap is large enough to push the fee to its maximum value of 30 bips + assertEq(swapFeeData.feeInBips, 30); + + assertGt(stexLens.getTotalValueToken1(address(stex)), TVL); + + params.amountIn = 1 ether; + // Fees in sovereign pool are applied as amountIn * BIPS / (BIPS + fee), + // so we expect some discrepancies + uint256 amountOutExpectedApprox = withdrawalModule.convertToToken1((params.amountIn * (10_000 - 30)) / 10_000); + (amountInUsed, amountOut) = pool.swap(params); + assertEq(amountInUsed, 1 ether); + // Discrepancy should not exceed 1 bips + assertEq((amountOut * 10_000) / amountOut, (amountOutExpectedApprox * 10_000) / amountOutExpectedApprox); + + TVL = stexLens.getTotalValueToken1(address(stex)); + + // Test token1 -> token0 swap + params.isZeroToOne = false; + params.swapTokenOut = address(token0); + + // 1:1 exchange rate + (amountInUsed, amountOut) = pool.swap(params); + assertEq(amountInUsed, 1 ether); + assertEq(amountOut, withdrawalModule.convertToToken0(1 ether)); + // amountOut is 1:1, because token0 is rebase + assertApproxEqAbs(amountOut, 1 ether, 1); + + // swaps with zero fee do not change TVL + assertEq(stexLens.getTotalValueToken1(address(stex)), TVL); + } + + function testSwap__SplitAmountVsFullAmount() public { + address recipient = makeAddr("RECIPIENT"); + _setSwapFeeParams(3000, 5000, 1, 30); + + _addPoolReserves(0, 30 ether); + + uint256 snapshot = vm.snapshotState(); + uint256 amountOutTotalSplitSwaps; + + // We will test two scenarios: + // two split swaps, each with amountIn = 5 eth + // one swap with full amountIn = 10 eth + + // token0 -> token1 split amount swap 1/2 + SovereignPoolSwapParams memory params; + params.isZeroToOne = true; + params.amountIn = 5 ether; + params.deadline = block.timestamp; + params.swapTokenOut = address(weth); + params.recipient = recipient; + + uint256 amountOutEstimate = stex.getAmountOut(address(token0), params.amountIn, false); + (uint256 amountInUsed, uint256 amountOut) = pool.swap(params); + assertLt(amountOut, withdrawalModule.convertToToken1(amountInUsed)); + assertLt(withdrawalModule.convertToToken0(amountOut), amountInUsed); + assertEq(amountInUsed, 5 ether); + assertEq(amountOut, amountOutEstimate); + SwapFeeModuleData memory swapFeeData = + swapFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + amountOutTotalSplitSwaps += amountOut; + + // token0 -> token1 split amount swap 2/2 + params.amountIn = 5 ether; + (amountInUsed, amountOut) = pool.swap(params); + assertEq(amountInUsed, 5 ether); + swapFeeData = + swapFeeModule.getSwapFeeInBips(address(token0), address(0), params.amountIn, address(0), new bytes(0)); + amountOutTotalSplitSwaps += amountOut; + + vm.revertToState(snapshot); + + // Test token0 -> token1 swap with full amount + params.amountIn = 10 ether; + amountOutEstimate = stex.getAmountOut(address(token0), params.amountIn, false); + (amountInUsed, amountOut) = pool.swap(params); + assertLt(amountOut, withdrawalModule.convertToToken1(amountInUsed)); + assertLt(withdrawalModule.convertToToken0(amountOut), amountInUsed); + assertEq(amountOut, amountOutEstimate); + swapFeeData = swapFeeModule.getSwapFeeInBips(address(token0), address(0), 0, address(0), new bytes(0)); + // Split swaps yields strictly worse trade execution + assertLt(amountOutTotalSplitSwaps, amountOut); + } + + function testClaimPoolManagerFees() public { + // Set 1% pool manager fee + vm.prank(owner); + stex.setPoolManagerFeeBips(100); + + address recipient = makeAddr("RECIPIENT"); + _setSwapFeeParams(100, 200, 1, 30); + + _addPoolReserves(0, 30 ether); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 30 ether); + + assertEq(token0.balanceOf(address(stex)), 0); + assertEq(weth.balanceOf(address(stex)), 0); + + // Execute token0 -> token1 swap + SovereignPoolSwapParams memory params; + params.isZeroToOne = true; + params.amountIn = 10 ether; + params.deadline = block.timestamp; + params.swapTokenOut = address(weth); + params.recipient = recipient; + (uint256 amountInUsed, uint256 amountOut) = pool.swap(params); + assertEq(amountInUsed, 10 ether); + assertEq(weth.balanceOf(recipient), amountOut); + + // swaps with non-zero fee always increase TVL + uint256 postSwapTVL = stexLens.getTotalValueToken1(address(stex)); + assertGt(postSwapTVL, 30 ether); + + // Mock token1 fee via donation + weth.transfer(address(stex), 1 ether); + + // Donations to STEX AMM do not affect TVL + assertEq(stexLens.getTotalValueToken1(address(stex)), postSwapTVL); + + // Pool manager fee has automatically been transferred to STEX during the swap + assertGt(token0.balanceOf(address(stex)), 0); + assertEq(weth.balanceOf(address(stex)), 1 ether); + + // Claim pool manager fees + stex.claimPoolManagerFees(); + assertGt(token0.balanceOf(poolFeeRecipient1), 0); + assertGt(token0.balanceOf(poolFeeRecipient2), 0); + assertEq(weth.balanceOf(poolFeeRecipient1), 0.5 ether); + assertEq(weth.balanceOf(poolFeeRecipient2), 0.5 ether); + assertEq(token0.balanceOf(address(stex)), 0); + assertEq(weth.balanceOf(address(stex)), 0); + } + + function testUnstakeToken0Reserves() public { + uint256 amountToken0ReservesInitial = token0.balanceOf(address(stex)); + vm.expectRevert(STEXAMM.STEXAMM__OnlyWithdrawalModule.selector); + stex.unstakeToken0Reserves(amountToken0ReservesInitial); + + _addPoolReserves(10 ether, 0); + + assertEq(stexLens.getTotalValueToken1(address(stex)), withdrawalModule.convertToToken1(10 ether)); + + uint256 amountToken0ReservesFinal = token0.balanceOf(address(pool)); + + vm.startPrank(address(withdrawalModule)); + + vm.expectRevert(STEXAMM.STEXAMM__unstakeToken0Reserves_amountCannotBeZero.selector); + stex.unstakeToken0Reserves(0); + + vm.expectRevert(STEXAMM.STEXAMM__unstakeToken0Reserves_amountTooHigh.selector); + stex.unstakeToken0Reserves(10 ether + 1); + + vm.stopPrank(); + + withdrawalModule.unstakeToken0Reserves(amountToken0ReservesFinal); + + assertEq(token0.balanceOf(address(pool)), 0); + assertEq( + withdrawalModule.amountToken0PendingUnstaking(), + (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + + // TVL gets reduced because of the unstaking fee + assertEq( + stexLens.getTotalValueToken1(address(stex)), + withdrawalModule.convertToToken1((10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + } + + function testUnstakeToken0ReservesPartial() public { + uint256 amountToken0ReservesInitial = token0.balanceOf(address(stex)); + vm.expectRevert(STEXAMM.STEXAMM__OnlyWithdrawalModule.selector); + stex.unstakeToken0Reserves(amountToken0ReservesInitial); + + _addPoolReserves(10 ether, 0); + uint256 amountToken0ReservesFinal = token0.balanceOf(address(pool)); + vm.startPrank(address(withdrawalModule)); + stex.unstakeToken0Reserves(amountToken0ReservesFinal / 2); + assertApproxEqAbs(token0.balanceOf(address(pool)), amountToken0ReservesFinal / 2, 1); + } + + function testSupplyToken1Reserves() public { + uint256 amount = 1 ether; + + vm.expectRevert(STEXAMM.STEXAMM__OnlyWithdrawalModule.selector); + stex.supplyToken1Reserves(amount); + + _addPoolReserves(0, 10 ether); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10 ether); + + withdrawalModule.supplyToken1ToLendingPool(amount); + + assertEq(stexLens.getTotalValueToken1(address(stex)), 10 ether); + } + + function testGetLiquidityQuote() public { + // Test token1 -> token0 + ALMLiquidityQuoteInput memory input; + input.amountInMinusFee = 123e18; + ALMLiquidityQuote memory quote = stex.getLiquidityQuote(input, new bytes(0), new bytes(0)); + assertEq(quote.amountInFilled, input.amountInMinusFee); + // tokenOut=token0 balances represents shares of native token + assertEq(quote.amountOut, (input.amountInMinusFee * token0.totalSupply()) / address(stakingManager).balance); + + vm.prank(owner); + stex.pause(); + + vm.expectRevert(Pausable.EnforcedPause.selector); + stex.getLiquidityQuote(input, new bytes(0), new bytes(0)); + + vm.prank(owner); + stex.unpause(); + + // Test token0 -> token1 + input.isZeroToOne = true; + quote = stex.getLiquidityQuote(input, new bytes(0), new bytes(0)); + assertEq(quote.amountInFilled, input.amountInMinusFee); + assertEq(quote.amountOut, (input.amountInMinusFee * address(stakingManager).balance) / token0.totalSupply()); + } + + function testOnSwapCallback() public { + vm.expectRevert(STEXAMM.STEXAMM__onSwapCallback_NotImplemented.selector); + stex.onSwapCallback(false, 0, 0); + } + + function _addPoolReserves(uint256 amount0, uint256 amount1) private { + (, uint256 preReserve1) = pool.getReserves(); + if (amount0 > 0) { + token0.mint(address(pool), amount0); + } + + if (amount1 > 0) { + weth.transfer(address(pool), amount1); + (, uint256 postReserve1) = pool.getReserves(); + assertEq(postReserve1, preReserve1 + amount1); + } + } +} diff --git a/test/kHYPEWithdrawalModule.t.sol b/test/kHYPEWithdrawalModule.t.sol new file mode 100644 index 0000000..3eed9aa --- /dev/null +++ b/test/kHYPEWithdrawalModule.t.sol @@ -0,0 +1,1158 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; +import {WETH} from "@solmate/tokens/WETH.sol"; + +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; +import {kHYPEWithdrawalModule} from "src/withdrawal-modules/kHYPEWithdrawalModule.sol"; +import {STEXLens} from "src/STEXLens.sol"; +import {IRebalanceModule} from "src/interfaces/IRebalanceModule.sol"; +import {MockLendingPool} from "src/mocks/MockLendingPool.sol"; +import {MockStakingAccountant} from "src/mocks/kinetiq/MockStakingAccountant.sol"; +import {MockStakingManager} from "src/mocks/kinetiq/MockStakingManager.sol"; +import {LPWithdrawalRequest} from "src/structs/WithdrawalModuleStructs.sol"; + +contract MockPool { + bool private _isLocked = false; + + ERC20Mock public token0; + ERC20Mock public token1; + + bool public decreaseReserve0; + bool public decreaseReserve1; + + constructor(address _token0, address _token1) { + token0 = ERC20Mock(_token0); + token1 = ERC20Mock(_token1); + } + + function isLocked() external view returns (bool) { + return _isLocked; + } + + function setIsLocked(bool _value) external { + _isLocked = _value; + } + + function setDecreaseReserves(bool _value0, bool _value1) external { + decreaseReserve0 = _value0; + decreaseReserve1 = _value1; + } + + function getReserves() external view returns (uint256 reserve0, uint256 reserve1) { + reserve0 = token0.balanceOf(address(this)); + reserve1 = token1.balanceOf(address(this)); + + if (decreaseReserve0) { + reserve0 -= 1; + } + + if (decreaseReserve1) { + reserve1 -= 1; + } + } +} + +contract MockRebalanceModule is IRebalanceModule { + WETH weth; + MockPool pool; + + constructor(WETH _weth, MockPool _pool) { + weth = _weth; + pool = _pool; + } + + function rebalance(uint256 amountToken1Min, bytes calldata payload) external returns (bytes4) { + (bool useWrongSelector, bool reduceToken0Reserves, bool reduceToken1Reserves, bool transferInsufficientAmount) = + abi.decode(payload, (bool, bool, bool, bool)); + + weth.transfer(msg.sender, transferInsufficientAmount ? amountToken1Min / 2 : amountToken1Min); + + if (reduceToken0Reserves) { + pool.setDecreaseReserves(true, false); + } + + if (reduceToken1Reserves) { + pool.setDecreaseReserves(false, true); + } + + if (useWrongSelector) { + return kHYPEWithdrawalModule.convertToToken0.selector; + } else { + return IRebalanceModule.rebalance.selector; + } + } +} + +contract kHYPEWithdrawalModuleTest is Test { + STEXLens stexLens; + + kHYPEWithdrawalModule _withdrawalModule; + + WETH weth; + // kHYPE + ERC20Mock private _token0; + + MockStakingAccountant stakingAccountant; + MockStakingManager stakingManager; + + MockLendingPool lendingPool; + AaveLendingModule lendingModule; + + MockRebalanceModule rebalanceModule; + + address private _pool; + + address public owner = makeAddr("OWNER"); + + uint256 private constant BIPS = 10_000; + + function setUp() public { + stexLens = new STEXLens(); + + _token0 = new ERC20Mock(); + weth = new WETH(); + + stakingAccountant = new MockStakingAccountant(address(_token0)); + stakingManager = new MockStakingManager(address(stakingAccountant), address(_token0)); + + // 10 bips unstaking fee + stakingManager.setUnstakeFeeRate(10); + + _pool = address(new MockPool(address(_token0), address(weth))); + + rebalanceModule = new MockRebalanceModule(weth, MockPool(_pool)); + + lendingPool = new MockLendingPool(address(weth)); + assertEq(lendingPool.underlyingAsset(), address(weth)); + assertEq(lendingPool.lendingPoolYieldToken(), address(lendingPool)); + + _withdrawalModule = new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), owner); + + vm.startPrank(owner); + // AMM will be mocked to make testing more flexible + _withdrawalModule.setSTEX(address(this)); + assertEq(_withdrawalModule.stex(), address(this)); + vm.stopPrank(); + + lendingModule = new AaveLendingModule( + address(lendingPool), + lendingPool.lendingPoolYieldToken(), + address(weth), + address(_withdrawalModule), + address(0x123), + 2 + ); + assertEq(lendingModule.yieldToken(), lendingPool.lendingPoolYieldToken()); + assertEq(lendingModule.asset(), address(weth)); + assertEq(lendingModule.tokenSweepManager(), address(0x123)); + assertEq(lendingModule.owner(), address(_withdrawalModule)); + assertEq(lendingModule.referralCode(), 2); + + vm.startPrank(owner); + _withdrawalModule.proposeLendingModule(address(lendingModule), 3 days); + vm.warp(block.timestamp + 3 days); + _withdrawalModule.setProposedLendingModule(); + vm.stopPrank(); + + assertEq(address(_withdrawalModule.lendingModule()), address(lendingModule)); + assertEq(_withdrawalModule.owner(), owner); + + vm.deal(address(this), 300 ether); + weth.deposit{value: 100 ether}(); + + weth.transfer(address(rebalanceModule), 10 ether); + assertEq(weth.balanceOf(address(rebalanceModule)), 10 ether); + + stakingManager.stake{value: 100 ether}(); + assertGt(_token0.totalSupply(), 0); + assertEq(_token0.balanceOf(address(this)), 100 ether); + assertEq(address(stakingManager).balance, 100 ether); + + _token0.approve(address(_withdrawalModule), 100 ether); + } + + // AMM mock functions // + + function withdrawalModule() external view returns (address) { + return address(_withdrawalModule); + } + + function token0() external view returns (address) { + return address(_token0); + } + + function token1() external view returns (address) { + return address(weth); + } + + function pool() external view returns (address) { + return _pool; + } + + function unstakeToken0Reserves(uint256 _unstakeAmountToken0) external {} + + function supplyToken1Reserves(uint256 amount) external { + weth.transfer(msg.sender, amount); + } + + // End of AMM mock functions // + + function testDeploy() public returns (kHYPEWithdrawalModule withdrawalModuleDeployment) { + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); + new kHYPEWithdrawalModule(address(0), address(stakingManager), address(this)); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); + new kHYPEWithdrawalModule(address(stakingAccountant), address(0), address(this)); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(0)); + + withdrawalModuleDeployment = + new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(this)); + assertEq(withdrawalModuleDeployment.stakingAccountant(), address(stakingAccountant)); + assertEq(withdrawalModuleDeployment.stakingManager(), address(stakingManager)); + assertEq(withdrawalModuleDeployment.owner(), address(this)); + assertEq(address(withdrawalModuleDeployment.lendingModule()), address(0)); + assertEq(withdrawalModuleDeployment.amountToken1LendingPool(), 0); + assertEq(withdrawalModuleDeployment.overseer(), address(stakingManager)); + } + + function testSweep() public { + ERC20Mock mockToken = new ERC20Mock(); + address recipient = makeAddr("MOCK_RECIPIENT"); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.sweep(address(mockToken), recipient); + + vm.startPrank(owner); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); + _withdrawalModule.sweep(address(0), recipient); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); + _withdrawalModule.sweep(address(mockToken), address(0)); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__sweep_Token0CannotBeSweeped.selector); + _withdrawalModule.sweep(address(_token0), recipient); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__sweep_Token1CannotBeSweeped.selector); + _withdrawalModule.sweep(address(weth), recipient); + + mockToken.mint(address(_withdrawalModule), 10 ether); + _withdrawalModule.sweep(address(mockToken), recipient); + assertEq(mockToken.balanceOf(recipient), 10 ether); + + vm.stopPrank(); + } + + function testToken0Conversion() public { + address recipient = makeAddr("MOCK_RECIPIENT"); + + uint256 amount0 = 1.1 ether; + uint256 amount1 = _withdrawalModule.convertToToken1(amount0); + // Current exchange rate is 1:1 + assertEq(amount0, amount1); + assertEq(amount0, _withdrawalModule.convertToToken0(amount1)); + + // Simulate rewards accrual + stakingAccountant.setTotalRewards(10 ether); + amount1 = _withdrawalModule.convertToToken1(amount0); + // Exchange rate has increased + assertGt(amount1, amount0); + assertEq(amount0, _withdrawalModule.convertToToken0(amount1)); + + // Simulate slashing + stakingAccountant.setTotalRewards(0); + stakingAccountant.setTotalSlashing(10 ether); + + amount1 = _withdrawalModule.convertToToken1(amount0); + // Exchange rate has decreased + assertLt(amount1, amount0); + assertEq(amount0, _withdrawalModule.convertToToken0(amount1)); + + uint256 shares = _withdrawalModule.token0BalanceToShares(amount1); + // token0 balance represents shares of ownership + assertEq(shares, amount0); + _token0.transfer(recipient, amount0); + + assertEq(_withdrawalModule.token0SharesOf(recipient), amount0); + assertEq(_withdrawalModule.token0SharesToBalance(shares), amount1); + } + + function testStakeAmount1() public { + assertFalse(_withdrawalModule.isLocked()); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.stakeToken1(1 ether); + + vm.startPrank(owner); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.stakeToken1(1 ether); + MockPool(_pool).setIsLocked(false); + + assertEq(_token0.balanceOf(_pool), 0); + uint256 preToken1Balance = weth.balanceOf(address(this)); + _withdrawalModule.stakeToken1(1 ether); + assertEq(weth.balanceOf(address(this)), preToken1Balance - 1 ether); + assertEq(_token0.balanceOf(_pool), _withdrawalModule.convertToToken0(1 ether)); + + vm.stopPrank(); + } + + function testAmountToken1LendingPool() public { + assertFalse(_withdrawalModule.isLocked()); + + vm.startPrank(owner); + + uint256 balance = _withdrawalModule.amountToken1LendingPool(); + assertEq(balance, 0); + + _withdrawalModule.supplyToken1ToLendingPool(2 ether); + + balance = _withdrawalModule.amountToken1LendingPool(); + assertEq(balance, 2 ether); + + vm.stopPrank(); + + // Simulate yield increase + weth.transfer(address(lendingPool), 0.1 ether); + balance = _withdrawalModule.amountToken1LendingPool(); + assertEq(balance, 2.1 ether); + } + + function testSetSTEX() public { + kHYPEWithdrawalModule withdrawalModuleDeployment = testDeploy(); + + vm.prank(_pool); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _pool)); + withdrawalModuleDeployment.setSTEX(address(this)); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); + withdrawalModuleDeployment.setSTEX(address(0)); + + withdrawalModuleDeployment.setSTEX(address(this)); + assertEq(withdrawalModuleDeployment.stex(), address(this)); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__setSTEX_AlreadySet.selector); + withdrawalModuleDeployment.setSTEX(_pool); + } + + function testReceive() public { + vm.deal(address(this), 1 ether); + (bool success,) = address(_withdrawalModule).call{value: 1 ether}(""); + assertTrue(success); + assertEq(address(_withdrawalModule).balance, 1 ether); + } + + function testBurnToken0AfterWithdraw() public { + assertFalse(_withdrawalModule.isLocked()); + + uint256 amountToken0 = 1 ether; + address recipient = makeAddr("MOCK_RECIPIENT"); + + _burnToken0AfterWithdraw(amountToken0, recipient); + } + + function testUnstakeToken0Reserves() public { + assertFalse(_withdrawalModule.isLocked()); + + _unstakeToken0Reserves(3 ether); + + // kHYPE amount gets transferred to stakingManager on withdrawals + assertEq(_token0.balanceOf(address(stakingManager)), 3 ether); + // withdrawal id has been incremented + assertEq(stakingManager.nextWithdrawalId(address(_withdrawalModule)), 1); + } + + function testRebalanceToken0Reserves() public { + address recipient = makeAddr("MOCK_RECIPIENT"); + + assertFalse(_withdrawalModule.isLocked()); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.rebalanceToken0Reserves(1 ether, recipient, address(rebalanceModule), new bytes(0)); + + vm.startPrank(owner); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); + _withdrawalModule.rebalanceToken0Reserves(1 ether, address(0), address(rebalanceModule), new bytes(0)); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__rebalanceToken0Reserves_InvalidRecipient.selector); + _withdrawalModule.rebalanceToken0Reserves(1 ether, recipient, address(rebalanceModule), new bytes(0)); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.rebalanceToken0Reserves(1 ether, recipient, address(rebalanceModule), new bytes(0)); + MockPool(_pool).setIsLocked(false); + + // No state changes + _withdrawalModule.rebalanceToken0Reserves(0, address(rebalanceModule), address(rebalanceModule), new bytes(0)); + assertEq(weth.balanceOf(_pool), 0); + assertEq(_token0.balanceOf(_pool), 0); + + vm.stopPrank(); + + weth.transfer(_pool, 2 ether); + _token0.transfer(_pool, 3 ether); + weth.transfer(owner, 1 ether); + + // Mocks unstaking 1 ether from pool + _token0.transfer(address(_withdrawalModule), 1 ether); + + vm.startPrank(owner); + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__rebalanceToken0Reserves_RebalanceModuleCallFailed.selector + ); + _withdrawalModule.rebalanceToken0Reserves( + 1 ether, address(rebalanceModule), address(rebalanceModule), abi.encode(true, false, false, false) + ); + + // Call to rebalance module cannot decrease pool reserves + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__rebalanceToken0Reserves_PoolToken0ReservesDecreased.selector + ); + _withdrawalModule.rebalanceToken0Reserves( + 1 ether, address(rebalanceModule), address(rebalanceModule), abi.encode(false, true, false, false) + ); + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__rebalanceToken0Reserves_PoolToken1ReservesDecreased.selector + ); + _withdrawalModule.rebalanceToken0Reserves( + 1 ether, address(rebalanceModule), address(rebalanceModule), abi.encode(false, false, true, false) + ); + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__rebalanceToken0Reserves_InsufficientToken1Received.selector + ); + _withdrawalModule.rebalanceToken0Reserves( + 1 ether, address(rebalanceModule), address(rebalanceModule), abi.encode(false, false, false, true) + ); + + uint256 snapshot = vm.snapshotState(); + + _withdrawalModule.rebalanceToken0Reserves( + 1 ether, address(rebalanceModule), address(rebalanceModule), abi.encode(false, false, false, false) + ); + + assertEq(weth.balanceOf(_pool), 2 ether + (1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + assertEq(_token0.balanceOf(address(rebalanceModule)), 1 ether); + assertEq(_token0.balanceOf(recipient), 0); + + vm.revertToState(snapshot); + + weth.approve(address(_withdrawalModule), (1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + _withdrawalModule.rebalanceToken0Reserves( + 1 ether, + recipient, + address(0), // Assumes that this contract will transfer token1 amount + abi.encode(false, false, false, false) + ); + + assertEq(weth.balanceOf(_pool), 2 ether + (1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + assertEq(_token0.balanceOf(recipient), 1 ether); + + vm.stopPrank(); + } + + function testUnstakeExcessToken0() public { + assertFalse(_withdrawalModule.isLocked()); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.unstakeExcessToken0(); + + vm.startPrank(owner); + + uint256 preBalance = _token0.balanceOf(address(stakingManager)); + _withdrawalModule.unstakeExcessToken0(); + uint256 postBalance = _token0.balanceOf(address(stakingManager)); + assertEq(preBalance, postBalance); + + vm.stopPrank(); + + _unstakeToken0Reserves(10 ether); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + assertEq(stakingManager.nextWithdrawalId(address(_withdrawalModule)), 1); + // Simulates cancellation of token0 withdrawal, + // which returns original token0 amount back to withdrawal module + stakingManager.cancelWithdrawal(address(_withdrawalModule), 0); + assertEq(_token0.balanceOf(address(_withdrawalModule)), 10 ether); + + vm.startPrank(owner); + + _withdrawalModule.unstakeExcessToken0(); + postBalance = _token0.balanceOf(address(stakingManager)); + assertEq(postBalance - preBalance, 10 ether); + // A new withdrawal request has been created + assertEq(stakingManager.nextWithdrawalId(address(_withdrawalModule)), 2); + // token0 amount pending unstaking does not get updated, + // since the token0 balance is the result of a cancelled withdrawal + // which was already unstaked and accounted for + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + assertEq( + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), + (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); + // First withdrawal request has been cancelled + assertFalse(isConfirmed); + + vm.deal(address(stakingManager), (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + vm.warp(block.timestamp + 7 days); + + // Second withdrawal request has been processed + isConfirmed = _withdrawalModule.confirmWithdrawal(1); + assertTrue(isConfirmed); + // update has been called + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + assertEq(weth.balanceOf(_pool), (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + + vm.stopPrank(); + + _withdrawalModule.update(); + // update has already been called, + // hence no state changes + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(weth.balanceOf(_pool), (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + } + + function testWithdrawToken1FromLendingPool() public { + assertFalse(_withdrawalModule.isLocked()); + + uint256 amountToken1 = 1 ether; + address recipient = makeAddr("MOCK_RECIPIENT"); + + vm.prank(recipient); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__OnlySTEXOrOwner.selector); + _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); + + vm.startPrank(owner); + + // Owner transfers liquidity from lending pool to sovereign pool + _withdrawalModule.supplyToken1ToLendingPool(2 * amountToken1); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); + MockPool(_pool).setIsLocked(false); + + _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); + assertEq(weth.balanceOf(_pool), amountToken1); + assertEq(weth.balanceOf(recipient), 0); + + vm.stopPrank(); + + uint256 snapshot = vm.snapshotState(); + + // AMM transfers liquidity from lending pool to recipient + _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); + assertEq(weth.balanceOf(recipient), amountToken1); + + vm.revertToState(snapshot); + + // Revert happens if recipient receives less than amountToken1 + lendingPool.setIsCompromised(true); + + vm.expectRevert( + kHYPEWithdrawalModule + .kHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn + .selector + ); + _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); + } + + function testConfirmWithdrawal() public { + _unstakeToken0Reserves(3 ether); + _unstakeToken0Reserves(7 ether); + + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + + // kHYPE amount gets transferred to stakingManager on withdrawals + assertEq(_token0.balanceOf(address(stakingManager)), 10 ether); + // withdrawal id has been incremented + assertEq(stakingManager.nextWithdrawalId(address(_withdrawalModule)), 2); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.confirmWithdrawal(0); + MockPool(_pool).setIsLocked(false); + + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertFalse(isConfirmed); + isConfirmed = _withdrawalModule.confirmWithdrawal(1); + assertFalse(isConfirmed); + + vm.deal(address(stakingManager), (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + + vm.warp(block.timestamp + 7 days); + + isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertTrue(isConfirmed); + vm.expectRevert(bytes("Insufficient contract balance")); + _withdrawalModule.confirmWithdrawal(1); + + vm.deal(address(stakingManager), (7 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + isConfirmed = _withdrawalModule.confirmWithdrawal(1); + assertTrue(isConfirmed); + + // kHYPE fee was transferred to treasury + assertGt(_token0.balanceOf(stakingManager.TREASURY()), 0); + // withdrawal module received correct amount of token1, + // and update has been called + assertEq(address(_withdrawalModule).balance, 0); + assertEq(weth.balanceOf(address(_withdrawalModule)), 0); + assertEq(weth.balanceOf(address(_pool)), (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + // No LP withdrawals to process + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + + // Withdrawals have already been confirmed + isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertFalse(isConfirmed); + isConfirmed = _withdrawalModule.confirmWithdrawal(1); + assertFalse(isConfirmed); + } + + function testSettlePendingWithdrawalsWithPoolReserves() public { + assertFalse(_withdrawalModule.isLocked()); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.settlePendingWithdrawalsWithPoolReserves(1 ether); + + vm.startPrank(owner); + + // No state updates have happened + _withdrawalModule.settlePendingWithdrawalsWithPoolReserves(0); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + assertEq(address(_withdrawalModule).balance, 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.settlePendingWithdrawalsWithPoolReserves(1 ether); + MockPool(_pool).setIsLocked(false); + + vm.stopPrank(); + + _unstakeToken0Reserves(3 ether); + + assertEq(address(_withdrawalModule).balance, 0); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + + address recipient = makeAddr("MOCK_RECIPIENT"); + + _burnToken0AfterWithdraw(3 ether, recipient); + + // Simulate partial unstaking of 1e18 native token + vm.deal(address(_withdrawalModule), 1 ether); + + assertEq(address(_withdrawalModule).balance, 1 ether); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawal(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - 1 ether + ); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + // 1e18 of native token has reduced the amount of token0 pending unstaking proportionally + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - 1 ether + ); + assertEq( + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + + vm.startPrank(owner); + + _withdrawalModule.settlePendingWithdrawalsWithPoolReserves(3 ether); + + assertEq(address(_withdrawalModule).balance, (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - 1 ether + ); + // update has been called at least once + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate() + ); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawal(), + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate() + ); + // LP withdrawal is fully claimable, + // since sufficient native token has been supplied from the pool + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq( + _withdrawalModule.amountToken1ClaimableLPWithdrawal(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + + // Left-over token1 amount got sent back to pool + assertEq(weth.balanceOf(_pool), (3 ether * stakingManager.unstakeFeeRate()) / BIPS + 1 ether); + + // recipient is able to claim + _withdrawalModule.claim(0); + assertEq(recipient.balance, (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + + vm.stopPrank(); + } + + function testUpdate() public { + assertFalse(_withdrawalModule.isLocked()); + + // No state updates have happened + _withdrawalModule.update(); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + assertEq(address(_withdrawalModule).balance, 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.cumulativeAmountToken1ClaimableLPWithdrawal(), 0); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.update(); + MockPool(_pool).setIsLocked(false); + + _unstakeToken0Reserves(3 ether); + + assertEq(address(_withdrawalModule).balance, 0); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + + uint256 snapshot = vm.snapshotState(); + uint256 snapshot2 = vm.snapshotState(); + + // Scenario 1: update with partial unstaking fulfilled + vm.deal(address(_withdrawalModule), 2 ether); + _withdrawalModule.update(); + + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - _withdrawalModule.convertToToken0(2 ether) + ); + // All native token got wrapped and transferred into pool, + // since there were no LP withdrawals to fulfill + assertEq(address(_withdrawalModule).balance, 0); + assertEq(weth.balanceOf(_pool), 2 ether); + + vm.revertToState(snapshot2); + + // Scenario 2: update with partial unstaking fulfilled and partial LP withdrawal + + address recipient = makeAddr("MOCK_RECIPIENT"); + _withdrawalModule.burnToken0AfterWithdraw(1 ether, recipient); + uint256 amountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawal(); + assertEq( + amountToken1PendingLPWithdrawal, + _withdrawalModule.convertToToken1((1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + + vm.deal(address(_withdrawalModule), 0.5 ether); + assertEq( + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), + (1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + _withdrawalModule.update(); + + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - _withdrawalModule.convertToToken0(0.5 ether) + ); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate() + ); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0.5 ether); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), amountToken1PendingLPWithdrawal - 0.5 ether); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawal(), + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate() + ); + assertEq(address(_withdrawalModule).balance, 0.5 ether); + // Not enough native token left to re-deposit into pool + assertEq(weth.balanceOf(_pool), 0); + + // Cannot claim withdrawal request because there is not enough native token available + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_InsufficientAmountToClaim.selector); + _withdrawalModule.claim(0); + + vm.revertToState(snapshot); + + // Scenario 3: Update with all unstaking requests and LP withdrawals fulfilled + // + remaining funds re-deposited into pool + + recipient = makeAddr("MOCK_RECIPIENT"); + + _withdrawalModule.burnToken0AfterWithdraw(1 ether, recipient); + + amountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawal(); + assertEq( + amountToken1PendingLPWithdrawal, + _withdrawalModule.convertToToken1((1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertFalse(isConfirmed); + + vm.warp(block.timestamp + 7 days); + + isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertTrue(isConfirmed); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + + _withdrawalModule.update(); + + // All unstaking requests got fulfilled + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + // Pending LP withdrawal can now be claimed + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), amountToken1PendingLPWithdrawal); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq(address(_withdrawalModule).balance, amountToken1PendingLPWithdrawal); + // Remaining native token amount got wrapped and re-deposited into pool + assertEq( + weth.balanceOf(_pool), + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - amountToken1PendingLPWithdrawal + ); + + _withdrawalModule.claim(0); + assertEq(recipient.balance, amountToken1PendingLPWithdrawal); + } + + function testClaim__WithPriority() public { + assertFalse(_withdrawalModule.isLocked()); + + uint256 amount1 = 1 ether; + address recipient1 = makeAddr("MOCK_RECIPIENT_1"); + // User 1 requests withdrawal (before unstaking fulfillment) + _burnToken0AfterWithdraw(amount1, recipient1); + LPWithdrawalRequest memory request1 = _withdrawalModule.getLPWithdrawals(0); + assertEq(request1.recipient, recipient1); + assertEq( + request1.amountToken1, + _withdrawalModule.convertToToken1((amount1 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(request1.cumulativeAmountToken1LPWithdrawalCheckpoint, 0); + assertEq(_withdrawalModule.cumulativeAmountToken1LPWithdrawal(), request1.amountToken1); + assertEq(_withdrawalModule.cumulativeAmountToken1ClaimableLPWithdrawal(), 0); + + // User 2 requests withdrawal (before unstaking fulfillment) + uint256 amount2 = 2 ether; + address recipient2 = makeAddr("MOCK_RECIPIENT_2"); + _burnToken0AfterWithdraw(amount2, recipient2); + LPWithdrawalRequest memory request2 = _withdrawalModule.getLPWithdrawals(1); + assertEq(request2.recipient, recipient2); + assertEq( + request2.amountToken1, + _withdrawalModule.convertToToken1((amount2 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(request2.cumulativeAmountToken1LPWithdrawalCheckpoint, request1.amountToken1); + assertEq(_withdrawalModule.cumulativeAmountToken1LPWithdrawal(), request1.amountToken1 + request2.amountToken1); + assertEq(_withdrawalModule.cumulativeAmountToken1ClaimableLPWithdrawal(), 0); + + // Simulate unstaking fulfillment + vm.deal(address(_withdrawalModule), 4 ether); + _withdrawalModule.update(); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), request1.amountToken1 + request2.amountToken1); + assertEq(_withdrawalModule.cumulativeAmountToken1LPWithdrawal(), request1.amountToken1 + request2.amountToken1); + + // Surplus wrapped native token was transferred to pool + assertEq(weth.balanceOf(address(_pool)), 4 ether - request1.amountToken1 - request2.amountToken1); + + // User 3 requests withdrawal (after unstaking fulfillment) + uint256 amount3 = 0.1 ether; + address recipient3 = makeAddr("MOCK_RECIPIENT_3"); + _burnToken0AfterWithdraw(amount3, recipient3); + LPWithdrawalRequest memory request3 = _withdrawalModule.getLPWithdrawals(2); + assertEq(request3.recipient, recipient3); + assertEq( + request3.amountToken1, + _withdrawalModule.convertToToken1((amount3 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(request3.cumulativeAmountToken1LPWithdrawalCheckpoint, request1.amountToken1 + request2.amountToken1); + + // User 1 can claim, because it requested withdrawal before the call to `update` + assertTrue(stexLens.canClaim(address(this), 0)); + _withdrawalModule.claim(0); + assertEq(recipient1.balance, request1.amountToken1); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_AlreadyClaimed.selector); + _withdrawalModule.claim(0); + + // User 3 cannot claim, because it requested withdrawal after the call to `update` + assertFalse(stexLens.canClaim(address(this), 2)); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_CannotYetClaim.selector); + _withdrawalModule.claim(2); + + // User 2 can claim, similar scenario to user 1 + assertTrue(stexLens.canClaim(address(this), 1)); + _withdrawalModule.claim(1); + assertEq(recipient2.balance, request2.amountToken1); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_AlreadyClaimed.selector); + _withdrawalModule.claim(1); + + // User 3 still cannot claim, also because there is not enough ETH + assertFalse(stexLens.canClaim(address(this), 2)); + + // Simulate unstaking fulfillment + vm.deal(address(_withdrawalModule), 0.1 ether); + _withdrawalModule.update(); + + // User 3 can now claim + assertTrue(stexLens.canClaim(address(this), 2)); + _withdrawalModule.claim(2); + assertEq(recipient3.balance, request3.amountToken1); + + // User 3 already claimed + assertFalse(stexLens.canClaim(address(this), 2)); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_AlreadyClaimed.selector); + _withdrawalModule.claim(2); + } + + function testClaim__WithPriority__LaterWithdrawalsCannotJumpQueuePriority() public { + address recipient1 = makeAddr("RECIPIENT_1"); + address recipient2 = makeAddr("RECIPIENT_2"); + + // user 1 creates a withdraw request + _burnToken0AfterWithdraw(10e18, recipient1); + + // Simulate partial unstaking via `overseer` + vm.deal(address(_withdrawalModule), 5 ether); + _withdrawalModule.update(); + + // user2 creates a smaller withdraw request + _burnToken0AfterWithdraw(1e18, recipient2); + + // More native token gets unstaked, but not enough to fulfill both requests + vm.deal(address(_withdrawalModule), (10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + _withdrawalModule.update(); + + // user2 tries to claim, but cannot because of queue priority + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__claim_CannotYetClaim.selector); + _withdrawalModule.claim(1); + + // user1 can claim + _withdrawalModule.claim(0); + assertEq( + recipient1.balance, + _withdrawalModule.convertToToken1((10 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(address(_withdrawalModule).balance, 0); + + // More native token gets unstaked via `overseer` + vm.deal(address(_withdrawalModule), (1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + _withdrawalModule.update(); + + // user2 can now claim + _withdrawalModule.claim(1); + assertEq( + recipient2.balance, + _withdrawalModule.convertToToken1((1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + } + + function testLendingModuleProposal() public { + assertEq(address(_withdrawalModule.lendingModule()), address(lendingModule)); + + // Deposit some wrapped native token into lending module + vm.startPrank(owner); + + uint256 amount = 2 ether; + _withdrawalModule.supplyToken1ToLendingPool(2 ether); + assertEq(lendingModule.assetBalance(), 2 ether); + + vm.stopPrank(); + + address lendingModuleMock = address( + new AaveLendingModule( + address(lendingPool), + lendingPool.lendingPoolYieldToken(), + address(weth), + address(_withdrawalModule), + address(0x123), + 2 + ) + ); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days); + + vm.startPrank(owner); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow.selector); + _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days - 1); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh.selector); + _withdrawalModule.proposeLendingModule(lendingModuleMock, 7 days + 1); + + _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days); + (address lendingModuleProposed, uint256 startTimestamp) = _withdrawalModule.lendingModuleProposal(); + assertEq(lendingModuleProposed, lendingModuleMock); + assertEq(startTimestamp, block.timestamp + 3 days); + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__proposeLendingModule_ProposalAlreadyActive.selector + ); + _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days); + + vm.stopPrank(); + + uint256 snapshot = vm.snapshotState(); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.cancelLendingModuleProposal(); + + vm.startPrank(owner); + + _withdrawalModule.cancelLendingModuleProposal(); + (lendingModuleProposed, startTimestamp) = _withdrawalModule.lendingModuleProposal(); + assertEq(lendingModuleProposed, address(0)); + assertEq(startTimestamp, 0); + + vm.stopPrank(); + + vm.revertToState(snapshot); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.setProposedLendingModule(); + + vm.startPrank(owner); + + // Cannot be called when Sovereign Pool is locked, to prevent read-only reentrancy + MockPool(_pool).setIsLocked(true); + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__PoolNonReentrant.selector); + _withdrawalModule.setProposedLendingModule(); + MockPool(_pool).setIsLocked(false); + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__setProposedLendingModule_ProposalNotActive.selector + ); + _withdrawalModule.setProposedLendingModule(); + + vm.warp(block.timestamp + 3 days); + + uint256 preBalancePool = weth.balanceOf(address(_pool)); + _withdrawalModule.setProposedLendingModule(); + assertEq(address(_withdrawalModule.lendingModule()), lendingModuleMock); + // Old lending module's asset (wrapped native) balance is now 0, + // all of it has been transferred to `_pool` + uint256 postBalancePool = weth.balanceOf(address(_pool)); + assertEq(lendingModule.assetBalance(), 0); + assertEq(postBalancePool - preBalancePool, amount); + + (lendingModuleProposed, startTimestamp) = _withdrawalModule.lendingModuleProposal(); + assertEq(lendingModuleProposed, address(0)); + assertEq(startTimestamp, 0); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__setProposedLendingModule_InactiveProposal.selector); + _withdrawalModule.setProposedLendingModule(); + + vm.stopPrank(); + } + + function _burnToken0AfterWithdraw(uint256 amountToken0, address recipient) private { + vm.prank(_pool); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__OnlySTEX.selector); + _withdrawalModule.burnToken0AfterWithdraw(amountToken0, recipient); + + uint256 preAmountToken0PendingUnstaking = _withdrawalModule.amountToken0PendingUnstaking(); + uint256 preAmountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawal(); + uint256 preAmountCumulative = _withdrawalModule.cumulativeAmountToken1LPWithdrawal(); + + _withdrawalModule.burnToken0AfterWithdraw(amountToken0, recipient); + // No token0 has been unstaked + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), preAmountToken0PendingUnstaking); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate() + ); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawal(), + _withdrawalModule.convertToToken1((amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + + preAmountToken1PendingLPWithdrawal + ); + assertEq( + _withdrawalModule.amountToken1PendingLPWithdrawal(), + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate() + ); + + uint256 preId = _withdrawalModule.idLPWithdrawal() - 1; + { + LPWithdrawalRequest memory request = _withdrawalModule.getLPWithdrawals(preId); + assertEq(request.recipient, recipient); + assertEq( + request.amountToken1, + _withdrawalModule.convertToToken1((amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) + ); + assertEq(request.cumulativeAmountToken1LPWithdrawalCheckpoint, preAmountCumulative); + } + } + + function _unstakeToken0Reserves(uint256 amount) private { + uint256 initialToken0Reserves = _token0.balanceOf(address(this)); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.unstakeToken0Reserves(initialToken0Reserves); + + uint256 preAmountToken0PendingUnstaking = _withdrawalModule.amountToken0PendingUnstaking(); + _token0.transfer(address(_withdrawalModule), amount); + + vm.startPrank(owner); + + _withdrawalModule.unstakeToken0Reserves(amount); + + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + preAmountToken0PendingUnstaking + (amount * (BIPS - stakingManager.unstakeFeeRate())) / BIPS + ); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), + _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate() + ); + + vm.stopPrank(); + } +} diff --git a/test/kHYPEWithdrawalModuleKeeper.t.sol b/test/kHYPEWithdrawalModuleKeeper.t.sol new file mode 100644 index 0000000..24314bc --- /dev/null +++ b/test/kHYPEWithdrawalModuleKeeper.t.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.25; + +import {Test} from "forge-std/Test.sol"; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {kHYPEWithdrawalModuleKeeper} from "src/owner/kHYPEWithdrawalModuleKeeper.sol"; +import {kHYPEWithdrawalModuleManager} from "src/owner/kHYPEWithdrawalModuleManager.sol"; +import {IRebalanceModule} from "src/interfaces/IRebalanceModule.sol"; + +contract kHYPEWithdrawalModuleKeeperTest is Test { + kHYPEWithdrawalModuleKeeper keeper; + kHYPEWithdrawalModuleManager manager; + + address public keeperAccount1 = makeAddr("KEEPER_ACCOUNT_1"); + address public keeperAccount2 = makeAddr("KEEPER_ACCOUNT_2"); + + function setUp() public { + keeper = new kHYPEWithdrawalModuleKeeper(address(this)); + assertEq(keeper.owner(), address(this)); + + manager = new kHYPEWithdrawalModuleManager(address(this), address(keeper)); + assertEq(manager.owner(), address(this)); + assertEq(manager.keeper(), address(keeper)); + + keeper.setKeeper(keeperAccount1); + assertTrue(keeper.isKeeper(keeperAccount1)); + assertFalse(keeper.isKeeper(keeperAccount2)); + + vm.deal(address(this), 10 ether); + } + + /// Withdrawal Module mock functions /// + + function stakeToken1(uint256 _amountToken1) external {} + + function withdrawToken1FromLendingPool(uint256 _amountToken1, address _recipient) external {} + + function supplyToken1ToLendingPool(uint256 _amountToken1) external {} + + function unstakeToken0Reserves(uint256 _unstakeAmountToken0) external {} + + function rebalanceToken0Reserves( + uint256 _amountToken0, + address _recipient, + address _rebalanceModule, + bytes calldata _payload + ) external {} + + function unstakeExcessToken0() external {} + + function settlePendingWithdrawalsWithPoolReserves(uint256 _amountToken1) external {} + + function update() external {} + + function confirmWithdrawal(uint256 _id) external returns (bool isConfirmed) {} + + /// End of Withdrawal Module mock functions /// + + /// Rebalance Module mock functions /// + + function rebalance( + uint256, + /*amountToken1Min*/ + bytes calldata /*_data*/ + ) external pure returns (bytes4) { + return IRebalanceModule.rebalance.selector; + } + + /// End of Rebalance Module mock functions /// + + function testDeployments() public { + kHYPEWithdrawalModuleKeeper keeperDeployment = new kHYPEWithdrawalModuleKeeper(address(this)); + assertEq(keeperDeployment.owner(), address(this)); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__ZeroAddress.selector); + new kHYPEWithdrawalModuleManager(address(this), address(0)); + + kHYPEWithdrawalModuleManager managerDeployment = + new kHYPEWithdrawalModuleManager(address(this), address(keeperDeployment)); + assertEq(managerDeployment.owner(), address(this)); + assertEq(managerDeployment.keeper(), address(keeperDeployment)); + } + + function testKeeperWhitelist() public { + vm.expectRevert(kHYPEWithdrawalModuleKeeper.kHYPEWithdrawalModuleKeeper__ZeroAddress.selector); + keeper.setKeeper(address(0)); + + keeper.setKeeper(keeperAccount2); + assertTrue(keeper.isKeeper(keeperAccount2)); + + vm.expectRevert(kHYPEWithdrawalModuleKeeper.kHYPEWithdrawalModuleKeeper__ZeroAddress.selector); + keeper.removeKeeper(address(0)); + + keeper.removeKeeper(keeperAccount2); + assertFalse(keeper.isKeeper(keeperAccount2)); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__ZeroAddress.selector); + manager.setKeeper(address(0)); + + manager.setKeeper(keeperAccount2); + assertEq(manager.keeper(), keeperAccount2); + } + + function testManagerContract__KeeperFunctions() public { + address withdrawalModule = address(this); + + // Only keeper can call the following functions + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.stakeToken1(withdrawalModule, 1 ether); + + vm.prank(address(keeper)); + manager.stakeToken1(withdrawalModule, 1 ether); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.unstakeToken0Reserves(withdrawalModule, 1 ether); + + vm.prank(address(keeper)); + manager.unstakeToken0Reserves(withdrawalModule, 1 ether); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.supplyToken1ToLendingPool(withdrawalModule, 1 ether); + + vm.prank(address(keeper)); + manager.supplyToken1ToLendingPool(withdrawalModule, 1 ether); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.withdrawToken1FromLendingPool(withdrawalModule, 1 ether); + + vm.prank(address(keeper)); + manager.withdrawToken1FromLendingPool(withdrawalModule, 1 ether); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.settlePendingWithdrawalsWithPoolReserves(withdrawalModule, 1 ether); + + vm.prank(address(keeper)); + manager.settlePendingWithdrawalsWithPoolReserves(withdrawalModule, 1 ether); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.rebalanceToken0Reserves(withdrawalModule, 1 ether, address(this), address(this), new bytes(0)); + + vm.prank(address(keeper)); + manager.rebalanceToken0Reserves(withdrawalModule, 1 ether, address(this), address(this), new bytes(0)); + + vm.expectRevert(kHYPEWithdrawalModuleManager.kHYPEWithdrawalModuleManager__OnlyKeeper.selector); + manager.unstakeExcessToken0(withdrawalModule); + + vm.prank(address(keeper)); + manager.unstakeExcessToken0(withdrawalModule); + + // Keeper cannot call owner restricted function + vm.prank(address(keeper)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(keeper))); + manager.call(withdrawalModule, new bytes(0)); + } + + function testManagerContract__OwnerFunctions() public { + address withdrawalModule = address(this); + + vm.prank(address(keeper)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(keeper))); + manager.call(withdrawalModule, new bytes(0)); + + // owner can call the same functions as keeper's via `call`, + // given its increased access privilleges + + manager.call(withdrawalModule, abi.encodeWithSelector(this.unstakeToken0Reserves.selector, 1 ether)); + + manager.call(withdrawalModule, abi.encodeWithSelector(this.supplyToken1ToLendingPool.selector, 1 ether)); + + manager.call( + withdrawalModule, abi.encodeWithSelector(this.withdrawToken1FromLendingPool.selector, 1 ether, address(0)) + ); + } + + function testKeeperContract__call() public { + address withdrawalModule = address(this); + + vm.expectRevert(kHYPEWithdrawalModuleKeeper.kHYPEWithdrawalModuleKeeper__call_onlyKeeper.selector); + vm.prank(keeperAccount2); + keeper.call(address(manager), new bytes(0)); + + vm.startPrank(keeperAccount1); + + // The following functions can be called by a whitelisted keeper role + + keeper.call( + address(manager), + abi.encodeWithSelector( + kHYPEWithdrawalModuleManager.unstakeToken0Reserves.selector, withdrawalModule, 1 ether + ) + ); + + keeper.call( + address(manager), + abi.encodeWithSelector( + kHYPEWithdrawalModuleManager.supplyToken1ToLendingPool.selector, withdrawalModule, 1 ether + ) + ); + + keeper.call( + address(manager), + abi.encodeWithSelector( + kHYPEWithdrawalModuleManager.withdrawToken1FromLendingPool.selector, withdrawalModule, 1 ether + ) + ); + + // `call` from WithdrawalModuleManager cannot be called by keeper contract + + vm.expectRevert(kHYPEWithdrawalModuleKeeper.kHYPEWithdrawalModuleKeeper__call_callFailed.selector); + keeper.call( + address(manager), + abi.encodeWithSelector(kHYPEWithdrawalModuleManager.call.selector, withdrawalModule, new bytes(0)) + ); + } + + function testKeeperContract__redeemBurnsAndUpdate() public { + address withdrawalModule = address(this); + + uint256[] memory burnIds = new uint256[](2); + burnIds[0] = 2; + burnIds[1] = 12; + + keeper.redeemBurnsAndUpdate(burnIds, withdrawalModule); + } +} diff --git a/test/STEXAMM.t.sol b/test/stHYPESTEXAMM.t.sol similarity index 96% rename from test/STEXAMM.t.sol rename to test/stHYPESTEXAMM.t.sol index bd634e5..49c3dc0 100644 --- a/test/STEXAMM.t.sol +++ b/test/stHYPESTEXAMM.t.sol @@ -17,21 +17,22 @@ import {WETH} from "@solmate/tokens/WETH.sol"; import {STEXAMM} from "src/STEXAMM.sol"; import {STEXLens} from "src/STEXLens.sol"; -import {STEXRatioSwapFeeModule} from "src/STEXRatioSwapFeeModule.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; -import {MockOverseer} from "src/mocks/MockOverseer.sol"; -import {MockStHype} from "src/mocks/MockStHype.sol"; +import {STEXRatioSwapFeeModule} from "src/swap-fee-modules/STEXRatioSwapFeeModule.sol"; +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; +import {MockOverseer} from "src/mocks/sthype/MockOverseer.sol"; +import {MockStHype} from "src/mocks/sthype/MockStHype.sol"; import {MockLendingPool} from "src/mocks/MockLendingPool.sol"; -import {AaveLendingModule} from "src/AaveLendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; import {DepositWrapper} from "src/DepositWrapper.sol"; import {FeeParams} from "src/structs/STEXRatioSwapFeeModuleStructs.sol"; import {LPWithdrawalRequest} from "src/structs/WithdrawalModuleStructs.sol"; -contract STEXAMMTest is Test { +contract stHYPESTEXAMMTest is Test { STEXAMM stex; STEXLens stexLens; STEXRatioSwapFeeModule swapFeeModule; + stHYPEWithdrawalModule withdrawalModule; DepositWrapper nativeWrapper; @@ -51,6 +52,8 @@ contract STEXAMMTest is Test { address public owner = makeAddr("OWNER"); + address public wstHYPE = makeAddr("WSTHYPE"); + ISovereignPool pool; function setUp() public { @@ -68,7 +71,7 @@ contract STEXAMMTest is Test { lendingPool = new MockLendingPool(address(weth)); - withdrawalModule = new stHYPEWithdrawalModule(address(overseer), address(this)); + withdrawalModule = new stHYPEWithdrawalModule(address(overseer), wstHYPE, address(this)); swapFeeModule = new STEXRatioSwapFeeModule(owner); assertEq(swapFeeModule.owner(), owner); @@ -136,8 +139,10 @@ contract STEXAMMTest is Test { } function testDeploy() public { - stHYPEWithdrawalModule withdrawalModuleDeployment = new stHYPEWithdrawalModule(address(overseer), address(this)); + stHYPEWithdrawalModule withdrawalModuleDeployment = + new stHYPEWithdrawalModule(address(overseer), wstHYPE, address(this)); assertEq(withdrawalModuleDeployment.overseer(), address(overseer)); + assertEq(withdrawalModuleDeployment.wstHYPE(), wstHYPE); assertEq(withdrawalModuleDeployment.stex(), address(0)); assertEq(withdrawalModuleDeployment.owner(), address(this)); @@ -593,6 +598,9 @@ contract STEXAMMTest is Test { testDeposit(); address recipient = makeAddr("NATIVE_TOKEN_RECIPIENT"); + + uint256 snapshot = vm.snapshotState(); + uint256 shares = nativeWrapper.depositFromNative(0, block.timestamp, recipient); // No native token has been sent assertEq(shares, 0); @@ -606,6 +614,22 @@ contract STEXAMMTest is Test { (uint256 postReserve0, uint256 postReserve1) = pool.getReserves(); assertEq(preReserve0, postReserve0); assertEq(preReserve1 + amount, postReserve1); + + vm.revertToState(snapshot); + + shares = nativeWrapper.depositFromNativeWithCode(0, block.timestamp, recipient, keccak256("valantis")); + // No native token has been sent + assertEq(shares, 0); + + amount = 2 ether; + (preReserve0, preReserve1) = pool.getReserves(); + shares = nativeWrapper.depositFromNative{value: amount}(0, block.timestamp, recipient); + assertGt(shares, 0); + assertEq(weth.allowance(address(nativeWrapper), address(stex)), 0); + assertEq(stex.balanceOf(recipient), shares); + (postReserve0, postReserve1) = pool.getReserves(); + assertEq(preReserve0, postReserve0); + assertEq(preReserve1 + amount, postReserve1); } function testDeposit__FromToken0() public { @@ -636,6 +660,8 @@ contract STEXAMMTest is Test { (uint256 amountToken1Min, uint256 minShares) = stexLens.getMinAmountsForToken0Deposit(address(stex), amountToken0, 0, 0); + uint256 snapshot = vm.snapshotState(); + uint256 shares = nativeWrapper.depositFromToken0(amountToken0, amountToken1Min, minShares, block.timestamp, recipient); assertEq(token0.balanceOf(address(nativeWrapper)), 0); @@ -643,6 +669,16 @@ contract STEXAMMTest is Test { assertEq(stex.balanceOf(recipient), minShares); assertEq(stex.balanceOf(recipient), shares); + vm.revertToState(snapshot); + + shares = nativeWrapper.depositFromToken0WithCode( + amountToken0, amountToken1Min, minShares, block.timestamp, recipient, keccak256("valantis") + ); + assertEq(token0.balanceOf(address(nativeWrapper)), 0); + assertEq(weth.balanceOf(address(nativeWrapper)), 0); + assertEq(stex.balanceOf(recipient), minShares); + assertEq(stex.balanceOf(recipient), shares); + vm.stopPrank(); } @@ -812,7 +848,7 @@ contract STEXAMMTest is Test { assertEq(amountToken1, 0); assertEq(cumulativeAmount, 0); - vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_alreadyClaimed.selector); + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_AlreadyClaimed.selector); withdrawalModule.claim(0); } diff --git a/test/stHYPEWithdrawalModule.t.sol b/test/stHYPEWithdrawalModule.t.sol index ba1daad..d2edcdc 100644 --- a/test/stHYPEWithdrawalModule.t.sol +++ b/test/stHYPEWithdrawalModule.t.sol @@ -5,12 +5,14 @@ import {Test} from "forge-std/Test.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {stHYPEWithdrawalModule} from "src/stHYPEWithdrawalModule.sol"; +import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol"; + +import {stHYPEWithdrawalModule} from "src/withdrawal-modules/stHYPEWithdrawalModule.sol"; import {LPWithdrawalRequest} from "src/structs/WithdrawalModuleStructs.sol"; -import {MockOverseer} from "src/mocks/MockOverseer.sol"; -import {MockStHype} from "src/mocks/MockStHype.sol"; +import {MockOverseer} from "src/mocks/sthype/MockOverseer.sol"; +import {MockStHype} from "src/mocks/sthype/MockStHype.sol"; import {MockLendingPool} from "src/mocks/MockLendingPool.sol"; -import {AaveLendingModule} from "src/AaveLendingModule.sol"; +import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; import {WETH} from "@solmate/tokens/WETH.sol"; import {STEXLens} from "src/STEXLens.sol"; @@ -43,6 +45,8 @@ contract stHYPEWithdrawalModuleTest is Test { address public owner = makeAddr("OWNER"); + address public wstHYPE = makeAddr("WSTHYPE"); + function setUp() public { stexLens = new STEXLens(); @@ -57,7 +61,7 @@ contract stHYPEWithdrawalModuleTest is Test { assertEq(lendingPool.underlyingAsset(), address(weth)); assertEq(lendingPool.lendingPoolYieldToken(), address(lendingPool)); - _withdrawalModule = new stHYPEWithdrawalModule(address(overseer), owner); + _withdrawalModule = new stHYPEWithdrawalModule(address(overseer), wstHYPE, owner); vm.startPrank(owner); // AMM will be mocked to make testing more flexible @@ -127,13 +131,17 @@ contract stHYPEWithdrawalModuleTest is Test { function testDeploy() public returns (stHYPEWithdrawalModule withdrawalModuleDeployment) { vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__ZeroAddress.selector); - new stHYPEWithdrawalModule(address(0), address(this)); + new stHYPEWithdrawalModule(address(0), wstHYPE, address(this)); + + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__ZeroAddress.selector); + new stHYPEWithdrawalModule(address(overseer), address(0), address(this)); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); - new stHYPEWithdrawalModule(address(overseer), address(0)); + new stHYPEWithdrawalModule(address(overseer), wstHYPE, address(0)); - withdrawalModuleDeployment = new stHYPEWithdrawalModule(address(overseer), address(this)); + withdrawalModuleDeployment = new stHYPEWithdrawalModule(address(overseer), wstHYPE, address(this)); assertEq(withdrawalModuleDeployment.overseer(), address(overseer)); + assertEq(withdrawalModuleDeployment.wstHYPE(), wstHYPE); assertEq(withdrawalModuleDeployment.owner(), address(this)); assertEq(address(withdrawalModuleDeployment.lendingModule()), address(0)); assertEq(withdrawalModuleDeployment.amountToken1LendingPool(), 0); @@ -154,6 +162,38 @@ contract stHYPEWithdrawalModuleTest is Test { assertEq(_withdrawalModule.token0SharesToBalance(shares), amount0); } + function testSweep() public { + ERC20Mock mockToken = new ERC20Mock(); + address recipient = makeAddr("MOCK_RECIPIENT"); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.sweep(address(mockToken), recipient); + + vm.startPrank(owner); + + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__ZeroAddress.selector); + _withdrawalModule.sweep(address(0), recipient); + + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__ZeroAddress.selector); + _withdrawalModule.sweep(address(mockToken), address(0)); + + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__sweep_Token0CannotBeSweeped.selector); + _withdrawalModule.sweep(address(_token0), recipient); + + // wstHYPE is the same as token0 + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__sweep_Token0CannotBeSweeped.selector); + _withdrawalModule.sweep(wstHYPE, recipient); + + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__sweep_Token1CannotBeSweeped.selector); + _withdrawalModule.sweep(address(weth), recipient); + + mockToken.mint(address(_withdrawalModule), 10 ether); + _withdrawalModule.sweep(address(mockToken), recipient); + assertEq(mockToken.balanceOf(recipient), 10 ether); + + vm.stopPrank(); + } + function testStakeAmount1() public { assertFalse(_withdrawalModule.isLocked()); @@ -282,7 +322,7 @@ contract stHYPEWithdrawalModuleTest is Test { vm.expectRevert( stHYPEWithdrawalModule - .stHYPEWithdrawalModule__withdrawToken1FromLendingPool_insufficientAmountWithdrawn + .stHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn .selector ); _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); @@ -353,7 +393,7 @@ contract stHYPEWithdrawalModuleTest is Test { assertEq(weth.balanceOf(_pool), 0); // Cannot claim withdrawal request because there is not enough ETH available - vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_insufficientAmountToClaim.selector); + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_InsufficientAmountToClaim.selector); _withdrawalModule.claim(0); vm.revertToState(snapshot); @@ -431,7 +471,7 @@ contract stHYPEWithdrawalModuleTest is Test { // User 3 cannot claim, because it requested withdrawal after the call to `update` assertFalse(stexLens.canClaim(address(this), 2)); - vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_cannotYetClaim.selector); + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_CannotYetClaim.selector); _withdrawalModule.claim(2); // User 2 can claim, similar scenario to user 1 @@ -474,7 +514,7 @@ contract stHYPEWithdrawalModuleTest is Test { _withdrawalModule.update(); // user2 tries to claim, but cannot because of queue priority - vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_cannotYetClaim.selector); + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule__claim_CannotYetClaim.selector); _withdrawalModule.claim(1); // user1 can claim @@ -503,16 +543,25 @@ contract stHYPEWithdrawalModuleTest is Test { vm.stopPrank(); - address lendingModuleMock = makeAddr("MOCK_LENDING_MODULE"); + address lendingModuleMock = address( + new AaveLendingModule( + address(lendingPool), + lendingPool.lendingPoolYieldToken(), + address(weth), + address(_withdrawalModule), + address(0x123), + 2 + ) + ); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days); vm.startPrank(owner); - vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule___verifyTimelockDelay_timelockTooLow.selector); + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow.selector); _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days - 1); - vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule___verifyTimelockDelay_timelockTooHigh.selector); + vm.expectRevert(stHYPEWithdrawalModule.stHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh.selector); _withdrawalModule.proposeLendingModule(lendingModuleMock, 7 days + 1); _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days); diff --git a/test/WithdrawalModuleKeeper.t.sol b/test/stHYPEWithdrawalModuleKeeper.t.sol similarity index 71% rename from test/WithdrawalModuleKeeper.t.sol rename to test/stHYPEWithdrawalModuleKeeper.t.sol index e8fbbbb..adc5ef4 100644 --- a/test/WithdrawalModuleKeeper.t.sol +++ b/test/stHYPEWithdrawalModuleKeeper.t.sol @@ -5,21 +5,21 @@ import {Test} from "forge-std/Test.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {WithdrawalModuleKeeper} from "src/owner/WithdrawalModuleKeeper.sol"; -import {WithdrawalModuleManager} from "src/owner/WithdrawalModuleManager.sol"; +import {stHYPEWithdrawalModuleKeeper} from "src/owner/stHYPEWithdrawalModuleKeeper.sol"; +import {stHYPEWithdrawalModuleManager} from "src/owner/stHYPEWithdrawalModuleManager.sol"; -contract WithdrawalModuleKeeperTest is Test { - WithdrawalModuleKeeper keeper; - WithdrawalModuleManager manager; +contract stHYPEWithdrawalModuleKeeperTest is Test { + stHYPEWithdrawalModuleKeeper keeper; + stHYPEWithdrawalModuleManager manager; address public keeperAccount1 = makeAddr("KEEPER_ACCOUNT_1"); address public keeperAccount2 = makeAddr("KEEPER_ACCOUNT_2"); function setUp() public { - keeper = new WithdrawalModuleKeeper(address(this)); + keeper = new stHYPEWithdrawalModuleKeeper(address(this)); assertEq(keeper.owner(), address(this)); - manager = new WithdrawalModuleManager(address(this), address(keeper)); + manager = new stHYPEWithdrawalModuleManager(address(this), address(keeper)); assertEq(manager.owner(), address(this)); assertEq(manager.keeper(), address(keeper)); @@ -68,32 +68,32 @@ contract WithdrawalModuleKeeperTest is Test { /// End of Overseer mock functions /// function testDeployments() public { - WithdrawalModuleKeeper keeperDeployment = new WithdrawalModuleKeeper(address(this)); + stHYPEWithdrawalModuleKeeper keeperDeployment = new stHYPEWithdrawalModuleKeeper(address(this)); assertEq(keeperDeployment.owner(), address(this)); - vm.expectRevert(WithdrawalModuleManager.WithdrawalModuleManager__ZeroAddress.selector); - new WithdrawalModuleManager(address(this), address(0)); + vm.expectRevert(stHYPEWithdrawalModuleManager.stHYPEWithdrawalModuleManager__ZeroAddress.selector); + new stHYPEWithdrawalModuleManager(address(this), address(0)); - WithdrawalModuleManager managerDeployment = - new WithdrawalModuleManager(address(this), address(keeperDeployment)); + stHYPEWithdrawalModuleManager managerDeployment = + new stHYPEWithdrawalModuleManager(address(this), address(keeperDeployment)); assertEq(managerDeployment.owner(), address(this)); assertEq(managerDeployment.keeper(), address(keeperDeployment)); } function testKeeperWhitelist() public { - vm.expectRevert(WithdrawalModuleKeeper.WithdrawalModuleKeeper__ZeroAddress.selector); + vm.expectRevert(stHYPEWithdrawalModuleKeeper.stHYPEWithdrawalModuleKeeper__ZeroAddress.selector); keeper.setKeeper(address(0)); keeper.setKeeper(keeperAccount2); assertTrue(keeper.isKeeper(keeperAccount2)); - vm.expectRevert(WithdrawalModuleKeeper.WithdrawalModuleKeeper__ZeroAddress.selector); + vm.expectRevert(stHYPEWithdrawalModuleKeeper.stHYPEWithdrawalModuleKeeper__ZeroAddress.selector); keeper.removeKeeper(address(0)); keeper.removeKeeper(keeperAccount2); assertFalse(keeper.isKeeper(keeperAccount2)); - vm.expectRevert(WithdrawalModuleManager.WithdrawalModuleManager__ZeroAddress.selector); + vm.expectRevert(stHYPEWithdrawalModuleManager.stHYPEWithdrawalModuleManager__ZeroAddress.selector); manager.setKeeper(address(0)); manager.setKeeper(keeperAccount2); @@ -105,19 +105,19 @@ contract WithdrawalModuleKeeperTest is Test { // Only keeper can call the following functions - vm.expectRevert(WithdrawalModuleManager.WithdrawalModuleManager__OnlyKeeper.selector); + vm.expectRevert(stHYPEWithdrawalModuleManager.stHYPEWithdrawalModuleManager__OnlyKeeper.selector); manager.unstakeToken0Reserves(withdrawalModule, 1 ether); vm.prank(address(keeper)); manager.unstakeToken0Reserves(withdrawalModule, 1 ether); - vm.expectRevert(WithdrawalModuleManager.WithdrawalModuleManager__OnlyKeeper.selector); + vm.expectRevert(stHYPEWithdrawalModuleManager.stHYPEWithdrawalModuleManager__OnlyKeeper.selector); manager.supplyToken1ToLendingPool(withdrawalModule, 1 ether); vm.prank(address(keeper)); manager.supplyToken1ToLendingPool(withdrawalModule, 1 ether); - vm.expectRevert(WithdrawalModuleManager.WithdrawalModuleManager__OnlyKeeper.selector); + vm.expectRevert(stHYPEWithdrawalModuleManager.stHYPEWithdrawalModuleManager__OnlyKeeper.selector); manager.withdrawToken1FromLendingPool(withdrawalModule, 1 ether); vm.prank(address(keeper)); @@ -151,7 +151,7 @@ contract WithdrawalModuleKeeperTest is Test { function testKeeperContract__call() public { address withdrawalModule = address(this); - vm.expectRevert(WithdrawalModuleKeeper.WithdrawalModuleKeeper__call_onlyKeeper.selector); + vm.expectRevert(stHYPEWithdrawalModuleKeeper.stHYPEWithdrawalModuleKeeper__call_onlyKeeper.selector); vm.prank(keeperAccount2); keeper.call(address(manager), new bytes(0)); @@ -161,29 +161,31 @@ contract WithdrawalModuleKeeperTest is Test { keeper.call( address(manager), - abi.encodeWithSelector(WithdrawalModuleManager.unstakeToken0Reserves.selector, withdrawalModule, 1 ether) + abi.encodeWithSelector( + stHYPEWithdrawalModuleManager.unstakeToken0Reserves.selector, withdrawalModule, 1 ether + ) ); keeper.call( address(manager), abi.encodeWithSelector( - WithdrawalModuleManager.supplyToken1ToLendingPool.selector, withdrawalModule, 1 ether + stHYPEWithdrawalModuleManager.supplyToken1ToLendingPool.selector, withdrawalModule, 1 ether ) ); keeper.call( address(manager), abi.encodeWithSelector( - WithdrawalModuleManager.withdrawToken1FromLendingPool.selector, withdrawalModule, 1 ether + stHYPEWithdrawalModuleManager.withdrawToken1FromLendingPool.selector, withdrawalModule, 1 ether ) ); // `call` from WithdrawalModuleManager cannot be called by keeper contract - vm.expectRevert(WithdrawalModuleKeeper.WithdrawalModuleKeeper__call_callFailed.selector); + vm.expectRevert(stHYPEWithdrawalModuleKeeper.stHYPEWithdrawalModuleKeeper__call_callFailed.selector); keeper.call( address(manager), - abi.encodeWithSelector(WithdrawalModuleManager.call.selector, withdrawalModule, new bytes(0)) + abi.encodeWithSelector(stHYPEWithdrawalModuleManager.call.selector, withdrawalModule, new bytes(0)) ); }