Skip to content

Commit

Permalink
Add loan interest payments (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
bricestacey authored Oct 14, 2022
1 parent 42e1cde commit 5a9fb15
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 1 deletion.
47 changes: 47 additions & 0 deletions contracts/Loan.sol
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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_`
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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)
Expand All @@ -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
*/
Expand Down
15 changes: 15 additions & 0 deletions contracts/interfaces/ILoan.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions contracts/libraries/LoanLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
}
}
131 changes: 130 additions & 1 deletion test/Loan.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
};
Expand Down

0 comments on commit 5a9fb15

Please sign in to comment.