From f2555b5853873cdf7655366b3d7d71c5e00842e1 Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Mon, 24 Oct 2022 15:39:01 -0400 Subject: [PATCH] Add `totalAvailableAssets` methods which account for withdrawable/redeemable funds (#58) * Add total assets available method * Properly handle liquidity reserve in crank --- contracts/Pool.sol | 61 ++++++++++++--- contracts/interfaces/IERC4626.sol | 1 + contracts/libraries/PoolLib.sol | 80 +++++++++++++++----- contracts/mocks/PoolLibTestWrapper.sol | 26 ++++++- test/Pool.test.ts | 8 +- test/libraries/PoolLib.test.ts | 72 +++++++++++++++--- test/scenarios/pool/withdraw-request.test.ts | 18 ++--- 7 files changed, 208 insertions(+), 58 deletions(-) diff --git a/contracts/Pool.sol b/contracts/Pool.sol index edecbc08..4cda810f 100644 --- a/contracts/Pool.sol +++ b/contracts/Pool.sol @@ -355,6 +355,42 @@ contract Pool is IPool, ERC20 { ); } + /** + * @dev Calculate the total amount of underlying assets held by the vault, + * excluding any assets due for withdrawal. + */ + function totalAvailableAssets() public view returns (uint256 assets) { + assets = PoolLib.calculateTotalAvailableAssets( + address(_liquidityAsset), + address(this), + _accountings.outstandingLoanPrincipals, + totalWithdrawableAssets() + ); + } + + /** + * @dev The total available supply that is not marked for withdrawal + */ + function totalAvailableSupply() public view returns (uint256 shares) { + shares = PoolLib.calculateTotalAvailableShares( + address(this), + totalRedeemableShares() + ); + } + + /** + * @dev The sum of all assets available in the liquidity pool, excluding + * any assets that are marked for withdrawal. + */ + function liquidityPoolAssets() public view returns (uint256 assets) { + assets = PoolLib.calculateTotalAvailableAssets( + address(_liquidityAsset), + address(this), + 0, // do not include any loan principles + totalWithdrawableAssets() + ); + } + /*////////////////////////////////////////////////////////////// Crank //////////////////////////////////////////////////////////////*/ @@ -364,17 +400,17 @@ contract Pool is IPool, ERC20 { */ function crank() external returns (uint256 redeemableShares) { // Calculate the amount available for withdrawal - // TODO: Find the real available liquidity - uint256 availableLiquidity = totalSupply(); + uint256 liquidAssets = liquidityPoolAssets(); - // How much is available to redeem for this period - uint256 availableShares = availableLiquidity + uint256 availableAssets = liquidAssets .mul(_poolSettings.withdrawGateBps) .mul(PoolLib.RAY) .div(10_000) .div(PoolLib.RAY); - if (availableShares <= 0) { + uint256 availableShares = convertToShares(availableAssets); + + if (availableAssets <= 0 || availableShares <= 0) { // unable to redeem anything redeemableShares = 0; return 0; @@ -393,6 +429,7 @@ contract Pool is IPool, ERC20 { globalState.eligibleShares ); + // Update the global withdraw state _globalWithdrawState = PoolLib.updateWithdrawStateForWithdraw( globalState, convertToAssets(redeemableShares), @@ -626,7 +663,7 @@ contract Pool is IPool, ERC20 { * @dev Returns the number of shares that are available to be redeemed * overall in the current block. */ - function totalRedeemableBalance() external view returns (uint256 shares) { + function totalRedeemableShares() public view returns (uint256 shares) { shares = _currentGlobalWithdrawState().redeemableShares; } @@ -634,7 +671,7 @@ contract Pool is IPool, ERC20 { * @dev Returns the number of `assets` that are available to be withdrawn * overall in the current block. */ - function totalWithdrawableBalance() external view returns (uint256 assets) { + function totalWithdrawableAssets() public view returns (uint256 assets) { assets = _currentGlobalWithdrawState().withdrawableAssets; } @@ -758,8 +795,8 @@ contract Pool is IPool, ERC20 { return PoolLib.calculateAssetsToShares( assets, - totalSupply(), - totalAssets() + totalAvailableSupply(), + totalAvailableAssets() ); } @@ -775,8 +812,8 @@ contract Pool is IPool, ERC20 { return PoolLib.calculateSharesToAssets( shares, - totalSupply(), - totalAssets() + totalAvailableSupply(), + totalAvailableAssets() ); } @@ -794,7 +831,7 @@ contract Pool is IPool, ERC20 { PoolLib.calculateMaxDeposit( lifeCycleState(), _poolSettings.maxCapacity, - totalAssets() + totalAvailableAssets() ); } diff --git a/contracts/interfaces/IERC4626.sol b/contracts/interfaces/IERC4626.sol index a904a599..4b5fdeab 100644 --- a/contracts/interfaces/IERC4626.sol +++ b/contracts/interfaces/IERC4626.sol @@ -35,6 +35,7 @@ interface IERC4626 is IERC20 { /** * @dev Calculate the total amount of underlying assets held by the vault. + * NOTE: This method includes assets that are marked for withdrawal. */ function totalAssets() external view returns (uint256); diff --git a/contracts/libraries/PoolLib.sol b/contracts/libraries/PoolLib.sol index 62b77954..909d9d53 100644 --- a/contracts/libraries/PoolLib.sol +++ b/contracts/libraries/PoolLib.sol @@ -86,9 +86,13 @@ library PoolLib { event LoanDefaulted(address indexed loan); /** - * @dev Math `ceil` method to round up on division + * @dev Divide two numbers and round the result up */ - function ceil(uint256 lhs, uint256 rhs) internal pure returns (uint256) { + function divideCeil(uint256 lhs, uint256 rhs) + internal + pure + returns (uint256) + { return (lhs + rhs - 1) / rhs; } @@ -213,47 +217,51 @@ library PoolLib { /** * @dev Computes the exchange rate for converting assets to shares * @param assets Amount of assets to exchange - * @param sharesTotalSupply Supply of Vault's ERC20 shares - * @param totalAssets Pool total assets + * @param totalAvailableShares Supply of Vault's ERC20 shares (excluding marked for redemption) + * @param totalAvailableAssets Pool total available assets (excluding marked for withdrawal) * @return shares The amount of shares */ function calculateAssetsToShares( uint256 assets, - uint256 sharesTotalSupply, - uint256 totalAssets + uint256 totalAvailableShares, + uint256 totalAvailableAssets ) external pure returns (uint256 shares) { - if (totalAssets == 0) { + if (totalAvailableAssets == 0) { return assets; } // TODO: add in interest rate. - uint256 rate = (sharesTotalSupply.mul(RAY)).div(totalAssets); + uint256 rate = (totalAvailableShares.mul(RAY)).div( + totalAvailableAssets + ); shares = (rate.mul(assets)).div(RAY); } /** * @dev Computes the exchange rate for converting shares to assets * @param shares Amount of shares to exchange - * @param sharesTotalSupply Supply of Vault's ERC20 shares - * @param totalAssets Pool NAV + * @param totalAvailableShares Supply of Vault's ERC20 shares (excluding marked for redemption) + * @param totalAvailableAssets Pool total available assets (excluding marked for withdrawal) * @return assets The amount of shares */ function calculateSharesToAssets( uint256 shares, - uint256 sharesTotalSupply, - uint256 totalAssets + uint256 totalAvailableShares, + uint256 totalAvailableAssets ) external pure returns (uint256 assets) { - if (sharesTotalSupply == 0) { + if (totalAvailableShares == 0) { return shares; } // TODO: add in interest rate. - uint256 rate = (totalAssets.mul(RAY)).div(sharesTotalSupply); + uint256 rate = (totalAvailableAssets.mul(RAY)).div( + totalAvailableShares + ); assets = (rate.mul(shares)).div(RAY); } /** - * @dev Calculates total assets held by Vault + * @dev Calculates total assets held by Vault (including those marked for withdrawal) * @param asset Amount of total assets held by the Vault * @param vault Address of the ERC4626 vault * @param outstandingLoanPrincipals Sum of all oustanding loan principals @@ -263,27 +271,59 @@ library PoolLib { address asset, address vault, uint256 outstandingLoanPrincipals - ) external view returns (uint256 totalAssets) { + ) public view returns (uint256 totalAssets) { totalAssets = IERC20(asset).balanceOf(vault) + outstandingLoanPrincipals; } + /** + * @dev Calculates total assets held by Vault (excluding marked for withdrawal) + * @param asset Amount of total assets held by the Vault + * @param vault Address of the ERC4626 vault + * @param outstandingLoanPrincipals Sum of all oustanding loan principals + * @param withdrawableAssets Sum of all withdrawable assets + * @return totalAvailableAssets Total available assets (excluding marked for withdrawal) + */ + function calculateTotalAvailableAssets( + address asset, + address vault, + uint256 outstandingLoanPrincipals, + uint256 withdrawableAssets + ) external view returns (uint256 totalAvailableAssets) { + totalAvailableAssets = + calculateTotalAssets(asset, vault, outstandingLoanPrincipals) - + withdrawableAssets; + } + + /** + * @dev Calculates total shares held by Vault (excluding marked for redemption) + * @param vault Address of the ERC4626 vault + * @param redeemableShares Sum of all withdrawable assets + * @return totalAvailableShares Total redeemable shares (excluding marked for redemption) + */ + function calculateTotalAvailableShares( + address vault, + uint256 redeemableShares + ) external view returns (uint256 totalAvailableShares) { + totalAvailableShares = IERC20(vault).totalSupply() - redeemableShares; + } + /** * @dev Calculates the max deposit allowed in the pool * @param poolLifeCycleState The current pool lifecycle state * @param poolMaxCapacity Max pool capacity allowed per the pool settings - * @param totalAssets Sum of all pool assets + * @param totalAvailableAssets Sum of all pool assets (excluding marked for withdrawal) * @return Max deposit allowed */ function calculateMaxDeposit( IPoolLifeCycleState poolLifeCycleState, uint256 poolMaxCapacity, - uint256 totalAssets + uint256 totalAvailableAssets ) external pure returns (uint256) { return poolLifeCycleState == IPoolLifeCycleState.Active - ? poolMaxCapacity - totalAssets + ? poolMaxCapacity - totalAvailableAssets : 0; } @@ -425,7 +465,7 @@ library PoolLib { pure returns (uint256) { - return ceil(shares * requestFeeBps, 10_000); + return divideCeil(shares * requestFeeBps, 10_000); } /** diff --git a/contracts/mocks/PoolLibTestWrapper.sol b/contracts/mocks/PoolLibTestWrapper.sol index 8e61aed8..0d3de588 100644 --- a/contracts/mocks/PoolLibTestWrapper.sol +++ b/contracts/mocks/PoolLibTestWrapper.sol @@ -93,16 +93,38 @@ contract PoolLibTestWrapper is ERC20("PoolLibTest", "PLT") { ); } + function calculateTotalAvailableAssets( + address asset, + address vault, + uint256 outstandingLoanPrincipals, + uint256 withdrawableAssets + ) external view returns (uint256) { + return + PoolLib.calculateTotalAvailableAssets( + asset, + vault, + outstandingLoanPrincipals, + withdrawableAssets + ); + } + + function calculateTotalAvailableShares( + address vault, + uint256 redeemableShares + ) external view returns (uint256) { + return PoolLib.calculateTotalAvailableShares(vault, redeemableShares); + } + function calculateMaxDeposit( IPoolLifeCycleState poolLifeCycleState, uint256 poolMaxCapacity, - uint256 totalAssets + uint256 totalAvailableAssets ) external pure returns (uint256) { return PoolLib.calculateMaxDeposit( poolLifeCycleState, poolMaxCapacity, - totalAssets + totalAvailableAssets ); } diff --git a/test/Pool.test.ts b/test/Pool.test.ts index d44cce0e..b0f475c1 100644 --- a/test/Pool.test.ts +++ b/test/Pool.test.ts @@ -1020,7 +1020,7 @@ describe("Pool", () => { }); }); - describe("totalRedeemableBalance()", () => { + describe("totalRedeemableShares()", () => { it("returns the redeemable number of shares in this pool", async () => { const { pool, @@ -1041,7 +1041,7 @@ describe("Pool", () => { await time.increase(withdrawRequestPeriodDuration); await pool.connect(poolManager).crank(); - expect(await pool.totalRedeemableBalance()).to.equal(40); + expect(await pool.totalRedeemableShares()).to.equal(40); }); }); @@ -1062,7 +1062,7 @@ describe("Pool", () => { }); }); - describe("totalWithdrawableBalance()", () => { + describe("totalWithdrawableAssets()", () => { it("returns the withdrawable number of shares in this pool", async () => { const { pool, @@ -1083,7 +1083,7 @@ describe("Pool", () => { await time.increase(withdrawRequestPeriodDuration); await pool.connect(poolManager).crank(); - expect(await pool.totalWithdrawableBalance()).to.equal(40); + expect(await pool.totalWithdrawableAssets()).to.equal(40); }); }); }); diff --git a/test/libraries/PoolLib.test.ts b/test/libraries/PoolLib.test.ts index cc61bd81..c88dc291 100644 --- a/test/libraries/PoolLib.test.ts +++ b/test/libraries/PoolLib.test.ts @@ -235,7 +235,7 @@ describe("PoolLib", () => { }); describe("calculateTotalAssets()", async () => { - it("combines balance of vault with oustanding loan principals", async () => { + it("combines balance of vault with outstanding loan principals", async () => { const { poolLibWrapper, liquidityAsset } = await loadFixture( deployFixture ); @@ -377,6 +377,56 @@ describe("PoolLib", () => { }); }); + describe("calculateTotalAvailableAssets()", async () => { + it("combines balance of vault with outstanding loan principals and subtracts withdrawable assets", async () => { + const { poolLibWrapper, liquidityAsset } = await loadFixture( + deployFixture + ); + + liquidityAsset.mint(poolLibWrapper.address, 200); + + expect( + await poolLibWrapper.calculateTotalAvailableAssets( + liquidityAsset.address, + poolLibWrapper.address, + 50, + 100 + ) + ).to.equal(150); + }); + }); + + describe("calculateTotalAvailableShares()", async () => { + it("returns the totalSupply minus redeemable shares", async () => { + const { poolLibWrapper, liquidityAsset, caller } = await loadFixture( + deployFixture + ); + + expect(await poolLibWrapper.balanceOf(caller.address)).to.equal(0); + const depositAmount = 10; + + // Deposit + await poolLibWrapper.executeDeposit( + liquidityAsset.address, + poolLibWrapper.address, + caller.address, + depositAmount, + 5, + 10 + ); + + // Check that shares were minted + expect(await poolLibWrapper.balanceOf(caller.address)).to.equal(5); + + expect( + await poolLibWrapper.calculateTotalAvailableShares( + /* vault address */ poolLibWrapper.address, + /* redeemableShares */ 2 + ) + ).to.equal(3); + }); + }); + describe("executeDeposit()", async () => { it("reverts if shares to be minted are 0", async () => { const { poolLibWrapper, liquidityAsset, caller } = await loadFixture( @@ -491,20 +541,20 @@ describe("PoolLib", () => { ); }); - it("calculates <1:1 if nav has increased in value", async () => { + it("calculates <1:1 if nav has increased in value, and properly rounds down", async () => { const { poolLibWrapper } = await loadFixture(deployFixture); expect( await poolLibWrapper.calculateAssetsToShares(500, 500, 525) - ).to.equal(476); + ).to.equal(476) /* 476.19 rounded down */; }); - it("calculates >1:1 if nav has decreased in value", async () => { + it("calculates >1:1 if nav has decreased in value, and properly rounds down", async () => { const { poolLibWrapper } = await loadFixture(deployFixture); expect( - await poolLibWrapper.calculateAssetsToShares(500, 500, 400) - ).to.equal(625); + await poolLibWrapper.calculateAssetsToShares(500, 500, 399) + ).to.equal(626); /* 626.53 rounded down */ }); }); @@ -517,20 +567,20 @@ describe("PoolLib", () => { ); }); - it("calculates <1:1 if nav has increased in value", async () => { + it("calculates <1:1 if nav has increased in value, and properly rounds down", async () => { const { poolLibWrapper } = await loadFixture(deployFixture); expect( await poolLibWrapper.calculateAssetsToShares(500, 500, 525) - ).to.equal(476); + ).to.equal(476); /* 476.19 rounded down */ }); - it("calculates >1:1 if nav has decreased in value", async () => { + it("calculates >1:1 if nav has decreased in value, and properly rounds down", async () => { const { poolLibWrapper } = await loadFixture(deployFixture); expect( - await poolLibWrapper.calculateAssetsToShares(500, 500, 400) - ).to.equal(625); + await poolLibWrapper.calculateAssetsToShares(500, 500, 399) + ).to.equal(626); /* 626.53 rounded down */ }); }); diff --git a/test/scenarios/pool/withdraw-request.test.ts b/test/scenarios/pool/withdraw-request.test.ts index 7914a064..8d1b1627 100644 --- a/test/scenarios/pool/withdraw-request.test.ts +++ b/test/scenarios/pool/withdraw-request.test.ts @@ -73,8 +73,8 @@ describe("Withdraw Requests", () => { // Verify the Global withdrawal state is updated expect(await pool.totalRequestedBalance()).to.equal(60); expect(await pool.totalEligibleBalance()).to.equal(0); - expect(await pool.totalRedeemableBalance()).to.equal(0); - expect(await pool.totalWithdrawableBalance()).to.equal(0); + expect(await pool.totalRedeemableShares()).to.equal(0); + expect(await pool.totalWithdrawableAssets()).to.equal(0); // Expect Alice's maxWithdrawRequest amounts have decreased expect(await pool.maxRedeemRequest(aliceLender.address)).to.equal( @@ -111,19 +111,19 @@ describe("Withdraw Requests", () => { // crank it await pool.crank(); - // 48 shares should be available due to 25% withdraw gate. - expect(await pool.totalRedeemableBalance()).to.equal(41); + // 170 assets, 25% withdraw gate = 42 assets. + expect(await pool.totalWithdrawableAssets()).to.equal(41); // 42? + expect(await pool.totalRedeemableShares()).to.equal(40); // verify the global state is updated expect(await pool.totalRequestedBalance()).to.equal(0); - expect(await pool.totalEligibleBalance()).to.equal(19); /* 60 - 41 */ - expect(await pool.totalWithdrawableBalance()).to.equal(42); + expect(await pool.totalEligibleBalance()).to.equal(20); /* 60 - 40 */ // verify Alice's state is updated expect(await pool.maxRedeem(aliceLender.address)).to.equal( - 34 - ); /* 50 * (41/60) */ - expect(await pool.maxWithdraw(aliceLender.address)).to.equal(35); + 33 + ); /* 50 * (40/60) */ + expect(await pool.maxWithdraw(aliceLender.address)).to.equal(34); // verify Bob's state is updated expect(await pool.maxRedeem(bobLender.address)).to.equal(