diff --git a/script/DeployFactory.s.sol b/script/DeployFactory.s.sol index 205f638..af2a8ca 100644 --- a/script/DeployFactory.s.sol +++ b/script/DeployFactory.s.sol @@ -21,7 +21,7 @@ contract DeployFactory is Script { f.stvStETHPoolFactory = address(new StvStETHPoolFactory()); f.withdrawalQueueFactory = address(new WithdrawalQueueFactory()); f.distributorFactory = address(new DistributorFactory()); - f.ggvStrategyFactory = address(new GGVStrategyFactory(_ggvTeller, _ggvBoringQueue, _steth, _wsteth)); + f.ggvStrategyFactory = address(new GGVStrategyFactory(_ggvTeller, _ggvBoringQueue)); f.timelockFactory = address(new TimelockFactory()); } diff --git a/src/Factory.sol b/src/Factory.sol index e9fcf79..e595ca3 100644 --- a/src/Factory.sol +++ b/src/Factory.sol @@ -10,6 +10,7 @@ import {StvPoolFactory} from "./factories/StvPoolFactory.sol"; import {StvStETHPoolFactory} from "./factories/StvStETHPoolFactory.sol"; import {TimelockFactory} from "./factories/TimelockFactory.sol"; import {WithdrawalQueueFactory} from "./factories/WithdrawalQueueFactory.sol"; +import {IStrategy} from "./interfaces/IStrategy.sol"; import {IStrategyFactory} from "./interfaces/IStrategyFactory.sol"; import {ILidoLocator} from "./interfaces/core/ILidoLocator.sol"; import {IVaultHub} from "./interfaces/core/IVaultHub.sol"; @@ -491,11 +492,15 @@ contract Factory { dashboard.grantRole(dashboard.BURN_ROLE(), address(pool)); } - address strategy = address(0); + address strategyProxy = address(0); if (_intermediate.strategyFactory != address(0)) { - strategy = IStrategyFactory(_intermediate.strategyFactory) + address strategyImpl = IStrategyFactory(_intermediate.strategyFactory) .deploy(address(pool), _intermediate.strategyDeployBytes); - pool.addToAllowList(strategy); + + strategyProxy = + address(new OssifiableProxy(strategyImpl, timelock, abi.encodeCall(IStrategy.initialize, (timelock)))); + + pool.addToAllowList(strategyProxy); } pool.grantRole(DEFAULT_ADMIN_ROLE, timelock); @@ -512,7 +517,7 @@ contract Factory { withdrawalQueue: address(withdrawalQueue), distributor: address(pool.DISTRIBUTOR()), timelock: _intermediate.timelock, - strategy: strategy + strategy: strategyProxy }); emit PoolCreated( diff --git a/src/factories/GGVStrategyFactory.sol b/src/factories/GGVStrategyFactory.sol index 7089ead..c65db50 100644 --- a/src/factories/GGVStrategyFactory.sol +++ b/src/factories/GGVStrategyFactory.sol @@ -6,26 +6,21 @@ import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; import {StrategyCallForwarder} from "src/strategy/StrategyCallForwarder.sol"; contract GGVStrategyFactory is IStrategyFactory { + bytes32 public immutable STRATEGY_ID = keccak256("strategy.ggv.v1"); address public immutable TELLER; address public immutable BORING_QUEUE; - address public immutable STETH; - address public immutable WSTETH; - constructor(address _teller, address _boringQueue, address _steth, address _wsteth) { + constructor(address _teller, address _boringQueue) { require(_teller.code.length > 0, "TELLER: not a contract"); require(_boringQueue.code.length > 0, "BORING_QUEUE: not a contract"); - require(_steth.code.length > 0, "STETH: not a contract"); - require(_wsteth.code.length > 0, "WSTETH: not a contract"); TELLER = _teller; BORING_QUEUE = _boringQueue; - STETH = _steth; - WSTETH = _wsteth; } function deploy(address _pool, bytes calldata _deployBytes) external returns (address impl) { // _deployBytes is unused for GGVStrategy, but required by IStrategyFactory interface _deployBytes; address strategyCallForwarderImpl = address(new StrategyCallForwarder()); - impl = address(new GGVStrategy(strategyCallForwarderImpl, _pool, STETH, WSTETH, TELLER, BORING_QUEUE)); + impl = address(new GGVStrategy(STRATEGY_ID, strategyCallForwarderImpl, _pool, TELLER, BORING_QUEUE)); } } diff --git a/src/interfaces/IStrategy.sol b/src/interfaces/IStrategy.sol index 8caa218..32345de 100644 --- a/src/interfaces/IStrategy.sol +++ b/src/interfaces/IStrategy.sol @@ -2,31 +2,96 @@ pragma solidity >=0.8.25; interface IStrategy { - event StrategySupplied(address indexed user, uint256 stv, uint256 stethShares, uint256 stethAmount, bytes data); - event StrategyExitRequested(address indexed user, bytes32 requestId, uint256 stethSharesToBurn, bytes data); - event StrategyExitFinalized(address indexed user, bytes32 requestId, uint256 stethShares); + event StrategySupplied( + address indexed user, address indexed referral, uint256 ethAmount, uint256 stv, uint256 wstethToMint, bytes data + ); + event StrategyExitRequested(address indexed user, bytes32 requestId, uint256 wsteth, bytes data); + event StrategyExitFinalized(address indexed user, bytes32 requestId, uint256 wsteth); + /** + * @notice Initializes the strategy + * @param _admin The admin address + */ + function initialize(address _admin) external; + + /** + * @notice Returns the address of the pool + * @return The address of the pool + */ function POOL() external view returns (address); - /// @notice Supplies stETH to the strategy - function supply(address _referral, bytes calldata _params) external payable; + /** + * @notice Supplies wstETH to the strategy + * @param _referral The referral address + * @param _wstethToMint The amount of wstETH to mint + * @param _params The parameters for the supply + * @return stv The minted amount of stv + */ + function supply(address _referral, uint256 _wstethToMint, bytes calldata _params) + external + payable + returns (uint256 stv); + + /** + * @notice Returns the remaining minting capacity shares of a user + * @param _user The user to get the remaining minting capacity shares for + * @param _ethToFund The amount of ETH to fund + * @return stethShares The remaining minting capacity shares + */ + function remainingMintingCapacitySharesOf(address _user, uint256 _ethToFund) + external + view + returns (uint256 stethShares); - /// @notice Requests a withdrawal from the Withdrawal Queue - function requestWithdrawal( - uint256 _stvToWithdraw, - uint256 _stethSharesToBurn, - uint256 _stethSharesToRebalance, - address _receiver - ) external returns (uint256 requestId); + /** + * @notice Requests exit from the strategy + * @param _wsteth The amount of wstETH to request exit for + * @param _params The parameters for the exit + * @return requestId The Strategy request id + */ + function requestExitByWsteth(uint256 _wsteth, bytes calldata _params) external returns (bytes32 requestId); - /// @notice Requests a withdrawal from the strategy - function requestExitByStethShares(uint256 stethSharesToBurn, bytes calldata params) + /** + * @notice Finalizes exit from the strategy + * @param requestId The Strategy request id + */ + function finalizeRequestExit(bytes32 requestId) external; + + /** + * @notice Burns wstETH to reduce the user's minted stETH shares obligation + * @param _wstethToBurn The amount of wstETH to burn + */ + function burnWsteth(uint256 _wstethToBurn) external; + + /** + * @notice Requests a withdrawal from the Withdrawal Queue + * @param _recipient The address to receive the withdrawal + * @param _stvToWithdraw The amount of stv to withdraw + * @param _stethSharesToRebalance The amount of stETH shares to rebalance + * @return requestId The Withdrawal Queue request ID + */ + function requestWithdrawalFromPool(address _recipient, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) external - returns (bytes32 requestId); + returns (uint256 requestId); + + /** + * @notice Returns the amount of wstETH of a user + * @param _user The user to get the wstETH for + * @return wsteth The amount of wstETH + */ + function wstethOf(address _user) external view returns (uint256); - /// @notice Finalizes a withdrawal from the strategy - function finalizeRequestExit(address receiver, bytes32 requestId) external; + /** + * @notice Returns the amount of stv of a user + * @param _user The user to get the stv for + * @return stv The amount of stv + */ + function stvOf(address _user) external view returns (uint256); - /// @notice Recovers ERC20 tokens from the strategy - function recoverERC20(address _token, address _recipient, uint256 _amount) external; + /** + * @notice Returns the amount of minted stETH shares of a user + * @param _user The user to get the minted stETH shares for + * @return mintedStethShares The amount of minted stETH shares + */ + function mintedStethSharesOf(address _user) external view returns (uint256 mintedStethShares); } diff --git a/src/interfaces/IStrategyCallForwarder.sol b/src/interfaces/IStrategyCallForwarder.sol index 037c4e2..155b76f 100644 --- a/src/interfaces/IStrategyCallForwarder.sol +++ b/src/interfaces/IStrategyCallForwarder.sol @@ -3,10 +3,7 @@ pragma solidity >=0.8.25; interface IStrategyCallForwarder { function initialize(address _owner) external; - function call(address _target, bytes calldata _data) external payable returns (bytes memory); - function callWithValue(address _target, bytes calldata _data, uint256 _value) - external - payable - returns (bytes memory); - function sendValue(address payable _recipient, uint256 _amount) external payable; + function doCall(address _target, bytes calldata _data) external returns (bytes memory); + function doCallWithValue(address _target, bytes calldata _data, uint256 _value) external returns (bytes memory); + function sendValue(address payable _recipient, uint256 _amount) external; } diff --git a/src/mock/ggv/GGVMockTeller.sol b/src/mock/ggv/GGVMockTeller.sol index 1ec2c53..04d1565 100644 --- a/src/mock/ggv/GGVMockTeller.sol +++ b/src/mock/ggv/GGVMockTeller.sol @@ -5,6 +5,7 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {GGVVaultMock} from "./GGVVaultMock.sol"; import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; contract GGVMockTeller is ITellerWithMultiAssetSupport { @@ -18,6 +19,7 @@ contract GGVMockTeller is ITellerWithMultiAssetSupport { GGVVaultMock public immutable _vault; uint256 internal immutable ONE_SHARE; IStETH public immutable steth; + IWstETH public immutable wsteth; mapping(ERC20 asset => Asset) public assets; @@ -27,12 +29,13 @@ contract GGVMockTeller is ITellerWithMultiAssetSupport { owner = _owner; _vault = GGVVaultMock(__vault); steth = IStETH(_steth); + wsteth = IWstETH(_wsteth); // eq to 10 ** vault.decimals() ONE_SHARE = 10 ** 18; _updateAssetData(ERC20(_steth), true, false, 0); - _updateAssetData(ERC20(_wsteth), false, true, 0); + _updateAssetData(ERC20(_wsteth), true, true, 0); } function deposit(ERC20 depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) @@ -47,7 +50,14 @@ contract GGVMockTeller is ITellerWithMultiAssetSupport { revert("Deposit amount must be greater than 0"); } - uint256 stethShares = steth.getSharesByPooledEth(depositAmount); + uint256 stethShares; + if (address(depositAsset) == address(steth)) { + stethShares = steth.getSharesByPooledEth(depositAmount); + } else if (address(depositAsset) == address(wsteth)) { + stethShares = depositAmount; + } else { + revert("Unsupported asset"); + } // hardcode share calculation for only steth shares = _vault.getSharesByAssets(stethShares); diff --git a/src/mock/ggv/GGVVaultMock.sol b/src/mock/ggv/GGVVaultMock.sol index 4d22b11..540e636 100644 --- a/src/mock/ggv/GGVVaultMock.sol +++ b/src/mock/ggv/GGVVaultMock.sol @@ -7,6 +7,7 @@ import {BorrowedMath} from "./BorrowedMath.sol"; import {GGVMockTeller} from "./GGVMockTeller.sol"; import {GGVQueueMock} from "./GGVQueueMock.sol"; import {IStETH} from "src/interfaces/core/IStETH.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; @@ -15,34 +16,64 @@ contract GGVVaultMock is ERC20 { ITellerWithMultiAssetSupport public immutable TELLER; GGVQueueMock public immutable BORING_QUEUE; IStETH public immutable steth; + IWstETH public immutable wsteth; // steth shares as base vault asset // real ggv uses weth but it should be okay to peg it to steth shares for mock uint256 public _totalAssets; + error OnlyOwner(); + error OnlyTeller(); + error OnlyQueue(); + constructor(address _owner, address _steth, address _wsteth) ERC20("GGVVaultMock", "tGGV") { owner = _owner; TELLER = ITellerWithMultiAssetSupport(address(new GGVMockTeller(_owner, address(this), _steth, _wsteth))); BORING_QUEUE = new GGVQueueMock(address(this), _steth, _wsteth, _owner); steth = IStETH(_steth); + wsteth = IWstETH(_wsteth); // Mint some initial tokens to the dead address to avoid zero totalSupply issues _mint(address(0xdead), 1e18); _totalAssets = 1e18; } - function rebase(uint256 stethSharesToRebaseWith) external { - require(msg.sender == owner, "Only owner can rebase"); - steth.transferSharesFrom(msg.sender, address(this), stethSharesToRebaseWith); - _totalAssets += stethSharesToRebaseWith; + function _onlyOwner() internal view { + if (msg.sender != owner) revert OnlyOwner(); + } + + function _onlyTeller() internal view { + if (msg.sender != address(TELLER)) revert OnlyTeller(); } - function negativeRebase(uint256 stethSharesToRebaseWith) external { - require(msg.sender == owner, "Only owner can rebase"); + function _onlyQueue() internal view { + if (msg.sender != address(BORING_QUEUE)) revert OnlyQueue(); + } + + function rebaseSteth(uint256 _stethShares) external { + _onlyOwner(); + steth.transferSharesFrom(msg.sender, address(this), _stethShares); + _totalAssets += _stethShares; + } + + function negativeRebaseSteth(uint256 stethSharesToRebaseWith) external { + _onlyOwner(); steth.transferShares(msg.sender, stethSharesToRebaseWith); _totalAssets -= stethSharesToRebaseWith; } + function rebaseWsteth(uint256 wstethAmount) external { + _onlyOwner(); + wsteth.transferFrom(msg.sender, address(this), wstethAmount); + _totalAssets += wstethAmount; + } + + function negativeRebaseWsteth(uint256 wstethAmount) external { + _onlyOwner(); + wsteth.transfer(msg.sender, wstethAmount); + _totalAssets -= wstethAmount; + } + function getSharesByAssets(uint256 assets) public view returns (uint256) { uint256 supply = totalSupply(); uint256 totalAssets_ = totalAssets(); @@ -59,17 +90,22 @@ contract GGVVaultMock is ERC20 { } function depositByTeller(address asset, uint256 shares, uint256 assets, address user) external { - require(msg.sender == address(TELLER), "Only teller can call depositByTeller"); - require(asset == address(steth), "Only steth asset supported"); + _onlyTeller(); - steth.transferSharesFrom(user, address(this), assets); + if (asset == address(steth)) { + steth.transferSharesFrom(user, address(this), assets); + } else if (asset == address(wsteth)) { + wsteth.transferFrom(user, address(this), assets); + } else { + revert("Unsupported asset"); + } _mint(user, shares); _totalAssets += assets; } function burnSharesReturnAssets(ERC20 assetOut, uint256 shares, uint256 assets, address user) external { - require(msg.sender == address(BORING_QUEUE), "Only queue can call burnShares"); + _onlyQueue(); _burn(address(BORING_QUEUE), shares); _totalAssets -= assets; assetOut.transfer(user, assets); diff --git a/src/strategy/GGVStrategy.sol b/src/strategy/GGVStrategy.sol index ffc5832..cc5adc5 100644 --- a/src/strategy/GGVStrategy.sol +++ b/src/strategy/GGVStrategy.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.25; +import { + AccessControlEnumerableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; @@ -9,196 +12,205 @@ import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {ITellerWithMultiAssetSupport} from "src/interfaces/ggv/ITellerWithMultiAssetSupport.sol"; -import {Strategy} from "src/strategy/Strategy.sol"; +import {StrategyCallForwarderRegistry} from "src/strategy/StrategyCallForwarderRegistry.sol"; +import {FeaturePausable} from "src/utils/FeaturePausable.sol"; + +import {IStrategy} from "src/interfaces/IStrategy.sol"; +import {IWstETH} from "src/interfaces/core/IWstETH.sol"; + +contract GGVStrategy is IStrategy, AccessControlEnumerableUpgradeable, FeaturePausable, StrategyCallForwarderRegistry { + StvStETHPool private immutable POOL_; + IWstETH public immutable WSTETH; -contract GGVStrategy is Strategy { ITellerWithMultiAssetSupport public immutable TELLER; IBoringOnChainQueue public immutable BORING_QUEUE; - // ==================== Events ==================== + // ACL + bytes32 public constant SUPPLY_FEATURE = keccak256("SUPPLY_FEATURE"); + bytes32 public constant SUPPLY_PAUSE_ROLE = keccak256("SUPPLY_PAUSE_ROLE"); + bytes32 public constant SUPPLY_RESUME_ROLE = keccak256("SUPPLY_RESUME_ROLE"); + + struct GGVParamsSupply { + uint16 minimumMint; + } + + struct GGVParamsRequestExit { + uint16 discount; + uint24 secondsToDeadline; + } event GGVDeposited( - address indexed recipient, uint256 stethAmount, uint256 ggvShares, address referralAddress, bytes data + address indexed recipient, + uint256 wstethAmount, + uint256 ggvShares, + address indexed referralAddress, + bytes paramsSupply + ); + event GGVWithdrawalRequested( + address indexed recipient, bytes32 requestId, uint256 ggvShares, bytes paramsRequestExit ); - event GGVWithdrawalRequested(address indexed recipient, bytes32 requestId, uint128 requestedGGV, bytes data); - - // ==================== Errors ==================== + error ZeroArgument(string name); error InvalidSender(); - error InvalidStethAmount(); - error AlreadyRequested(); - error InvalidRequestId(); + error InvalidWstethAmount(); + error NothingToExit(); + error InvalidGGVSharesAmount(); error NotImplemented(); - error InvalidGGVAmount(); - - struct GGVParams { - uint16 discount; - uint16 minimumMint; - uint24 secondsToDeadline; - } constructor( - address _strategyCallForwarderImplementation, + bytes32 _strategyId, + address _strategyCallForwarderImpl, address _pool, - address _stETH, - address _wstETH, address _teller, address _boringQueue - ) Strategy(_pool, _stETH, _wstETH, _strategyCallForwarderImplementation) { + ) StrategyCallForwarderRegistry(_strategyId, _strategyCallForwarderImpl) { + POOL_ = StvStETHPool(payable(_pool)); + WSTETH = IWstETH(POOL_.WSTETH()); + TELLER = ITellerWithMultiAssetSupport(_teller); BORING_QUEUE = IBoringOnChainQueue(_boringQueue); - } - /// @notice Supplies stETH to the strategy - /// @param _referral The referral address - /// @param _params The parameters for the supply - function supply(address _referral, bytes calldata _params) external payable { - address callForwarder = _getOrCreateCallForwarder(msg.sender); - uint256 stethShares = POOL_.remainingMintingCapacitySharesOf(callForwarder, msg.value); // TODO: replace with argument - uint256 stv = POOL_.depositETH{value: msg.value}(callForwarder, _referral); + _disableInitializers(); + _pauseFeature(SUPPLY_FEATURE); + } - IStrategyCallForwarder(callForwarder) - .call(address(POOL_), abi.encodeWithSelector(POOL_.mintStethShares.selector, stethShares)); + /** + * @notice Initialize the contract storage explicitly + * @param _admin Admin address that can change every role + * @dev Reverts if `_admin` equals to `address(0)` + */ + function initialize(address _admin) external initializer { + if (_admin == address(0)) revert ZeroArgument("_admin"); - uint256 stethAmount = STETH.getPooledEthByShares(stethShares); + __AccessControlEnumerable_init(); - IStrategyCallForwarder(callForwarder) - .call(address(STETH), abi.encodeWithSelector(STETH.approve.selector, TELLER.vault(), stethAmount)); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + } - GGVParams memory params = abi.decode(_params, (GGVParams)); + /** + * @inheritdoc IStrategy + */ + function POOL() external view returns (address) { + return address(POOL_); + } - bytes memory data = IStrategyCallForwarder(callForwarder) - .call( - address(TELLER), - abi.encodeWithSelector( - TELLER.deposit.selector, address(STETH), stethAmount, params.minimumMint, _referral - ) - ); - uint256 ggvShares = abi.decode(data, (uint256)); + // ================================================================================= + // PAUSE / RESUME + // ================================================================================= - emit StrategySupplied(msg.sender, stv, stethShares, stethAmount, _params); - emit GGVDeposited(msg.sender, stethAmount, ggvShares, _referral, _params); + /** + * @notice Pause supply + */ + function pauseSupply() external { + _checkRole(SUPPLY_PAUSE_ROLE, msg.sender); + _pauseFeature(SUPPLY_FEATURE); } - /// @notice Requests a withdrawal of ggv shares from the strategy - /// @param _stethAmount The amount of stETH to withdraw - /// @return requestId The request id - function requestExitByStETH(uint256 _stethAmount, bytes calldata _params) external returns (bytes32 requestId) { - uint256 stethSharesToBurn = STETH.getSharesByPooledEth(_stethAmount); - requestId = requestExitByStethShares(stethSharesToBurn, _params); + /** + * @notice Resume supply + */ + function resumeSupply() external { + _checkRole(SUPPLY_RESUME_ROLE, msg.sender); + _resumeFeature(SUPPLY_FEATURE); } - /// @notice Previews the amount of stETH shares that can be withdrawn by a given amount of GGV shares - /// @param _user The user to preview the amount of stETH shares for - /// @param _ggvShares The amount of GGV shares to preview the amount of stETH shares for - /// @param _params The parameters for the withdrawal - /// @return stethShares The amount of stETH shares that can be withdrawn - function previewStethSharesByGGV(address _user, uint256 _ggvShares, bytes calldata _params) + // ================================================================================= + // SUPPLY + // ================================================================================= + + /** + * @inheritdoc IStrategy + */ + function supply(address _referral, uint256 _wstethToMint, bytes calldata _params) external - view - returns (uint256 stethShares) + payable + returns (uint256 stv) { - address callForwarder = getStrategyCallForwarderAddress(_user); + _checkFeatureNotPaused(SUPPLY_FEATURE); - GGVParams memory params = abi.decode(_params, (GGVParams)); + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); - IERC20 boringVault = IERC20(TELLER.vault()); - uint256 totalGGV = boringVault.balanceOf(callForwarder); + if (msg.value > 0) { + stv = POOL_.depositETH{value: msg.value}(address(callForwarder), _referral); + } + + callForwarder.doCall(address(POOL_), abi.encodeWithSelector(POOL_.mintWsteth.selector, _wstethToMint)); + callForwarder.doCall( + address(WSTETH), abi.encodeWithSelector(WSTETH.approve.selector, TELLER.vault(), _wstethToMint) + ); + + GGVParamsSupply memory params = abi.decode(_params, (GGVParamsSupply)); + + bytes memory data = callForwarder.doCall( + address(TELLER), + abi.encodeWithSelector( + TELLER.deposit.selector, address(WSTETH), _wstethToMint, params.minimumMint, _referral + ) + ); + uint256 ggvShares = abi.decode(data, (uint256)); - if (totalGGV == 0) return 0; - if (_ggvShares > totalGGV) revert InvalidGGVAmount(); + emit StrategySupplied(msg.sender, _referral, msg.value, stv, _wstethToMint, _params); + emit GGVDeposited(msg.sender, _wstethToMint, ggvShares, _referral, _params); + } - uint256 totalStethSharesFromGgv = - BORING_QUEUE.previewAssetsOut(address(WSTETH), uint128(totalGGV), params.discount); - stethShares = Math.mulDiv(_ggvShares, totalStethSharesFromGgv, totalGGV); + // ================================================================================= + // REQUEST EXIT FROM STRATEGY + // ================================================================================= + + /** + * @notice Previews the amount of wstETH that can be withdrawn by a given amount of GGV shares + * @param _ggvShares The amount of GGV shares to preview the amount of wstETH for + * @param _params The parameters for the withdrawal + * @return wsteth The amount of wstETH that can be withdrawn + */ + function previewWstethByGGV(uint256 _ggvShares, bytes calldata _params) public view returns (uint256 wsteth) { + if (_ggvShares > type(uint128).max) revert InvalidGGVSharesAmount(); + GGVParamsRequestExit memory params = abi.decode(_params, (GGVParamsRequestExit)); + wsteth = BORING_QUEUE.previewAssetsOut(address(WSTETH), uint128(_ggvShares), params.discount); } - /// @notice Requests a withdrawal of ggv shares from the strategy - /// @param _stethSharesToBurn The amount of steth shares to burn - /// @param _params The parameters for the withdrawal - /// @return requestId The request id - function requestExitByStethShares(uint256 _stethSharesToBurn, bytes calldata _params) - public - returns (bytes32 requestId) - { - GGVParams memory params = abi.decode(_params, (GGVParams)); + /** + * @inheritdoc IStrategy + */ + function requestExitByWsteth(uint256 _wsteth, bytes calldata _params) external returns (bytes32 requestId) { + GGVParamsRequestExit memory params = abi.decode(_params, (GGVParamsRequestExit)); - address callForwarder = _getOrCreateCallForwarder(msg.sender); + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); IERC20 boringVault = IERC20(TELLER.vault()); // Calculate how much wsteth we'll get from total GGV shares - uint256 totalGGV = boringVault.balanceOf(callForwarder); - uint256 totalStethSharesFromGgv = - BORING_QUEUE.previewAssetsOut(address(WSTETH), uint128(totalGGV), params.discount); - if (totalStethSharesFromGgv == 0) revert InvalidStethAmount(); - if (_stethSharesToBurn > totalStethSharesFromGgv) revert InvalidStethAmount(); + uint256 totalGGV = boringVault.balanceOf(address(callForwarder)); + uint256 totalWstethFromGGV = previewWstethByGGV(totalGGV, _params); + if (totalWstethFromGGV == 0) revert InvalidWstethAmount(); + if (_wsteth > totalWstethFromGGV) revert NothingToExit(); // Approve GGV shares - uint256 ggvShares = Math.mulDiv(totalGGV, _stethSharesToBurn, totalStethSharesFromGgv); - IStrategyCallForwarder(callForwarder) - .call( - address(boringVault), - abi.encodeWithSelector(boringVault.approve.selector, address(BORING_QUEUE), ggvShares) - ); - - uint128 requestedGGV = uint128(ggvShares); + uint256 ggvShares = Math.mulDiv(totalGGV, _wsteth, totalWstethFromGGV, Math.Rounding.Ceil); + callForwarder.doCall( + address(boringVault), abi.encodeWithSelector(boringVault.approve.selector, address(BORING_QUEUE), ggvShares) + ); // Withdrawal request from GGV - bytes memory data = IStrategyCallForwarder(callForwarder) - .call( - address(BORING_QUEUE), - abi.encodeWithSelector( - BORING_QUEUE.requestOnChainWithdraw.selector, - address(WSTETH), - requestedGGV, - params.discount, - params.secondsToDeadline - ) - ); + bytes memory data = callForwarder.doCall( + address(BORING_QUEUE), + abi.encodeWithSelector( + BORING_QUEUE.requestOnChainWithdraw.selector, + address(WSTETH), + uint128(ggvShares), + params.discount, + params.secondsToDeadline + ) + ); requestId = abi.decode(data, (bytes32)); - emit StrategyExitRequested(msg.sender, requestId, _stethSharesToBurn, _params); - emit GGVWithdrawalRequested(msg.sender, requestId, requestedGGV, _params); + emit StrategyExitRequested(msg.sender, requestId, _wsteth, _params); + emit GGVWithdrawalRequested(msg.sender, requestId, ggvShares, _params); } - /// @notice Cancels a withdrawal request - /// @param request The request to cancel - function cancelGgvRequest(IBoringOnChainQueue.OnChainWithdraw memory request) external { - address callForwarder = getStrategyCallForwarderAddress(msg.sender); - if (callForwarder != request.user) revert InvalidSender(); - - IStrategyCallForwarder(callForwarder) - .call(address(BORING_QUEUE), abi.encodeWithSelector(BORING_QUEUE.cancelOnChainWithdraw.selector, request)); - } - - /// @notice Replaces a withdrawal request - /// @param request The request to replace - /// @param discount The discount to use - /// @param secondsToDeadline The deadline to use - /// @return oldRequestId The old request id - /// @return newRequestId The new request id - function replaceGgvOnChainWithdraw( - IBoringOnChainQueue.OnChainWithdraw memory request, - uint16 discount, - uint24 secondsToDeadline - ) external returns (bytes32 oldRequestId, bytes32 newRequestId) { - address callForwarder = getStrategyCallForwarderAddress(msg.sender); - if (callForwarder != request.user) revert InvalidSender(); - - bytes memory data = IStrategyCallForwarder(callForwarder) - .call( - address(BORING_QUEUE), - abi.encodeWithSelector( - BORING_QUEUE.replaceOnChainWithdraw.selector, request, discount, secondsToDeadline - ) - ); - (oldRequestId, newRequestId) = abi.decode(data, (bytes32, bytes32)); - } - - /// @notice Finalizes a withdrawal of stETH from the strategy + /** + * @inheritdoc IStrategy + */ function finalizeRequestExit( - address, - /*_receiver*/ bytes32 /*_requestId*/ ) external @@ -211,72 +223,147 @@ contract GGVStrategy is Strategy { revert NotImplemented(); } - /// @notice Returns the amount of stETH shares of a user - /// @param _user The user to get the stETH shares for - /// @return stethShares The amount of stETH shares - function proxyStethSharesOf(address _user) public view returns (uint256 stethShares) { - address callForwarder = getStrategyCallForwarderAddress(_user); + // ================================================================================= + // CANCEL / REPLACE GGV REQUEST + // ================================================================================= + + /** + * @notice Cancels a GGV withdrawal request + * @param _request The request to cancel + */ + function cancelGGVOnChainWithdraw(IBoringOnChainQueue.OnChainWithdraw memory _request) external { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + if (address(callForwarder) != _request.user) revert InvalidSender(); + + callForwarder.doCall( + address(BORING_QUEUE), abi.encodeWithSelector(BORING_QUEUE.cancelOnChainWithdraw.selector, _request) + ); + } - // simulate the unwrapping of wstETH to stETH with rounding issue - uint256 wstethAmount = WSTETH.balanceOf(callForwarder); - uint256 stETHAmount = STETH.getPooledEthByShares(wstethAmount); - uint256 sharesAfterUnwrapping = STETH.getSharesByPooledEth(stETHAmount); + /** + * @notice Replaces a withdrawal request + * @param request The request to replace + * @param discount The discount to use + * @param secondsToDeadline The deadline to use + * @return oldRequestId The old request id + * @return newRequestId The new request id + */ + function replaceGGVOnChainWithdraw( + IBoringOnChainQueue.OnChainWithdraw memory request, + uint16 discount, + uint24 secondsToDeadline + ) external returns (bytes32 oldRequestId, bytes32 newRequestId) { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + if (address(callForwarder) != request.user) revert InvalidSender(); - // add the stETH shares of the call forwarder - stethShares = sharesAfterUnwrapping + STETH.sharesOf(callForwarder); + bytes memory data = callForwarder.doCall( + address(BORING_QUEUE), + abi.encodeWithSelector(BORING_QUEUE.replaceOnChainWithdraw.selector, request, discount, secondsToDeadline) + ); + (oldRequestId, newRequestId) = abi.decode(data, (bytes32, bytes32)); } - /// @notice Calculates the amount of stETH shares to rebalance - /// @param _user The user to calculate the amount of stETH shares to rebalance for - /// @return stethShares The amount of stETH shares to rebalance - function proxyStethSharesToRebalance(address _user) external view returns (uint256 stethShares) { - address callForwarder = getStrategyCallForwarderAddress(_user); - uint256 mintedStethShares = POOL_.mintedStethSharesOf(callForwarder); + // ================================================================================= + // HELPERS + // ================================================================================= - uint256 sharesAfterUnwrapping = proxyStethSharesOf(_user); + /** + * @inheritdoc IStrategy + */ + function mintedStethSharesOf(address _user) external view returns (uint256 mintedStethShares) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + mintedStethShares = POOL_.mintedStethSharesOf(address(callForwarder)); + } - if (mintedStethShares > sharesAfterUnwrapping) { - stethShares = mintedStethShares - sharesAfterUnwrapping; - } + /** + * @inheritdoc IStrategy + */ + function remainingMintingCapacitySharesOf(address _user, uint256 _ethToFund) + external + view + returns (uint256 stethShares) + { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + stethShares = POOL_.remainingMintingCapacitySharesOf(address(callForwarder), _ethToFund); + } + + /** + * @inheritdoc IStrategy + */ + function wstethOf(address _user) external view returns (uint256 wsteth) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + wsteth = WSTETH.balanceOf(address(callForwarder)); + } + + /** + * @inheritdoc IStrategy + */ + function stvOf(address _user) external view returns (uint256 stv) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + stv = POOL_.balanceOf(address(callForwarder)); } - /// @notice Calculates the amount of stv that can be withdrawn - /// @param _user The user to calculate the amount of stv to withdraw for - /// @param _stethSharesToBurn The amount of stETH shares to burn - /// @return stv The amount of stv that can be withdrawn - function proxyUnlockedStvOf(address _user, uint256 _stethSharesToBurn) external view returns (uint256 stv) { - address callForwarder = getStrategyCallForwarderAddress(_user); - stv = POOL_.unlockedStvOf(callForwarder, _stethSharesToBurn); + /** + * @notice Returns the amount of GGV shares of a user + * @param _user The user to get the GGV shares for + * @return ggvShares The amount of GGV shares + */ + function ggvOf(address _user) external view returns (uint256 ggvShares) { + IStrategyCallForwarder callForwarder = getStrategyCallForwarderAddress(_user); + ggvShares = IERC20(TELLER.vault()).balanceOf(address(callForwarder)); } - /// @notice Requests a withdrawal from the Withdrawal Queue - /// @param _stvToWithdraw The amount of stv to withdraw - /// @param _stethSharesToBurn The amount of stETH shares to burn - /// @param _stethSharesToRebalance The amount of stETH shares to rebalance - /// @param _receiver The address to receive the stv - /// @return requestId The Withdrawal Queue request ID - function requestWithdrawal( - uint256 _stvToWithdraw, - uint256 _stethSharesToBurn, - uint256 _stethSharesToRebalance, - address _receiver - ) external returns (uint256 requestId) { - address callForwarder = _getOrCreateCallForwarder(msg.sender); - - IStrategyCallForwarder(callForwarder) - .call(address(WSTETH), abi.encodeWithSelector(WSTETH.unwrap.selector, WSTETH.balanceOf(callForwarder))); - - IStrategyCallForwarder(callForwarder) - .call(address(POOL_), abi.encodeWithSelector(StvStETHPool.burnStethShares.selector, _stethSharesToBurn)); + // ================================================================================= + // REQUEST WITHDRAWAL FROM POOL + // ================================================================================= + + /** + * @inheritdoc IStrategy + */ + function requestWithdrawalFromPool(address _recipient, uint256 _stvToWithdraw, uint256 _stethSharesToRebalance) + external + returns (uint256 requestId) + { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); // request withdrawal from pool - bytes memory withdrawalData = IStrategyCallForwarder(callForwarder) - .call( - address(POOL_.WITHDRAWAL_QUEUE()), - abi.encodeWithSelector( - WithdrawalQueue.requestWithdrawal.selector, _receiver, _stvToWithdraw, _stethSharesToRebalance - ) - ); + bytes memory withdrawalData = callForwarder.doCall( + address(POOL_.WITHDRAWAL_QUEUE()), + abi.encodeWithSelector( + WithdrawalQueue.requestWithdrawal.selector, _recipient, _stvToWithdraw, _stethSharesToRebalance + ) + ); requestId = abi.decode(withdrawalData, (uint256)); } + + /** + * @notice Burns wstETH to reduce the user's minted stETH obligation + * @param _wstethToBurn The amount of wstETH to burn + */ + function burnWsteth(uint256 _wstethToBurn) external { + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + callForwarder.doCall( + address(WSTETH), abi.encodeWithSelector(WSTETH.approve.selector, address(POOL_), _wstethToBurn) + ); + callForwarder.doCall(address(POOL_), abi.encodeWithSelector(StvStETHPool.burnWsteth.selector, _wstethToBurn)); + } + + // ================================================================================= + // RECOVERY + // ================================================================================= + + /** + * @notice Recovers ERC20 tokens from the call forwarder + * @param _token The token to recover + * @param _recipient The recipient of the tokens + * @param _amount The amount of tokens to recover + */ + function recoverERC20(address _token, address _recipient, uint256 _amount) external { + if (_token == address(0)) revert ZeroArgument("_token"); + if (_recipient == address(0)) revert ZeroArgument("_recipient"); + if (_amount == 0) revert ZeroArgument("_amount"); + + IStrategyCallForwarder callForwarder = _getOrCreateCallForwarder(msg.sender); + callForwarder.doCall(_token, abi.encodeWithSelector(IERC20.transfer.selector, _recipient, _amount)); + } } diff --git a/src/strategy/Strategy.sol b/src/strategy/Strategy.sol deleted file mode 100644 index 2ca172b..0000000 --- a/src/strategy/Strategy.sol +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.25; - -import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import {StvStETHPool} from "src/StvStETHPool.sol"; -import {IStrategy} from "src/interfaces/IStrategy.sol"; -import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; -import {IStETH} from "src/interfaces/core/IStETH.sol"; -import {IWstETH} from "src/interfaces/core/IWstETH.sol"; - -abstract contract Strategy is IStrategy { - StvStETHPool internal immutable POOL_; - IStETH public immutable STETH; - IWstETH public immutable WSTETH; - address public immutable STRATEGY_CALL_FORWARDER_IMPL; - - /// @dev WARNING: This ID is used to calculate user proxy addresses. - /// Changing this value will break user proxy address calculations. - bytes32 public constant STRATEGY_ID = keccak256("strategy.ggv.v1"); - - mapping(bytes32 salt => address proxy) private userStrategyCallForwarder; - - error ZeroArgument(string name); - - constructor(address _pool, address _stETH, address _wstETH, address _strategyCallForwarderImpl) { - STETH = IStETH(_stETH); - WSTETH = IWstETH(_wstETH); - STRATEGY_CALL_FORWARDER_IMPL = _strategyCallForwarderImpl; - POOL_ = StvStETHPool(payable(_pool)); - } - - function POOL() external view returns (address) { - return address(POOL_); - } - - /// @notice Recovers ERC20 tokens from the strategy - /// @param _token The token to recover - /// @param _recipient The recipient of the tokens - /// @param _amount The amount of tokens to recover - function recoverERC20(address _token, address _recipient, uint256 _amount) external { - if (_token == address(0)) revert ZeroArgument("_token"); - if (_recipient == address(0)) revert ZeroArgument("_recipient"); - if (_amount == 0) revert ZeroArgument("_amount"); - - address proxy = getStrategyCallForwarderAddress(msg.sender); - - IStrategyCallForwarder(proxy) - .call(address(STETH), abi.encodeWithSelector(IERC20.transfer.selector, _recipient, _amount)); - } - - /// @notice Returns the address of the strategy proxy for a given user - /// @param user The user for which to get the strategy call forwarder address - /// @return callForwarder The address of the strategy call forwarder - function getStrategyCallForwarderAddress(address user) public view returns (address callForwarder) { - bytes32 salt = _generateSalt(user); - callForwarder = Clones.predictDeterministicAddress(STRATEGY_CALL_FORWARDER_IMPL, salt); - } - - function _getOrCreateCallForwarder(address _user) internal returns (address callForwarder) { - if (_user == address(0)) revert ZeroArgument("_user"); - - bytes32 salt = _generateSalt(_user); - callForwarder = userStrategyCallForwarder[salt]; - if (callForwarder != address(0)) return callForwarder; - - callForwarder = Clones.cloneDeterministic(STRATEGY_CALL_FORWARDER_IMPL, salt); - IStrategyCallForwarder(callForwarder).initialize(address(this)); - IStrategyCallForwarder(callForwarder) - .call(address(STETH), abi.encodeWithSelector(STETH.approve.selector, address(POOL_), type(uint256).max)); - userStrategyCallForwarder[salt] = callForwarder; - } - - function _generateSalt(address _user) internal view returns (bytes32 salt) { - salt = keccak256(abi.encode(STRATEGY_ID, address(this), _user)); - } -} diff --git a/src/strategy/StrategyCallForwarder.sol b/src/strategy/StrategyCallForwarder.sol index d44cc11..470d100 100644 --- a/src/strategy/StrategyCallForwarder.sol +++ b/src/strategy/StrategyCallForwarder.sol @@ -25,32 +25,39 @@ contract StrategyCallForwarder is /// @notice Function for receiving native assets receive() external payable {} - /// @notice Executes a call on the target contract - /// @dev Only callable by owner. To convert to the expected return value, use abi.decode. - /// @param _target The address of the target contract - /// @param _data The call data - /// @return Returns the raw returned data. - function call(address _target, bytes calldata _data) external payable onlyOwner returns (bytes memory) { - return Address.functionCall(_target, _data); + /** + * @notice Executes a call on the target contract + * @dev Only callable by owner. To convert to the expected return value, use abi.decode. + * @param _target The address of the target contract + * @param _data The call data + * @return data The raw returned data. + */ + function doCall(address _target, bytes calldata _data) external onlyOwner returns (bytes memory data) { + data = Address.functionCall(_target, _data); } - /// @notice Executes a call on the target contract, but also transferring value wei to the target. - /// @dev Only callable by owner. To convert to the expected return value, use abi.decode. - /// @param _target The address of the target contract - /// @param _data The call data - /// @param _value The value to send with the call - /// @return Returns the raw returned data. - function callWithValue(address _target, bytes calldata _data, uint256 _value) + /** + * @notice Executes a call on the target contract, but also transferring value wei to the target. + * @dev Only callable by owner. To convert to the expected return value, use abi.decode. + * @param _target The address of the target contract + * @param _data The call data + * @param _value The value to send with the call + * @return Returns the raw returned data. + */ + function doCallWithValue(address _target, bytes calldata _data, uint256 _value) external - payable onlyOwner returns (bytes memory) { return Address.functionCallWithValue(_target, _data, _value); } - /// @notice sends `_amount` wei to `_recipient` - function sendValue(address payable _recipient, uint256 _amount) external payable onlyOwner nonReentrant { + /** + * @notice sends `_amount` wei to `_recipient` + * @param _recipient The address to send the value to + * @param _amount The amount of value to send + */ + function sendValue(address payable _recipient, uint256 _amount) external onlyOwner nonReentrant { Address.sendValue(_recipient, _amount); } } diff --git a/src/strategy/StrategyCallForwarderRegistry.sol b/src/strategy/StrategyCallForwarderRegistry.sol new file mode 100644 index 0000000..c8f1978 --- /dev/null +++ b/src/strategy/StrategyCallForwarderRegistry.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.25; + +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; + +abstract contract StrategyCallForwarderRegistry { + error CallForwarderZeroArgument(string name); + + /// @dev WARNING: This ID is used to calculate user proxy addresses. + /// Changing this value will break user proxy address calculations. + bytes32 public immutable STRATEGY_ID; + address public immutable STRATEGY_CALL_FORWARDER_IMPL; + + /// @custom:storage-location erc7201:pool.storage.StrategyCallForwarderRegistry + struct CallForwarderStorage { + mapping(bytes32 salt => IStrategyCallForwarder callForwarder) userCallForwarder; + } + + // keccak256(abi.encode(uint256(keccak256("pool.storage.StrategyCallForwarderRegistry")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant CALL_FORWARDER_STORAGE_LOCATION = + 0x3074294e9a887c21033ca796133e603629c1fad03ac5b84cce0cfe20ad599d00; + + function _getCallForwarderRegistryStorage() internal pure returns (CallForwarderStorage storage $) { + assembly { + $.slot := CALL_FORWARDER_STORAGE_LOCATION + } + } + + constructor(bytes32 _strategyId, address _strategyCallForwarderImpl) { + if (_strategyId == bytes32(0)) revert CallForwarderZeroArgument("_strategyId"); + if (_strategyCallForwarderImpl == address(0)) revert CallForwarderZeroArgument("_strategyCallForwarderImpl"); + + STRATEGY_ID = _strategyId; + STRATEGY_CALL_FORWARDER_IMPL = _strategyCallForwarderImpl; + } + + /** + * @notice Returns the address of the strategy call forwarder for a given user + * @param _user The user for which to get the strategy call forwarder address + * @return callForwarder The address of the strategy call forwarder + */ + function getStrategyCallForwarderAddress(address _user) public view returns (IStrategyCallForwarder callForwarder) { + bytes32 salt = _generateSalt(_user); + callForwarder = IStrategyCallForwarder(Clones.predictDeterministicAddress(STRATEGY_CALL_FORWARDER_IMPL, salt)); + } + + function _getOrCreateCallForwarder(address _user) internal returns (IStrategyCallForwarder callForwarder) { + if (_user == address(0)) revert CallForwarderZeroArgument("_user"); + + CallForwarderStorage storage $ = _getCallForwarderRegistryStorage(); + + bytes32 salt = _generateSalt(_user); + callForwarder = $.userCallForwarder[salt]; + if (address(callForwarder) != address(0)) return callForwarder; + + callForwarder = IStrategyCallForwarder(Clones.cloneDeterministic(STRATEGY_CALL_FORWARDER_IMPL, salt)); + callForwarder.initialize(address(this)); + + $.userCallForwarder[salt] = callForwarder; + } + + function _generateSalt(address _user) internal view returns (bytes32 salt) { + salt = keccak256(abi.encode(block.chainid, STRATEGY_ID, address(this), _user)); + } +} diff --git a/test/Factory.test.sol b/test/Factory.test.sol index 1b2612a..070e5fb 100644 --- a/test/Factory.test.sol +++ b/test/Factory.test.sol @@ -60,8 +60,7 @@ contract FactoryTest is Test { subFactories.distributorFactory = address(new DistributorFactory()); address dummyTeller = address(new DummyImplementation()); address dummyQueue = address(new DummyImplementation()); - subFactories.ggvStrategyFactory = - address(new GGVStrategyFactory(dummyTeller, dummyQueue, address(stETH), address(wstETH))); + subFactories.ggvStrategyFactory = address(new GGVStrategyFactory(dummyTeller, dummyQueue)); subFactories.timelockFactory = address(new TimelockFactory()); wrapperFactory = new Factory(address(locator), subFactories); diff --git a/test/integration/ggv.test.sol b/test/integration/ggv.test.sol index 2162b60..29a97ce 100644 --- a/test/integration/ggv.test.sol +++ b/test/integration/ggv.test.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.25; import {console} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {IBoringOnChainQueue} from "src/interfaces/ggv/IBoringOnChainQueue.sol"; import {IBoringSolver} from "src/interfaces/ggv/IBoringSolver.sol"; @@ -15,6 +16,7 @@ import {StvStETHPool} from "src/StvStETHPool.sol"; import {WithdrawalQueue} from "src/WithdrawalQueue.sol"; import {IStrategy} from "src/interfaces/IStrategy.sol"; import {GGVStrategy} from "src/strategy/GGVStrategy.sol"; +import {IStrategyCallForwarder} from "src/interfaces/IStrategyCallForwarder.sol"; import {TableUtils} from "../utils/format/TableUtils.sol"; import {AllowList} from "src/AllowList.sol"; @@ -22,6 +24,8 @@ import {GGVMockTeller} from "src/mock/ggv/GGVMockTeller.sol"; import {GGVQueueMock} from "src/mock/ggv/GGVQueueMock.sol"; import {GGVVaultMock} from "src/mock/ggv/GGVVaultMock.sol"; +import {console} from "forge-std/console.sol"; + interface IAuthority { function setUserRole(address user, uint8 role, bool enabled) external; function setRoleCapability(uint8 role, address code, bytes4 sig, bool enabled) external; @@ -91,10 +95,10 @@ contract GGVTest is StvStrategyPoolHarness { strategy = IStrategy(ctx.strategy); ggvStrategy = GGVStrategy(address(strategy)); - user1StrategyCallForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER1); + user1StrategyCallForwarder = address(ggvStrategy.getStrategyCallForwarderAddress(USER1)); vm.label(user1StrategyCallForwarder, "User1StrategyCallForwarder"); - user2StrategyCallForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER2); + user2StrategyCallForwarder = address(ggvStrategy.getStrategyCallForwarderAddress(USER2)); vm.label(user2StrategyCallForwarder, "User2StrategyCallForwarder"); _log.init(address(pool), address(boringVault), address(steth), address(wsteth), address(boringOnChainQueue)); @@ -105,7 +109,7 @@ contract GGVTest is StvStrategyPoolHarness { vm.stopPrank(); vm.startPrank(SOLVER); - uint256 solverSteth = steth.submit{value: 1 ether}(SOLVER); + uint256 solverSteth = steth.submit{value: 2 ether}(SOLVER); steth.approve(address(wsteth), type(uint256).max); uint256 solverWsteth = wsteth.wrap(solverSteth); wsteth.transfer(address(boringVault), solverWsteth); @@ -139,6 +143,13 @@ contract GGVTest is StvStrategyPoolHarness { console.log("setup GGV finished\n"); } + function test_revert_if_user_is_not_allowlisted() public { + uint256 depositAmount = 1 ether; + vm.prank(USER1); + vm.expectRevert(abi.encodeWithSelector(AllowList.NotAllowListed.selector, USER1)); + pool.depositETH{value: depositAmount}(USER1, address(0)); + } + function test_rebase_scenario() public { uint256 stethIncrease = 0; uint256 vaultIncrease = 0; @@ -148,7 +159,7 @@ contract GGVTest is StvStrategyPoolHarness { uint256 vaultProfit = depositAmount * vaultIncrease / 100; // 0.05 ether profit logUsers.push(TableUtils.User(USER1, "user1")); - logUsers.push(TableUtils.User(user1StrategyCallForwarder, "user1_call_forwarder")); + logUsers.push(TableUtils.User(user1StrategyCallForwarder, "user1_forwarder")); logUsers.push(TableUtils.User(address(pool), "pool")); logUsers.push(TableUtils.User(address(pool.WITHDRAWAL_QUEUE()), "wq")); logUsers.push(TableUtils.User(address(boringVault), "boringVault")); @@ -158,42 +169,31 @@ contract GGVTest is StvStrategyPoolHarness { core.increaseBufferedEther(steth.totalSupply() * stethIncrease / 100); console.log("INITIAL share rate %s", steth.getPooledEthByShares(1e18)); - // _log.printUsers("[SCENARIO] Initial State", logUsers, ggvDiscount); - - // Check that user is not allowed to deposit directly - vm.prank(USER1); - vm.expectRevert(abi.encodeWithSelector(AllowList.NotAllowListed.selector, USER1)); - pool.depositETH{value: depositAmount}(USER1, address(0)); + _log.printUsers("[SCENARIO] Initial State", logUsers, ggvDiscount); // 1. Initial Deposit - vm.prank(USER1); - ggvStrategy.supply{value: depositAmount}(address(0), abi.encode(GGVStrategy.GGVParams(0, 0, 0))); - // _log.printUsers("[SCENARIO] After Deposit (1 ETH)", logUsers, ggvDiscount); + uint256 wstethToMint = pool.remainingMintingCapacitySharesOf(USER1, depositAmount); - // 2. Simulate Rebases - console.log("\n[SCENARIO] Simulating Rebases (Vault +5%, stETH +4%)"); - - // a) Vault Rebase (simulated via mock report) - // uint256 currentLiabilityShares = pool.DASHBOARD().liabilityShares(); - // uint256 currentTotalAssets = pool.totalAssets(); + vm.prank(USER1); + ggvStrategy.supply{value: depositAmount}(address(0), wstethToMint, abi.encode(GGVStrategy.GGVParamsSupply(0))); - // core.applyVaultReport(address(ctx.vault), currentTotalAssets + vaultProfit, 0, currentLiabilityShares, 0, false); + _log.printUsers("[SCENARIO] After Deposit (1 ETH)", logUsers, ggvDiscount); - // _log.printUsers("[SCENARIO] After report (increase vault balance)", logUsers, ggvDiscount); + uint256 userMintedStethSharesAfterDeposit = ggvStrategy.mintedStethSharesOf(USER1); // 3. Request withdrawal (full amount, based on appreciated value) uint256 totalGgvShares = boringVault.balanceOf(user1StrategyCallForwarder); - uint256 withdrawalStethAmount = - boringOnChainQueue.previewAssetsOut(address(steth), uint128(totalGgvShares), uint16(ggvDiscount)); + uint256 withdrawalWstethAmount = + boringOnChainQueue.previewAssetsOut(address(wsteth), uint128(totalGgvShares), uint16(ggvDiscount)); - console.log("\n[SCENARIO] Requesting withdrawal based on new appreciated assets:", withdrawalStethAmount); + console.log("\n[SCENARIO] Requesting withdrawal based on new appreciated assets:", withdrawalWstethAmount); - GGVStrategy.GGVParams memory params = - GGVStrategy.GGVParams({discount: uint16(ggvDiscount), minimumMint: 0, secondsToDeadline: type(uint24).max}); + GGVStrategy.GGVParamsRequestExit memory params = + GGVStrategy.GGVParamsRequestExit({discount: uint16(ggvDiscount), secondsToDeadline: type(uint24).max}); vm.prank(USER1); - bytes32 requestId = ggvStrategy.requestExitByStETH(withdrawalStethAmount, abi.encode(params)); + bytes32 requestId = ggvStrategy.requestExitByWsteth(withdrawalWstethAmount, abi.encode(params)); assertNotEq(requestId, 0); // Apply 1% increase to core (stETH share ratio) @@ -220,12 +220,30 @@ contract GGVTest is StvStrategyPoolHarness { // 5. User Finalizes Withdrawal (Wrapper side) console.log("\n[SCENARIO] Step 5. Finalize Wrapper withdrawal"); - uint256 _stethSharesToBurn = ggvStrategy.proxyStethSharesOf(USER1); - uint256 _stethSharesToRebalance = ggvStrategy.proxyStethSharesToRebalance(USER1); - uint256 _stvToWithdraw = ggvStrategy.proxyUnlockedStvOf(USER1, _stethSharesToRebalance + _stethSharesToBurn); + // simulate the unwrapping of wstETH to stETH with rounding issue + uint256 wstethUserBalance = ggvStrategy.wstethOf(USER1); + assertGt( + userMintedStethSharesAfterDeposit, + wstethUserBalance, + "user minted steth shares should be greater than wsteth balance" + ); + + uint256 mintedStethShares = ggvStrategy.mintedStethSharesOf(USER1); + uint256 wstethToBurn = Math.min(mintedStethShares, wstethUserBalance); + + uint256 stETHAmount = steth.getPooledEthByShares(wstethToBurn); + uint256 sharesAfterUnwrapping = steth.getSharesByPooledEth(stETHAmount); + + uint256 stethSharesToRebalance = 0; + if (mintedStethShares > sharesAfterUnwrapping) { + stethSharesToRebalance = mintedStethShares - sharesAfterUnwrapping; + } + + uint256 stvToWithdraw = ggvStrategy.stvOf(USER1); vm.startPrank(USER1); - ggvStrategy.requestWithdrawal(_stvToWithdraw, _stethSharesToBurn, _stethSharesToRebalance, USER1); + ggvStrategy.burnWsteth(wstethToBurn); + ggvStrategy.requestWithdrawalFromPool(USER1, stvToWithdraw, stethSharesToRebalance); vm.stopPrank(); _log.printUsers("After User Finalizes Wrapper", logUsers, ggvDiscount); @@ -253,21 +271,86 @@ contract GGVTest is StvStrategyPoolHarness { console.log("ETH Claimed:", ethClaimed); _log.printUsers("After User Claims ETH", logUsers, ggvDiscount); + } + + function test_positive_wsteth_rebase_flow() public { + uint256 depositAmount = 1 ether; + uint16 discount = 0; + + uint256 wstethToMint = pool.remainingMintingCapacitySharesOf(USER1, depositAmount); + + vm.prank(USER1); + ggvStrategy.supply{value: depositAmount}(address(0), wstethToMint, abi.encode(GGVStrategy.GGVParamsSupply(0))); + + uint256 mintedSharesBefore = ggvStrategy.mintedStethSharesOf(USER1); + assertEq(mintedSharesBefore, wstethToMint, "minted shares mismatch"); + + IStrategyCallForwarder callForwarder = ggvStrategy.getStrategyCallForwarderAddress(USER1); + uint256 totalGGVShares = boringVault.balanceOf(address(callForwarder)); + + // Simulate GGV rewards + uint256 rebaseStethAmount = 0.1 ether; + vm.startPrank(ADMIN); + steth.approve(address(wsteth), type(uint256).max); + uint256 rebaseWstethAmount = wsteth.wrap(rebaseStethAmount); + wsteth.approve(address(boringVault), type(uint256).max); + boringVault.rebaseWsteth(rebaseWstethAmount); + vm.stopPrank(); + + uint128 withdrawSharesPreview = + boringOnChainQueue.previewAssetsOut(address(wsteth), uint128(totalGGVShares), discount); - // // 8. Recover Surplus stETH (если есть) - // uint256 surplusStETH = steth.balanceOf(user1StrategyCallForwarder); - // if (surplusStETH > 0) { - // uint256 stethBalance = steth.sharesOf(user1StrategyCallForwarder); - // uint256 stethDebt = pool.mintedStethSharesOf(user1StrategyCallForwarder); - // uint256 surplusInShares = stethBalance > stethDebt ? stethBalance - stethDebt : 0; - // uint256 maxAmount = steth.getPooledEthByShares(surplusInShares); + GGVStrategy.GGVParamsRequestExit memory params = + GGVStrategy.GGVParamsRequestExit({discount: discount, secondsToDeadline: type(uint24).max}); - // console.log("\n[SCENARIO] Step 8. Recover Surplus stETH:", maxAmount); - // vm.prank(USER1); - // ggvStrategy.recoverERC20(address(steth), USER1, maxAmount); - // } + vm.prank(USER1); + bytes32 requestId = ggvStrategy.requestExitByWsteth(uint256(withdrawSharesPreview), abi.encode(params)); + + IBoringOnChainQueue.OnChainWithdraw memory request = + GGVQueueMock(address(boringOnChainQueue)).mockGetRequestById(requestId); + IBoringOnChainQueue.OnChainWithdraw[] memory requests = new IBoringOnChainQueue.OnChainWithdraw[](1); + requests[0] = request; - // _log.printUsers("After Recovery", logUsers); + vm.prank(SOLVER); + boringOnChainQueue.solveOnChainWithdraws(requests, new bytes(0), address(0)); + + uint256 wstethAfterSolve = ggvStrategy.wstethOf(USER1); + assertGt(wstethAfterSolve, mintedSharesBefore, "wstETH returned should exceed supplied amount"); + + uint256 stvBalance = ggvStrategy.stvOf(USER1); + + vm.startPrank(USER1); + ggvStrategy.burnWsteth(mintedSharesBefore); + + uint256 remainingLiability = ggvStrategy.mintedStethSharesOf(USER1); + uint256 leftoverWsteth = ggvStrategy.wstethOf(USER1); + ggvStrategy.requestWithdrawalFromPool(USER1, stvBalance, remainingLiability); + vm.stopPrank(); + + assertGt(leftoverWsteth, 0, "surplus wstETH expected after covering liability"); + + _finalizeWQ(1, 0); + + uint256[] memory wqRequestIds = withdrawalQueue.withdrawalRequestsOf(USER1); + uint256 userEthBefore = USER1.balance; + + vm.prank(USER1); + withdrawalQueue.claimWithdrawal(USER1, wqRequestIds[0]); + + assertGt(USER1.balance - userEthBefore, 0, "user should receive ETH on claim"); + + uint256 recoverableWsteth = ggvStrategy.wstethOf(USER1); + assertEq(recoverableWsteth, leftoverWsteth, "unexpected wstETH balance on strategy"); + + uint256 userWstethBefore = wsteth.balanceOf(USER1); + + vm.prank(USER1); + ggvStrategy.recoverERC20(address(wsteth), USER1, recoverableWsteth); + + assertEq(ggvStrategy.wstethOf(USER1), 0, "strategy call forwarder should have no wstETH left"); + assertEq( + wsteth.balanceOf(USER1) - userWstethBefore, recoverableWsteth, "user must receive recovered wstETH amount" + ); } function _finalizeWQ(uint256 _maxRequest, uint256 vaultProfit) public { diff --git a/test/unit/ggv-mock.test.sol b/test/unit/ggv-mock.test.sol index abf7a6d..e93e286 100644 --- a/test/unit/ggv-mock.test.sol +++ b/test/unit/ggv-mock.test.sol @@ -56,7 +56,7 @@ contract GGVMockTest is Test { vm.startPrank(admin); // add 1 steth to ggv balance for rebase - vault.rebase(1 ether); + vault.rebaseSteth(1 ether); uint256 newGgvUserAssets = vault.getAssetsByShares(ggvShares); assertEq(newGgvUserAssets > ggvUserAssets, true); } diff --git a/test/utils/FactoryHelper.sol b/test/utils/FactoryHelper.sol index 912cf5f..0f23a9c 100644 --- a/test/utils/FactoryHelper.sol +++ b/test/utils/FactoryHelper.sol @@ -18,14 +18,12 @@ contract FactoryHelper { constructor() { address dummyTeller = address(new DummyImplementation()); address dummyQueue = address(new DummyImplementation()); - address dummySteth = address(new DummyImplementation()); - address dummyWsteth = address(new DummyImplementation()); subFactories.stvPoolFactory = address(new StvPoolFactory()); subFactories.stvStETHPoolFactory = address(new StvStETHPoolFactory()); subFactories.withdrawalQueueFactory = address(new WithdrawalQueueFactory()); subFactories.distributorFactory = address(new DistributorFactory()); - subFactories.ggvStrategyFactory = address(new GGVStrategyFactory(dummyTeller, dummyQueue, dummySteth, dummyWsteth)); + subFactories.ggvStrategyFactory = address(new GGVStrategyFactory(dummyTeller, dummyQueue)); subFactories.timelockFactory = address(new TimelockFactory()); defaultTimelockConfig = Factory.TimelockConfig({ @@ -46,10 +44,7 @@ contract FactoryHelper { ) external returns (Factory factory) { Factory.SubFactories memory factories = subFactories; if (ggvTeller != address(0) && ggvBoringQueue != address(0)) { - ILidoLocator locator = ILidoLocator(locatorAddress); - address steth = address(locator.lido()); - address wsteth = address(locator.wstETH()); - factories.ggvStrategyFactory = address(new GGVStrategyFactory(ggvTeller, ggvBoringQueue, steth, wsteth)); + factories.ggvStrategyFactory = address(new GGVStrategyFactory(ggvTeller, ggvBoringQueue)); } factory = new Factory(locatorAddress, factories); diff --git a/test/utils/format/TableUtils.sol b/test/utils/format/TableUtils.sol index 17cc517..589e443 100644 --- a/test/utils/format/TableUtils.sol +++ b/test/utils/format/TableUtils.sol @@ -69,9 +69,7 @@ library TableUtils { padLeft("debt.stethShares", 20), padLeft("ggv", 20), padLeft("ggv.wstETHOut", 20), - padLeft("wstETH", 20), - padLeft("stETH", 20), - padLeft("stethShares", 20) + padLeft("wstETH", 20) ) ); console.log( @@ -102,8 +100,6 @@ library TableUtils { uint256 ggv = self.boringVault.balanceOf(_user); uint256 ggvStethOut = self.boringQueue.previewAssetsOut(address(self.wsteth), uint128(ggv), uint16(_discount)); uint256 wsteth = self.wsteth.balanceOf(_user); - uint256 steth = self.steth.balanceOf(_user); - uint256 stethShares = self.steth.sharesOf(_user); console.log( string.concat( @@ -114,9 +110,7 @@ library TableUtils { padLeft(vm.toString(debtSteth), 20), padLeft(vm.toString(ggv), 20), padLeft(vm.toString(ggvStethOut), 20), - padLeft(vm.toString(wsteth), 20), - padLeft(vm.toString(steth), 20), - padLeft(vm.toString(stethShares), 20) + padLeft(vm.toString(wsteth), 20) ) ); }