diff --git a/src/PrizeVault.sol b/src/PrizeVault.sol index 4212193..7e57e1a 100644 --- a/src/PrizeVault.sol +++ b/src/PrizeVault.sol @@ -249,6 +249,16 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab /// @param totalSupply The total shares minted and internally accounted for by the vault error LossyDeposit(uint256 totalAssets, uint256 totalSupply); + /// @notice Thrown when a withdraw call burns more shares than the max share limit provided. + /// @param shares The shares burned by the withdrawal + /// @param maxShares The max share limit provided + error MaxSharesExceeded(uint256 shares, uint256 maxShares); + + /// @notice Thrown when a redeem call returns less assets than the min threshold provided. + /// @param assets The assets provided by the redemption + /// @param minAssets The min asset threshold requested + error MinAssetsNotReached(uint256 assets, uint256 minAssets); + //////////////////////////////////////////////////////////////////////////////// // Modifiers //////////////////////////////////////////////////////////////////////////////// @@ -561,6 +571,47 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab return _shares; } + //////////////////////////////////////////////////////////////////////////////// + // Additional Withdrawal Flows + //////////////////////////////////////////////////////////////////////////////// + + /// @notice Alternate flow for `IERC4626.withdraw` that reverts if the max share limit is exceeded. + /// @param _assets See `IERC4626.withdraw` + /// @param _receiver See `IERC4626.withdraw` + /// @param _owner See `IERC4626.withdraw` + /// @param _maxShares The max shares that can be burned for the withdrawal to succeed. + /// @return The amount of shares burned for the withdrawal + function withdraw( + uint256 _assets, + address _receiver, + address _owner, + uint256 _maxShares + ) external returns (uint256) { + uint256 _shares = previewWithdraw(_assets); + if (_shares > _maxShares) revert MaxSharesExceeded(_shares, _maxShares); + _burnAndWithdraw(msg.sender, _receiver, _owner, _shares, _assets); + return _shares; + } + + /// @notice Alternate flow for `IERC4626.redeem` that reverts if the assets returned does not reach the + /// minimum asset threshold. + /// @param _shares See `IERC4626.redeem` + /// @param _receiver See `IERC4626.redeem` + /// @param _owner See `IERC4626.redeem` + /// @param _minAssets The minimum assets that can be returned for the redemption to succeed + /// @return The amount of assets returned for the redemption + function redeem( + uint256 _shares, + address _receiver, + address _owner, + uint256 _minAssets + ) external returns (uint256) { + uint256 _assets = previewRedeem(_shares); + if (_assets < _minAssets) revert MinAssetsNotReached(_assets, _minAssets); + _burnAndWithdraw(msg.sender, _receiver, _owner, _shares, _assets); + return _assets; + } + //////////////////////////////////////////////////////////////////////////////// // Additional Accounting //////////////////////////////////////////////////////////////////////////////// diff --git a/test/unit/PrizeVault/WithdrawalSlippage.t.sol b/test/unit/PrizeVault/WithdrawalSlippage.t.sol new file mode 100644 index 0000000..3c99b37 --- /dev/null +++ b/test/unit/PrizeVault/WithdrawalSlippage.t.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC4626 } from "openzeppelin/token/ERC20/extensions/ERC4626.sol"; +import { IERC20, UnitBaseSetup, PrizeVault } from "./UnitBaseSetup.t.sol"; +import { YieldVaultMaxSetter } from "../../contracts/mock/YieldVaultMaxSetter.sol"; + +contract PrizeVaultWithdrawalSlippageTest is UnitBaseSetup { + + /* ============ withdraw slippage test ============ */ + + function testWithdrawSlippage() public { + // alice deposits 100 assets and receives 100 shares + vm.startPrank(alice); + underlyingAsset.mint(alice, 100); + underlyingAsset.approve(address(vault), 100); + vault.deposit(100, alice); + vm.stopPrank(); + + assertEq(vault.balanceOf(alice), 100); + assertEq(vault.totalAssets(), 100); + + // yield vault loses 50% of assets + vm.startPrank(address(yieldVault)); + underlyingAsset.burn(address(yieldVault), 50); + vm.stopPrank(); + + assertEq(vault.totalAssets(), 50); + + // alice should be able to withdraw up to 50 assets for 100 shares + assertEq(vault.maxWithdraw(alice), 50); + assertEq(vault.maxRedeem(alice), 100); + assertEq(vault.previewWithdraw(50), 100); + + vm.startPrank(alice); + { + // make a snapshot + uint256 snap = vm.snapshot(); + + // should fail if 99 shares is passed as the limit + vm.expectRevert(abi.encodeWithSelector(PrizeVault.MaxSharesExceeded.selector, 100, 99)); + vault.withdraw(50, alice, alice, 99); + + // should succeed if 100 shares is passed as the limit + vm.revertTo(snap); + uint256 shares = vault.withdraw(50, alice, alice, 100); + assertEq(shares, 100); + + // should succeed if 101 shares is passed as the limit + vm.revertTo(snap); + shares = vault.withdraw(50, alice, alice, 101); + assertEq(shares, 100); // still only uses 100 + } + vm.stopPrank(); + } + + /* ============ redeem slippage test ============ */ + + function testRedeemSlippage() public { + // alice deposits 100 assets and receives 100 shares + vm.startPrank(alice); + underlyingAsset.mint(alice, 100); + underlyingAsset.approve(address(vault), 100); + vault.deposit(100, alice); + vm.stopPrank(); + + assertEq(vault.balanceOf(alice), 100); + assertEq(vault.totalAssets(), 100); + + // yield vault loses 50% of assets + vm.startPrank(address(yieldVault)); + underlyingAsset.burn(address(yieldVault), 50); + vm.stopPrank(); + + assertEq(vault.totalAssets(), 50); + + // alice should be able to redeem up to 100 shares for 50 assets + assertEq(vault.maxWithdraw(alice), 50); + assertEq(vault.maxRedeem(alice), 100); + assertEq(vault.previewRedeem(100), 50); + + vm.startPrank(alice); + { + // make a snapshot + uint256 snap = vm.snapshot(); + + // should fail if 51 assets is passed as the threshold + vm.expectRevert(abi.encodeWithSelector(PrizeVault.MinAssetsNotReached.selector, 50, 51)); + vault.redeem(100, alice, alice, 51); + + // should succeed if 50 assets is passed as the threshold + vm.revertTo(snap); + uint256 assets = vault.redeem(100, alice, alice, 50); + assertEq(assets, 50); + + // should succeed if 49 assets is passed as the threshold + vm.revertTo(snap); + assets = vault.redeem(100, alice, alice, 49); + assertEq(assets, 50); // still returns 50 assets + } + vm.stopPrank(); + } + +} \ No newline at end of file