From 00359c195ad28fa6a5a98f7586cbec4763f48a74 Mon Sep 17 00:00:00 2001 From: ams9198 <111915188+ams9198@users.noreply.github.com> Date: Mon, 17 Oct 2022 10:50:27 -0400 Subject: [PATCH] VAL-39 Apply first-loss to the pool in case of default (#49) --- contracts/Pool.sol | 10 ++-- contracts/interfaces/ILoan.sol | 6 +-- contracts/interfaces/IPool.sol | 9 ++++ contracts/libraries/PoolLib.sol | 90 +++++++++++++++++++++++++-------- test/Pool.test.ts | 41 ++++++++++++--- 5 files changed, 123 insertions(+), 33 deletions(-) diff --git a/contracts/Pool.sol b/contracts/Pool.sol index 45c8a7ba..1d8ffb4e 100644 --- a/contracts/Pool.sol +++ b/contracts/Pool.sol @@ -260,9 +260,13 @@ contract Pool is IPool, ERC20 { "Pool: invalid loan" ); - ILoan(loan).markDefaulted(); - _accountings.activeLoanPrincipals -= ILoan(loan).principal(); - emit LoanDefaulted(loan); + PoolLib.executeDefault( + asset(), + address(_firstLossVault), + loan, + address(this), + _accountings + ); } /*////////////////////////////////////////////////////////////// diff --git a/contracts/interfaces/ILoan.sol b/contracts/interfaces/ILoan.sol index 7f38d8af..13209549 100644 --- a/contracts/interfaces/ILoan.sol +++ b/contracts/interfaces/ILoan.sol @@ -78,17 +78,17 @@ interface ILoan { /** * @dev Number of payments remaining */ - function paymentsRemaining() external returns (uint256); + function paymentsRemaining() external view returns (uint256); /** * @dev Amount expected in each payment */ - function payment() external returns (uint256); + function payment() external view returns (uint256); /** * @dev Due date for the next payment */ - function paymentDueDate() external returns (uint256); + function paymentDueDate() external view returns (uint256); function postFungibleCollateral(address asset, uint256 amount) external diff --git a/contracts/interfaces/IPool.sol b/contracts/interfaces/IPool.sol index f726aa80..0fa2e3d2 100644 --- a/contracts/interfaces/IPool.sol +++ b/contracts/interfaces/IPool.sol @@ -76,6 +76,15 @@ interface IPool is IERC4626 { */ event PoolSettingsUpdated(IPoolConfigurableSettings settings); + /** + * @dev Emitted when first loss capital is used to cover loan defaults + */ + event FirstLossApplied( + address indexed loan, + uint256 amount, + uint256 outstandingLoss + ); + /** * @dev Returns the current pool lifecycle state. */ diff --git a/contracts/libraries/PoolLib.sol b/contracts/libraries/PoolLib.sol index 1b35c591..c60ae3c0 100644 --- a/contracts/libraries/PoolLib.sol +++ b/contracts/libraries/PoolLib.sol @@ -48,6 +48,35 @@ library PoolLib { uint256 shares ); + /** + * @dev See IPool + */ + event FirstLossApplied( + address indexed loan, + uint256 amount, + uint256 outstandingLosses + ); + + /** + * @dev Determines whether an address corresponds to a pool loan + * @param loan address of loan + * @param serviceConfiguration address of service configuration + * @param pool address of pool + */ + function isPoolLoan( + address loan, + address serviceConfiguration, + address pool + ) public view returns (bool) { + address factory = ILoan(loan).factory(); + return + IServiceConfiguration(serviceConfiguration).isLoanFactory( + factory + ) && + LoanFactory(factory).isLoan(loan) && + ILoan(loan).pool() == pool; + } + /** * @dev See IPool for event definition */ @@ -216,6 +245,47 @@ library PoolLib { return shares; } + /** + * @dev Executes a default, supplying first-loss to cover losses. + * @param asset Pool liquidity asset + * @param firstLossVault Vault holding first-loss capital + * @param loan Address of loan in default + * @param accountings Pool accountings to update + */ + function executeDefault( + address asset, + address firstLossVault, + address loan, + address pool, + IPoolAccountings storage accountings + ) external { + ILoan(loan).markDefaulted(); + accountings.activeLoanPrincipals -= ILoan(loan).principal(); + + uint256 firstLossBalance = IERC20(asset).balanceOf( + address(firstLossVault) + ); + + // TODO - handle open-term loans where principal may + // not be fully oustanding. + uint256 outstandingLoanDebt = ILoan(loan).principal() + + ILoan(loan).paymentsRemaining() * + ILoan(loan).payment(); + + uint256 firstLossRequired = firstLossBalance >= outstandingLoanDebt + ? outstandingLoanDebt + : firstLossBalance; + + FirstLossVault(firstLossVault).withdraw(firstLossRequired, pool); + + emit LoanDefaulted(loan); + emit FirstLossApplied( + loan, + firstLossRequired, + outstandingLoanDebt.sub(firstLossRequired) + ); + } + /*////////////////////////////////////////////////////////////// Withdrawal Request Methods //////////////////////////////////////////////////////////////*/ @@ -245,24 +315,4 @@ library PoolLib { ) public view returns (uint256) { return currentWithdrawPeriod(activatedAt, withdrawalWindowDuration) + 1; } - - /** - * @dev Determines whether an address corresponds to a pool loan - * @param loan address of loan - * @param serviceConfiguration address of service configuration - * @param pool address of pool - */ - function isPoolLoan( - address loan, - address serviceConfiguration, - address pool - ) public view returns (bool) { - address factory = ILoan(loan).factory(); - return - IServiceConfiguration(serviceConfiguration).isLoanFactory( - factory - ) && - LoanFactory(factory).isLoan(loan) && - ILoan(loan).pool() == pool; - } } diff --git a/test/Pool.test.ts b/test/Pool.test.ts index a7c3c9cf..f4c1f137 100644 --- a/test/Pool.test.ts +++ b/test/Pool.test.ts @@ -232,22 +232,49 @@ describe("Pool", () => { await depositToPool(pool, otherAccount, liquidityAsset, loanPrincipal); await fundLoan(loan, pool, poolManager); + // Confirm that pool liquidity reserve is now empty + expect(await liquidityAsset.balanceOf(pool.address)).to.equal(0); + // Get an accounting snapshot prior to the default const activeLoanPrincipalBefore = (await pool.accountings()) .activeLoanPrincipals; + const firstLossAvailable = await pool.firstLoss(); - // Trigger default - await expect(pool.connect(poolManager).defaultLoan(loan.address)).to.emit( - pool, - "LoanDefaulted" + // Expected loan outstanding stand = principal + numberPayments * payments + const loanPaymentsRemaining = await loan.paymentsRemaining(); + const loanPaymentAmount = await loan.payment(); + const loanOustandingDebt = loanPrincipal.add( + loanPaymentsRemaining.mul(loanPaymentAmount) ); + // Confirm that first loss is NOT enough to cover the oustanding loan debt + expect(firstLossAvailable).to.be.lessThan(loanOustandingDebt); + + // Trigger default + // Since first-loss is not enough to cover oustanding debt, all of it is used + await expect(pool.connect(poolManager).defaultLoan(loan.address)) + .to.emit(pool, "LoanDefaulted") + .withArgs(loan.address) + .to.emit(pool, "FirstLossApplied") + .withArgs( + loan.address, + firstLossAvailable, + loanOustandingDebt.sub(firstLossAvailable) + ); + // Check accountings after - const activeLoanPrincipalsAfter = (await pool.accountings()) - .activeLoanPrincipals; - expect(activeLoanPrincipalsAfter).is.equal( + // Pool accountings should be updated + expect((await pool.accountings()).activeLoanPrincipals).is.equal( activeLoanPrincipalBefore.sub(loanPrincipal) ); + + // First loss vault should be empty + expect(await pool.firstLoss()).to.equal(0); + + // Pool liquidity reserve should now contain the first loss + expect(await liquidityAsset.balanceOf(pool.address)).to.equal( + firstLossAvailable + ); }); });