diff --git a/contracts/Loan.sol b/contracts/Loan.sol index d1520375..94f29006 100644 --- a/contracts/Loan.sol +++ b/contracts/Loan.sol @@ -16,7 +16,7 @@ import "./FundingVault.sol"; */ contract Loan is ILoan { using SafeMath for uint256; - uint256 constant RAY = 10**27; + uint256 constant RAY = 10 ** 27; IServiceConfiguration private immutable _serviceConfiguration; address private immutable _factory; @@ -133,7 +133,6 @@ contract Loan is ILoan { serviceConfiguration, settings.duration, settings.paymentPeriod, - settings.loanType, settings.principal, liquidityAsset ); @@ -235,7 +234,10 @@ contract Loan is ILoan { /** * @dev Post ERC20 tokens as collateral */ - function postFungibleCollateral(address asset, uint256 amount) + function postFungibleCollateral( + address asset, + uint256 amount + ) external virtual onlyPermittedBorrower @@ -261,7 +263,10 @@ contract Loan is ILoan { /** * @dev Post ERC721 tokens as collateral */ - function postNonFungibleCollateral(address asset, uint256 tokenId) + function postNonFungibleCollateral( + address asset, + uint256 tokenId + ) external virtual onlyPermittedBorrower @@ -320,13 +325,9 @@ contract Loan is ILoan { /** * @dev Drawdown the Loan */ - function drawdown(uint256 amount) - external - virtual - onlyPermittedBorrower - onlyBorrower - returns (uint256) - { + function drawdown( + uint256 amount + ) external virtual onlyPermittedBorrower onlyBorrower returns (uint256) { (_state, paymentDueDate) = LoanLib.drawdown( amount, fundingVault, @@ -344,11 +345,9 @@ contract Loan is ILoan { * @dev Prepay principal. * @dev Only callable by open term loans */ - function paydownPrincipal(uint256 amount) - external - onlyPermittedBorrower - onlyBorrower - { + function paydownPrincipal( + uint256 amount + ) external onlyPermittedBorrower onlyBorrower { require(outstandingPrincipal >= amount, "Loan: amount too high"); require(settings.loanType == ILoanType.Open, "Loan: invalid loan type"); LoanLib.paydownPrincipal(liquidityAsset, amount, fundingVault); @@ -367,15 +366,21 @@ contract Loan is ILoan { { require(paymentsRemaining > 0, "Loan: No more payments remain"); - ILoanFees memory _fees = previewFees(payment); + ILoanFees memory _fees = LoanLib.previewFees( + settings, + payment, + _serviceConfiguration.firstLossFeeBps(), + IPool(_pool).poolFeePercentOfInterest(), + block.timestamp, + paymentDueDate, + RAY + ); LoanLib.payFees( liquidityAsset, IPool(_pool).firstLossVault(), - _fees.firstLossFee, IPool(_pool).feeVault(), - _fees.serviceFee, - _fees.originationFee + _fees ); LoanLib.completePayment( @@ -392,11 +397,9 @@ contract Loan is ILoan { * @dev Preview fees for a given interest payment amount. * @param amount allows previewing the fee for a full or prorated payment. */ - function previewFees(uint256 amount) - public - view - returns (ILoanFees memory) - { + function previewFees( + uint256 amount + ) public view returns (ILoanFees memory) { return LoanLib.previewFees( settings, @@ -404,7 +407,8 @@ contract Loan is ILoan { _serviceConfiguration.firstLossFeeBps(), IPool(_pool).poolFeePercentOfInterest(), block.timestamp, - paymentDueDate + paymentDueDate, + RAY ); } @@ -418,35 +422,40 @@ contract Loan is ILoan { atState(ILoanLifeCycleState.Active) returns (uint256) { - uint256 amount = payment.mul(paymentsRemaining); uint256 scalingValue = RAY; - // We will pro-rate open term loans for their last month of service - // If payment is overdue, we use default value of RAY. scalingValue is in RAYS. - if ( - settings.loanType == ILoanType.Open && - paymentDueDate > block.timestamp - ) { - // Calculate the scaling value - // RAY - ((paymentDueDate - blocktimestamp) * RAY / paymentPeriod (seconds)) - scalingValue = RAY.sub( - (paymentDueDate - block.timestamp).mul(RAY).div( - settings.paymentPeriod * 1 days - ) - ); - // Adjust payment accordingly - amount = (payment * scalingValue) / RAY; + if (settings.loanType == ILoanType.Open) { + // If an open term loan payment is not overdue, we will prorate the + // payment + if (paymentDueDate > block.timestamp) { + // Calculate the scaling value + // RAY - ((paymentDueDate - blocktimestamp) * RAY / paymentPeriod (seconds)) + scalingValue = RAY.sub( + (paymentDueDate - block.timestamp).mul(RAY).div( + settings.paymentPeriod * 1 days + ) + ); + } + } else { + // Fixed term loans must pay all outstanding interest payments and fees. + scalingValue = RAY.mul(paymentsRemaining); } - ILoanFees memory _fees = previewFees(amount); + ILoanFees memory _fees = LoanLib.previewFees( + settings, + payment, + _serviceConfiguration.firstLossFeeBps(), + IPool(_pool).poolFeePercentOfInterest(), + block.timestamp, + paymentDueDate, + scalingValue + ); LoanLib.payFees( liquidityAsset, IPool(_pool).firstLossVault(), - _fees.firstLossFee, IPool(_pool).feeVault(), - _fees.serviceFee, - _fees.originationFee.mul(scalingValue).div(RAY) + _fees ); LoanLib.completePayment( @@ -462,7 +471,7 @@ contract Loan is ILoan { _state = ILoanLifeCycleState.Matured; IPool(_pool).notifyLoanStateTransitioned(); - return amount; + return payment; } /** diff --git a/contracts/controllers/PoolController.sol b/contracts/controllers/PoolController.sol index d5bfa1b4..8d07c608 100644 --- a/contracts/controllers/PoolController.sol +++ b/contracts/controllers/PoolController.sol @@ -201,6 +201,19 @@ contract PoolController is IPoolController { return _settings.withdrawGateBps; } + /** + * @inheritdoc IPoolController + */ + function withdrawRequestPeriodDuration() public view returns (uint256) { + return + Math.min( + _settings.withdrawRequestPeriodDuration, + state() == IPoolLifeCycleState.Closed + ? 1 days + : _settings.withdrawRequestPeriodDuration + ); + } + /** * @inheritdoc IPoolController */ diff --git a/contracts/controllers/WithdrawController.sol b/contracts/controllers/WithdrawController.sol index 696f06f0..e69adb3c 100644 --- a/contracts/controllers/WithdrawController.sol +++ b/contracts/controllers/WithdrawController.sol @@ -64,7 +64,7 @@ contract WithdrawController is IWithdrawController { period = PoolLib.calculateCurrentWithdrawPeriod( block.timestamp, _pool.activatedAt(), - _pool.settings().withdrawRequestPeriodDuration + _pool.poolController().withdrawRequestPeriodDuration() ); } @@ -257,9 +257,7 @@ contract WithdrawController is IWithdrawController { view returns (uint256 shares) { - IPoolWithdrawState memory withdrawState = _currentWithdrawState( - msg.sender - ); + IPoolWithdrawState memory withdrawState = _currentWithdrawState(owner); shares = PoolLib.calculateConversion( assets, withdrawState.redeemableShares, @@ -495,8 +493,12 @@ contract WithdrawController is IWithdrawController { /** * @dev Cranks a lender */ - function crankLender(address addr) internal { - _withdrawState[addr] = _currentWithdrawState(addr); + function crankLender(address addr) + internal + returns (IPoolWithdrawState memory state) + { + state = _currentWithdrawState(addr); + _withdrawState[addr] = state; } /*////////////////////////////////////////////////////////////// @@ -511,10 +513,9 @@ contract WithdrawController is IWithdrawController { onlyPool returns (uint256 assets) { - crankLender(owner); + IPoolWithdrawState memory state = crankLender(owner); // Calculate how many assets should be transferred - IPoolWithdrawState memory state = _currentWithdrawState(owner); assets = PoolLib.calculateConversion( shares, state.withdrawableAssets, @@ -522,7 +523,7 @@ contract WithdrawController is IWithdrawController { false ); - _performWithdraw(owner, shares, assets); + _performWithdraw(owner, state, shares, assets); } /** @@ -533,10 +534,9 @@ contract WithdrawController is IWithdrawController { onlyPool returns (uint256 shares) { - crankLender(owner); + IPoolWithdrawState memory state = crankLender(owner); // Calculate how many shares should be burned - IPoolWithdrawState memory state = _currentWithdrawState(owner); shares = PoolLib.calculateConversion( assets, state.redeemableShares, @@ -544,7 +544,7 @@ contract WithdrawController is IWithdrawController { true ); - _performWithdraw(owner, shares, assets); + _performWithdraw(owner, state, shares, assets); } /** @@ -552,11 +552,10 @@ contract WithdrawController is IWithdrawController { */ function _performWithdraw( address owner, + IPoolWithdrawState memory currentState, uint256 shares, uint256 assets ) internal { - IPoolWithdrawState memory currentState = _currentWithdrawState(owner); - require( assets <= currentState.withdrawableAssets, "Pool: InsufficientBalance" diff --git a/contracts/controllers/interfaces/IPoolController.sol b/contracts/controllers/interfaces/IPoolController.sol index 6fb37d75..535b402d 100644 --- a/contracts/controllers/interfaces/IPoolController.sol +++ b/contracts/controllers/interfaces/IPoolController.sol @@ -103,6 +103,12 @@ interface IPoolController { */ function withdrawGate() external view returns (uint256); + /** + * @dev Returns the current withdraw request period duration in seconds. If the pool is closed, + * this is lowered (if needed) to 1 day. + */ + function withdrawRequestPeriodDuration() external view returns (uint256); + /** * @dev */ diff --git a/contracts/libraries/LoanLib.sol b/contracts/libraries/LoanLib.sol index e41806f8..8f9973c0 100644 --- a/contracts/libraries/LoanLib.sol +++ b/contracts/libraries/LoanLib.sol @@ -76,10 +76,9 @@ library LoanLib { IServiceConfiguration config, uint256 duration, uint256 paymentPeriod, - ILoanType loanType, uint256 principal, address liquidityAsset - ) external { + ) external view { require(duration > 0, "LoanLib: Duration cannot be zero"); require(paymentPeriod > 0, "LoanLib: Payment period cannot be zero"); require( @@ -300,19 +299,16 @@ library LoanLib { return RAY.mul(payment).mul(serviceFeeBps).div(100_00).div(RAY); } - function previewOriginationFee(ILoanSettings calldata settings) - public - pure - returns (uint256) - { - uint256 numOfPayments = settings.duration.div(settings.paymentPeriod); - + function previewOriginationFee( + ILoanSettings calldata settings, + uint256 scalingValue + ) public pure returns (uint256) { return settings .principal .mul(settings.originationBps) - .mul(settings.duration.mul(RAY).div(360)) - .div(numOfPayments) + .mul(settings.duration.mul(scalingValue).div(360)) + .div(settings.duration.div(settings.paymentPeriod)) .div(RAY) .div(10000); } @@ -325,6 +321,8 @@ library LoanLib { if (blockTimestamp > paymentDueDate) { return settings.latePayment; } + + return 0; } /** @@ -336,13 +334,16 @@ library LoanLib { uint256 firstLoss, uint256 poolFeePercentOfInterest, uint256 blockTimestamp, - uint256 paymentDueDate + uint256 paymentDueDate, + uint256 scalingValue ) public pure returns (ILoanFees memory) { + // If there is a scaling value + payment = payment.mul(scalingValue).div(RAY); ILoanFees memory fees; fees.payment = payment; fees.firstLossFee = previewFirstLossFee(payment, firstLoss); fees.serviceFee = previewServiceFee(payment, poolFeePercentOfInterest); - fees.originationFee = previewOriginationFee(settings); + fees.originationFee = previewOriginationFee(settings, scalingValue); fees.latePaymentFee = previewLatePaymentFee( settings, blockTimestamp, @@ -356,30 +357,25 @@ library LoanLib { function payFees( address asset, address firstLossVault, - uint256 firstLoss, - address poolAdmin, - uint256 poolFeePercentOfInterest, - uint256 originationFee + address feeVault, + ILoanFees calldata fees ) public { - if (firstLoss > 0) { + if (fees.firstLossFee > 0) { IERC20(asset).safeTransferFrom( msg.sender, firstLossVault, - firstLoss + fees.firstLossFee ); } - if (poolFeePercentOfInterest > 0) { - IERC20(asset).safeTransferFrom( - msg.sender, - poolAdmin, - poolFeePercentOfInterest - ); - } - if (originationFee > 0) { + + // The FeeVault holds the balance of fees intended for the PoolAdmin. + // This include both the service fee and origiantion fees. + uint256 feeVaultAmount = fees.serviceFee + fees.originationFee; + if (feeVaultAmount > 0) { IERC20(asset).safeTransferFrom( msg.sender, - poolAdmin, - originationFee + feeVault, + feeVaultAmount ); } } diff --git a/contracts/permissioned/PermissionedPool.sol b/contracts/permissioned/PermissionedPool.sol index 226d75d4..84cc61d4 100644 --- a/contracts/permissioned/PermissionedPool.sol +++ b/contracts/permissioned/PermissionedPool.sol @@ -74,12 +74,9 @@ contract PermissionedPool is Pool { * @dev Since Pool does not enforce that msg.sender == receiver, we only * check the receiver here. */ - function maxDeposit(address receiver) - public - view - override - returns (uint256) - { + function maxDeposit( + address receiver + ) public view override returns (uint256) { if (!poolAccessControl.isAllowed(receiver)) { return 0; } diff --git a/test/Loan.test.ts b/test/Loan.test.ts index d52ce90a..e9ae52b8 100644 --- a/test/Loan.test.ts +++ b/test/Loan.test.ts @@ -1182,6 +1182,55 @@ describe("Loan", () => { const newDueDate = await loan.paymentDueDate(); expect(newDueDate).to.equal(dueDate.add(THIRTY_DAYS)); }); + + it("can collect origination fees from the full payment", async () => { + const { + borrower, + collateralAsset, + liquidityAsset, + loan, + pool, + poolController, + poolAdmin + } = await loadFixture(deployFixtureOriginationFees); + + // Setup + await collateralAsset.connect(borrower).approve(loan.address, 100); + await loan + .connect(borrower) + .postFungibleCollateral(collateralAsset.address, 100); + await poolController.connect(poolAdmin).fundLoan(loan.address); + await loan.connect(borrower).drawdown(await loan.principal()); + + // 500,000 token loan with 180 day term + // Loan has 100bps origination fee + await liquidityAsset.mint(borrower.address, 2_500); + // Loan has 500bps interest + await liquidityAsset.mint(borrower.address, 12_498); + + // Make payment + const firstLoss = await poolController.firstLossVault(); + const feeVault = await pool.feeVault(); + await liquidityAsset + .connect(borrower) + .approve(loan.address, 12498 + 500_000 + 2_500); + const tx = loan.connect(borrower).completeFullPayment(); + await expect(tx).to.not.be.reverted; + await expect(tx).to.changeTokenBalance( + liquidityAsset, + borrower, + -12498 - 500_000 - 2_500 + ); + await expect(tx).to.changeTokenBalance( + liquidityAsset, + pool, + 500_000 + 12_498 - 624 + ); + await expect(tx).to.changeTokenBalance(liquidityAsset, feeVault, 2_500); + await expect(tx).to.changeTokenBalance(liquidityAsset, firstLoss, 624); + expect(await loan.paymentsRemaining()).to.equal(0); + expect(await loan.state()).to.equal(5); + }); }); describe("callbacks", () => { @@ -1281,7 +1330,7 @@ describe("Loan", () => { expect(await liquidityAsset.balanceOf(fundingVault)).to.equal(0); // Mint additional tokens to cover the interest payments - await liquidityAsset.mint(borrower.address, 12498); + await liquidityAsset.mint(borrower.address, 12498 + 50_000); // Repay some of the principal const prepaidPrincipal = 1_000; @@ -1306,8 +1355,8 @@ describe("Loan", () => { // Relative to a full month payments, the fees will be halved await time.increase(THIRTY_DAYS / 2); const firstLossFee = 52; - const poolAdminFee = 208; - const interestPayment = 1041; + const serviceFee = 208; + const interestPayment = 989; const principal = 500_000; const originationFee = 208; @@ -1316,12 +1365,17 @@ describe("Loan", () => { await expect(tx).to.changeTokenBalance( liquidityAsset, borrower, - 0 - interestPayment - principal - originationFee + prepaidPrincipal + 0 - + interestPayment - + principal - + originationFee - + firstLossFee + + prepaidPrincipal ); await expect(tx).to.changeTokenBalance( liquidityAsset, pool, - interestPayment + principal - prepaidPrincipal - firstLossFee + interestPayment + principal - prepaidPrincipal ); const firstLoss = await pool.firstLossVault(); @@ -1333,7 +1387,7 @@ describe("Loan", () => { await expect(tx).to.changeTokenBalance( liquidityAsset, await pool.feeVault(), - poolAdminFee + serviceFee ); expect(await loan.paymentsRemaining()).to.equal(0); diff --git a/test/controllers/PoolController.test.ts b/test/controllers/PoolController.test.ts index 3ac53635..93e41b29 100644 --- a/test/controllers/PoolController.test.ts +++ b/test/controllers/PoolController.test.ts @@ -8,7 +8,12 @@ import { fundLoan, DEFAULT_LOAN_SETTINGS } from "../support/loan"; -import { activatePool, deployPool, depositToPool } from "../support/pool"; +import { + activatePool, + DEFAULT_POOL_SETTINGS, + deployPool, + depositToPool +} from "../support/pool"; describe("PoolController", () => { async function loadPoolFixture() { @@ -273,6 +278,50 @@ describe("PoolController", () => { }); }); + describe("withdrawRequestPeriodDuration()", () => { + it("returns the value set by PA", async () => { + const { poolController } = await loadFixture(loadPoolFixture); + + expect(await poolController.withdrawRequestPeriodDuration()).to.equal( + DEFAULT_POOL_SETTINGS.withdrawRequestPeriodDuration + ); + }); + + it("if the pool is closed, the withdraw window shrinks to 1 day", async () => { + const { poolController } = await loadFixture(loadPoolFixture); + + const { endDate } = await poolController.settings(); + await time.increaseTo(endDate); + + expect(await poolController.state()).to.equal(3); // sanity check its closed + expect(await poolController.withdrawRequestPeriodDuration()).to.equal( + 86400 + ); // 1 day + }); + + it("if the pool is closed, the withdraw window won't increase if it's already less than 1 day", async () => { + const { operator, poolAdmin } = await loadFixture(loadPoolFixture); + + const overriddenPoolSettings = { + withdrawRequestPeriodDuration: 86399 + }; + + const { poolController: newPoolController } = await deployPool({ + operator, + poolAdmin: poolAdmin, + settings: overriddenPoolSettings + }); + + const { endDate } = await newPoolController.settings(); + await time.increaseTo(endDate); + + expect(await newPoolController.state()).to.equal(3); // sanity check its closed + expect(await newPoolController.withdrawRequestPeriodDuration()).to.equal( + overriddenPoolSettings.withdrawRequestPeriodDuration + ); // Unchanged + }); + }); + describe("setPoolCapacity()", () => { it("prevents setting capacity to less than current pool size", async () => { const { pool, poolController, otherAccount, poolAdmin, liquidityAsset } = diff --git a/test/scenarios/pool/crank-variations.test.ts b/test/scenarios/pool/crank-variations.test.ts index e8cfed63..5ed0a6fc 100644 --- a/test/scenarios/pool/crank-variations.test.ts +++ b/test/scenarios/pool/crank-variations.test.ts @@ -19,12 +19,6 @@ describe("Crank Variations", () => { // Set the request fee to 0, for simplicity await poolController.connect(poolAdmin).setRequestFee(0); - // activate the pool - await activatePool(pool, poolAdmin, liquidityAsset); - - // deposit 1M tokens from Alice - await depositToPool(pool, aliceLender, liquidityAsset, DEPOSIT_AMOUNT); - const { withdrawRequestPeriodDuration } = await pool.settings(); return { @@ -46,12 +40,19 @@ describe("Crank Variations", () => { poolAdmin, withdrawRequestPeriodDuration, withdrawController, + liquidityAsset, poolController } = await loadFixture(loadPoolFixture); // Set the withdraw gate to 25% await poolController.connect(poolAdmin).setWithdrawGate(5000); + // activate the pool + await activatePool(pool, poolAdmin, liquidityAsset); + + // deposit 1M tokens from Alice + await depositToPool(pool, aliceLender, liquidityAsset, DEPOSIT_AMOUNT); + // Request maximum in window 0 expect(await withdrawController.withdrawPeriod()).to.equal(0); await pool.connect(aliceLender).requestRedeem(DEPOSIT_AMOUNT); @@ -105,6 +106,12 @@ describe("Crank Variations", () => { poolController } = await loadFixture(loadPoolFixture); + // activate the pool + await activatePool(pool, poolAdmin, liquidityAsset); + + // deposit 1M tokens from Alice + await depositToPool(pool, aliceLender, liquidityAsset, DEPOSIT_AMOUNT); + // deposit 1M tokens from Bob as well await depositToPool(pool, bobLender, liquidityAsset, DEPOSIT_AMOUNT); @@ -192,6 +199,12 @@ describe("Crank Variations", () => { poolController } = await loadFixture(loadPoolFixture); + // activate the pool + await activatePool(pool, poolAdmin, liquidityAsset); + + // deposit 1M tokens from Alice + await depositToPool(pool, aliceLender, liquidityAsset, DEPOSIT_AMOUNT); + // Set the withdraw gate to 50% await poolController.connect(poolAdmin).setWithdrawGate(5000); @@ -246,4 +259,50 @@ describe("Crank Variations", () => { DEPOSIT_AMOUNT / 2 - 1 ); }); + + it("on pool close, you can withdraw sooner", async () => { + const { + pool, + aliceLender, + bobLender, + liquidityAsset, + poolAdmin, + withdrawRequestPeriodDuration, + withdrawController, + poolController + } = await loadFixture(loadPoolFixture); + + // Set the withdraw gate to 50%, and fees to 0 to simplify numbers. + await poolController.connect(poolAdmin).setWithdrawGate(5000); + await poolController.connect(poolAdmin).setRequestFee(0); + + // activate the pool + await activatePool(pool, poolAdmin, liquidityAsset); + + // deposit 1M tokens from Alice + await depositToPool(pool, aliceLender, liquidityAsset, DEPOSIT_AMOUNT); + + // Request maximum in window 0 for Alice + expect(await withdrawController.withdrawPeriod()).to.equal(0); + await pool.connect(aliceLender).requestRedeem(DEPOSIT_AMOUNT); + + // close the pool + const newCloseDate = (await time.latest()) + 2; // Skip ahead so that it's not in the past by the time of the next call. + await poolController.connect(poolAdmin).setPoolEndDate(newCloseDate); + await time.increaseTo(newCloseDate + 1); + + // Check that the pool is closed + expect(await pool.state()).to.equal(3); + + // Check that we're still in the same withdraw period, since it has only been a few seconds. + expect(await withdrawController.withdrawPeriod()).to.equal(0); + + // Fast forward 1 day...previously the window was 30 days + await time.increase(86400); + + await pool.crank(); + expect(await pool.maxRedeem(aliceLender.address)).to.equal( + DEPOSIT_AMOUNT - 1 + ); // withdrawal dust + }); });