Skip to content

Commit

Permalink
VAL-143 Snapshot the pool on all loan payments (#186)
Browse files Browse the repository at this point in the history
* Add failing test showing that lender accumulated interest on eligible balance

* Implement fix. Tweak test to be more precise

* Update implemention and fix scenario tests where we weren't advancing to the proper withdraw period (as per the scenario spec)

* Move Loan / snapshots tests to dedicated section

* Update test comment
  • Loading branch information
ams9198 authored Feb 17, 2023
1 parent a8f157b commit cc1ef9d
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 20 deletions.
3 changes: 2 additions & 1 deletion contracts/Loan.sol
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ contract Loan is ILoan, BeaconImplementation {
atState(ILoanLifeCycleState.Active)
{
require(paymentsRemaining > 0, "Loan: No more payments remain");

IPool(_pool).onLoanWillMakePayment();
ILoanFees memory _fees = LoanLib.previewFees(
settings,
payment,
Expand Down Expand Up @@ -456,6 +456,7 @@ contract Loan is ILoan, BeaconImplementation {
onlyBorrower
atState(ILoanLifeCycleState.Active)
{
IPool(_pool).onLoanWillMakePayment();
uint256 scalingValue = LoanLib.RAY;

if (settings.loanType == ILoanType.Open) {
Expand Down
8 changes: 8 additions & 0 deletions contracts/Pool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ contract Pool is IPool, ERC20Upgradeable, BeaconImplementation {
_accountings.totalFirstLossApplied += firstLossApplied;
}

/**
* @inheritdoc IPool
*/
function onLoanWillMakePayment() external override {
require(_activeLoans.contains(msg.sender), "Pool: caller not loan");
_performSnapshot();
}

/**
* @inheritdoc IPool
*/
Expand Down
5 changes: 5 additions & 0 deletions contracts/interfaces/IPool.sol
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ interface IPool is IERC4626, IRequestWithdrawable {
*/
function onLoanDefaulted(address loan, uint256 firstLossApplied) external;

/**
* @dev Called by an active loan, this notifies the Pool that payment will be made.
*/
function onLoanWillMakePayment() external;

/**
* @dev Called by the Pool Controller, it transfers the fixed fee
*/
Expand Down
95 changes: 89 additions & 6 deletions test/Loan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ describe("Loan", () => {
await getCommonSigners();

// Create a pool
const { pool, poolController, liquidityAsset, serviceConfiguration } =
await deployPool({
poolAdmin: poolAdmin,
settings: poolSettings,
pauser
});
const {
pool,
poolController,
withdrawController,
liquidityAsset,
serviceConfiguration
} = await deployPool({
poolAdmin: poolAdmin,
settings: poolSettings,
pauser
});

await activatePool(pool, poolAdmin, liquidityAsset);

Expand Down Expand Up @@ -107,6 +112,7 @@ describe("Loan", () => {
pool,
deployer,
poolController,
withdrawController,
loan,
loanLib,
loanFactory,
Expand Down Expand Up @@ -1200,6 +1206,83 @@ describe("Loan", () => {
});
});

describe("PoolSnapshots", () => {
it("triggers a snapshot of the pool when completing the next payment", async () => {
const {
borrower,
collateralAsset,
liquidityAsset,
loan,
pool,
poolController,
withdrawController,
poolAdmin
} = await loadFixture(deployFixture);

// 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());

// Advance to 2nd withdraw period
expect(await withdrawController.withdrawPeriod()).to.equal(0);
await time.increase(
(
await pool.settings()
).withdrawRequestPeriodDuration
);
expect(await withdrawController.withdrawPeriod()).to.equal(1);

await liquidityAsset.connect(borrower).approve(loan.address, 2083);
await expect(loan.connect(borrower).completeNextPayment()).to.emit(
pool,
"PoolSnapshotted"
);
});

it("triggers a snapshot of the pool when completing the full payment", async () => {
const {
borrower,
collateralAsset,
liquidityAsset,
loan,
pool,
poolController,
withdrawController,
poolAdmin
} = await loadFixture(deployFixture);

// 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());

// Advance to 2nd withdraw period
expect(await withdrawController.withdrawPeriod()).to.equal(0);
await time.increase(
(
await pool.settings()
).withdrawRequestPeriodDuration
);
expect(await withdrawController.withdrawPeriod()).to.equal(1);

await liquidityAsset
.connect(borrower)
.approve(loan.address, 12498 + 500_000);
await liquidityAsset.mint(borrower.address, 12498);
await expect(loan.connect(borrower).completeFullPayment()).to.emit(
pool,
"PoolSnapshotted"
);
});
});

describe("payments", () => {
it("reverts if the protocol is paused", async () => {
const {
Expand Down
26 changes: 17 additions & 9 deletions test/scenarios/business/1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@ describe("Business Scenario 1", () => {
"MUSDC",
6
);
const { pool, serviceConfiguration, poolController } = await deployPool({
poolAdmin: poolAdmin,
settings: poolSettings,
liquidityAsset: mockUSDC
});
const { pool, serviceConfiguration, poolController, withdrawController } =
await deployPool({
poolAdmin: poolAdmin,
settings: poolSettings,
liquidityAsset: mockUSDC
});

// Confirm FL fee is set to 5%
expect(await serviceConfiguration.firstLossFeeBps()).to.equal(500);
Expand Down Expand Up @@ -115,6 +116,7 @@ describe("Business Scenario 1", () => {
startTime,
pool,
poolController,
withdrawController,
lenderA,
lenderB,
mockUSDC,
Expand All @@ -131,6 +133,7 @@ describe("Business Scenario 1", () => {
startTime,
pool,
poolController,
withdrawController,
lenderA,
lenderB,
mockUSDC,
Expand Down Expand Up @@ -191,11 +194,15 @@ describe("Business Scenario 1", () => {
);
await loanOne.connect(borrowerOne).completeFullPayment();

// +14 days, request full withdrawal at start of 2nd window
await advanceToDay(startTime, 14);
// +15 days, request full withdrawal at start of 2nd window
expect(await withdrawController.withdrawPeriod()).to.equal(0);
await advanceToDay(startTime, 15);
expect(await withdrawController.withdrawPeriod()).to.equal(1);

await pool
.connect(lenderA)
.requestRedeem(await pool.maxRedeemRequest(lenderA.address));

await pool
.connect(lenderB)
.requestRedeem(await pool.maxRedeemRequest(lenderB.address));
Expand All @@ -208,10 +215,11 @@ describe("Business Scenario 1", () => {
loanTwo.address,
INPUTS.loanTwoPayment + INPUTS.loanTwo.principal
);

await loanTwo.connect(borrowerTwo).completeFullPayment();

// Request window is 14 days, so fast forward to +28 days to claim in next window
await advanceToDay(startTime, 28);
// Request window is 14 days, so fast forward to +29 days to claim in next window
await advanceToDay(startTime, 29);
await pool.snapshot();
await pool.connect(lenderA).claimSnapshots(10);
await pool.connect(lenderB).claimSnapshots(10);
Expand Down
8 changes: 4 additions & 4 deletions test/scenarios/business/permissioned/1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ describe("Permissioned Business Scenario 1", () => {
);
await loanOne.connect(borrowerOne).completeFullPayment();

// +14 days, request full withdrawal at start of 2nd window
await advanceToDay(startTime, 14);
// +15 days, request full withdrawal at start of 2nd window
await advanceToDay(startTime, 15);
await performVeriteVerification(poolAccessControl, poolAdmin, lenderA);
await pool
.connect(lenderA)
Expand All @@ -263,8 +263,8 @@ describe("Permissioned Business Scenario 1", () => {
);
await loanTwo.connect(borrowerTwo).completeFullPayment();

// Request window is 14 days, so fast forward to +28 days to claim in next window
await advanceToDay(startTime, 28);
// Request window is 14 days, so fast forward to +29 days to claim in next window
await advanceToDay(startTime, 29);
await performVeriteVerification(poolAccessControl, poolAdmin, lenderA);
await pool.connect(lenderA).snapshot();
await pool.connect(lenderA).claimSnapshots(1);
Expand Down
49 changes: 49 additions & 0 deletions test/scenarios/pool/snapshot-variations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,4 +627,53 @@ describe("Snapshot Variations", () => {
DEPOSIT_AMOUNT // 1:1, even though interest was paid back to the pool, making shares more valuable
);
});

it("eligible balances should not accumulate interest as active loans are repaid with no other pool activity", async () => {
const {
pool,
loan,
borrower,
aliceLender,
bobLender,
liquidityAsset,
poolAdmin,
withdrawController,
poolController,
withdrawRequestPeriodDuration
} = await loadFixture(loadPoolFixture);

await poolController.connect(poolAdmin).setWithdrawGate(10_000);
await poolController.connect(poolAdmin).setRequestFee(0);

// activate the pool
await activatePool(pool, poolAdmin, liquidityAsset);

// deposit 1M tokens from Alice and Bob
await depositToPool(pool, aliceLender, liquidityAsset, DEPOSIT_AMOUNT);
await depositToPool(pool, bobLender, liquidityAsset, DEPOSIT_AMOUNT);

// Request maximum in window 0 for Alice
expect(await withdrawController.withdrawPeriod()).to.equal(0);
await pool.connect(aliceLender).requestRedeem(DEPOSIT_AMOUNT);

// Fund loan immediately in window 0
await fundLoan(loan, poolController, poolAdmin);
await liquidityAsset.mint(borrower.address, 1_000_000); // mint extra to pay back interest
await liquidityAsset.connect(borrower).approve(loan.address, 2_000_000);
await loan.connect(borrower).drawdown(await loan.principal());

// Make payback loan in window 1
await time.increase(withdrawRequestPeriodDuration);
expect(await withdrawController.withdrawPeriod()).to.equal(1);
await loan.connect(borrower).completeFullPayment();

// Claim
// Since the lender requested a full redeem, and there's 100% liquidity gate,
// the full requested amount should be serviced at whenever the next snapshot is.
// We expect that the interest accrued in the intervening time should not accrue to the lender.
await pool.connect(aliceLender).claimSnapshots(1);
expect(await pool.maxWithdraw(aliceLender.address)).to.equal(
DEPOSIT_AMOUNT // 1:1, even though interest was paid back to the pool, making shares more valuable
);
});
});

0 comments on commit cc1ef9d

Please sign in to comment.