From d4d1f6b80c8b7aaa12679175cc0632d8c165873a Mon Sep 17 00:00:00 2001 From: ams9198 <111915188+ams9198@users.noreply.github.com> Date: Thu, 6 Oct 2022 15:01:39 -0400 Subject: [PATCH] Add support for defaults (#44) --- contracts/Loan.sol | 22 +++++ contracts/LoanFactory.sol | 14 +++ contracts/Pool.sol | 29 +++++- contracts/PoolFactory.sol | 1 + contracts/ServiceConfiguration.sol | 22 +++++ contracts/interfaces/ILoan.sol | 9 ++ contracts/interfaces/IPool.sol | 6 +- .../interfaces/IServiceConfiguration.sol | 14 +++ contracts/libraries/PoolLib.sol | 28 ++++++ contracts/mocks/PoolLibTestWrapper.sol | 9 ++ contracts/permissioned/PermissionedPool.sol | 12 ++- .../permissioned/PermissionedPoolFactory.sol | 3 +- test/Loan.test.ts | 60 +++++++++-- test/Pool.test.ts | 72 +++++++++++++- test/libraries/PoolLib.test.ts | 99 ++++++++++++++++++- test/permissioned/PermissionedPool.test.ts | 2 +- test/support/loan.ts | 78 +++++++++++++++ test/support/permissionedpool.ts | 4 + test/support/pool.ts | 8 +- test/support/serviceconfiguration.ts | 16 +++ 20 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 test/support/loan.ts create mode 100644 test/support/serviceconfiguration.ts diff --git a/contracts/Loan.sol b/contracts/Loan.sol index 5713fe4a..f5fb4073 100644 --- a/contracts/Loan.sol +++ b/contracts/Loan.sol @@ -14,6 +14,7 @@ import "./FundingVault.sol"; */ contract Loan is ILoan { IServiceConfiguration private immutable _serviceConfiguration; + address private immutable _factory; ILoanLifeCycleState private _state = ILoanLifeCycleState.Requested; address private immutable _borrower; address private immutable _pool; @@ -78,6 +79,7 @@ contract Loan is ILoan { constructor( IServiceConfiguration serviceConfiguration, + address factory, address borrower, address pool, uint256 duration_, @@ -89,6 +91,7 @@ contract Loan is ILoan { uint256 dropDeadTimestamp ) { _serviceConfiguration = serviceConfiguration; + _factory = factory; _borrower = borrower; _pool = pool; _collateralVault = new CollateralVault(address(this)); @@ -231,6 +234,21 @@ contract Loan is ILoan { return amount; } + /** + * @inheritdoc ILoan + */ + function markDefaulted() + external + override + onlyPool + atState(ILoanLifeCycleState.Funded) + returns (ILoanLifeCycleState) + { + _state = ILoanLifeCycleState.Defaulted; + emit LifeCycleStateTransition(_state); + return _state; + } + function state() external view returns (ILoanLifeCycleState) { return _state; } @@ -243,6 +261,10 @@ contract Loan is ILoan { return _pool; } + function factory() external view returns (address) { + return _factory; + } + function dropDeadTimestamp() external view returns (uint256) { return _dropDeadTimestamp; } diff --git a/contracts/LoanFactory.sol b/contracts/LoanFactory.sol index ec1b439c..69e368f3 100644 --- a/contracts/LoanFactory.sol +++ b/contracts/LoanFactory.sol @@ -13,6 +13,11 @@ contract LoanFactory { */ IServiceConfiguration private _serviceConfiguration; + /** + * @dev Mapping of created loans + */ + mapping(address => bool) private _isLoan; + /** * @dev Emitted when a Loan is created. */ @@ -43,6 +48,7 @@ contract LoanFactory { ); Loan loan = new Loan( _serviceConfiguration, + address(this), borrower, pool, duration, @@ -55,6 +61,14 @@ contract LoanFactory { ); address addr = address(loan); emit LoanCreated(addr); + _isLoan[addr] = true; return addr; } + + /** + * @dev Checks whether the address corresponds to a created loan for this factory + */ + function isLoan(address loan) public view returns (bool) { + return _isLoan[loan]; + } } diff --git a/contracts/Pool.sol b/contracts/Pool.sol index c999d688..e24aa15f 100644 --- a/contracts/Pool.sol +++ b/contracts/Pool.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.16; import "./interfaces/ILoan.sol"; import "./interfaces/IPool.sol"; +import "./interfaces/IServiceConfiguration.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -19,6 +20,7 @@ contract Pool is IPool, ERC20 { IPoolLifeCycleState private _poolLifeCycleState; address private _manager; + IServiceConfiguration private _serviceConfiguration; IERC20 private _liquidityAsset; IPoolConfigurableSettings private _poolSettings; FirstLossVault private _firstLossVault; @@ -94,12 +96,14 @@ contract Pool is IPool, ERC20 { * @param liquidityAsset asset held by the poo * @param poolManager manager of the pool * @param poolSettings configurable settings for the pool + * @param serviceConfiguration address of global service configuration * @param tokenName Name used for issued pool tokens * @param tokenSymbol Symbol used for issued pool tokens */ constructor( address liquidityAsset, address poolManager, + address serviceConfiguration, IPoolConfigurableSettings memory poolSettings, string memory tokenName, string memory tokenSymbol @@ -107,6 +111,7 @@ contract Pool is IPool, ERC20 { _liquidityAsset = IERC20(liquidityAsset); _poolSettings = poolSettings; _manager = poolManager; + _serviceConfiguration = IServiceConfiguration(serviceConfiguration); _firstLossVault = new FirstLossVault(address(this), liquidityAsset); _setPoolLifeCycleState(IPoolLifeCycleState.Initialized); } @@ -221,13 +226,31 @@ contract Pool is IPool, ERC20 { _liquidityAsset.safeApprove(address(loan), loan.principal()); loan.fund(); + _accountings.activeLoanPrincipals += loan.principal(); } /** - * @dev Called by the pool manager, marks a loan as in default, updating pool accounting and allowing loan - * collateral to be claimed. + * @inheritdoc IPool */ - function markLoanAsInDefault(address) external onlyManager {} + function defaultLoan(address loan) + external + onlyManager + atState(IPoolLifeCycleState.Active) + { + require(loan != address(0), "Pool: 0 address"); + require( + PoolLib.isPoolLoan( + loan, + address(_serviceConfiguration), + address(this) + ), + "Pool: invalid loan" + ); + + ILoan(loan).markDefaulted(); + _accountings.activeLoanPrincipals -= ILoan(loan).principal(); + emit LoanDefaulted(loan); + } /*////////////////////////////////////////////////////////////// Withdrawal Request Methods diff --git a/contracts/PoolFactory.sol b/contracts/PoolFactory.sol index a06e2bfe..3f0582b5 100644 --- a/contracts/PoolFactory.sol +++ b/contracts/PoolFactory.sol @@ -53,6 +53,7 @@ contract PoolFactory { Pool pool = new Pool( liquidityAsset, msg.sender, + address(_serviceConfiguration), settings, "ValyriaPoolToken", "VPT" diff --git a/contracts/ServiceConfiguration.sol b/contracts/ServiceConfiguration.sol index ad265a4c..b8f23b0f 100644 --- a/contracts/ServiceConfiguration.sol +++ b/contracts/ServiceConfiguration.sol @@ -21,6 +21,11 @@ contract ServiceConfiguration is AccessControl, IServiceConfiguration { mapping(address => bool) public isLiquidityAsset; + /** + * @dev Holds a reference to valid LoanFactories + */ + mapping(address => bool) public isLoanFactory; + /** * @dev Emitted when an address is changed. */ @@ -36,6 +41,11 @@ contract ServiceConfiguration is AccessControl, IServiceConfiguration { */ event ProtocolPaused(bool paused); + /** + * @dev Emitted when a loan factory is set + */ + event LoanFactorySet(address indexed factory, bool isValid); + /** * @dev Constructor for the contract, which sets up the default roles and * owners. @@ -78,4 +88,16 @@ contract ServiceConfiguration is AccessControl, IServiceConfiguration { function isOperator(address addr) external view returns (bool) { return hasRole(OPERATOR_ROLE, addr); } + + /** + * @inheritdoc IServiceConfiguration + */ + function setLoanFactory(address addr, bool isValid) + external + override + onlyOperator + { + isLoanFactory[addr] = isValid; + emit LoanFactorySet(addr, isValid); + } } diff --git a/contracts/interfaces/ILoan.sol b/contracts/interfaces/ILoan.sol index c0eb8577..3ec929b6 100644 --- a/contracts/interfaces/ILoan.sol +++ b/contracts/interfaces/ILoan.sol @@ -26,6 +26,11 @@ struct ILoanNonFungibleCollateral { } interface ILoan { + /** + * @dev Emitted when a Loan's lifecycle state transitions + */ + event LifeCycleStateTransition(ILoanLifeCycleState state); + /** * @dev Emitted when collateral is posted to the loan. */ @@ -52,6 +57,8 @@ interface ILoan { function pool() external view returns (address); + function factory() external view returns (address); + function dropDeadTimestamp() external view returns (uint256); function cancelRequested() external returns (ILoanLifeCycleState); @@ -90,4 +97,6 @@ interface ILoan { function principal() external returns (uint256); function fundingVault() external returns (FundingVault); + + function markDefaulted() external returns (ILoanLifeCycleState); } diff --git a/contracts/interfaces/IPool.sol b/contracts/interfaces/IPool.sol index 27b3d0fb..f726aa80 100644 --- a/contracts/interfaces/IPool.sol +++ b/contracts/interfaces/IPool.sol @@ -142,8 +142,8 @@ interface IPool is IERC4626 { function fundLoan(address) external; /** - * @dev Called by the pool manager, marks a loan as in default, updating pool accounting and allowing loan - * collateral to be claimed. + * @dev Called by the pool manager, this marks a loan as in default, triggering liquiditation + * proceedings and updating pool accounting. */ - function markLoanAsInDefault(address) external; + function defaultLoan(address) external; } diff --git a/contracts/interfaces/IServiceConfiguration.sol b/contracts/interfaces/IServiceConfiguration.sol index 7b91acac..1d0d5933 100644 --- a/contracts/interfaces/IServiceConfiguration.sol +++ b/contracts/interfaces/IServiceConfiguration.sol @@ -15,4 +15,18 @@ interface IServiceConfiguration is IAccessControl { function paused() external view returns (bool); function isLiquidityAsset(address addr) external view returns (bool); + + /** + * @dev checks if an address is a valid loan factory + * @param addr Address of loan factory + * @return bool whether the loan factory is valid + */ + function isLoanFactory(address addr) external view returns (bool); + + /** + * @dev Sets whether a loan factory is valid + * @param addr Address of loan factory + * @param isValid Whether the loan factory is valid + */ + function setLoanFactory(address addr, bool isValid) external; } diff --git a/contracts/libraries/PoolLib.sol b/contracts/libraries/PoolLib.sol index 96b32361..1b35c591 100644 --- a/contracts/libraries/PoolLib.sol +++ b/contracts/libraries/PoolLib.sol @@ -6,7 +6,10 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {SafeMath} from "@openzeppelin/contracts/utils/math/SafeMath.sol"; import "../interfaces/ILoan.sol"; import "../interfaces/IPool.sol"; +import "../interfaces/ILoan.sol"; +import "../interfaces/IServiceConfiguration.sol"; import "../FirstLossVault.sol"; +import "../LoanFactory.sol"; /** * @title Collection of functions used by the Pool @@ -45,6 +48,11 @@ library PoolLib { uint256 shares ); + /** + * @dev See IPool for event definition + */ + event LoanDefaulted(address indexed loan); + /** * @dev Transfers first loss to the vault. * @param liquidityAsset Pool liquidity asset @@ -237,4 +245,24 @@ 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/contracts/mocks/PoolLibTestWrapper.sol b/contracts/mocks/PoolLibTestWrapper.sol index fb7c3604..e4cf9ec6 100644 --- a/contracts/mocks/PoolLibTestWrapper.sol +++ b/contracts/mocks/PoolLibTestWrapper.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.16; import "../libraries/PoolLib.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../interfaces/IPool.sol"; /** * @title PoolLibTestWrapper @@ -119,4 +120,12 @@ contract PoolLibTestWrapper is ERC20("PoolLibTest", "PLT") { _mint ); } + + function isPoolLoan( + address loan, + address serviceConfiguration, + address pool + ) public view returns (bool) { + return PoolLib.isPoolLoan(loan, serviceConfiguration, pool); + } } diff --git a/contracts/permissioned/PermissionedPool.sol b/contracts/permissioned/PermissionedPool.sol index 46daf646..9f8773d5 100644 --- a/contracts/permissioned/PermissionedPool.sol +++ b/contracts/permissioned/PermissionedPool.sol @@ -44,10 +44,20 @@ contract PermissionedPool is Pool { constructor( address liquidityAsset, address poolManager, + address serviceConfiguration, IPoolConfigurableSettings memory poolSettings, string memory tokenName, string memory tokenSymbol - ) Pool(liquidityAsset, poolManager, poolSettings, tokenName, tokenSymbol) { + ) + Pool( + liquidityAsset, + poolManager, + serviceConfiguration, + poolSettings, + tokenName, + tokenSymbol + ) + { _poolAccessControl = new PoolAccessControl(address(this)); } diff --git a/contracts/permissioned/PermissionedPoolFactory.sol b/contracts/permissioned/PermissionedPoolFactory.sol index 49da0b28..ac7f70d9 100644 --- a/contracts/permissioned/PermissionedPoolFactory.sol +++ b/contracts/permissioned/PermissionedPoolFactory.sol @@ -60,9 +60,10 @@ contract PermissionedPoolFactory is PoolFactory { firstLossInitialMinimum, withdrawRequestPeriodDuration ); - Pool pool = new PermissionedPool( + PermissionedPool pool = new PermissionedPool( liquidityAsset, msg.sender, + address(_serviceConfiguration), settings, "ValyriaPoolToken", "VPT" diff --git a/test/Loan.test.ts b/test/Loan.test.ts index 73eb5703..082328f4 100644 --- a/test/Loan.test.ts +++ b/test/Loan.test.ts @@ -1,7 +1,8 @@ import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; -import { DEFAULT_POOL_SETTINGS } from "./support/pool"; +import { DEFAULT_POOL_SETTINGS, activatePool } from "./support/pool"; +import { deployMockERC20 } from "./support/erc20"; describe("Loan", () => { const SEVEN_DAYS = 6 * 60 * 60 * 24; @@ -11,9 +12,9 @@ describe("Loan", () => { const [operator, poolManager, borrower, lender, other] = await ethers.getSigners(); - const LiquidityAsset = await ethers.getContractFactory("MockERC20"); - const liquidityAsset = await LiquidityAsset.deploy("Test Coin", "TC"); - await liquidityAsset.deployed(); + // deploy mock liquidity + const { mockERC20 } = await deployMockERC20(); + const liquidityAsset = mockERC20; // Deploy the Service Configuration contract const ServiceConfiguration = await ethers.getContractFactory( @@ -47,6 +48,8 @@ describe("Loan", () => { const loanFactory = await LoanFactory.deploy(serviceConfiguration.address); await loanFactory.deployed(); + await serviceConfiguration.setLoanFactory(loanFactory.address, true); + // Create a pool const tx1 = await poolFactory .connect(poolManager) @@ -124,6 +127,7 @@ describe("Loan", () => { return { pool, loan, + loanFactory, operator, poolManager, borrower, @@ -136,16 +140,18 @@ describe("Loan", () => { describe("after initialization", () => { it("is initialized!", async () => { - const { loan, pool, borrower } = await loadFixture(deployFixture); + const { loan, pool, borrower, loanFactory } = await loadFixture( + deployFixture + ); expect(await loan.state()).to.equal(0); expect(await loan.borrower()).to.equal(borrower.address); expect(await loan.pool()).to.equal(pool.address); - expect(await loan.duration()).to.equal(180); // 6 month duration expect(await loan.paymentPeriod()).to.equal(30); // 30 day payments expect(await loan.loanType()).to.equal(0); // fixed expect(await loan.apr()).to.equal(500); // apr 5.00% expect(await loan.principal()).to.equal(500_000); // $500,000 + expect(await loan.factory()).to.equal(loanFactory.address); }); }); @@ -448,6 +454,48 @@ describe("Loan", () => { }); }); + describe("markDefaulted", () => { + it("reverts if not called by pool", async () => { + const { loan, other } = await loadFixture(deployFixture); + + await expect(loan.connect(other).markDefaulted()).to.be.revertedWith( + "Loan: caller is not pool" + ); + }); + + it("transitions state only if defaulted while in a Funded state", async () => { + const fixture = await loadFixture(deployFixture); + const { borrower, collateralAsset, poolManager } = fixture; + const loan = fixture.loan.connect(borrower); + const pool = fixture.pool.connect(poolManager); + + // Check Loan is in requested state; defaults should revert + expect(await loan.state()).to.equal(0); + await expect(pool.defaultLoan(loan.address)).to.be.revertedWith( + "Loan: FunctionInvalidAtThisILoanLifeCycleState" + ); + + // Loan is collateralized; defaults should still revert + await collateralAsset.connect(borrower).approve(loan.address, 100); + await loan.postFungibleCollateral(collateralAsset.address, 100); + expect(await loan.state()).to.equal(1); + await expect(pool.defaultLoan(loan.address)).to.be.revertedWith( + "Loan: FunctionInvalidAtThisILoanLifeCycleState" + ); + + // Loan is funded + await pool.fundLoan(loan.address); + expect(await loan.state()).to.equal(4); + + // Default should proceed + await expect(pool.defaultLoan(loan.address)).to.emit( + loan, + "LifeCycleStateTransition" + ); + expect(await loan.state()).to.equal(3); + }); + }); + const findEventByName = (receipt, name) => { return receipt.events?.find((event) => event.event == name); }; diff --git a/test/Pool.test.ts b/test/Pool.test.ts index ad3c69fa..c0f26f17 100644 --- a/test/Pool.test.ts +++ b/test/Pool.test.ts @@ -7,13 +7,23 @@ import { depositToPool, activatePool } from "./support/pool"; +import { deployLoan, collateralizeLoan, fundLoan } from "./support/loan"; describe("Pool", () => { async function loadPoolFixture() { const [poolManager, otherAccount] = await ethers.getSigners(); - const { pool, liquidityAsset } = await deployPool(poolManager); - - return { pool, liquidityAsset, poolManager, otherAccount }; + const { pool, liquidityAsset, serviceConfiguration } = await deployPool( + poolManager + ); + + const { loan } = await deployLoan( + pool.address, + otherAccount.address, + liquidityAsset.address, + serviceConfiguration + ); + + return { pool, liquidityAsset, poolManager, otherAccount, loan }; } describe("Deployment", () => { @@ -189,6 +199,58 @@ describe("Pool", () => { }); }); + describe("defaultLoan()", () => { + it("reverts if Pool state is initialized", async () => { + const { pool, poolManager, otherAccount } = await loadFixture( + loadPoolFixture + ); + await expect( + pool.connect(poolManager).defaultLoan(otherAccount.address) + ).to.be.revertedWith("Pool: FunctionInvalidAtThisLifeCycleState"); + }); + + it("reverts if loan is not in a valid state", async () => { + const { pool, poolManager, liquidityAsset, loan } = await loadFixture( + loadPoolFixture + ); + await activatePool(pool, poolManager, liquidityAsset); + await expect( + pool.connect(poolManager).defaultLoan(loan.address) + ).to.be.revertedWith("Loan: FunctionInvalidAtThisILoanLifeCycleState"); + }); + + it("defaults loan if loan is funded, and pool is active", async () => { + const { pool, poolManager, liquidityAsset, loan, otherAccount } = + await loadFixture(loadPoolFixture); + await activatePool(pool, poolManager, liquidityAsset); + + // Collateralize loan + await collateralizeLoan(loan, otherAccount); + + // Deposit to pool and fund loan + const loanPrincipal = await loan.principal(); + await depositToPool(pool, otherAccount, liquidityAsset, loanPrincipal); + await fundLoan(loan, pool, poolManager); + + // Get an accounting snapshot prior to the default + const activeLoanPrincipalBefore = (await pool.accountings()) + .activeLoanPrincipals; + + // Trigger default + await expect(pool.connect(poolManager).defaultLoan(loan.address)).to.emit( + pool, + "LoanDefaulted" + ); + + // Check accountings after + const activeLoanPrincipalsAfter = (await pool.accountings()) + .activeLoanPrincipals; + expect(activeLoanPrincipalsAfter).is.equal( + activeLoanPrincipalBefore.sub(loanPrincipal) + ); + }); + }); + describe("Permissions", () => { describe("updatePoolCapacity()", () => { it("reverts if not called by Pool Manager", async () => { @@ -220,12 +282,12 @@ describe("Pool", () => { }); }); - describe("markLoanAsInDefault()", () => { + describe("defaultLoan()", () => { it("reverts if not called by Pool Manager", async () => { const { pool, otherAccount } = await loadFixture(loadPoolFixture); await expect( - pool.connect(otherAccount).markLoanAsInDefault(otherAccount.address) + pool.connect(otherAccount).defaultLoan(otherAccount.address) ).to.be.revertedWith("Pool: caller is not manager"); }); }); diff --git a/test/libraries/PoolLib.test.ts b/test/libraries/PoolLib.test.ts index e1b190c4..c641fbf8 100644 --- a/test/libraries/PoolLib.test.ts +++ b/test/libraries/PoolLib.test.ts @@ -1,6 +1,8 @@ import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; +import { deployLoan } from "../support/loan"; +import { deployMockERC20 } from "../support/erc20"; describe("PoolLib", () => { const FIRST_LOSS_AMOUNT = 100; @@ -23,9 +25,7 @@ describe("PoolLib", () => { const poolLibWrapper = await PoolLibWrapper.deploy(); await poolLibWrapper.deployed(); - const LiquidityAsset = await ethers.getContractFactory("MockERC20"); - const liquidityAsset = await LiquidityAsset.deploy("Test Coin", "TC"); - await liquidityAsset.deployed(); + const liquidityAsset = (await deployMockERC20()).mockERC20; await liquidityAsset.mint(caller.address, FIRST_LOSS_AMOUNT); await liquidityAsset @@ -39,12 +39,21 @@ describe("PoolLib", () => { ); await firstLossVault.deployed(); + const { loan, loanFactory, serviceConfiguration } = await deployLoan( + poolLibWrapper.address, + otherAccount.address, + liquidityAsset.address + ); + return { poolLibWrapper, caller, firstLossVault, liquidityAsset, - otherAccount + otherAccount, + loanFactory, + loan, + serviceConfiguration }; } @@ -388,4 +397,86 @@ describe("PoolLib", () => { ).to.equal(625); }); }); + + describe("isPoolLoan()", async () => { + it("reverts if not passed an ILoan", async () => { + const { poolLibWrapper, serviceConfiguration, caller } = + await loadFixture(deployFixture); + + await expect( + poolLibWrapper.isPoolLoan( + caller.address, + serviceConfiguration.address, + poolLibWrapper.address + ) + ).to.be.reverted; + }); + + it("reverts if not passed a service configuration", async () => { + const { poolLibWrapper, loan } = await loadFixture(deployFixture); + + await expect( + poolLibWrapper.isPoolLoan( + loan.address, + loan.address, + poolLibWrapper.address + ) + ).to.be.reverted; + }); + + it("returns true if conditions are met", async () => { + const { poolLibWrapper, loan, serviceConfiguration } = await loadFixture( + deployFixture + ); + + expect( + await poolLibWrapper.isPoolLoan( + loan.address, + serviceConfiguration.address, + poolLibWrapper.address + ) + ).to.equal(true); + }); + }); + + describe("isPoolLoan()", async () => { + it("reverts if not passed an ILoan", async () => { + const { poolLibWrapper, serviceConfiguration, caller } = + await loadFixture(deployFixture); + + await expect( + poolLibWrapper.isPoolLoan( + caller.address, + serviceConfiguration.address, + poolLibWrapper.address + ) + ).to.be.reverted; + }); + + it("reverts if not passed a service configuration", async () => { + const { poolLibWrapper, loan } = await loadFixture(deployFixture); + + await expect( + poolLibWrapper.isPoolLoan( + loan.address, + loan.address, + poolLibWrapper.address + ) + ).to.be.reverted; + }); + + it("returns true if conditions are met", async () => { + const { poolLibWrapper, loan, serviceConfiguration } = await loadFixture( + deployFixture + ); + + expect( + await poolLibWrapper.isPoolLoan( + loan.address, + serviceConfiguration.address, + poolLibWrapper.address + ) + ).to.equal(true); + }); + }); }); diff --git a/test/permissioned/PermissionedPool.test.ts b/test/permissioned/PermissionedPool.test.ts index 23d79713..a6d1b8f8 100644 --- a/test/permissioned/PermissionedPool.test.ts +++ b/test/permissioned/PermissionedPool.test.ts @@ -3,7 +3,7 @@ import { expect } from "chai"; import { ethers } from "hardhat"; import { deployPermissionedPool } from "../support/permissionedpool"; -describe("Pool", () => { +describe("PermissionedPool", () => { async function loadPoolFixture() { const [poolManager, otherAccount, thirdAccount] = await ethers.getSigners(); const { pool, liquidityAsset } = await deployPermissionedPool(poolManager); diff --git a/test/support/loan.ts b/test/support/loan.ts new file mode 100644 index 00000000..2b4d4a2d --- /dev/null +++ b/test/support/loan.ts @@ -0,0 +1,78 @@ +import { ethers } from "hardhat"; +import { deployServiceConfiguration } from "./serviceconfiguration"; + +const SEVEN_DAYS = 6 * 60 * 60 * 24; + +/** + * Deploy a loan + */ +export async function deployLoan( + pool: any, + borrower: any, + liquidityAsset: any, + existingServiceConfiguration: any = null +) { + const { serviceConfiguration } = await (existingServiceConfiguration == null + ? deployServiceConfiguration() + : { + serviceConfiguration: existingServiceConfiguration + }); + + await serviceConfiguration.setLiquidityAsset(liquidityAsset, true); + + const LoanLib = await ethers.getContractFactory("LoanLib"); + const loanLib = await LoanLib.deploy(); + + const LoanFactory = await ethers.getContractFactory("LoanFactory", { + libraries: { + LoanLib: loanLib.address + } + }); + const loanFactory = await LoanFactory.deploy(serviceConfiguration.address); + await loanFactory.deployed(); + + await serviceConfiguration.setLoanFactory(loanFactory.address, true); + + const txn = await loanFactory.createLoan( + borrower, + pool, + 180, + 30, + 0, + 500, + liquidityAsset, + 1_000_000, + Math.floor(Date.now() / 1000) + SEVEN_DAYS + ); + + const txnReceipt = await txn.wait(); + + const loanCreatedEvent = txnReceipt.events?.find( + (e) => e.event == "LoanCreated" + ); + const loanAddress = loanCreatedEvent?.args?.[0]; + const loan = await ethers.getContractAt("Loan", loanAddress); + + return { loan, loanFactory, serviceConfiguration }; +} + +export async function collateralizeLoan(loan: any, borrower: any) { + const CollateralAsset = await ethers.getContractFactory("MockERC20"); + const collateralAsset = await CollateralAsset.deploy( + "Test Collateral Coin", + "TCC" + ); + await collateralAsset.deployed(); + + await collateralAsset.mint(borrower.address, 1_000_000); + await collateralAsset.connect(borrower).approve(loan.address, 100); + await loan + .connect(borrower) + .postFungibleCollateral(collateralAsset.address, 100); + + return { loan, borrower, collateralAsset }; +} + +export async function fundLoan(loan: any, pool: any, pm: any) { + await pool.connect(pm).fundLoan(loan.address); +} diff --git a/test/support/permissionedpool.ts b/test/support/permissionedpool.ts index 1fca2836..1b0f0078 100644 --- a/test/support/permissionedpool.ts +++ b/test/support/permissionedpool.ts @@ -1,6 +1,7 @@ import { ethers } from "hardhat"; import { deployMockERC20 } from "./erc20"; import { DEFAULT_POOL_SETTINGS } from "./pool"; +import { deployServiceConfiguration } from "./serviceconfiguration"; /** * Deploy an "Initialized" Pool @@ -11,6 +12,8 @@ export async function deployPermissionedPool( ) { const { mockERC20: liquidityAsset } = await deployMockERC20(); + const { serviceConfiguration } = await deployServiceConfiguration(); + const PoolLib = await ethers.getContractFactory("PoolLib"); const poolLib = await PoolLib.deploy(); @@ -23,6 +26,7 @@ export async function deployPermissionedPool( const pool = await PermissionedPool.deploy( liquidityAsset.address, poolManager.address, + serviceConfiguration.address, poolSettings, "Valyria PoolToken", "VPT" diff --git a/test/support/pool.ts b/test/support/pool.ts index c59bbbc7..460df2ce 100644 --- a/test/support/pool.ts +++ b/test/support/pool.ts @@ -1,6 +1,7 @@ import { ethers } from "hardhat"; import { MockERC20, Pool } from "../../typechain-types"; import { deployMockERC20 } from "./erc20"; +import { deployServiceConfiguration } from "./serviceconfiguration"; export const DEFAULT_POOL_SETTINGS = { maxCapacity: 10_000_000, @@ -19,6 +20,8 @@ export async function deployPool( ) { const { mockERC20: liquidityAsset } = await deployMockERC20(); + const { serviceConfiguration } = await deployServiceConfiguration(); + const PoolLib = await ethers.getContractFactory("PoolLib"); const poolLib = await PoolLib.deploy(); @@ -31,6 +34,7 @@ export async function deployPool( const pool = await Pool.deploy( liquidityAsset.address, poolManager.address, + serviceConfiguration.address, poolSettings, "Valyria PoolToken", "VPT" @@ -42,7 +46,7 @@ export async function deployPool( poolSettings.firstLossInitialMinimum ); - return { pool, liquidityAsset }; + return { pool, liquidityAsset, serviceConfiguration }; } /** @@ -74,7 +78,7 @@ export async function depositToPool( pool: Pool, depositorAccount: any, asset: MockERC20, - amount: number + amount: any ) { // Provide capital to lender await asset.mint(depositorAccount.address, amount); diff --git a/test/support/serviceconfiguration.ts b/test/support/serviceconfiguration.ts new file mode 100644 index 00000000..ab00d5af --- /dev/null +++ b/test/support/serviceconfiguration.ts @@ -0,0 +1,16 @@ +import { ethers } from "hardhat"; + +/** + * Deploy ServiceConfiguration + */ +export async function deployServiceConfiguration() { + const ServiceConfiguration = await ethers.getContractFactory( + "ServiceConfiguration" + ); + const serviceConfiguration = await ServiceConfiguration.deploy(); + await serviceConfiguration.deployed(); + + return { + serviceConfiguration + }; +}