diff --git a/src/PrizeVault.sol b/src/PrizeVault.sol index 7e57e1a..ee91748 100644 --- a/src/PrizeVault.sol +++ b/src/PrizeVault.sol @@ -14,6 +14,9 @@ import { ILiquidationSource } from "pt-v5-liquidator-interfaces/ILiquidationSour import { PrizePool } from "pt-v5-prize-pool/PrizePool.sol"; import { TwabController, SPONSORSHIP_ADDRESS } from "pt-v5-twab-controller/TwabController.sol"; +/// @dev The TWAB supply limit is the max number of shares that can be minted in the TWAB controller. +uint256 constant TWAB_SUPPLY_LIMIT = type(uint96).max; + /// @title PoolTogether V5 Prize Vault /// @author G9 Software Inc. /// @notice The prize vault takes deposits of an asset and earns yield with the deposits through an underlying yield @@ -249,6 +252,10 @@ 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 the mint limit is exceeded after increasing an external or internal share balance. + /// @param excess The amount in excess over the limit + error MintLimitExceeded(uint256 excess); + /// @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 @@ -373,28 +380,27 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab } /// @inheritdoc IERC4626 - /// @dev Considers the uint96 limit on total share supply in the TwabController + /// @dev Considers the TWAB mint limit /// @dev Returns zero if any deposit would result in a loss of assets /// @dev Any latent balance of assets in the prize vault will be swept in with the deposit as a part of /// the "dust collection strategy". This means that the max deposit must account for the latent balance /// by subtracting it from the max deposit available otherwise. function maxDeposit(address /* receiver */) public view returns (uint256) { - uint256 _totalSupply = totalSupply(); - uint256 totalDebt_ = _totalDebt(_totalSupply); - if (totalAssets() < totalDebt_) return 0; + uint256 _totalDebt = totalDebt(); + if (totalAssets() < _totalDebt) return 0; - // the vault will never mint more than 1 share per asset, so no need to convert supply limit to assets - uint256 twabSupplyLimit_ = _twabSupplyLimit(_totalSupply); - uint256 _maxDeposit; uint256 _latentBalance = _asset.balanceOf(address(this)); uint256 _maxYieldVaultDeposit = yieldVault.maxDeposit(address(this)); if (_latentBalance >= _maxYieldVaultDeposit) { return 0; } else { + // the vault will never mint more than 1 share per asset, so no need to convert mint limit to assets + uint256 _depositLimit = _mintLimit(_totalDebt); + uint256 _maxDeposit; unchecked { _maxDeposit = _maxYieldVaultDeposit - _latentBalance; } - return twabSupplyLimit_ < _maxDeposit ? twabSupplyLimit_ : _maxDeposit; + return _depositLimit < _maxDeposit ? _depositLimit : _maxDeposit; } } @@ -619,7 +625,7 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab /// @notice Returns the total assets that are owed to share holders and any other internal balances. /// @return The total asset debt of the vault function totalDebt() public view returns (uint256) { - return _totalDebt(totalSupply()); + return totalSupply() + yieldFeeBalance; } //////////////////////////////////////////////////////////////////////////////// @@ -675,11 +681,11 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab /// @dev Returns the liquid amount of `_tokenOut` minus any yield fees. /// @dev Supports the liquidation of either assets or prize vault shares. function liquidatableBalanceOf(address _tokenOut) public view returns (uint256) { - uint256 _totalSupply = totalSupply(); + uint256 _totalDebt = totalDebt(); uint256 _maxAmountOut; if (_tokenOut == address(this)) { - // Liquidation of vault shares is capped to the TWAB supply limit. - _maxAmountOut = _twabSupplyLimit(_totalSupply); + // Liquidation of vault shares is capped to the mint limit. + _maxAmountOut = _mintLimit(_totalDebt); } else if (_tokenOut == address(_asset)) { // Liquidation of yield assets is capped at the max yield vault withdraw plus any latent balance. _maxAmountOut = _maxYieldVaultWithdraw() + _asset.balanceOf(address(this)); @@ -687,16 +693,15 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab return 0; } - // The liquid yield is computed by taking the available yield balance and multiplying it - // by (1 - yieldFeePercentage), rounding down, to ensure that enough yield is left for the - // yield fee. - uint256 _liquidYield = - _availableYieldBalance(totalAssets(), _totalDebt(_totalSupply)) - .mulDiv(FEE_PRECISION - yieldFeePercentage, FEE_PRECISION); - // The liquid yield is limited by the max that can be minted or withdrawn, depending on // `_tokenOut`. - return _liquidYield >= _maxAmountOut ? _maxAmountOut : _liquidYield; + uint256 _availableYield = _availableYieldBalance(totalAssets(), _totalDebt); + uint256 _liquidYield = _availableYield >= _maxAmountOut ? _maxAmountOut : _availableYield; + + // The final balance is computed by taking the liquid yield and multiplying it by + // (1 - yieldFeePercentage), rounding down, to ensure that enough yield is left for + // the yield fee. + return _liquidYield.mulDiv(FEE_PRECISION - yieldFeePercentage, FEE_PRECISION); } /// @inheritdoc ILiquidationSource @@ -710,7 +715,8 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab ) public virtual onlyLiquidationPair returns (bytes memory) { if (_amountOut == 0) revert LiquidationAmountOutZero(); - uint256 _availableYield = availableYieldBalance(); + uint256 _totalDebtBefore = totalDebt(); + uint256 _availableYield = _availableYieldBalance(totalAssets(), _totalDebtBefore); uint32 _yieldFeePercentage = yieldFeePercentage; // Determine the proportional yield fee based on the amount being liquidated: @@ -728,13 +734,15 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab // Increase yield fee balance: if (_yieldFee > 0) { - yieldFeeBalance += _yieldFee; + yieldFeeBalance = yieldFeeBalance + _yieldFee; } // Mint or withdraw amountOut to `_receiver`: if (_tokenOut == address(_asset)) { - _withdraw(_receiver, _amountOut); + _enforceMintLimit(_totalDebtBefore, _yieldFee); + _withdraw(_receiver, _amountOut); } else if (_tokenOut == address(this)) { + _enforceMintLimit(_totalDebtBefore, _amountOut + _yieldFee); _mint(_receiver, _amountOut); } else { revert LiquidationTokenOutNotSupported(_tokenOut); @@ -828,22 +836,25 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab return (false, 0); } - /// @notice Returns the total assets that are owed to share holders and any other internal balances. - /// @dev The yield fee balance is included since it's cheaper to keep track of those shares - /// internally instead of doing an additional TWAB mint on every liquidation. - /// @param _totalSupply The total share supply of the vault - /// @return The total asset debt of the vault - function _totalDebt(uint256 _totalSupply) internal view returns (uint256) { - return _totalSupply + yieldFeeBalance; + /// @notice Returns the shares that can be minted without exceeding the TwabController supply limit. + /// @dev The TwabController limits the total supply for each vault. + /// @param _existingShares The current allocated prize vault shares (internal and external) + /// @return The remaining shares that can be minted without exceeding TWAB limits + function _mintLimit(uint256 _existingShares) internal pure returns (uint256) { + return TWAB_SUPPLY_LIMIT - _existingShares; } - /// @notice Returns the remaining supply that can be minted without exceeding the TwabController limits. - /// @dev The TwabController limits the total supply for each vault to uint96 - /// @param _totalSupply The total share supply of the vault - /// @return The remaining supply that can be minted without exceeding TWAB limits - function _twabSupplyLimit(uint256 _totalSupply) internal pure returns (uint256) { - unchecked { - return type(uint96).max - _totalSupply; + /// @notice Verifies that the mint limit can support the new share balance. + /// @dev Reverts if the mint limit is exceeded. + /// @dev This MUST be called anytime there is a positive increase in the net total shares. + /// @param _existingShares The total existing prize vault shares (internal and external) + /// @param _newShares The new shares + function _enforceMintLimit(uint256 _existingShares, uint256 _newShares) internal pure { + uint256 _limit = _mintLimit(_existingShares); + if (_newShares > _limit) { + unchecked { + revert MintLimitExceeded(_newShares - _limit); + } } } @@ -911,9 +922,14 @@ contract PrizeVault is TwabERC20, Claimable, IERC4626, ILiquidationSource, Ownab uint256 _yieldVaultShares = yieldVault.previewDeposit(_assetsWithDust); uint256 _assetsUsed = yieldVault.mint(_yieldVaultShares, address(this)); - _mint(_receiver, _shares); + // Enforce the mint limit and protect against lossy deposits. + uint256 _totalDebtBeforeMint = totalDebt(); + _enforceMintLimit(_totalDebtBeforeMint, _shares); + if (totalAssets() < _totalDebtBeforeMint + _shares) { + revert LossyDeposit(totalAssets(), _totalDebtBeforeMint + _shares); + } - if (totalAssets() < totalDebt()) revert LossyDeposit(totalAssets(), totalDebt()); + _mint(_receiver, _shares); emit Deposit(_caller, _receiver, _assets, _shares); } diff --git a/test/invariant/PrizeVault/LossyPrizeVaultInvariant.t.sol b/test/invariant/PrizeVault/LossyPrizeVaultInvariant.t.sol index 1c2bba4..123a84f 100644 --- a/test/invariant/PrizeVault/LossyPrizeVaultInvariant.t.sol +++ b/test/invariant/PrizeVault/LossyPrizeVaultInvariant.t.sol @@ -35,6 +35,12 @@ contract LossyPrizeVaultInvariant is Test { } } + function invariantYieldFeeBalanceAlwaysClaimable() external { + uint256 supplyLimit = type(uint96).max - lossyVaultHarness.vault().totalSupply(); + uint256 yieldFeeBalance = lossyVaultHarness.vault().yieldFeeBalance(); + assertLe(yieldFeeBalance, supplyLimit); + } + function invariantAllAssetsAccountedFor() external { uint256 totalAssets = lossyVaultHarness.vault().totalAssets(); uint256 totalDebt = lossyVaultHarness.vault().totalDebt(); diff --git a/test/invariant/PrizeVault/PrizeVaultInvariant.t.sol b/test/invariant/PrizeVault/PrizeVaultInvariant.t.sol index 98a9cfa..745481c 100644 --- a/test/invariant/PrizeVault/PrizeVaultInvariant.t.sol +++ b/test/invariant/PrizeVault/PrizeVaultInvariant.t.sol @@ -47,6 +47,12 @@ contract PrizeVaultInvariant is Test { assertLe(liquidBalance, availableYieldBalance); } + function invariantYieldFeeBalanceAlwaysClaimable() external useCurrentTime { + uint256 supplyLimit = type(uint96).max - vaultHarness.vault().totalSupply(); + uint256 yieldFeeBalance = vaultHarness.vault().yieldFeeBalance(); + assertLe(yieldFeeBalance, supplyLimit); + } + function invariantAllAssetsAccountedFor() external useCurrentTime { PrizeVault vault = vaultHarness.vault(); uint256 totalAssets = vault.totalAssets(); diff --git a/test/unit/PrizeVault/Liquidate.t.sol b/test/unit/PrizeVault/Liquidate.t.sol index a309063..4de013e 100644 --- a/test/unit/PrizeVault/Liquidate.t.sol +++ b/test/unit/PrizeVault/Liquidate.t.sol @@ -92,6 +92,37 @@ contract PrizeVaultLiquidationTest is UnitBaseSetup { assertEq(vault.liquidatableBalanceOf(address(vault)), supplyCapLeft); // less than available yield since shares are capped at uint96 max } + function testLiquidatableBalanceOf_respectsMaxShareMintWithFee() public { + vault.setYieldFeePercentage(1e8); // 10% + vault.setYieldFeeRecipient(address(this)); + vault.setLiquidationPair(address(this)); + + uint256 supplyCapLeft = 100; + + // make a large deposit to use most of the shares: + underlyingAsset.mint(address(alice), type(uint96).max); + vm.startPrank(alice); + underlyingAsset.approve(address(vault), type(uint96).max - supplyCapLeft); + vault.deposit(type(uint96).max - supplyCapLeft, alice); + vm.stopPrank(); + + underlyingAsset.mint(address(vault), 1e18); + uint256 availableYield = vault.availableYieldBalance(); + assertApproxEqAbs(availableYield, 1e18 - vault.yieldBuffer(), 1); + + assertLt(supplyCapLeft, availableYield); + + uint256 amountOut = (supplyCapLeft * 9) / 10; + assertEq(vault.liquidatableBalanceOf(address(vault)), amountOut); + vault.transferTokensOut(address(0), address(this), address(vault), amountOut); + + assertEq(vault.liquidatableBalanceOf(address(vault)), 0); + assertEq(vault.yieldFeeBalance(), supplyCapLeft - amountOut); + + // ensure the yield fee can be minted + vault.claimYieldFeeShares(supplyCapLeft - amountOut); + } + /* ============ transferTokensOut ============ */ function testTransferTokensOut_noFee() public { @@ -244,6 +275,34 @@ contract PrizeVaultLiquidationTest is UnitBaseSetup { vault.transferTokensOut(address(0), bob, address(vault), amountOut + 1); } + function testTransferTokensOut_YieldFeeExceedsSupplyCap() public { + vault.setYieldFeePercentage(1e8); // 10% + vault.setYieldFeeRecipient(bob); + vault.setLiquidationPair(address(this)); + + uint256 supplyCapLeft = 100; + + // make a large deposit to use most of the shares: + underlyingAsset.mint(address(alice), type(uint96).max); + vm.startPrank(alice); + underlyingAsset.approve(address(vault), type(uint96).max - supplyCapLeft); + vault.deposit(type(uint96).max - supplyCapLeft, alice); + vm.stopPrank(); + + underlyingAsset.mint(address(vault), 1e18); + uint256 availableYield = vault.availableYieldBalance(); + assertApproxEqAbs(availableYield, 1e18 - vault.yieldBuffer(), 1); + + assertLt(supplyCapLeft, availableYield); + assertEq((supplyCapLeft * 9) / 10, vault.liquidatableBalanceOf(address(vault))); + + uint256 amountOut = supplyCapLeft; // 10 assets too much + // (even though there is available yield, the supply cap will be exceeded by the yield fee) + + vm.expectRevert(abi.encodeWithSelector(PrizeVault.MintLimitExceeded.selector, 11)); // yield fee is 11 + vault.transferTokensOut(address(0), address(this), address(vault), amountOut); + } + /* ============ verifyTokensIn ============ */ function testVerifyTokensIn() public {