diff --git a/README.md b/README.md index 8f40e0b..fdaa380 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,11 @@ Contains STEX AMM's core contracts, Module dependencies and Mock contracts used **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). - **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/). +**stHYPEWithdrawalModule.sol**: Module that manages all of STEX AMM's interactions with [stakedHYPE](https://app.valantis.xyz/staking). -**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/). +**kHYPEWithdrawalModule.sol**: Module that manages all of STEX AMM's interactions with [kHYPE](https://kinetiq.xyz/). **owner/stHYPEWithdrawalModuleManager.sol**: Custom contract that has the `owner` role in `stHYPEWithdrawalModule`. It is controlled by a multi-sig. @@ -100,22 +98,25 @@ Role: - 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`. +- Can set `_amountToken0PendingUnstakingOffset` up to its immutable maximum bound, which acts as a virtual reserve amount for STEXAMM. 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. +- `owner` can set `_amountTokenPendingUnstakingOffset`, up to its maximum bound, hence inflating STEXAMM's TVL. **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. +The `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. +- Lending module upgrades do not enforce full recall of lending reserves. This is to allow unconditional upgrades, even in cases where there has been a realized loss in external lending protocol integrations. The owner role is entrusted to ensure that all possible token1 reserves are recalled from the current `Lending Module` back into the pool, before upgrading to a new `Lending Module`. **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. @@ -129,6 +130,14 @@ kHYPE AMM protects against secondary-market depeg arbitrage loss by never sellin 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. +**Dynamic Unstake Fee Risk** + +kHYPEWithdrawalModule assumes that kHYPE's unstaking fee is mostly constant over time, with rare changes. Unexpected changes in the unstake fee can lead to over or under reservation of STEXAMM obligations for LP withdrawals. + +**kHYPE Unstake Cancellation Risk** + +kHYPE protocol reserves the right to cancel kHYPE unstaking requests, which returns the original kHYPE amount back to `kHYPEWithdrawalModule`. If this ever happens, `kHYPEWithdrawalModule` assumes that it can simply try to unstake again, one or multiple times, until the pending kHYPE withdrawal gets settled, hence why there is no change in account for `_amountToken0PendingUnstaking` in case of kHYPE withdrawal cancellations. + ### 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/scripts/stHYPESTEXDeploy.s.sol b/scripts/stHYPESTEXDeploy.s.sol deleted file mode 100644 index 58082c4..0000000 --- a/scripts/stHYPESTEXDeploy.s.sol +++ /dev/null @@ -1,224 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.25; - -import "forge-std/Script.sol"; -import {Test} from "forge-std/Test.sol"; - -import {AaveLendingModule} from "src/lending-modules/AaveLendingModule.sol"; -import {STEXAMM} from "src/STEXAMM.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 {stHYPEWithdrawalModuleManager} from "src/owner/stHYPEWithdrawalModuleManager.sol"; -import {stHYPEWithdrawalModuleKeeper} from "src/owner/stHYPEWithdrawalModuleKeeper.sol"; - -contract stHYPESTEXDeployScript 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; - - // stHYPE - address token0 = 0xfFaa4a3D97fE9107Cef8a3F48c069F577Ff76cC1; - // WHYPE - address token1 = 0x5555555555555555555555555555555555555555; - - // Valantis Protocol Factory - address protocolFactory = 0x7E028ac56cB2AF75292F3D967978189698C24732; - - // Thunderhead's Overseer - address overseer = 0xB96f07367e69e86d6e9C3F29215885104813eeAE; - - // Uncomment to deploy Swap Fee Module - /*STEXRatioSwapFeeModule swapFeeModule = new STEXRatioSwapFeeModule( - deployerAddress - ); - assertEq(swapFeeModule.owner(), deployerAddress);*/ - STEXRatioSwapFeeModule swapFeeModule = STEXRatioSwapFeeModule( - 0x18cdf91aF34E44D4B4CF5e86b31B42BD86569418 - ); - // 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 - /*{ - uint32 minThresholdRatioBips = 4_000; - uint32 maxThresholdRatioBips = 9_500; - uint32 feeMinBips = 1; - uint32 feeMaxBips = 1; - - bytes memory payload = abi.encodeWithSelector( - STEXRatioSwapFeeModule.setSwapFeeParams.selector, - minThresholdRatioBips, - maxThresholdRatioBips, - feeMinBips, - feeMaxBips - ); - - console.log("payload for swapFeeModule.setSwapFeeParams: "); - console.logBytes(payload); - - vm.startPrank(ownerMultisig); - swapFeeModule.setSwapFeeParams( - minThresholdRatioBips, - maxThresholdRatioBips, - feeMinBips, - feeMaxBips - ); - vm.stopPrank(); - }*/ - /*{ - ( - uint32 minThresholdRatioBips, - uint32 maxThresholdRatioBips, - uint32 feeMinBips, - uint32 feeMaxBips - ) = swapFeeModule.feeParams(); - assertEq(minThresholdRatioBips, 4_000); - assertEq(maxThresholdRatioBips, 9_500); - assertEq(feeMinBips, 1); - assertEq(feeMaxBips, 1); - }*/ - - // Uncomment for deployment of Withdrawal Module - /*stHYPEWithdrawalModule withdrawalModule = new stHYPEWithdrawalModule( - overseer, - deployerAddress - ); - assertEq(withdrawalModule.owner(), deployerAddress); - assertEq(withdrawalModule.overseer(), overseer);*/ - stHYPEWithdrawalModule withdrawalModule = stHYPEWithdrawalModule( - payable(0x69e487aA3132708d08a979b2d07c5119Bb77F698) - ); - - // Uncomment for deployment of STEX AMM - /*STEXAMM stex = new STEXAMM( - "stHYPE AMM", - "stHYPE AMM LP", - token0, - token1, - address(swapFeeModule), - protocolFactory, - 0xA2666B4dD1242Def4c3cf5731a85Aa8457fe01C1, // feeRecipient1 - 0x24577bacbd3B74C4065226a97e789023bba3296e, // feeRecipient2 - deployerAddress, // owner - address(withdrawalModule), - 10 - ); - assertEq(stex.owner(), deployerAddress);*/ - STEXAMM stex = STEXAMM( - payable(0x39694eFF3b02248929120c73F90347013Aec834d) - ); - //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(2_000); - - // 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(0x640b752B6452C7FeE6afE15e0667EBeB058aB0D2) - ); - - // Uncomment for deployment of withdrawal module's keeper - /*stHYPEWithdrawalModuleKeeper keeper = new stHYPEWithdrawalModuleKeeper( - deployerAddress - ); - assertEq(keeper.owner(), deployerAddress); - console.log("keeper deployed: ", address(keeper));*/ - stHYPEWithdrawalModuleKeeper keeper = stHYPEWithdrawalModuleKeeper( - 0x0Aef1eAAd539C16292faEB16D3F4AB5842F0aa6c - ); - /*address keeperEOA = 0x6Fa0b094b71EF7fcA715177242682bdf1954e2e8; - keeper.setKeeper(keeperEOA); - assertTrue(keeper.isKeeper(keeperEOA));*/ - //keeper.transferOwnership(ownerMultisig); - assertEq(keeper.owner(), ownerMultisig); - - // Uncomment for deployment of withdrawal module's owner - /*stHYPEWithdrawalModuleManager manager = new stHYPEWithdrawalModuleManager( - deployerAddress, - address(keeper) - );*/ - //assertEq(manager.owner(), deployerAddress); - //assertEq(manager.keeper(), address(keeper)); - stHYPEWithdrawalModuleManager manager = stHYPEWithdrawalModuleManager( - 0x80c7f89398160fCD9E74519f63F437459E5d02E2 - ); - //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 Aave Lending Module - /*{ - AaveLendingModule lendingModule = new AaveLendingModule( - 0xceCcE0EB9DD2Ef7996e01e25DD70e461F918A14b, // AAVE V3 pool - 0x7C97cd7B57b736c6AD74fAE97C0e21e856251dcf, // aWHYPE - stex.token1(), // WHYPE - address(withdrawalModule), // owner - ownerMultisig, // tokenSweepManager - 2 - ); - assertEq( - address(lendingModule.pool()), - 0xceCcE0EB9DD2Ef7996e01e25DD70e461F918A14b - ); - assertEq( - lendingModule.yieldToken(), - 0x7C97cd7B57b736c6AD74fAE97C0e21e856251dcf - ); - assertEq(lendingModule.owner(), address(withdrawalModule)); - assertEq(lendingModule.tokenSweepManager(), ownerMultisig); - assertEq(lendingModule.referralCode(), 2); - }*/ - - vm.stopBroadcast(); - } -} diff --git a/src/STEXAMM.sol b/src/STEXAMM.sol index 8edbf2e..d046d5c 100644 --- a/src/STEXAMM.sol +++ b/src/STEXAMM.sol @@ -155,8 +155,8 @@ contract STEXAMM is ISTEXAMM, Ownable, ERC20, ReentrancyGuardTransient, Pausable ) Ownable(_owner) ERC20(_name, _symbol) { if ( _token0 == address(0) || _token1 == address(0) || _swapFeeModule == address(0) - || _protocolFactory == address(0) || _poolFeeRecipient1 == address(0) || _poolFeeRecipient2 == address(0) - || _owner == address(0) || withdrawalModule_ == address(0) + || _protocolFactory == address(0) || _poolFeeRecipient1 == address(0) + || _poolFeeRecipient2 == address(0) || _owner == address(0) || withdrawalModule_ == address(0) ) revert STEXAMM__ZeroAddress(); SovereignPoolConstructorArgs memory args = SovereignPoolConstructorArgs( @@ -248,9 +248,8 @@ contract STEXAMM is ISTEXAMM, Ownable, ERC20, ReentrancyGuardTransient, Pausable } address swapFeeModule = ISovereignPool(pool).swapFeeModule(); - SwapFeeModuleData memory swapFeeData = ISwapFeeModuleMinimalView(swapFeeModule).getSwapFeeInBips( - _tokenIn, address(0), _isInstantWithdraw ? 0 : _amountIn, address(0), new bytes(0) - ); + SwapFeeModuleData memory swapFeeData = ISwapFeeModuleMinimalView(swapFeeModule) + .getSwapFeeInBips(_tokenIn, address(0), _isInstantWithdraw ? 0 : _amountIn, address(0), new bytes(0)); uint256 amountInWithoutFee = Math.mulDiv(_amountIn, BIPS, BIPS + swapFeeData.feeInBips); bool isZeroToOne = _tokenIn == token0; @@ -535,7 +534,11 @@ contract STEXAMM is ISTEXAMM, Ownable, ERC20, ReentrancyGuardTransient, Pausable /*_amount0*/ uint256 _amount1, bytes memory _data - ) external override onlyPool { + ) + external + override + onlyPool + { address user = abi.decode(_data, (address)); // Only token1 deposits are allowed @@ -656,9 +659,8 @@ contract STEXAMM is ISTEXAMM, Ownable, ERC20, ReentrancyGuardTransient, Pausable (, uint256 reserve1) = ISovereignPool(pool).getReserves(); if (cache.amount1Remaining <= reserve1) { // If pool has enough token1 liquidity - ISovereignPool(pool).withdrawLiquidity( - 0, cache.amount1Remaining, msg.sender, address(this), new bytes(0) - ); + ISovereignPool(pool) + .withdrawLiquidity(0, cache.amount1Remaining, msg.sender, address(this), new bytes(0)); } else { // If pool does not have enough token1 liquidity, // we withdraw full reserves from pool, @@ -692,7 +694,13 @@ contract STEXAMM is ISTEXAMM, Ownable, ERC20, ReentrancyGuardTransient, Pausable ALMLiquidityQuoteInput memory _almLiquidityQuoteInput, bytes calldata, /*_externalContext*/ bytes calldata /*_verifierData*/ - ) external view override whenNotPaused returns (ALMLiquidityQuote memory quote) { + ) + external + view + override + whenNotPaused + returns (ALMLiquidityQuote memory quote) + { // Prevents read-only reentrancy via `SovereignPool::swap`, // while keeping `getLiquidityQuote` as read-only if (_reentrancyGuardEntered()) { @@ -720,7 +728,11 @@ contract STEXAMM is ISTEXAMM, Ownable, ERC20, ReentrancyGuardTransient, Pausable /*_isZeroToOne*/ uint256, /*_amountIn*/ uint256 /*_amountOut*/ - ) external pure override { + ) + external + pure + override + { revert STEXAMM__onSwapCallback_NotImplemented(); } diff --git a/src/STEXLens.sol b/src/STEXLens.sol index e074fa0..750455d 100644 --- a/src/STEXLens.sol +++ b/src/STEXLens.sol @@ -213,9 +213,9 @@ contract STEXLens { uint256 amount1SwapEquivalent = stexInterface.getAmountOut(stexInterface.token0(), amount0, true); // Apply manager fee on instant withdrawals in token1 uint256 amount1WithFee = withdrawalModule.convertToToken1(amount0); - cache.instantWithdrawalFee1 = ( - (amount1WithFee - amount1SwapEquivalent) * ISovereignPool(stexInterface.pool()).poolManagerFeeBips() - ) / BIPS; + cache.instantWithdrawalFee1 = + ((amount1WithFee - amount1SwapEquivalent) * ISovereignPool(stexInterface.pool()).poolManagerFeeBips()) + / BIPS; amount1 += amount1SwapEquivalent; amount0 = 0; diff --git a/src/interfaces/ISTEXRatioSwapFeeModule.sol b/src/interfaces/ISTEXRatioSwapFeeModule.sol deleted file mode 100644 index 6de66ce..0000000 --- a/src/interfaces/ISTEXRatioSwapFeeModule.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.25; - -import {ISwapFeeModuleMinimal} from "@valantis-core/swap-fee-modules/interfaces/ISwapFeeModule.sol"; - -interface ISTEXRatioSwapFeeModule is ISwapFeeModuleMinimal { - event PoolSet(address pool); - - event SwapFeeParamsSet( - uint32 minThresholdRatioBips, uint32 maxThresholdRatioBips, uint32 feeMinBips, uint32 feeMaxBips - ); - - function setSwapFeeParams( - uint32 _minThresholdRatioBips, - uint32 _maxThresholdRatioBips, - uint32 _feeMinBips, - uint32 _feeMaxBips - ) external; -} diff --git a/src/interfaces/kinetiq/IStakingManager.sol b/src/interfaces/kinetiq/IStakingManager.sol index ff3ac2d..074e2d8 100644 --- a/src/interfaces/kinetiq/IStakingManager.sol +++ b/src/interfaces/kinetiq/IStakingManager.sol @@ -8,6 +8,7 @@ interface IStakingManager { uint256 hypeAmount; // Amount in HYPE to withdraw uint256 kHYPEAmount; // Amount in kHYPE to burn (excluding fee) uint256 kHYPEFee; // Fee amount in kHYPE tokens + uint256 bufferUsed; // Amount fulfilled from hypeBuffer uint256 timestamp; // Request timestamp } diff --git a/src/mocks/MockERC4626LendingPool.sol b/src/mocks/MockERC4626LendingPool.sol index 05ddc92..20c7d79 100644 --- a/src/mocks/MockERC4626LendingPool.sol +++ b/src/mocks/MockERC4626LendingPool.sol @@ -45,9 +45,10 @@ contract MockERC4626LendingPool { } function maxWithdraw(address account) external view returns (uint256) { - return _totalSupply == 0 - ? 0 - : (_shares[account] * ERC20Mock(underlyingAsset).balanceOf(address(this))) / _totalSupply; + return + _totalSupply == 0 + ? 0 + : (_shares[account] * ERC20Mock(underlyingAsset).balanceOf(address(this))) / _totalSupply; } function setIsCompromised(bool value) public { @@ -88,7 +89,14 @@ contract MockERC4626LendingPool { _totalAssets += amountDeposited; } - function withdraw(uint256 assets, address receiver, address /*owner*/ ) external returns (uint256 shares) { + function withdraw( + uint256 assets, + address receiver, + address /*owner*/ + ) + external + returns (uint256 shares) + { require(receiver != address(0), "receiver cannot be zero"); if (assets == 0) { diff --git a/src/mocks/MockLendingPool.sol b/src/mocks/MockLendingPool.sol index 8e416bb..3eec0cf 100644 --- a/src/mocks/MockLendingPool.sol +++ b/src/mocks/MockLendingPool.sol @@ -39,7 +39,15 @@ contract MockLendingPool is IPool { isExcessTransfer = value; } - function supply(address asset, uint256 amount, address onBehalfOf, uint16 /*referralCode*/ ) external override { + 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"); require(onBehalfOf != address(0), "onBehalfOf cannot be zero"); diff --git a/src/mocks/kinetiq/MockStakingManager.sol b/src/mocks/kinetiq/MockStakingManager.sol index d9c7a61..0d0d0fb 100644 --- a/src/mocks/kinetiq/MockStakingManager.sol +++ b/src/mocks/kinetiq/MockStakingManager.sol @@ -83,6 +83,7 @@ contract MockStakingManager is IStakingManager { hypeAmount: hypeAmount, kHYPEAmount: postFeeKHYPE, kHYPEFee: kHYPEFee, + bufferUsed: 0, timestamp: block.timestamp }); @@ -102,6 +103,8 @@ contract MockStakingManager is IStakingManager { require(success, "Transfer failed"); } + /// @dev In production Kinetiq contracts, cancellation is gated under governance. + /// This mock is intentionally unprotected for test flexibility. function cancelWithdrawal(address user, uint256 withdrawalId) external { WithdrawalRequest storage request = _withdrawalRequests[user][withdrawalId]; require(request.hypeAmount > 0, "No such withdrawal request"); @@ -124,6 +127,14 @@ contract MockStakingManager is IStakingManager { _cancelledWithdrawalAmount += hypeAmount; } + function setWithdrawalRequestBufferUsed(address user, uint256 withdrawalId, uint256 bufferUsed) external { + WithdrawalRequest storage request = _withdrawalRequests[user][withdrawalId]; + require(request.hypeAmount > 0, "No such withdrawal request"); + require(bufferUsed <= request.hypeAmount, "Buffer exceeds withdrawal"); + + request.bufferUsed = bufferUsed; + } + function _processConfirmation(address user, uint256 withdrawalId) internal returns (uint256) { WithdrawalRequest memory request = _withdrawalRequests[user][withdrawalId]; diff --git a/src/mocks/sthype/MockOverseer.sol b/src/mocks/sthype/MockOverseer.sol index 3e82f0b..46315b0 100644 --- a/src/mocks/sthype/MockOverseer.sol +++ b/src/mocks/sthype/MockOverseer.sol @@ -23,22 +23,48 @@ contract MockOverseer is IOverseer { receive() external payable {} - function getBurnIds(address /*account*/ ) external pure override returns (uint256[] memory) { + function getBurnIds( + address /*account*/ + ) + external + pure + override + returns (uint256[] memory) + { // not implemented return new uint256[](0); } - function redeemable(uint256 /*_burnId*/ ) external pure override returns (bool) { + function redeemable( + uint256 /*_burnId*/ + ) + external + pure + override + returns (bool) + { // not implemented return false; } - function mint(address to, string memory /*communityCode*/ ) external payable override returns (uint256) { + function mint( + address to, + string memory /*communityCode*/ + ) + external + payable + override + returns (uint256) + { mockStHype.mint{value: msg.value}(to); return msg.value; } - function burnAndRedeemIfPossible(address to, uint256 amount, string memory /*_communityCode*/ ) + function burnAndRedeemIfPossible( + address to, + uint256 amount, + string memory /*_communityCode*/ + ) external override returns (uint256) @@ -64,7 +90,12 @@ contract MockOverseer is IOverseer { require(success, "failed to send ETH"); } - function redeem(uint256 /*_burnId*/ ) external override { + function redeem( + uint256 /*_burnId*/ + ) + external + override + { // not implemented } } diff --git a/src/owner/kHYPEWithdrawalModuleManager.sol b/src/owner/kHYPEWithdrawalModuleManager.sol index 4a1b657..81d092f 100644 --- a/src/owner/kHYPEWithdrawalModuleManager.sol +++ b/src/owner/kHYPEWithdrawalModuleManager.sol @@ -127,10 +127,11 @@ contract kHYPEWithdrawalModuleManager is Ownable { * @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 - ); + IWithdrawalModule(_withdrawalModule) + .withdrawToken1FromLendingPool( + _amountToken1, + address(0) // _recipient is unused, since it must the STEX pool + ); } /** @@ -164,9 +165,8 @@ contract kHYPEWithdrawalModuleManager is Ownable { address _rebalanceModule, bytes calldata _payload ) external onlyKeeper { - kHYPEWithdrawalModule(payable(_withdrawalModule)).rebalanceToken0Reserves( - _amountToken0, _recipient, _rebalanceModule, _payload - ); + kHYPEWithdrawalModule(payable(_withdrawalModule)) + .rebalanceToken0Reserves(_amountToken0, _recipient, _rebalanceModule, _payload); } /** diff --git a/src/owner/stHYPEWithdrawalModuleManager.sol b/src/owner/stHYPEWithdrawalModuleManager.sol index a6a7655..8a61d0a 100644 --- a/src/owner/stHYPEWithdrawalModuleManager.sol +++ b/src/owner/stHYPEWithdrawalModuleManager.sol @@ -115,9 +115,10 @@ contract stHYPEWithdrawalModuleManager is Ownable { * @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 - ); + IWithdrawalModule(_withdrawalModule) + .withdrawToken1FromLendingPool( + _amountToken1, + address(0) // _recipient is unused, since it must the STEX pool + ); } } diff --git a/src/structs/STEXRatioSwapFeeModuleStructs.sol b/src/structs/STEXRatioSwapFeeModuleStructs.sol deleted file mode 100644 index 6780a48..0000000 --- a/src/structs/STEXRatioSwapFeeModuleStructs.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.25; - -struct FeeParams { - uint32 minThresholdRatioBips; - uint32 maxThresholdRatioBips; - uint32 feeMinBips; - uint32 feeMaxBips; -} diff --git a/src/swap-fee-modules/STEXRatioSwapFeeModule.sol b/src/swap-fee-modules/STEXRatioSwapFeeModule.sol deleted file mode 100644 index 4ba1d59..0000000 --- a/src/swap-fee-modules/STEXRatioSwapFeeModule.sol +++ /dev/null @@ -1,184 +0,0 @@ -// 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 {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. - */ -contract STEXRatioSwapFeeModule is ISTEXRatioSwapFeeModule, Ownable { - /** - * - * CUSTOM ERRORS - * - */ - error STEXRatioSwapFeeModule__ZeroAddress(); - error STEXRatioSwapFeeModule__getSwapFeeInBips_ZeroReserveToken1(); - error STEXRatioSwapFeeModule__setSwapFeeParams_inconsistentFeeParams(); - error STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMin(); - error STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMax(); - error STEXRatioSwapFeeModule__setSwapFeeParams_inconsistentThresholdRatioParams(); - error STEXRatioSwapFeeModule__setPool_alreadySet(); - - /** - * - * CONSTANTS - * - */ - uint256 private constant BIPS = 10_000; - - /** - * - * STORAGE - * - */ - - /** - * @notice Address of Valantis Sovereign Pool. - */ - address public pool; - - /** - * - * CONSTRUCTOR - * - */ - constructor(address _owner) Ownable(_owner) {} - - /** - * - * STORAGE - * - */ - - /** - * @notice Dynamic swap fee parameters. - */ - FeeParams public feeParams; - - /** - * - * VIEW FUNCTIONS - * - */ - function getSwapFeeInBips( - address _tokenIn, - address, /*_tokenOut*/ - uint256 _amountIn, - address, /*_user*/ - bytes memory /*_swapFeeModuleContext*/ - ) external view override returns (SwapFeeModuleData memory swapFeeModuleData) { - ISovereignPool poolInterface = ISovereignPool(pool); - ISTEXAMM stexInterface = ISTEXAMM(poolInterface.alm()); - // Fee is only applied on token0 -> token1 swaps - if (_tokenIn == poolInterface.token0()) { - (uint256 reserve0, uint256 reserve1) = poolInterface.getReserves(); - IWithdrawalModule withdrawalModuleInterface = IWithdrawalModule(stexInterface.withdrawalModule()); - - uint256 amount0PendingUnstaking = withdrawalModuleInterface.amountToken0PendingUnstaking(); - uint256 amountToken0PendingLPWithdrawal = - withdrawalModuleInterface.convertToToken0(withdrawalModuleInterface.amountToken1PendingLPWithdrawal()); - - uint256 amount0Total = reserve0 + amount0PendingUnstaking + _amountIn - amountToken0PendingLPWithdrawal; - - FeeParams memory feeParamsCache = feeParams; - uint256 feeInBips; - - uint256 reserve1Total = reserve1 + withdrawalModuleInterface.amountToken1LendingPool(); - - if (reserve1Total == 0) { - revert STEXRatioSwapFeeModule__getSwapFeeInBips_ZeroReserveToken1(); - } - - uint256 ratioBips = (amount0Total * BIPS) / reserve1Total; - - if (ratioBips > feeParamsCache.maxThresholdRatioBips) { - feeInBips = feeParamsCache.feeMaxBips; - } else if (ratioBips < feeParamsCache.minThresholdRatioBips) { - feeInBips = feeParamsCache.feeMinBips; - } else { - uint256 numerator = ratioBips - feeParamsCache.minThresholdRatioBips; - uint256 denominator = feeParamsCache.maxThresholdRatioBips - feeParamsCache.minThresholdRatioBips; - - feeInBips = feeParamsCache.feeMinBips - + ((feeParamsCache.feeMaxBips - feeParamsCache.feeMinBips) * numerator) / denominator; - } - - // 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 * BIPS) / (BIPS - feeInBips) - BIPS; - } - } - - /** - * - * 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 STEXRatioSwapFeeModule__ZeroAddress(); - // Pool can only be set once - if (pool != address(0)) { - revert STEXRatioSwapFeeModule__setPool_alreadySet(); - } - pool = _pool; - - emit PoolSet(_pool); - } - - /** - * @notice Update AMM's dynamic swap fee parameters. - * @dev Only callable by `owner`. - * @param _minThresholdRatioBips Threshold value below which `_feeMinBips` will be applied. - * @param _maxThresholdRatioBips Threshold value above which `_feeMaxBips` will be applied. - * @param _feeMinBips Lower-bound for the dynamic swap fee. - * @param _feeMaxBips Upper-bound for the dynamic swap fee. - */ - function setSwapFeeParams( - uint32 _minThresholdRatioBips, - uint32 _maxThresholdRatioBips, - uint32 _feeMinBips, - uint32 _feeMaxBips - ) external override onlyOwner { - if (_minThresholdRatioBips >= _maxThresholdRatioBips) { - revert STEXRatioSwapFeeModule__setSwapFeeParams_inconsistentThresholdRatioParams(); - } - - // Fees must be lower than 50% (5_000 bips) - if (_feeMinBips >= BIPS / 2) { - revert STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMin(); - } - if (_feeMaxBips >= BIPS / 2) { - revert STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMax(); - } - - if (_feeMinBips > _feeMaxBips) { - revert STEXRatioSwapFeeModule__setSwapFeeParams_inconsistentFeeParams(); - } - - feeParams = FeeParams({ - minThresholdRatioBips: _minThresholdRatioBips, - maxThresholdRatioBips: _maxThresholdRatioBips, - feeMinBips: _feeMinBips, - feeMaxBips: _feeMaxBips - }); - - emit SwapFeeParamsSet(_minThresholdRatioBips, _maxThresholdRatioBips, _feeMinBips, _feeMaxBips); - } -} diff --git a/src/swap-fee-modules/StepwiseFeeModule.sol b/src/swap-fee-modules/StepwiseFeeModule.sol index 5ebe5ce..fe86b1f 100644 --- a/src/swap-fee-modules/StepwiseFeeModule.sol +++ b/src/swap-fee-modules/StepwiseFeeModule.sol @@ -88,7 +88,12 @@ contract StepwiseFeeModule is IStepwiseFeeModule, Ownable { uint256 _amountIn, address, /*_user*/ bytes memory /*_swapFeeModuleContext*/ - ) external view override returns (SwapFeeModuleData memory swapFeeModuleData) { + ) + external + view + override + returns (SwapFeeModuleData memory swapFeeModuleData) + { ISovereignPool poolInterface = ISovereignPool(pool); // Fee is only applied on token0 -> token1 swaps if (_tokenIn == poolInterface.token0()) { diff --git a/src/withdrawal-modules/kHYPEWithdrawalModule.sol b/src/withdrawal-modules/kHYPEWithdrawalModule.sol index 831ee16..3391f7c 100644 --- a/src/withdrawal-modules/kHYPEWithdrawalModule.sol +++ b/src/withdrawal-modules/kHYPEWithdrawalModule.sol @@ -41,6 +41,11 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O event LendingModuleSet(address lendingModule); event AmountToken1Staked(uint256 amount); event AmountToken0Unstaked(uint256 amount); + event ExcessToken0Unstaked(uint256 amount); + event AmountToken0PendingUnstakingOffsetSet(uint256 amountToken0PendingUnstakingOffset); + event BurnFeeBipsProposed(uint256 burnFeeBips, uint256 startTimestamp); + event BurnFeeBipsProposalCancelled(); + event BurnFeeBipsSet(uint256 burnFeeBips); event AmountSuppliedToLendingModule(uint256 amount); event AmountWithdrawnFromLendingModule(uint256 amount); event Update(); @@ -68,10 +73,17 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O error kHYPEWithdrawalModule__rebalanceToken0Reserves_RebalanceModuleCallFailed(); error kHYPEWithdrawalModule__setProposedLendingModule_InactiveProposal(); error kHYPEWithdrawalModule__setProposedLendingModule_ProposalNotActive(); + error kHYPEWithdrawalModule__setBurnFeeBips_FeeTooHigh(); + error kHYPEWithdrawalModule__proposeBurnFeeBips_ProposalAlreadyActive(); + error kHYPEWithdrawalModule__setProposedBurnFeeBips_InactiveProposal(); + error kHYPEWithdrawalModule__setProposedBurnFeeBips_ProposalNotActive(); error kHYPEWithdrawalModule__setSTEX_AlreadySet(); error kHYPEWithdrawalModule__sweep_Token0CannotBeSweeped(); error kHYPEWithdrawalModule__sweep_Token1CannotBeSweeped(); + error kHYPEWithdrawalModule__pendingUnstakeRequestIdAt_IndexOutOfBounds(); + error kHYPEWithdrawalModule__unstakeToken0Reserves_MaxPendingRequestsReached(); error kHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn(); + error kHYPEWithdrawalModule__setAmountToken0PendingUnstakingOffset_OffsetTooHigh(); error kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow(); error kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh(); @@ -81,6 +93,8 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O * */ uint256 private constant BIPS = 10_000; + uint256 private constant MAX_BURN_FEE_BIPS = 100; + uint256 private constant MAX_PENDING_UNSTAKE_REQUESTS = 5; uint256 private constant MIN_TIMELOCK_DELAY = 3 days; uint256 private constant MAX_TIMELOCK_DELAY = 7 days; @@ -101,6 +115,11 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O */ address public immutable stakingManager; + /** + * @notice Immutable per-deployment upper bound for owner-controlled pending-unstake correction offset. + */ + uint256 public immutable MAX_AMOUNT_TOKEN0_PENDING_UNSTAKING_OFFSET; + /** * * STORAGE @@ -161,24 +180,73 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O */ uint256 private _amountToken0PendingUnstaking; + /** + * @notice Owner-controlled correction offset added on top of `_amountToken0PendingUnstaking` getters. + * @dev This is a display/accounting correction for integrations and does not affect queue accounting. + */ + uint256 private _amountToken0PendingUnstakingOffset; + + /** + * @notice Queue of active `stakingManager` withdrawal request IDs. + * @dev Kept small and bounded to preserve deterministic gas in `update`. + * @dev This array is unordered, in order to avoid re-ordering the array when removing an item. + */ + uint256[MAX_PENDING_UNSTAKE_REQUESTS] private _pendingUnstakeRequestIds; + + /** + * @notice Number of active entries in `_pendingUnstakeRequestIds`. + */ + uint256 private _pendingUnstakeRequestCount; + /** * @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; + /** + * @notice Pending proposal to update `burnFeeBips`. + */ + struct BurnFeeBipsProposal { + uint256 burnFeeBips; + uint256 startTimestamp; + } + + /** + * @notice Minimum fee charged on `burnToken0AfterWithdraw`, in bips. + * @dev Acts as a conservative floor when protocol unstake fee is unexpectedly low. + */ + uint256 public burnFeeBips; + + /** + * @notice Pending proposal to update `burnFeeBips`. + */ + BurnFeeBipsProposal public burnFeeBipsProposal; + + /** + * @notice Portion of `_amountToken1PendingLPWithdrawal` already covered by queued unstaking requests. + * @dev Tracked in native `token1` units (post-unstake-fee expected amount). + */ + uint256 private _amountToken1PendingLPWithdrawalCoveredByQueuedUnstake; + /** * * CONSTRUCTOR * */ - constructor(address _stakingAccountant, address _stakingManager, address _owner) Ownable(_owner) { + constructor( + address _stakingAccountant, + address _stakingManager, + address _owner, + uint256 _maxAmountToken0PendingUnstakingOffset + ) Ownable(_owner) { if (_stakingAccountant == address(0) || _stakingManager == address(0) || _owner == address(0)) { revert kHYPEWithdrawalModule__ZeroAddress(); } stakingAccountant = _stakingAccountant; stakingManager = _stakingManager; + MAX_AMOUNT_TOKEN0_PENDING_UNSTAKING_OFFSET = _maxAmountToken0PendingUnstakingOffset; } /** @@ -259,20 +327,14 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O /** * @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. + * @dev This value is decremented when a withdrawal request gets confirmed, + * either via `confirmWithdrawal()` or automatically in `update()`, + * using the request's queued `kHYPEAmount` (post-unstake-fee token0 shares), + * so it is independent from current exchange-rate conversions. + * @dev Includes owner-controlled `_amountToken0PendingUnstakingOffset`. */ 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; - } + return _amountToken0PendingUnstaking + _amountToken0PendingUnstakingOffset; } /** @@ -280,7 +342,33 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O * but returns the value in storage prior to calling `update`. */ function amountToken0PendingUnstakingBeforeUpdate() external view override returns (uint256) { - return _amountToken0PendingUnstaking; + return _amountToken0PendingUnstaking + _amountToken0PendingUnstakingOffset; + } + + /** + * @notice Returns owner-controlled offset added to token0 pending-unstake getters. + */ + function amountToken0PendingUnstakingOffset() external view returns (uint256) { + return _amountToken0PendingUnstakingOffset; + } + + /** + * @notice Number of active unstake requests tracked by this module. + */ + function pendingUnstakeRequestCount() external view returns (uint256) { + return _pendingUnstakeRequestCount; + } + + /** + * @notice Returns tracked unstake request ID at `_index`. + * @dev IDs are stored in an unordered set-like array. + */ + function pendingUnstakeRequestIdAt(uint256 _index) external view returns (uint256) { + if (_index >= _pendingUnstakeRequestCount) { + revert kHYPEWithdrawalModule__pendingUnstakeRequestIdAt_IndexOutOfBounds(); + } + + return _pendingUnstakeRequestIds[_index]; } /** @@ -293,7 +381,36 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O uint256 amountToken1PendingLPWithdrawalCache = _amountToken1PendingLPWithdrawal; if (amountToken1PendingLPWithdrawalCache > excessNativeBalance) { - return amountToken1PendingLPWithdrawalCache - excessNativeBalance; + uint256 pendingToken1Net = amountToken1PendingLPWithdrawalCache - excessNativeBalance; + uint256 pendingToken1CoveredByQueuedUnstake = + Math.min(_amountToken1PendingLPWithdrawalCoveredByQueuedUnstake, pendingToken1Net); + uint256 pendingToken1Uncovered = pendingToken1Net - pendingToken1CoveredByQueuedUnstake; + + // LP liabilities are tracked as token1 net amount expected by LPs. + // The uncovered portion still requires unstaking token0 reserves, + // so STEX must reserve token0 pre-unstake-fee amount for it. + if (pendingToken1Uncovered > 0) { + uint256 feeToken0Bips = IStakingManager(stakingManager).unstakeFeeRate(); + // WARNING: kHYPE unstake fee should always be less than 100%, + // but if it is not, we cap it at to avoid division by zero. + feeToken0Bips = Math.min(feeToken0Bips, BIPS - 1); + pendingToken1Uncovered = + Math.mulDiv(pendingToken1Uncovered, BIPS, BIPS - feeToken0Bips, Math.Rounding.Ceil); + } + + uint256 pendingToken1 = pendingToken1CoveredByQueuedUnstake + pendingToken1Uncovered; + + // STEXAMM reserves pending token1 liabilities by converting this value + // to token0 with rounding down. If `pendingToken1` is not exactly + // representable in token0 shares, we add a tiny buffer so that the + // conversion is conservative and cannot be under-reserved. + uint256 pendingToken0Floor = convertToToken0(pendingToken1); + if (convertToToken1(pendingToken0Floor) < pendingToken1) { + uint256 token1PerToken0Share = convertToToken1(1); + pendingToken1 += token1PerToken0Share + 1; + } + + return pendingToken1; } else { return 0; } @@ -307,6 +424,13 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O return _amountToken1PendingLPWithdrawal; } + /** + * @notice Returns pending LP withdrawal amount already covered by queued unstake requests. + */ + function amountToken1PendingLPWithdrawalCoveredByQueuedUnstake() external view returns (uint256) { + return _amountToken1PendingLPWithdrawalCoveredByQueuedUnstake; + } + /** * @notice Returns amount of token1 owned in the lending module. */ @@ -343,6 +467,74 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O emit STEXSet(_stex); } + /** + * @notice Sets owner-controlled correction offset for token0 pending-unstake getters. + * @dev Only callable by `owner`. + * @dev This does not affect internal pending-unstake accounting. + * @param _offset New offset value. + */ + function setAmountToken0PendingUnstakingOffset(uint256 _offset) external onlyOwner { + if (_offset > MAX_AMOUNT_TOKEN0_PENDING_UNSTAKING_OFFSET) { + revert kHYPEWithdrawalModule__setAmountToken0PendingUnstakingOffset_OffsetTooHigh(); + } + + _amountToken0PendingUnstakingOffset = _offset; + + emit AmountToken0PendingUnstakingOffsetSet(_offset); + } + + /** + * @notice Proposes a new `burnFeeBips` value under timelock. + * @dev Only callable by `owner`. + * @param _burnFeeBips Fee in bips, capped to 1%. + * @param _timelockDelay 3-7 days timelock delay. + */ + function proposeBurnFeeBips(uint256 _burnFeeBips, uint256 _timelockDelay) external onlyOwner { + if (_burnFeeBips > MAX_BURN_FEE_BIPS) { + revert kHYPEWithdrawalModule__setBurnFeeBips_FeeTooHigh(); + } + _verifyTimelockDelay(_timelockDelay); + + if (burnFeeBipsProposal.startTimestamp > 0) { + revert kHYPEWithdrawalModule__proposeBurnFeeBips_ProposalAlreadyActive(); + } + + burnFeeBipsProposal = + BurnFeeBipsProposal({burnFeeBips: _burnFeeBips, startTimestamp: block.timestamp + _timelockDelay}); + + emit BurnFeeBipsProposed(_burnFeeBips, block.timestamp + _timelockDelay); + } + + /** + * @notice Cancels a pending `burnFeeBips` proposal. + * @dev Only callable by `owner`. + */ + function cancelBurnFeeBipsProposal() external onlyOwner { + emit BurnFeeBipsProposalCancelled(); + + delete burnFeeBipsProposal; + } + + /** + * @notice Executes a pending `burnFeeBips` proposal after timelock. + * @dev Only callable by `owner`. + */ + function setProposedBurnFeeBips() external onlyOwner { + if (burnFeeBipsProposal.startTimestamp > block.timestamp) { + revert kHYPEWithdrawalModule__setProposedBurnFeeBips_ProposalNotActive(); + } + + if (burnFeeBipsProposal.startTimestamp == 0) { + revert kHYPEWithdrawalModule__setProposedBurnFeeBips_InactiveProposal(); + } + + burnFeeBips = burnFeeBipsProposal.burnFeeBips; + + delete burnFeeBipsProposal; + + emit BurnFeeBipsSet(burnFeeBips); + } + /** * @notice Sweep token balances which have been locked into this contract. * @dev Only callable by `owner`. @@ -455,10 +647,14 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O { IStakingManager stakingManagerInterface = IStakingManager(stakingManager); - uint256 feeToken0Bips = stakingManagerInterface.unstakeFeeRate(); - // `stakingManager` charges an unstaking fee in token0 - uint256 feeToken0 = Math.mulDiv(_amountToken0, feeToken0Bips, BIPS); + uint256 unstakeFeeToken0Bips = stakingManagerInterface.unstakeFeeRate(); + unstakeFeeToken0Bips = Math.min(unstakeFeeToken0Bips, BIPS - 1); + + // Fee is the maximum of the unstaking fee and the burn fee + uint256 feeToken0Bips = Math.max(unstakeFeeToken0Bips, burnFeeBips); + + uint256 feeToken0 = Math.mulDiv(_amountToken0, feeToken0Bips, BIPS, Math.Rounding.Ceil); // Amount of token1 which the LP expects to receive after unstaking, // excluding token0 fee and assuming no slashing @@ -553,8 +749,12 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O { if (_amountToken1 == 0) return; + // Ensure finalized unstake requests are confirmed first, + // so all available native token balance is accounted before drawing from pool reserves. + _syncPendingUnstakeRequests(true); + // Ensure that net new native token balance is properly accounted for - _update(false); + _update(); ISTEXAMM(stex).supplyToken1Reserves(_amountToken1); @@ -563,7 +763,7 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O // Use native token balance to net against pending LP withdrawals, // and transfer left-over amount as token1 back into pool - _update(true); + _update(); } /** @@ -674,6 +874,15 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O * @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`. + * @dev This function intentionally does NOT increment `_amountToken0PendingUnstaking` + * nor track the new request ID. + * In the cancellation case, `_amountToken0PendingUnstaking` is already stale-but-accurate: + * it still reflects the original queued amount, which is backed by the kHYPE tokens + * returned to this contract. The STEX AMM TVL remains correct throughout because + * `_amountToken0PendingUnstaking` represents real token0 value in the system + * regardless of whether that value sits in the StakingManager queue or in this contract. + * Once the new request is confirmed via `confirmWithdrawal(newId)`, + * `_decreaseAmountToken0PendingUnstaking` reconciles the tracker. */ function unstakeExcessToken0() external nonReentrant onlyOwner { ERC20 token0 = _getToken(true); @@ -682,19 +891,21 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O if (token0Balance == 0) return; _unstakeToken0(token0Balance); + emit ExcessToken0Unstaked(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. + * @dev Bubbles up reverts from `stakingManager.confirmWithdrawal()`, including "not ready" failures. */ function confirmWithdrawal(uint256 _id) external nonReentrant whenPoolNotLocked returns (bool isConfirmed) { isConfirmed = _confirmWithdrawal(_id); // Update accounting state immediately after confirmation if (isConfirmed) { - _update(false); + _update(); } } @@ -705,7 +916,8 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O * the AMM's Sovereign Pool. */ function update() external override nonReentrant whenPoolNotLocked { - _update(false); + _syncPendingUnstakeRequests(true); + _update(); } /** @@ -751,35 +963,34 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O * PRIVATE FUNCTIONS * */ - function _update(bool isPoolRebalance) private { + function _update() 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 + // Finalized staking-manager withdrawals are synchronized before calling this function + // so pending unstaking and native balances are reconciled first. // 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; + uint256 amountToken1PendingCoveredByQueuedUnstakeCache = + Math.min(_amountToken1PendingLPWithdrawalCoveredByQueuedUnstake, amountToken1PendingLPWithdrawalCache); if (excessNativeBalance > amountToken1PendingLPWithdrawalCache) { excessNativeBalance -= amountToken1PendingLPWithdrawalCache; amountToken1ClaimableLPWithdrawal += amountToken1PendingLPWithdrawalCache; cumulativeAmountToken1ClaimableLPWithdrawal += amountToken1PendingLPWithdrawalCache; _amountToken1PendingLPWithdrawal = 0; + _amountToken1PendingLPWithdrawalCoveredByQueuedUnstake = 0; } else { _amountToken1PendingLPWithdrawal -= excessNativeBalance; + if (amountToken1PendingCoveredByQueuedUnstakeCache > excessNativeBalance) { + _amountToken1PendingLPWithdrawalCoveredByQueuedUnstake = + amountToken1PendingCoveredByQueuedUnstakeCache - excessNativeBalance; + } else { + _amountToken1PendingLPWithdrawalCoveredByQueuedUnstake = 0; + } amountToken1ClaimableLPWithdrawal += excessNativeBalance; cumulativeAmountToken1ClaimableLPWithdrawal += excessNativeBalance; excessNativeBalance = 0; @@ -808,33 +1019,152 @@ contract kHYPEWithdrawalModule is IWithdrawalModule, ReentrancyGuardTransient, O // 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; + isConfirmed = true; + _decreaseAmountToken0PendingUnstaking(request.kHYPEAmount); + _removePendingUnstakeRequestId(id); emit WithdrawalRequestConfirmed(id, request.hypeAmount, isConfirmed); } function _unstakeToken0Reserves(uint256 amountToken0) private { + _syncPendingUnstakeRequests(false); + if (_pendingUnstakeRequestCount >= MAX_PENDING_UNSTAKE_REQUESTS) { + revert kHYPEWithdrawalModule__unstakeToken0Reserves_MaxPendingRequestsReached(); + } + + IStakingManager stakingManagerInterface = IStakingManager(stakingManager); + uint256 unstakeRequestId = stakingManagerInterface.nextWithdrawalId(address(this)); + ISTEXAMM(stex).unstakeToken0Reserves(amountToken0); // Kinetiq charges an unstaking fee - uint256 feeToken0 = Math.mulDiv(amountToken0, IStakingManager(stakingManager).unstakeFeeRate(), BIPS); + uint256 feeToken0 = Math.mulDiv(amountToken0, stakingManagerInterface.unstakeFeeRate(), BIPS); + uint256 amountToken0AfterFee = amountToken0 - feeToken0; - _amountToken0PendingUnstaking += (amountToken0 - feeToken0); + _amountToken0PendingUnstaking += amountToken0AfterFee; + + // Mark pending LP liabilities now covered by queued unstaking, + // so covered liabilities are not grossed-up again in + // `amountToken1PendingLPWithdrawal()`. + // WARNING: This assumes that there is no slashing enabled in the LST protocol + uint256 amountToken1CoveredByThisUnstake = convertToToken1(amountToken0AfterFee); + uint256 excessNativeBalance = _getExcessNativeBalance(); + uint256 pendingToken1Net = _amountToken1PendingLPWithdrawal > excessNativeBalance + ? _amountToken1PendingLPWithdrawal - excessNativeBalance + : 0; + + uint256 pendingToken1CoveredByQueuedUnstake = + Math.min(_amountToken1PendingLPWithdrawalCoveredByQueuedUnstake, pendingToken1Net); + uint256 pendingToken1Uncovered = pendingToken1Net - pendingToken1CoveredByQueuedUnstake; + if (amountToken1CoveredByThisUnstake > pendingToken1Uncovered) { + amountToken1CoveredByThisUnstake = pendingToken1Uncovered; + } + _amountToken1PendingLPWithdrawalCoveredByQueuedUnstake = + pendingToken1CoveredByQueuedUnstake + amountToken1CoveredByThisUnstake; _unstakeToken0(amountToken0); + _trackPendingUnstakeRequest(unstakeRequestId); + } + + function _syncPendingUnstakeRequests(bool _confirmReadyRequests) private { + uint256 pendingCount = _pendingUnstakeRequestCount; + // No pending unstaking requests to sync or confirm + if (pendingCount == 0) return; + + IStakingManager stakingManagerInterface = IStakingManager(stakingManager); + + uint256 i; + while (i < _pendingUnstakeRequestCount) { + uint256 requestId = _pendingUnstakeRequestIds[i]; + IStakingManager.WithdrawalRequest memory request = + stakingManagerInterface.withdrawalRequests(address(this), requestId); + + if (request.hypeAmount == 0) { + // Request has been cancelled by Kinetiq governance (gated operation) + // or has already been confirmed via `confirmWithdrawal`. + // + // `_decreaseAmountToken0PendingUnstaking` is intentionally NOT called here. + // + // - If already confirmed: the confirming call path (`_confirmWithdrawal` or the + // `_confirmReadyRequests` branch below) already decremented the tracker. + // + // - If cancelled: Kinetiq returns the kHYPE to this contract, so + // `_amountToken0PendingUnstaking` remains backed by real token0 value + // (now held here instead of in the StakingManager queue). STEX AMM TVL + // is unaffected because the tracker still represents real system-owned token0. + // The owner should call `unstakeExcessToken0()` to re-queue, and once the + // new request is confirmed, `_decreaseAmountToken0PendingUnstaking` reconciles + // the tracker. See `unstakeExcessToken0` natspec for details. + _removePendingUnstakeRequestAt(i); + continue; + } + + if (_confirmReadyRequests) { + try stakingManagerInterface.confirmWithdrawal(requestId) { + _decreaseAmountToken0PendingUnstaking(request.kHYPEAmount); + emit WithdrawalRequestConfirmed(requestId, request.hypeAmount, true); + _removePendingUnstakeRequestAt(i); + continue; + } catch { + // Leave tracked; a future sync can retry once the staking manager accepts confirmation. + } + } + + i++; + } + } + + function _trackPendingUnstakeRequest(uint256 _requestId) private { + // Registers a new pending unstaking request to be tracked, + // ensuring it does not exceed MAX_PENDING_UNSTAKE_REQUESTS + uint256 pendingCount = _pendingUnstakeRequestCount; + if (pendingCount >= MAX_PENDING_UNSTAKE_REQUESTS) { + revert kHYPEWithdrawalModule__unstakeToken0Reserves_MaxPendingRequestsReached(); + } + + _pendingUnstakeRequestIds[pendingCount] = _requestId; + _pendingUnstakeRequestCount = pendingCount + 1; + } + + function _removePendingUnstakeRequestId(uint256 _requestId) private { + // Removes a pending unstaking request, by _requestId, from the tracked list + uint256 pendingCount = _pendingUnstakeRequestCount; + for (uint256 i; i < pendingCount; i++) { + if (_pendingUnstakeRequestIds[i] == _requestId) { + _removePendingUnstakeRequestAt(i); + return; + } + } + } + + function _removePendingUnstakeRequestAt(uint256 _index) private { + uint256 lastIndex = _pendingUnstakeRequestCount - 1; + if (_index != lastIndex) { + // Replaces the value from the removed index with the value from the last index + _pendingUnstakeRequestIds[_index] = _pendingUnstakeRequestIds[lastIndex]; + } + // Deletes the value from the last index + delete _pendingUnstakeRequestIds[lastIndex]; + // Reduces array size by 1 + _pendingUnstakeRequestCount = lastIndex; + } + + function _decreaseAmountToken0PendingUnstaking(uint256 _amountToken0Settled) private { + uint256 amountToken0PendingUnstakingCache = _amountToken0PendingUnstaking; + if (amountToken0PendingUnstakingCache > _amountToken0Settled) { + _amountToken0PendingUnstaking = amountToken0PendingUnstakingCache - _amountToken0Settled; + } else { + _amountToken0PendingUnstaking = 0; + } } function _unstakeToken0(uint256 amountToken0) private { - // Burn `amountToken0` worth of token0 through `stakingManager` withdrawal queue. + // Low-level primitive: queues a kHYPE withdrawal on StakingManager. + // Does NOT update `_amountToken0PendingUnstaking` or track the request ID. + // Callers that need accounting updates must handle those separately + // (see `_unstakeToken0Reserves` vs `unstakeExcessToken0`). // WARNING: This implementation assumes that there is no slashing enabled in the LST protocol _getToken(true).forceApprove(stakingManager, amountToken0); IStakingManager(stakingManager).queueWithdrawal(amountToken0); diff --git a/test/STEXAMMStepwiseFeeModule.t.sol b/test/STEXAMMStepwiseFeeModule.t.sol index 22e590c..e369d8e 100644 --- a/test/STEXAMMStepwiseFeeModule.t.sol +++ b/test/STEXAMMStepwiseFeeModule.t.sol @@ -132,9 +132,8 @@ contract STEXAMMStepwiseFeeModuleTest is Test { function testSetStepwiseSwapFeeParams_revertsWhenThresholdFlipped() public { vm.expectRevert( abi.encodeWithSelector( - StepwiseFeeModule - .StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold - .selector + StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold + .selector ) ); uint256 minThreshold = 100 ether; @@ -148,9 +147,8 @@ contract STEXAMMStepwiseFeeModuleTest is Test { function testSetStepwiseSwapFeeParams_revertsWhenThresholdEqual() public { vm.expectRevert( abi.encodeWithSelector( - StepwiseFeeModule - .StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold - .selector + StepwiseFeeModule.StepwiseFeeModule__setFeeParamsToken0__MinToken1ThresholdAboveMaxToken1Threshold + .selector ) ); uint256 minThreshold = 100 ether; diff --git a/test/kHYPESTEXAMM.t.sol b/test/kHYPESTEXAMM.t.sol index a744590..61c31a1 100644 --- a/test/kHYPESTEXAMM.t.sol +++ b/test/kHYPESTEXAMM.t.sol @@ -17,23 +17,23 @@ 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 {StepwiseFeeModule} from "src/swap-fee-modules/StepwiseFeeModule.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; + uint256 private constant MAX_PENDING_UNSTAKING_OFFSET = type(uint96).max; STEXAMM stex; STEXLens stexLens; - STEXRatioSwapFeeModule swapFeeModule; + StepwiseFeeModule swapFeeModule; kHYPEWithdrawalModule withdrawalModule; @@ -77,9 +77,11 @@ contract stHYPESTEXAMMTest is Test { lendingPool = new MockLendingPool(address(weth)); - withdrawalModule = new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(this)); + withdrawalModule = new kHYPEWithdrawalModule( + address(stakingAccountant), address(stakingManager), address(this), MAX_PENDING_UNSTAKING_OFFSET + ); - swapFeeModule = new STEXRatioSwapFeeModule(owner); + swapFeeModule = new StepwiseFeeModule(owner); assertEq(swapFeeModule.owner(), owner); stex = new STEXAMM( @@ -143,14 +145,15 @@ contract stHYPESTEXAMMTest is Test { } function testDeploy() public { - kHYPEWithdrawalModule withdrawalModuleDeployment = - new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(this)); + kHYPEWithdrawalModule withdrawalModuleDeployment = new kHYPEWithdrawalModule( + address(stakingAccountant), address(stakingManager), address(this), MAX_PENDING_UNSTAKING_OFFSET + ); 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); + StepwiseFeeModule swapFeeModuleDeployment = new StepwiseFeeModule(owner); assertEq(swapFeeModuleDeployment.owner(), owner); vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); @@ -297,9 +300,11 @@ contract stHYPESTEXAMMTest is Test { swapFeeModuleDeployment.setPool(address(poolDeployment)); vm.startPrank(owner); + vm.expectRevert(StepwiseFeeModule.StepwiseFeeModule__ZeroAddress.selector); + swapFeeModuleDeployment.setPool(address(0)); swapFeeModuleDeployment.setPool(stexDeployment.pool()); assertEq(swapFeeModuleDeployment.pool(), stexDeployment.pool()); - vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setPool_alreadySet.selector); + vm.expectRevert(StepwiseFeeModule.StepwiseFeeModule__setPool_AlreadySet.selector); swapFeeModuleDeployment.setPool(makeAddr("MOCK_POOL")); vm.stopPrank(); @@ -473,43 +478,38 @@ contract stHYPESTEXAMMTest is Test { } 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 { + // Non-owner cannot set params + uint32[] memory feeSteps = new uint32[](2); + feeSteps[0] = 1; + feeSteps[1] = 30; 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.setFeeParamsToken0(15 ether, 30 ether, feeSteps); - swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, feeMinBips, feeMaxBips); + // Successfully set params + _setSwapFeeParams(1, 20); + assertEq(swapFeeModule.minThresholdToken1(), 15 ether); + assertEq(swapFeeModule.maxThresholdToken1(), 30 ether); - (uint32 minThresholdRatio, uint32 maxThresholdRatio, uint32 feeMin, uint32 feeMax) = swapFeeModule.feeParams(); - assertEq(minThresholdRatio, minThresholdRatioBips); - assertEq(maxThresholdRatio, maxThresholdRatioBips); - assertEq(feeMin, feeMinBips); - assertEq(feeMax, feeMaxBips); + _setSwapFeeParams(1, 4999); + assertEq(swapFeeModule.minThresholdToken1(), 15 ether); + assertEq(swapFeeModule.maxThresholdToken1(), 30 ether); + } + function _setSwapFeeParams(uint32 feeMinBips, uint32 feeMaxBips) private { + vm.startPrank(owner); + if (feeMinBips == feeMaxBips) { + uint32[] memory feeSteps = new uint32[](1); + feeSteps[0] = feeMinBips; + swapFeeModule.setFeeParamsToken0(15 ether, 30 ether, feeSteps); + } else { + uint32[] memory feeSteps = new uint32[](5); + feeSteps[0] = feeMinBips; + feeSteps[1] = feeMinBips + (feeMaxBips - feeMinBips) / 4; + feeSteps[2] = feeMinBips + (feeMaxBips - feeMinBips) / 2; + feeSteps[3] = feeMinBips + 3 * (feeMaxBips - feeMinBips) / 4; + feeSteps[4] = feeMaxBips; + swapFeeModule.setFeeParamsToken0(15 ether, 30 ether, feeSteps); + } vm.stopPrank(); } @@ -642,7 +642,7 @@ contract stHYPESTEXAMMTest is Test { testDeposit(); // AMM swap fee as 1 bips - _setSwapFeeParams(3000, 5000, 1, 1); + _setSwapFeeParams(1, 1); address recipient = makeAddr("MOCK_RECIPIENT_FROM_TOKEN0"); @@ -820,6 +820,8 @@ contract stHYPESTEXAMMTest is Test { vm.startPrank(recipient); (uint256 amount0, uint256 amount1) = stex.withdraw(shares, 0, 0, block.timestamp, recipient, false, false); + uint256 amountToken1Request = + withdrawalModule.convertToToken1((amount0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); assertEq( stexLens.getTotalValueToken1(address(stex)), withdrawalModule.convertToToken1(10e18) + 10e18 + 1000 + 1 - amount1 @@ -828,15 +830,11 @@ contract stHYPESTEXAMMTest is Test { 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) - ); + // Pending liabilities reported to STEX are conservative (grossed-up for unstake fee). + assertGe(withdrawalModule.amountToken1PendingLPWithdrawal(), amountToken1Request); assertEq(withdrawalModule.idLPWithdrawal(), 1); (address to, uint96 amountToken1, uint256 cumulativeAmount) = withdrawalModule.LPWithdrawals(0); - assertEq( - amountToken1, withdrawalModule.convertToToken1((amount0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) - ); + assertEq(amountToken1, amountToken1Request); assertEq(cumulativeAmount, 0); assertEq(to, recipient); @@ -896,7 +894,7 @@ contract stHYPESTEXAMMTest is Test { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _deposit(10e18, recipient); @@ -952,7 +950,7 @@ contract stHYPESTEXAMMTest is Test { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _deposit(10e18, recipient); @@ -999,7 +997,7 @@ contract stHYPESTEXAMMTest is Test { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _deposit(10e18, recipient); @@ -1108,7 +1106,7 @@ contract stHYPESTEXAMMTest is Test { address recipient1 = makeAddr("RECIPIENT_1"); address recipient2 = makeAddr("RECIPIENT_2"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); // user 1 deposits _deposit(10 ether, recipient1); @@ -1151,18 +1149,18 @@ contract stHYPESTEXAMMTest is Test { // Kinetiq charges an unstaking fee uint256 feeToken0 = (amount0 * stakingManager.unstakeFeeRate()) / BIPS; + uint256 amountToken1Request = withdrawalModule.convertToToken1(amount0 - feeToken0); + uint256 amountToken1Pending = withdrawalModule.amountToken1PendingLPWithdrawal(); - assertGt(withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertGt(amountToken1Pending, 0); - assertEq( - withdrawalModule.amountToken1PendingLPWithdrawal(), - withdrawalModule.convertToToken1(amount0 - feeToken0) - ); + // Pending liabilities reported to STEX are conservative (grossed-up for unstake fee). + assertGe(amountToken1Pending, amountToken1Request); assertEq( stexLens.getTotalValueToken1(address(stex)), 10 ether + 1000 + 1 + 1 ether + withdrawalModule.convertToToken1(5 ether) - 5 ether - amount1 - - withdrawalModule.convertToToken1(amount0 - feeToken0) + - amountToken1Pending ); } // No unstaking has happened @@ -1237,7 +1235,7 @@ contract stHYPESTEXAMMTest is Test { assertFalse(stex.isLocked()); address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); { uint256 amountOutSimulation = stex.getAmountOut(address(token0), 0, false); @@ -1254,10 +1252,6 @@ contract stHYPESTEXAMMTest is Test { 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); @@ -1332,7 +1326,7 @@ contract stHYPESTEXAMMTest is Test { function testSwap__SplitAmountVsFullAmount() public { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _addPoolReserves(0, 30 ether); @@ -1379,8 +1373,6 @@ contract stHYPESTEXAMMTest is Test { 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 { @@ -1389,7 +1381,7 @@ contract stHYPESTEXAMMTest is Test { stex.setPoolManagerFeeBips(100); address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(100, 200, 1, 30); + _setSwapFeeParams(1, 30); _addPoolReserves(0, 30 ether); diff --git a/test/kHYPEWithdrawalModule.t.sol b/test/kHYPEWithdrawalModule.t.sol index 3eed9aa..7fb9e6e 100644 --- a/test/kHYPEWithdrawalModule.t.sol +++ b/test/kHYPEWithdrawalModule.t.sol @@ -2,16 +2,17 @@ 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 {Math} from "@openzeppelin/contracts/utils/math/Math.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 {IStakingManager} from "src/interfaces/kinetiq/IStakingManager.sol"; import {MockLendingPool} from "src/mocks/MockLendingPool.sol"; import {MockStakingAccountant} from "src/mocks/kinetiq/MockStakingAccountant.sol"; import {MockStakingManager} from "src/mocks/kinetiq/MockStakingManager.sol"; @@ -111,6 +112,7 @@ contract kHYPEWithdrawalModuleTest is Test { address public owner = makeAddr("OWNER"); uint256 private constant BIPS = 10_000; + uint256 private constant MAX_PENDING_UNSTAKING_OFFSET = type(uint96).max; function setUp() public { stexLens = new STEXLens(); @@ -132,7 +134,9 @@ contract kHYPEWithdrawalModuleTest is Test { assertEq(lendingPool.underlyingAsset(), address(weth)); assertEq(lendingPool.lendingPoolYieldToken(), address(lendingPool)); - _withdrawalModule = new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), owner); + _withdrawalModule = new kHYPEWithdrawalModule( + address(stakingAccountant), address(stakingManager), owner, MAX_PENDING_UNSTAKING_OFFSET + ); vm.startPrank(owner); // AMM will be mocked to make testing more flexible @@ -205,22 +209,132 @@ contract kHYPEWithdrawalModuleTest is Test { function testDeploy() public returns (kHYPEWithdrawalModule withdrawalModuleDeployment) { vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); - new kHYPEWithdrawalModule(address(0), address(stakingManager), address(this)); + new kHYPEWithdrawalModule(address(0), address(stakingManager), address(this), MAX_PENDING_UNSTAKING_OFFSET); vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__ZeroAddress.selector); - new kHYPEWithdrawalModule(address(stakingAccountant), address(0), address(this)); + new kHYPEWithdrawalModule(address(stakingAccountant), address(0), address(this), MAX_PENDING_UNSTAKING_OFFSET); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); - new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(0)); + new kHYPEWithdrawalModule( + address(stakingAccountant), address(stakingManager), address(0), MAX_PENDING_UNSTAKING_OFFSET + ); - withdrawalModuleDeployment = - new kHYPEWithdrawalModule(address(stakingAccountant), address(stakingManager), address(this)); + withdrawalModuleDeployment = new kHYPEWithdrawalModule( + address(stakingAccountant), address(stakingManager), address(this), MAX_PENDING_UNSTAKING_OFFSET + ); 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)); + assertEq(withdrawalModuleDeployment.burnFeeBips(), 0); + assertEq(withdrawalModuleDeployment.amountToken0PendingUnstakingOffset(), 0); + } + + function testAmountToken0PendingUnstakingOffset_OwnerOnlyAndBounds() public { + assertEq(_withdrawalModule.amountToken0PendingUnstakingOffset(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.setAmountToken0PendingUnstakingOffset(1 ether); + + uint256 maxOffset = _withdrawalModule.MAX_AMOUNT_TOKEN0_PENDING_UNSTAKING_OFFSET(); + vm.startPrank(owner); + _withdrawalModule.setAmountToken0PendingUnstakingOffset(maxOffset); + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__setAmountToken0PendingUnstakingOffset_OffsetTooHigh.selector + ); + _withdrawalModule.setAmountToken0PendingUnstakingOffset(maxOffset + 1); + vm.stopPrank(); + + assertEq(_withdrawalModule.amountToken0PendingUnstakingOffset(), maxOffset); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), maxOffset); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), maxOffset); + } + + function testAmountToken0PendingUnstakingOffset_DoesNotAffectInternalQueueAccounting() public { + uint256 offset = 2 ether; + + vm.prank(owner); + _withdrawalModule.setAmountToken0PendingUnstakingOffset(offset); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), offset); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), offset); + + uint256 unstakeAmountToken0 = 3 ether; + uint256 unstakeAmountToken0AfterFee = (unstakeAmountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + _unstakeToken0Reserves(unstakeAmountToken0); + + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), offset + unstakeAmountToken0AfterFee); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), offset + unstakeAmountToken0AfterFee); + + vm.deal(address(stakingManager), unstakeAmountToken0AfterFee); + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertTrue(isConfirmed); + + // Internal pending queue accounting clears to zero, while offset remains until explicitly reduced. + assertEq(_withdrawalModule.amountToken0PendingUnstakingOffset(), offset); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), offset); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), offset); + + vm.prank(owner); + _withdrawalModule.setAmountToken0PendingUnstakingOffset(0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingOffset(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + } + + function testBurnFeeBipsProposal() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.proposeBurnFeeBips(1, 3 days); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.cancelBurnFeeBipsProposal(); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); + _withdrawalModule.setProposedBurnFeeBips(); + + vm.startPrank(owner); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__setBurnFeeBips_FeeTooHigh.selector); + _withdrawalModule.proposeBurnFeeBips(101, 3 days); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooLow.selector); + _withdrawalModule.proposeBurnFeeBips(50, 3 days - 1); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule___verifyTimelockDelay_TimelockTooHigh.selector); + _withdrawalModule.proposeBurnFeeBips(50, 7 days + 1); + + _withdrawalModule.proposeBurnFeeBips(100, 3 days); + (uint256 proposedFeeBips, uint256 startTimestamp) = _withdrawalModule.burnFeeBipsProposal(); + assertEq(proposedFeeBips, 100); + assertEq(startTimestamp, block.timestamp + 3 days); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__proposeBurnFeeBips_ProposalAlreadyActive.selector); + _withdrawalModule.proposeBurnFeeBips(55, 3 days); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__setProposedBurnFeeBips_ProposalNotActive.selector); + _withdrawalModule.setProposedBurnFeeBips(); + + _withdrawalModule.cancelBurnFeeBipsProposal(); + (proposedFeeBips, startTimestamp) = _withdrawalModule.burnFeeBipsProposal(); + assertEq(proposedFeeBips, 0); + assertEq(startTimestamp, 0); + + vm.expectRevert(kHYPEWithdrawalModule.kHYPEWithdrawalModule__setProposedBurnFeeBips_InactiveProposal.selector); + _withdrawalModule.setProposedBurnFeeBips(); + + _withdrawalModule.proposeBurnFeeBips(55, 3 days); + vm.warp(block.timestamp + 3 days); + _withdrawalModule.setProposedBurnFeeBips(); + assertEq(_withdrawalModule.burnFeeBips(), 55); + (proposedFeeBips, startTimestamp) = _withdrawalModule.burnFeeBipsProposal(); + assertEq(proposedFeeBips, 0); + assertEq(startTimestamp, 0); + + vm.stopPrank(); } function testSweep() public { @@ -362,6 +476,34 @@ contract kHYPEWithdrawalModuleTest is Test { _burnToken0AfterWithdraw(amountToken0, recipient); } + function testBurnToken0AfterWithdraw_UsesBurnFeeFloorWhenUnstakeFeeIsLower() public { + stakingManager.setUnstakeFeeRate(0); + + _setBurnFeeBips(100); + + uint256 amountToken0 = 1 ether; + address recipient = makeAddr("MOCK_RECIPIENT"); + _withdrawalModule.burnToken0AfterWithdraw(amountToken0, recipient); + + LPWithdrawalRequest memory request = _withdrawalModule.getLPWithdrawals(0); + assertEq(request.amountToken1, _expectedAmountToken1AfterWithdrawFee(amountToken0)); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), request.amountToken1); + } + + function testBurnToken0AfterWithdraw_UsesUnstakeFeeWhenHigherThanBurnFeeFloor() public { + stakingManager.setUnstakeFeeRate(200); + + _setBurnFeeBips(100); + + uint256 amountToken0 = 1 ether; + address recipient = makeAddr("MOCK_RECIPIENT"); + _withdrawalModule.burnToken0AfterWithdraw(amountToken0, recipient); + + LPWithdrawalRequest memory request = _withdrawalModule.getLPWithdrawals(0); + assertEq(request.amountToken1, _expectedAmountToken1AfterWithdrawFee(amountToken0)); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), request.amountToken1); + } + function testUnstakeToken0Reserves() public { assertFalse(_withdrawalModule.isLocked()); @@ -495,6 +637,8 @@ contract kHYPEWithdrawalModuleTest is Test { vm.startPrank(owner); + vm.expectEmit(false, false, false, true, address(_withdrawalModule)); + emit kHYPEWithdrawalModule.ExcessToken0Unstaked(10 ether); _withdrawalModule.unstakeExcessToken0(); postBalance = _token0.balanceOf(address(stakingManager)); assertEq(postBalance - preBalance, 10 ether); @@ -578,9 +722,8 @@ contract kHYPEWithdrawalModuleTest is Test { lendingPool.setIsCompromised(true); vm.expectRevert( - kHYPEWithdrawalModule - .kHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn - .selector + kHYPEWithdrawalModule.kHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn + .selector ); _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient); } @@ -605,16 +748,16 @@ contract kHYPEWithdrawalModuleTest is Test { _withdrawalModule.confirmWithdrawal(0); MockPool(_pool).setIsLocked(false); - bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); - assertFalse(isConfirmed); - isConfirmed = _withdrawalModule.confirmWithdrawal(1); - assertFalse(isConfirmed); + vm.expectRevert(bytes("No valid withdrawal request")); + _withdrawalModule.confirmWithdrawal(0); + vm.expectRevert(bytes("No valid withdrawal request")); + _withdrawalModule.confirmWithdrawal(1); vm.deal(address(stakingManager), (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); vm.warp(block.timestamp + 7 days); - isConfirmed = _withdrawalModule.confirmWithdrawal(0); + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); assertTrue(isConfirmed); vm.expectRevert(bytes("Insufficient contract balance")); _withdrawalModule.confirmWithdrawal(1); @@ -641,6 +784,325 @@ contract kHYPEWithdrawalModuleTest is Test { assertFalse(isConfirmed); } + function testUpdate_AutoConfirmsTrackedWithdrawalRequests() public { + uint256 amountToken0PerRequest = 1 ether; + uint256 amountToken1PerRequest = (amountToken0PerRequest * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + + for (uint256 i; i < 5; i++) { + _unstakeToken0Reserves(amountToken0PerRequest); + } + + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 5 * amountToken1PerRequest); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 5 * amountToken1PerRequest); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + + vm.deal(address(stakingManager), 5 * amountToken1PerRequest); + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + + _withdrawalModule.update(); + + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq(address(_withdrawalModule).balance, 0); + assertEq(weth.balanceOf(address(_pool)), 5 * amountToken1PerRequest); + } + + function testUpdate_FirstTrackedRequestTemporarilyRevertsButRemainingRequestsClear() public { + // Queue 5 requests with request 0 intentionally larger than the rest. + // We then underfund StakingManager so request 0 reverts on confirm, + // while requests 1..4 are still confirmable and should clear in the same update. + uint256 firstAmountToken0 = 5 ether; + uint256 otherAmountToken0 = 1 ether; + + _unstakeToken0Reserves(firstAmountToken0); // request id = 0 + for (uint256 i; i < 4; i++) { + _unstakeToken0Reserves(otherAmountToken0); // request ids = 1..4 + } + + uint256 firstAmountToken0AfterFee = (firstAmountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + uint256 otherAmountToken0AfterFee = (otherAmountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 5); + assertEq( + _withdrawalModule.amountToken0PendingUnstaking(), firstAmountToken0AfterFee + 4 * otherAmountToken0AfterFee + ); + + // Make all requests mature. + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + + // Underfund so id=0 reverts ("Insufficient contract balance"), but ids 1..4 can still be confirmed. + vm.deal(address(stakingManager), 4 * otherAmountToken0AfterFee); + _withdrawalModule.update(); + + // Request 0 remains queued; requests 1..4 are cleared. + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 1); + assertEq(_withdrawalModule.pendingUnstakeRequestIdAt(0), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), firstAmountToken0AfterFee); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), firstAmountToken0AfterFee); + + IStakingManager.WithdrawalRequest memory request0 = + stakingManager.withdrawalRequests(address(_withdrawalModule), 0); + assertGt(request0.hypeAmount, 0); + for (uint256 id = 1; id <= 4; id++) { + IStakingManager.WithdrawalRequest memory requestCleared = + stakingManager.withdrawalRequests(address(_withdrawalModule), id); + assertEq(requestCleared.hypeAmount, 0); + } + + // Confirmed native token from requests 1..4 was wrapped and returned to pool. + assertEq(weth.balanceOf(address(_pool)), 4 * otherAmountToken0AfterFee); + + // Temporary failure resolves once StakingManager gets funded for request 0. + vm.deal(address(stakingManager), firstAmountToken0AfterFee); + _withdrawalModule.update(); + + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(weth.balanceOf(address(_pool)), 4 * otherAmountToken0AfterFee + firstAmountToken0AfterFee); + } + + function testUpdate_MixedCancelledMatureAndNotReadyRequests_DoNotSkipSwappedEntries() public { + uint256 amountToken0 = 1 ether; + uint256 amountToken0AfterFee = (amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + + // Requests 0 and 1 are older than requests 2..4. + _unstakeToken0Reserves(amountToken0); // id = 0 + _unstakeToken0Reserves(amountToken0); // id = 1 + + vm.warp(block.timestamp + stakingManager.withdrawalDelay() - 1 days); + + _unstakeToken0Reserves(amountToken0); // id = 2 + _unstakeToken0Reserves(amountToken0); // id = 3 + _unstakeToken0Reserves(amountToken0); // id = 4 + + // id=1 is cancelled and should be pruned during sync. + stakingManager.cancelWithdrawal(address(_withdrawalModule), 1); + + // Only id=0 is mature and confirmable; ids 2..4 are not yet ready. + IStakingManager.WithdrawalRequest memory request0 = + stakingManager.withdrawalRequests(address(_withdrawalModule), 0); + vm.deal(address(stakingManager), request0.hypeAmount); + vm.warp(block.timestamp + 1 days); + + _withdrawalModule.update(); + + // Tracked queue keeps only not-ready ids 2..4. + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 3); + + bool seen2; + bool seen3; + bool seen4; + for (uint256 i; i < 3; i++) { + uint256 id = _withdrawalModule.pendingUnstakeRequestIdAt(i); + assertTrue(id == 2 || id == 3 || id == 4); + if (id == 2) seen2 = true; + if (id == 3) seen3 = true; + if (id == 4) seen4 = true; + } + assertTrue(seen2 && seen3 && seen4); + + // id=0 confirmed, id=1 cancelled/pruned, ids 2..4 still live. + assertEq(stakingManager.withdrawalRequests(address(_withdrawalModule), 0).hypeAmount, 0); + assertEq(stakingManager.withdrawalRequests(address(_withdrawalModule), 1).hypeAmount, 0); + assertGt(stakingManager.withdrawalRequests(address(_withdrawalModule), 2).hypeAmount, 0); + assertGt(stakingManager.withdrawalRequests(address(_withdrawalModule), 3).hypeAmount, 0); + assertGt(stakingManager.withdrawalRequests(address(_withdrawalModule), 4).hypeAmount, 0); + + // Accounting decreases only by successfully confirmed requests. + // Cancellation does not decrement pending unstaking because cancellation-retry flow + // re-unstakes the returned token0 separately via `unstakeExcessToken0`. + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 4 * amountToken0AfterFee); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 4 * amountToken0AfterFee); + assertEq(weth.balanceOf(address(_pool)), amountToken0AfterFee); + } + + function testUpdate_RepeatedTemporaryConfirmFailures_AreIdempotentUntilFundingArrives() public { + uint256 amountToken0 = 3 ether; + uint256 amountToken0AfterFee = (amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + + _unstakeToken0Reserves(amountToken0); + + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + vm.deal(address(stakingManager), 0); + + // Multiple retries should not mutate accounting when confirmation keeps failing. + for (uint256 i; i < 3; i++) { + _withdrawalModule.update(); + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 1); + assertEq(_withdrawalModule.pendingUnstakeRequestIdAt(0), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), amountToken0AfterFee); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), amountToken0AfterFee); + assertGt(stakingManager.withdrawalRequests(address(_withdrawalModule), 0).hypeAmount, 0); + assertEq(weth.balanceOf(address(_pool)), 0); + } + + // Once funded, a later retry clears exactly once. + vm.deal(address(stakingManager), amountToken0AfterFee); + _withdrawalModule.update(); + + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(stakingManager.withdrawalRequests(address(_withdrawalModule), 0).hypeAmount, 0); + assertEq(weth.balanceOf(address(_pool)), amountToken0AfterFee); + } + + function testSettlePendingWithdrawalsWithPoolReserves_PreSyncConfirmFailureKeepsTrackedUnstake() public { + uint256 amountToken0 = 3 ether; + uint256 amountToken0AfterFee = (amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + address recipient = makeAddr("MOCK_RECIPIENT"); + + _unstakeToken0Reserves(amountToken0); + _burnToken0AfterWithdraw(amountToken0, recipient); + + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + vm.deal(address(stakingManager), 0); + + uint256 pendingLPBefore = _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); + uint256 poolBalanceBefore = weth.balanceOf(address(_pool)); + + vm.prank(owner); + _withdrawalModule.settlePendingWithdrawalsWithPoolReserves(pendingLPBefore); + + // Pre-sync confirmation attempt failed (temporary), so unstake remains tracked. + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 1); + assertEq(_withdrawalModule.pendingUnstakeRequestIdAt(0), 0); + assertGt(stakingManager.withdrawalRequests(address(_withdrawalModule), 0).hypeAmount, 0); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), amountToken0AfterFee); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), amountToken0AfterFee); + + // LP obligations are still settled from pool reserves atomically. + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), 0); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), pendingLPBefore); + assertEq(address(_withdrawalModule).balance, pendingLPBefore); + assertEq(weth.balanceOf(address(_pool)), poolBalanceBefore); + } + + function testPendingUnstakeRequestGetters() public { + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 0); + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__pendingUnstakeRequestIdAt_IndexOutOfBounds.selector + ); + _withdrawalModule.pendingUnstakeRequestIdAt(0); + + _unstakeToken0Reserves(1 ether); + _unstakeToken0Reserves(1 ether); + + assertEq(_withdrawalModule.pendingUnstakeRequestCount(), 2); + assertEq(stakingManager.nextWithdrawalId(address(_withdrawalModule)), 2); + + uint256 id0 = _withdrawalModule.pendingUnstakeRequestIdAt(0); + uint256 id1 = _withdrawalModule.pendingUnstakeRequestIdAt(1); + assertTrue((id0 == 0 && id1 == 1) || (id0 == 1 && id1 == 0)); + + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__pendingUnstakeRequestIdAt_IndexOutOfBounds.selector + ); + _withdrawalModule.pendingUnstakeRequestIdAt(2); + } + + function testAmountToken1PendingLPWithdrawalCoveredByQueuedUnstakeGetter() public { + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawalCoveredByQueuedUnstake(), 0); + + address recipient = makeAddr("MOCK_RECIPIENT"); + _burnToken0AfterWithdraw(3 ether, recipient); + _unstakeToken0Reserves(1 ether); + + uint256 amountToken0AfterUnstakeFee = (1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + uint256 expectedCovered = _withdrawalModule.convertToToken1(amountToken0AfterUnstakeFee); + + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawalCoveredByQueuedUnstake(), expectedCovered); + } + + function testUnstakeToken0Reserves_RevertsAtMaxPendingRequests() public { + uint256 amountToken0 = 1 ether; + for (uint256 i; i < 5; i++) { + _unstakeToken0Reserves(amountToken0); + } + + _token0.transfer(address(_withdrawalModule), amountToken0); + + vm.startPrank(owner); + vm.expectRevert( + kHYPEWithdrawalModule.kHYPEWithdrawalModule__unstakeToken0Reserves_MaxPendingRequestsReached.selector + ); + _withdrawalModule.unstakeToken0Reserves(amountToken0); + vm.stopPrank(); + } + + function testUnstakeToken0Reserves_PrunesClearedRequestBeforeCapCheck() public { + uint256 amountToken0 = 1 ether; + uint256 amountToken1PerRequest = (amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS; + + for (uint256 i; i < 5; i++) { + _unstakeToken0Reserves(amountToken0); + } + + uint256 pendingBefore = _withdrawalModule.amountToken0PendingUnstaking(); + stakingManager.cancelWithdrawal(address(_withdrawalModule), 0); + + _token0.transfer(address(_withdrawalModule), amountToken0); + + vm.prank(owner); + _withdrawalModule.unstakeToken0Reserves(amountToken0); + + assertEq(stakingManager.nextWithdrawalId(address(_withdrawalModule)), 6); + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), pendingBefore + amountToken1PerRequest); + } + + function testConfirmWithdrawal_WithExchangeRateIncrease_ClearsPendingUnstaking() public { + _unstakeToken0Reserves(5 ether); + uint256 pendingBefore = _withdrawalModule.amountToken0PendingUnstaking(); + assertGt(pendingBefore, 0); + + // Increase kHYPE exchange ratio while request is queued. + stakingAccountant.setTotalRewards(50 ether); + + vm.deal(address(stakingManager), (5 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + vm.warp(block.timestamp + 7 days); + + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertTrue(isConfirmed); + + // Pending unstaking is settled using queued post-fee kHYPE shares. + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + + // No LP withdrawals to fulfill, so confirmed native token gets wrapped back to pool. + assertEq(address(_withdrawalModule).balance, 0); + assertEq(weth.balanceOf(address(_pool)), (5 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); + } + + function testConfirmWithdrawal_WithBufferUsed_DoesNotChangePendingUnstakingAccounting() public { + _unstakeToken0Reserves(5 ether); + + IStakingManager.WithdrawalRequest memory request = + stakingManager.withdrawalRequests(address(_withdrawalModule), 0); + uint256 bufferUsed = request.hypeAmount / 3; + stakingManager.setWithdrawalRequestBufferUsed(address(_withdrawalModule), 0, bufferUsed); + + IStakingManager.WithdrawalRequest memory updatedRequest = + stakingManager.withdrawalRequests(address(_withdrawalModule), 0); + assertEq(updatedRequest.bufferUsed, bufferUsed); + assertEq(updatedRequest.kHYPEAmount, request.kHYPEAmount); + + vm.deal(address(stakingManager), updatedRequest.hypeAmount); + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); + assertTrue(isConfirmed); + + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(address(_withdrawalModule).balance, 0); + assertEq(weth.balanceOf(address(_pool)), updatedRequest.hypeAmount); + assertEq(stakingManager.withdrawalRequests(address(_withdrawalModule), 0).bufferUsed, 0); + } + function testSettlePendingWithdrawalsWithPoolReserves() public { assertFalse(_withdrawalModule.isLocked()); @@ -681,7 +1143,7 @@ contract kHYPEWithdrawalModuleTest is Test { vm.deal(address(_withdrawalModule), 1 ether); assertEq(address(_withdrawalModule).balance, 1 ether); - assertEq( + assertGe( _withdrawalModule.amountToken1PendingLPWithdrawal(), (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - 1 ether ); @@ -689,10 +1151,11 @@ contract kHYPEWithdrawalModuleTest is Test { _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS ); - // 1e18 of native token has reduced the amount of token0 pending unstaking proportionally + // Extra native token balance does not reduce token0 pending unstaking directly. + // Pending unstaking is decremented only on successful queued withdrawal confirmation. assertEq( _withdrawalModule.amountToken0PendingUnstaking(), - (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - 1 ether + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS ); assertEq( _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), @@ -706,7 +1169,7 @@ contract kHYPEWithdrawalModuleTest is Test { assertEq(address(_withdrawalModule).balance, (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS); assertEq( _withdrawalModule.amountToken0PendingUnstaking(), - (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - 1 ether + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS ); // update has been called at least once assertEq( @@ -735,6 +1198,36 @@ contract kHYPEWithdrawalModuleTest is Test { vm.stopPrank(); } + function testSettlePendingWithdrawalsWithPoolReserves_ConfirmsMaturedQueueBeforeUsingPoolReserves() public { + _unstakeToken0Reserves(3 ether); + + address recipient = makeAddr("MOCK_RECIPIENT"); + _burnToken0AfterWithdraw(3 ether, recipient); + + uint256 pendingLPBefore = _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); + assertGt(pendingLPBefore, 0); + + IStakingManager.WithdrawalRequest memory request = + stakingManager.withdrawalRequests(address(_withdrawalModule), 0); + vm.deal(address(stakingManager), request.hypeAmount); + vm.warp(block.timestamp + stakingManager.withdrawalDelay()); + + uint256 poolBalanceBefore = weth.balanceOf(_pool); + + vm.prank(owner); + _withdrawalModule.settlePendingWithdrawalsWithPoolReserves(pendingLPBefore); + + // Matured unstake is confirmed before settling with pool reserves. + assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); + assertEq(_withdrawalModule.amountToken0PendingUnstakingBeforeUpdate(), 0); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), 0); + assertEq(_withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), 0); + assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), pendingLPBefore); + + // Since pending LP was already funded by confirmed unstake, the supplied token1 is fully returned to pool. + assertEq(weth.balanceOf(_pool) - poolBalanceBefore, pendingLPBefore); + } + function testUpdate() public { assertFalse(_withdrawalModule.isLocked()); @@ -762,13 +1255,13 @@ contract kHYPEWithdrawalModuleTest is Test { uint256 snapshot = vm.snapshotState(); uint256 snapshot2 = vm.snapshotState(); - // Scenario 1: update with partial unstaking fulfilled + // Scenario 1: update with extra native token but no confirmation vm.deal(address(_withdrawalModule), 2 ether); _withdrawalModule.update(); assertEq( _withdrawalModule.amountToken0PendingUnstaking(), - (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - _withdrawalModule.convertToToken0(2 ether) + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS ); // All native token got wrapped and transferred into pool, // since there were no LP withdrawals to fulfill @@ -781,11 +1274,12 @@ contract kHYPEWithdrawalModuleTest is Test { address recipient = makeAddr("MOCK_RECIPIENT"); _withdrawalModule.burnToken0AfterWithdraw(1 ether, recipient); - uint256 amountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawal(); + uint256 amountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); assertEq( amountToken1PendingLPWithdrawal, _withdrawalModule.convertToToken1((1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) ); + assertGe(_withdrawalModule.amountToken1PendingLPWithdrawal(), amountToken1PendingLPWithdrawal); vm.deal(address(_withdrawalModule), 0.5 ether); assertEq( @@ -800,17 +1294,16 @@ contract kHYPEWithdrawalModuleTest is Test { assertEq( _withdrawalModule.amountToken0PendingUnstaking(), - (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS - _withdrawalModule.convertToToken0(0.5 ether) + (3 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS ); assertEq( _withdrawalModule.amountToken0PendingUnstaking(), _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate() ); assertEq(_withdrawalModule.amountToken1ClaimableLPWithdrawal(), 0.5 ether); - assertEq(_withdrawalModule.amountToken1PendingLPWithdrawal(), amountToken1PendingLPWithdrawal - 0.5 ether); + assertGe(_withdrawalModule.amountToken1PendingLPWithdrawal(), amountToken1PendingLPWithdrawal - 0.5 ether); assertEq( - _withdrawalModule.amountToken1PendingLPWithdrawal(), - _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate() + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), amountToken1PendingLPWithdrawal - 0.5 ether ); assertEq(address(_withdrawalModule).balance, 0.5 ether); // Not enough native token left to re-deposit into pool @@ -829,18 +1322,19 @@ contract kHYPEWithdrawalModuleTest is Test { _withdrawalModule.burnToken0AfterWithdraw(1 ether, recipient); - amountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawal(); + amountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); assertEq( amountToken1PendingLPWithdrawal, _withdrawalModule.convertToToken1((1 ether * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) ); + assertGe(_withdrawalModule.amountToken1PendingLPWithdrawal(), amountToken1PendingLPWithdrawal); - bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); - assertFalse(isConfirmed); + vm.expectRevert(bytes("No valid withdrawal request")); + _withdrawalModule.confirmWithdrawal(0); vm.warp(block.timestamp + 7 days); - isConfirmed = _withdrawalModule.confirmWithdrawal(0); + bool isConfirmed = _withdrawalModule.confirmWithdrawal(0); assertTrue(isConfirmed); assertEq(_withdrawalModule.amountToken0PendingUnstaking(), 0); @@ -1092,6 +1586,43 @@ contract kHYPEWithdrawalModuleTest is Test { vm.stopPrank(); } + function testLendingModuleProposal_OldLendingShortWithdrawDoesNotRevert() public { + assertEq(address(_withdrawalModule.lendingModule()), address(lendingModule)); + + vm.startPrank(owner); + + uint256 amount = 2 ether; + _withdrawalModule.supplyToken1ToLendingPool(amount); + assertEq(lendingModule.assetBalance(), amount); + + // Simulates compromised lending-pool withdrawals returning less than requested. + lendingPool.setIsCompromised(true); + + address lendingModuleMock = address( + new AaveLendingModule( + address(lendingPool), + lendingPool.lendingPoolYieldToken(), + address(weth), + address(_withdrawalModule), + address(0x123), + 2 + ) + ); + _withdrawalModule.proposeLendingModule(lendingModuleMock, 3 days); + vm.warp(block.timestamp + 3 days); + + uint256 preBalancePool = weth.balanceOf(address(_pool)); + _withdrawalModule.setProposedLendingModule(); + uint256 postBalancePool = weth.balanceOf(address(_pool)); + + // Module migration succeeds even when the old lending module sends less than expected. + assertEq(postBalancePool - preBalancePool, 0); + assertEq(lendingModule.assetBalance(), 0); + assertEq(address(_withdrawalModule.lendingModule()), lendingModuleMock); + + vm.stopPrank(); + } + function _burnToken0AfterWithdraw(uint256 amountToken0, address recipient) private { vm.prank(_pool); @@ -1099,8 +1630,9 @@ contract kHYPEWithdrawalModuleTest is Test { _withdrawalModule.burnToken0AfterWithdraw(amountToken0, recipient); uint256 preAmountToken0PendingUnstaking = _withdrawalModule.amountToken0PendingUnstaking(); - uint256 preAmountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawal(); + uint256 preAmountToken1PendingLPWithdrawal = _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(); uint256 preAmountCumulative = _withdrawalModule.cumulativeAmountToken1LPWithdrawal(); + uint256 amountToken1Request = _expectedAmountToken1AfterWithdrawFee(amountToken0); _withdrawalModule.burnToken0AfterWithdraw(amountToken0, recipient); // No token0 has been unstaked @@ -1110,11 +1642,10 @@ contract kHYPEWithdrawalModuleTest is Test { _withdrawalModule.amountToken0PendingUnstakingBeforeUpdate() ); assertEq( - _withdrawalModule.amountToken1PendingLPWithdrawal(), - _withdrawalModule.convertToToken1((amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) - + preAmountToken1PendingLPWithdrawal + _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate(), + amountToken1Request + preAmountToken1PendingLPWithdrawal ); - assertEq( + assertGe( _withdrawalModule.amountToken1PendingLPWithdrawal(), _withdrawalModule.amountToken1PendingLPWithdrawalBeforeUpdate() ); @@ -1123,14 +1654,31 @@ contract kHYPEWithdrawalModuleTest is Test { { LPWithdrawalRequest memory request = _withdrawalModule.getLPWithdrawals(preId); assertEq(request.recipient, recipient); - assertEq( - request.amountToken1, - _withdrawalModule.convertToToken1((amountToken0 * (BIPS - stakingManager.unstakeFeeRate())) / BIPS) - ); + assertEq(request.amountToken1, amountToken1Request); assertEq(request.cumulativeAmountToken1LPWithdrawalCheckpoint, preAmountCumulative); } } + function _expectedAmountToken1AfterWithdrawFee(uint256 amountToken0) private view returns (uint256) { + uint256 unstakeFeeBips = stakingManager.unstakeFeeRate(); + if (unstakeFeeBips > BIPS - 1) { + unstakeFeeBips = BIPS - 1; + } + + uint256 feeBips = Math.max(unstakeFeeBips, _withdrawalModule.burnFeeBips()); + uint256 feeToken0 = Math.mulDiv(amountToken0, feeBips, BIPS, Math.Rounding.Ceil); + + return _withdrawalModule.convertToToken1(amountToken0 - feeToken0); + } + + function _setBurnFeeBips(uint256 burnFee) private { + vm.startPrank(owner); + _withdrawalModule.proposeBurnFeeBips(burnFee, 3 days); + vm.warp(block.timestamp + 3 days); + _withdrawalModule.setProposedBurnFeeBips(); + vm.stopPrank(); + } + function _unstakeToken0Reserves(uint256 amount) private { uint256 initialToken0Reserves = _token0.balanceOf(address(this)); @@ -1144,6 +1692,11 @@ contract kHYPEWithdrawalModuleTest is Test { _withdrawalModule.unstakeToken0Reserves(amount); + uint256 requestId = stakingManager.nextWithdrawalId(address(_withdrawalModule)) - 1; + IStakingManager.WithdrawalRequest memory request = + stakingManager.withdrawalRequests(address(_withdrawalModule), requestId); + assertEq(request.bufferUsed, 0); + assertEq( _withdrawalModule.amountToken0PendingUnstaking(), preAmountToken0PendingUnstaking + (amount * (BIPS - stakingManager.unstakeFeeRate())) / BIPS diff --git a/test/kHYPEWithdrawalModuleKeeper.t.sol b/test/kHYPEWithdrawalModuleKeeper.t.sol index 24314bc..4995836 100644 --- a/test/kHYPEWithdrawalModuleKeeper.t.sol +++ b/test/kHYPEWithdrawalModuleKeeper.t.sol @@ -64,7 +64,11 @@ contract kHYPEWithdrawalModuleKeeperTest is Test { uint256, /*amountToken1Min*/ bytes calldata /*_data*/ - ) external pure returns (bytes4) { + ) + external + pure + returns (bytes4) + { return IRebalanceModule.rebalance.selector; } diff --git a/test/stHYPESTEXAMM.t.sol b/test/stHYPESTEXAMM.t.sol index 49c3dc0..4adb3a4 100644 --- a/test/stHYPESTEXAMM.t.sol +++ b/test/stHYPESTEXAMM.t.sol @@ -17,21 +17,20 @@ 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 {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"; 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 { STEXAMM stex; STEXLens stexLens; - STEXRatioSwapFeeModule swapFeeModule; + StepwiseFeeModule swapFeeModule; stHYPEWithdrawalModule withdrawalModule; @@ -73,7 +72,7 @@ contract stHYPESTEXAMMTest is Test { withdrawalModule = new stHYPEWithdrawalModule(address(overseer), wstHYPE, address(this)); - swapFeeModule = new STEXRatioSwapFeeModule(owner); + swapFeeModule = new StepwiseFeeModule(owner); assertEq(swapFeeModule.owner(), owner); stex = new STEXAMM( @@ -146,7 +145,7 @@ contract stHYPESTEXAMMTest is Test { assertEq(withdrawalModuleDeployment.stex(), address(0)); assertEq(withdrawalModuleDeployment.owner(), address(this)); - STEXRatioSwapFeeModule swapFeeModuleDeployment = new STEXRatioSwapFeeModule(owner); + StepwiseFeeModule swapFeeModuleDeployment = new StepwiseFeeModule(owner); assertEq(swapFeeModuleDeployment.owner(), owner); vm.expectRevert(STEXAMM.STEXAMM__ZeroAddress.selector); @@ -295,7 +294,7 @@ contract stHYPESTEXAMMTest is Test { vm.startPrank(owner); swapFeeModuleDeployment.setPool(stexDeployment.pool()); assertEq(swapFeeModuleDeployment.pool(), stexDeployment.pool()); - vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setPool_alreadySet.selector); + vm.expectRevert(StepwiseFeeModule.StepwiseFeeModule__setPool_AlreadySet.selector); swapFeeModuleDeployment.setPool(makeAddr("MOCK_POOL")); vm.stopPrank(); @@ -469,43 +468,38 @@ contract stHYPESTEXAMMTest is Test { } 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 { + // Non-owner cannot set params + uint32[] memory feeSteps = new uint32[](2); + feeSteps[0] = 1; + feeSteps[1] = 30; 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); + swapFeeModule.setFeeParamsToken0(15 ether, 30 ether, feeSteps); - vm.expectRevert(STEXRatioSwapFeeModule.STEXRatioSwapFeeModule__setSwapFeeParams_invalidFeeMax.selector); - swapFeeModule.setSwapFeeParams(minThresholdRatioBips, maxThresholdRatioBips, feeMinBips, 5_000); + // Successfully set params + _setSwapFeeParams(1, 20); + assertEq(swapFeeModule.minThresholdToken1(), 15 ether); + assertEq(swapFeeModule.maxThresholdToken1(), 30 ether); - 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); + _setSwapFeeParams(1, 4999); + assertEq(swapFeeModule.minThresholdToken1(), 15 ether); + assertEq(swapFeeModule.maxThresholdToken1(), 30 ether); + } + function _setSwapFeeParams(uint32 feeMinBips, uint32 feeMaxBips) private { + vm.startPrank(owner); + if (feeMinBips == feeMaxBips) { + uint32[] memory feeSteps = new uint32[](1); + feeSteps[0] = feeMinBips; + swapFeeModule.setFeeParamsToken0(15 ether, 30 ether, feeSteps); + } else { + uint32[] memory feeSteps = new uint32[](5); + feeSteps[0] = feeMinBips; + feeSteps[1] = feeMinBips + (feeMaxBips - feeMinBips) / 4; + feeSteps[2] = feeMinBips + (feeMaxBips - feeMinBips) / 2; + feeSteps[3] = feeMinBips + 3 * (feeMaxBips - feeMinBips) / 4; + feeSteps[4] = feeMaxBips; + swapFeeModule.setFeeParamsToken0(15 ether, 30 ether, feeSteps); + } vm.stopPrank(); } @@ -636,7 +630,7 @@ contract stHYPESTEXAMMTest is Test { testDeposit(); // AMM swap fee as 1 bips - _setSwapFeeParams(3000, 5000, 1, 1); + _setSwapFeeParams(1, 1); address recipient = makeAddr("MOCK_RECIPIENT_FROM_TOKEN0"); @@ -857,7 +851,7 @@ contract stHYPESTEXAMMTest is Test { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _deposit(10e18, recipient); @@ -894,7 +888,7 @@ contract stHYPESTEXAMMTest is Test { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _deposit(10e18, recipient); @@ -933,7 +927,7 @@ contract stHYPESTEXAMMTest is Test { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _deposit(10e18, recipient); @@ -1012,7 +1006,7 @@ contract stHYPESTEXAMMTest is Test { address recipient1 = makeAddr("RECIPIENT_1"); address recipient2 = makeAddr("RECIPIENT_2"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); // user 1 deposits _deposit(10 ether, recipient1); @@ -1112,7 +1106,7 @@ contract stHYPESTEXAMMTest is Test { assertFalse(stex.isLocked()); address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); { uint256 amountOutSimulation = stex.getAmountOut(address(token0), 0, false); @@ -1129,10 +1123,6 @@ contract stHYPESTEXAMMTest is Test { 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); uint256 amountOutEstimate = stex.getAmountOut(address(token0), params.amountIn, false); @@ -1190,7 +1180,7 @@ contract stHYPESTEXAMMTest is Test { function testSwap__SplitAmountVsFullAmount() public { address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(3000, 5000, 1, 30); + _setSwapFeeParams(1, 30); _addPoolReserves(0, 30 ether); @@ -1237,8 +1227,6 @@ contract stHYPESTEXAMMTest is Test { 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 { @@ -1247,7 +1235,7 @@ contract stHYPESTEXAMMTest is Test { stex.setPoolManagerFeeBips(100); address recipient = makeAddr("RECIPIENT"); - _setSwapFeeParams(100, 200, 1, 30); + _setSwapFeeParams(1, 30); _addPoolReserves(0, 30 ether); diff --git a/test/stHYPEWithdrawalModule.t.sol b/test/stHYPEWithdrawalModule.t.sol index d2edcdc..6588888 100644 --- a/test/stHYPEWithdrawalModule.t.sol +++ b/test/stHYPEWithdrawalModule.t.sol @@ -321,8 +321,7 @@ contract stHYPEWithdrawalModuleTest is Test { lendingPool.setIsCompromised(true); vm.expectRevert( - stHYPEWithdrawalModule - .stHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn + stHYPEWithdrawalModule.stHYPEWithdrawalModule__withdrawToken1FromLendingPool_InsufficientAmountWithdrawn .selector ); _withdrawalModule.withdrawToken1FromLendingPool(amountToken1, recipient);