diff --git a/contracts/Loan.sol b/contracts/Loan.sol index f5fb4073..578944c6 100644 --- a/contracts/Loan.sol +++ b/contracts/Loan.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.16; +import {SafeMath} from "@openzeppelin/contracts/utils/math/SafeMath.sol"; import "./interfaces/ILoan.sol"; import "./interfaces/IServiceConfiguration.sol"; import "./libraries/LoanLib.sol"; @@ -13,6 +14,9 @@ import "./FundingVault.sol"; * Empty Loan contract. */ contract Loan is ILoan { + using SafeMath for uint256; + uint256 constant RAY = 10**27; + IServiceConfiguration private immutable _serviceConfiguration; address private immutable _factory; ILoanLifeCycleState private _state = ILoanLifeCycleState.Requested; @@ -30,6 +34,9 @@ contract Loan is ILoan { uint256 public immutable principal; address public immutable liquidityAsset; ILoanType public immutable loanType = ILoanType.Fixed; + uint256 public immutable payment; + uint256 public paymentsRemaining; + uint256 public paymentDueDate; /** * @dev Modifier that requires the Loan be in the given `state_` @@ -112,6 +119,14 @@ contract Loan is ILoan { principal, liquidityAsset ); + + paymentsRemaining = duration.div(paymentPeriod); + uint256 paymentsTotal = principal + .mul(apr) + .mul(duration.mul(RAY).div(360)) + .div(RAY) + .div(10000); + payment = paymentsTotal.mul(RAY).div(paymentsRemaining).div(RAY); } /** @@ -226,6 +241,11 @@ contract Loan is ILoan { atState(ILoanLifeCycleState.Funded) returns (uint256) { + // First drawdown kicks off the payment schedule + if (paymentDueDate == 0) { + paymentDueDate = block.timestamp + (paymentPeriod * 1 days); + } + // Fixed term loans require the borrower to drawdown the full amount uint256 amount = IERC20(liquidityAsset).balanceOf( address(fundingVault) @@ -234,6 +254,33 @@ contract Loan is ILoan { return amount; } + function completeNextPayment() + public + onlyBorrower + atState(ILoanLifeCycleState.Funded) + returns (uint256) + { + require(paymentsRemaining > 0, "Loan: No more payments remain"); + LoanLib.completePayment(liquidityAsset, _pool, payment); + paymentsRemaining -= 1; + paymentDueDate += paymentPeriod * 1 days; + return payment; + } + + function completeFullPayment() + public + onlyBorrower + atState(ILoanLifeCycleState.Funded) + returns (uint256) + { + uint256 amount = payment.mul(paymentsRemaining).add(principal); + LoanLib.completePayment(liquidityAsset, _pool, amount); + paymentsRemaining = 0; + paymentDueDate += paymentPeriod * 1 days; + _state = ILoanLifeCycleState.Matured; + return amount; + } + /** * @inheritdoc ILoan */ diff --git a/contracts/interfaces/ILoan.sol b/contracts/interfaces/ILoan.sol index 3e863795..7f38d8af 100644 --- a/contracts/interfaces/ILoan.sol +++ b/contracts/interfaces/ILoan.sol @@ -75,6 +75,21 @@ interface ILoan { function cancelCollateralized() external returns (ILoanLifeCycleState); + /** + * @dev Number of payments remaining + */ + function paymentsRemaining() external returns (uint256); + + /** + * @dev Amount expected in each payment + */ + function payment() external returns (uint256); + + /** + * @dev Due date for the next payment + */ + function paymentDueDate() external returns (uint256); + function postFungibleCollateral(address asset, uint256 amount) external returns (ILoanLifeCycleState); diff --git a/contracts/libraries/LoanLib.sol b/contracts/libraries/LoanLib.sol index 71400e7a..4c8a897b 100644 --- a/contracts/libraries/LoanLib.sol +++ b/contracts/libraries/LoanLib.sol @@ -25,6 +25,11 @@ library LoanLib { */ event LoanDrawnDown(address asset, uint256 amount); + /** + * @dev Emitted when a loan payment is made. + */ + event LoanPaymentMade(address pool, address liquidityAsset, uint256 amount); + /** * @dev Emitted when collateral is posted to the loan. */ @@ -193,4 +198,16 @@ library LoanLib { fundingVault.withdraw(amount, receiver); emit LoanDrawnDown(address(fundingVault.asset()), amount); } + + /** + * Make a payment + */ + function completePayment( + address liquidityAsset, + address pool, + uint256 amount + ) public { + IERC20(liquidityAsset).safeTransferFrom(msg.sender, pool, amount); + emit LoanPaymentMade(pool, liquidityAsset, amount); + } } diff --git a/test/Loan.test.ts b/test/Loan.test.ts index be529627..fcc876ee 100644 --- a/test/Loan.test.ts +++ b/test/Loan.test.ts @@ -1,11 +1,12 @@ import { time, loadFixture } from "@nomicfoundation/hardhat-network-helpers"; import { expect } from "chai"; import { ethers } from "hardhat"; -import { DEFAULT_POOL_SETTINGS, activatePool } from "./support/pool"; +import { DEFAULT_POOL_SETTINGS } from "./support/pool"; import { deployMockERC20 } from "./support/erc20"; describe("Loan", () => { const SEVEN_DAYS = 6 * 60 * 60 * 24; + const THIRTY_DAYS = 30 * 60 * 60 * 24; async function deployFixture() { // Contracts are deployed using the first signer/account by default @@ -466,6 +467,10 @@ describe("Loan", () => { .to.emit(loan, "LoanDrawnDown") .withArgs(loan.liquidityAsset, 500_000); + const latestBlock = await ethers.provider.getBlock("latest"); + const now = latestBlock.timestamp; + expect(await loan.paymentDueDate()).to.equal(now + THIRTY_DAYS); + // Try again const drawDownTx2 = loan.connect(borrower).drawdown(); await expect(drawDownTx2).not.to.be.reverted; @@ -519,6 +524,130 @@ describe("Loan", () => { }); }); + describe("payments", () => { + it("calculates payments correctly", async () => { + const fixture = await loadFixture(deployFixture); + const { loan } = fixture; + + expect(await loan.paymentsRemaining()).to.equal(6); + expect(await loan.payment()).to.equal(2083); + expect(await loan.paymentDueDate()).to.equal(0); + }); + + it("can complete the next payment", async () => { + const fixture = await loadFixture(deployFixture); + const { + borrower, + collateralAsset, + liquidityAsset, + loan, + pool, + poolManager + } = fixture; + + // Setup + await collateralAsset.connect(borrower).approve(loan.address, 100); + await loan + .connect(borrower) + .postFungibleCollateral(collateralAsset.address, 100); + await pool.connect(poolManager).fundLoan(loan.address); + await loan.connect(borrower).drawdown(); + + // Make payment + const dueDate = await loan.paymentDueDate(); + expect(await loan.paymentsRemaining()).to.equal(6); + await liquidityAsset.connect(borrower).approve(loan.address, 2083); + const tx = loan.connect(borrower).completeNextPayment(); + await expect(tx).to.not.be.reverted; + await expect(tx).to.changeTokenBalance(liquidityAsset, borrower, -2083); + await expect(tx).to.changeTokenBalance(liquidityAsset, pool, +2083); + expect(await loan.paymentsRemaining()).to.equal(5); + const newDueDate = await loan.paymentDueDate(); + expect(newDueDate).to.equal(dueDate.add(THIRTY_DAYS)); + }); + + it("can payoff the entire loan at once", async () => { + const fixture = await loadFixture(deployFixture); + const { + borrower, + collateralAsset, + liquidityAsset, + loan, + pool, + poolManager + } = fixture; + + // Setup + await collateralAsset.connect(borrower).approve(loan.address, 100); + await loan + .connect(borrower) + .postFungibleCollateral(collateralAsset.address, 100); + await pool.connect(poolManager).fundLoan(loan.address); + await loan.connect(borrower).drawdown(); + + // Mint additional tokens to cover the interest payments + await liquidityAsset.mint(borrower.address, 12498); + + // Make payment + await liquidityAsset + .connect(borrower) + .approve(loan.address, 12498 + 500_000); + const tx = loan.connect(borrower).completeFullPayment(); + await expect(tx).to.not.be.reverted; + await expect(tx).to.changeTokenBalance( + liquidityAsset, + borrower, + -12498 - 500_000 + ); + await expect(tx).to.changeTokenBalance( + liquidityAsset, + pool, + 12498 + 500_000 + ); + + expect(await loan.paymentsRemaining()).to.equal(0); + expect(await loan.state()).to.equal(5); + }); + + it("can make payments and pay off the loan", async () => { + const fixture = await loadFixture(deployFixture); + const { + borrower, + collateralAsset, + liquidityAsset, + loan, + pool, + poolManager + } = fixture; + + // Setup + await collateralAsset.connect(borrower).approve(loan.address, 100); + await loan + .connect(borrower) + .postFungibleCollateral(collateralAsset.address, 100); + await pool.connect(poolManager).fundLoan(loan.address); + await loan.connect(borrower).drawdown(); + + // Mint additional tokens to cover the interest payments + await liquidityAsset.mint(borrower.address, 12498); + + // Make payment + await liquidityAsset + .connect(borrower) + .approve(loan.address, 12498 + 500_000); + await loan.connect(borrower).completeNextPayment(); + await loan.connect(borrower).completeNextPayment(); + await loan.connect(borrower).completeNextPayment(); + await loan.connect(borrower).completeNextPayment(); + await loan.connect(borrower).completeNextPayment(); + await loan.connect(borrower).completeNextPayment(); + expect(await loan.state()).to.equal(4); + await loan.connect(borrower).completeFullPayment(); + expect(await loan.paymentsRemaining()).to.equal(0); + expect(await loan.state()).to.equal(5); + }); + }); + const findEventByName = (receipt, name) => { return receipt.events?.find((event) => event.event == name); };