Skip to content

Commit

Permalink
Merging SimpleInterest and Vault from spikes/multi-vault to the devel…
Browse files Browse the repository at this point in the history
…opment branch (#96)

* Adding MultiVault Token, SimpleInterest calculator, and Batch based contract

* Adding MultiVault Token, SimpleInterest calculator, and Batch based contract

* Add variables into test

* Delete playground contracts

* Add Vault based on SimpleInterest

* Add Vault based on SimpleInterest

* Add Vault based on SimpleInterest

* Add Calc for Discounted to Principal.  Add Tests for SimpleInterestVault

* Adding redeem methods to SimpleInterestVault

* Fixed Daily Discount Calculation.  Add Tests for Daily Vault

* Seperate SimpleInterest and Vault from tests

* Fix lint warnings

* Add a TimeLockVault

* Fix lint error

* Simplified TimeLock for example purposes

* Restrict import to fix lint

* Restrict import to fix lint

* Update SimpleInterestVault to be a TimelockVault

* Update SimpleInterestVault to be a TimelockVault

* Refactor name to be numTimePeriodsElapsed

* Remove errant comment

* Fix Scaling in SimpleInterest

* Fix Scaling in SimpleInterest

* Add Frequency Type

* Add Frequency Type

* Renamed Frequencies to Tenor

* Change to use 365 Days for Interest Tests

* Change to use 365 Days for Interest Tests

* Change to use 365 Days for Interest Tests

* Calculate Deposit and Redeem Time periods from Tenor.  Cycle through the Vault Tenor

* Calculate Deposit and Redeem Time periods from Tenor.  Cycle through the Vault Tenor

* Change SimpleInterest to use uint256 for frequency

* Change SimpleInterest to use uint256 for frequency

* Add monthly tests to SimpleInterestTest

* Start to seperate Frequency from Vault Tenor

* Start to seperate Frequency from Vault Tenor

* Seperate Frequency from Vault Tenor

* Fix unused import linting

* Shorten names and cleanup SimpleInterest

* Add back in Logs

* Refactor names

* Add Interfaces for SimpleInterest and Vault

* Use Interfaces instead of contract in functions

* Update SimpleInterestVault to add a price

* Update SimpleInterestVault to add a price

* Refactor to move Price into SimpleInterest

* Refactor to move Price into SimpleInterest

* Change PAR to be 1

* Adding calcPrice to interface

* Vault uses SimpleInterest price now

* Fix linting - errant imports

* Use the discounted in Price calc

* Checking about removing the cycle concept

* Checking about removing the cycle concept

* Add check that early redemption results in financial penalty

* Remove Price - it isn't needed and only adds complexity

* Update SimpleInterest to leverage inverse property between discounted and principalFromDiscounted

* Change SimpleInterest Test cases to be scaled

* Add unscaled versions to test case

* Add Check to make sure Principal is scaled

* Inline with scaled calcuations to simplify interface

* Cleanup of method signatures

* Fix unused imports

* Update Vault Test to be follow SimpleInterest test pattern

* Fix Linting issue

* Minor update to comment
  • Loading branch information
lucasia authored Aug 23, 2024
1 parent e91b49f commit 117c8b9
Show file tree
Hide file tree
Showing 10 changed files with 779 additions and 0 deletions.
25 changes: 25 additions & 0 deletions packages/contracts/test/src/multi/Frequencies.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

library Frequencies {
error InvalidFrequency(Frequency frequency);

enum Frequency {
ANNUAL,
MONTHLY,
QUARTERLY,
DAYS_360,
DAYS_365
}

// Helper function to convert enum value to corresponding uint256 frequency
function toValue(Frequency frequency) external pure returns (uint256) {
if (frequency == Frequency.ANNUAL) return 1;
if (frequency == Frequency.MONTHLY) return 12;
if (frequency == Frequency.QUARTERLY) return 4;
if (frequency == Frequency.DAYS_360) return 360;
if (frequency == Frequency.DAYS_365) return 365;

revert InvalidFrequency(frequency);
}
}
31 changes: 31 additions & 0 deletions packages/contracts/test/src/multi/IERC4626Interest.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (interfaces/IERC4626.sol)

pragma solidity ^0.8.20;

import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol";
import { ISimpleInterest } from "./ISimpleInterest.s.sol";

/**
* @dev Extension to Interface Vault Standard
*/
interface IERC4626Interest is IERC4626, ISimpleInterest {
function convertToSharesAtPeriod(uint256 assets, uint256 numTimePeriodsElapsed)
external
view
returns (uint256 shares);

function convertToAssetsAtPeriod(uint256 shares, uint256 numTimePeriodsElapsed)
external
view
returns (uint256 assets);

// TODO - confirm if required on interface
function getCurrentTimePeriodsElapsed() external pure returns (uint256 currentTimePeriodsElapsed);

// TODO - confirm if required on interface
function setCurrentTimePeriodsElapsed(uint256 currentTimePeriodsElapsed) external;

// TODO - confirm if required on interface
function getTenor() external view returns (uint256 tenor);
}
39 changes: 39 additions & 0 deletions packages/contracts/test/src/multi/ISimpleInterest.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

/**
* @title Simple Interest Interface
* @dev This interface provides functions to calculate interest and principal amounts over time.
*
* @notice The `calcPrincipalFromDiscounted` and `calcDiscounted` functions are designed to be mathematical inverses of each other.
* This means that applying `calcPrincipalFromDiscounted` to the output of `calcDiscounted` will return the original principal amount.
*
* For example:
* ```
* uint256 originalPrincipal = 1000;
* uint256 discountedValue = calcDiscounted(originalPrincipal);
* uint256 recoveredPrincipal = calcPrincipalFromDiscounted(discountedValue);
* assert(recoveredPrincipal == originalPrincipal);
* ```
*
* This property ensures that no information is lost when discounting and then recovering the principal,
* making the system consistent and predictable.
*/
interface ISimpleInterest {
function calcInterest(uint256 principal, uint256 numTimePeriodsElapsed) external view returns (uint256 interest);

function calcDiscounted(uint256 principal, uint256 numTimePeriodsElapsed)
external
view
returns (uint256 discounted);

function calcPrincipalFromDiscounted(uint256 discounted, uint256 numTimePeriodsElapsed)
external
view
returns (uint256 principal);

function getFrequency() external view returns (uint256 frequency);

function getInterestInPercentage() external view returns (uint256 interestRateInPercentage);
}
88 changes: 88 additions & 0 deletions packages/contracts/test/src/multi/InterestTest.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { ISimpleInterest } from "./ISimpleInterest.s.sol";

import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";

import { Test } from "forge-std/Test.sol";
import { console2 } from "forge-std/console2.sol";

abstract contract InterestTest is Test {
uint256 public constant TOLERANCE = 500; // with 18 decimals, means allowed difference of 5E+16
uint256 public constant NUM_CYCLES_TO_TEST = 2; // number of cycles in test (e.g. 2 years, 24 months, 720 days)

uint256 public constant SCALE = 1 * 10 ** 18; // number of cycles in test (e.g. 2 years, 24 months, 720 days)

using Math for uint256;

function testInterestToMaxPeriods(uint256 principal, ISimpleInterest simpleInterest) internal {
uint256 maxNumPeriods = simpleInterest.getFrequency() * NUM_CYCLES_TO_TEST; // e.g. 2 years, 24 months, 720 days

// due to small fractional numbers, principal needs to be SCALED to calculate correctly
assertGe(principal, SCALE, "principal not in SCALE");

// check all periods for 24 months
for (uint256 numTimePeriods = 0; numTimePeriods <= maxNumPeriods; numTimePeriods++) {
testInterestAtPeriod(principal, simpleInterest, numTimePeriods);
}
}

function testInterestAtPeriod(uint256 principal, ISimpleInterest simpleInterest, uint256 numTimePeriods)
internal
virtual
{
console2.log("---------------------- simpleInterestTestHarness ----------------------");

// The `calcPrincipalFromDiscounted` and `calcDiscounted` functions are designed to be mathematical inverses of each other.
// This means that applying `calcPrincipalFromDiscounted` to the output of `calcDiscounted` will return the original principal amount.

uint256 discounted = simpleInterest.calcDiscounted(principal, numTimePeriods);
uint256 principalFromDiscounted = simpleInterest.calcPrincipalFromDiscounted(discounted, numTimePeriods);

assertApproxEqAbs(
principal,
principalFromDiscounted,
TOLERANCE,
assertMsg("principalFromDiscountW not inverse of principalInWei", simpleInterest, numTimePeriods)
);

// discountedFactor = principal - interest, therefore interest = principal - discountedFactor
assertApproxEqAbs(
principal - discounted,
simpleInterest.calcInterest(principal, numTimePeriods),
10, // even smaller tolerance here
assertMsg("calcInterest incorrect for ", simpleInterest, numTimePeriods)
);
}

function assertMsg(string memory prefix, ISimpleInterest simpleInterest, uint256 numTimePeriods)
internal
view
returns (string memory)
{
return string.concat(prefix, toString(simpleInterest), " timePeriod= ", vm.toString(numTimePeriods));
}

function toString(ISimpleInterest simpleInterest) internal view returns (string memory) {
return string.concat(
" ISimpleInterest [ ",
" IR = ",
vm.toString(simpleInterest.getInterestInPercentage()),
" Freq = ",
vm.toString(simpleInterest.getFrequency()),
" ] "
);
}

function transferAndAssert(IERC20 _token, address fromAddress, address toAddress, uint256 amount) internal {
uint256 beforeBalance = _token.balanceOf(toAddress);

vm.startPrank(fromAddress);
_token.transfer(toAddress, amount);
vm.stopPrank();

assertEq(beforeBalance + amount, _token.balanceOf(toAddress));
}
}
165 changes: 165 additions & 0 deletions packages/contracts/test/src/multi/SimpleInterest.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { Strings } from "@openzeppelin/contracts/utils/Strings.sol";

import { console2 } from "forge-std/console2.sol";
import { ISimpleInterest } from "./ISimpleInterest.s.sol";

/**
* https://en.wikipedia.org/wiki/Interest
*
* Simple interest is calculated only on the principal amount, or on that portion of the principal amount that remains.
* It excludes the effect of compounding. Simple interest can be applied over a time period other than a year, for example, every month.
*
* Simple interest is calculated according to the following formula: (IR * P * m) / f
* - IR is the simple annual interest rate
* - P is the Principal (aka initial amount)
* - m is the number of time periods elapsed
* - f is the frequency of applying interest (how many interest periods in a year)
*
*
* @notice The `calcPrincipalFromDiscounted` and `calcDiscounted` functions are designed to be mathematical inverses of each other.
* This means that applying `calcPrincipalFromDiscounted` to the output of `calcDiscounted` will return the original principal amount.
*
* For example:
* ```
* uint256 originalPrincipal = 1000;
* uint256 discountedValue = calcDiscounted(originalPrincipal);
* uint256 recoveredPrincipal = calcPrincipalFromDiscounted(discountedValue);
* assert(recoveredPrincipal == originalPrincipal);
* ```
*
* This property ensures that no information is lost when discounting and then recovering the principal,
* making the system consistent and predictable.
*
*/
contract SimpleInterest is ISimpleInterest {
using Math for uint256;

uint256 public immutable INTEREST_RATE_PERCENTAGE;
uint256 public immutable FREQUENCY;

uint256 public constant DECIMALS = 18;
uint256 public constant SCALE = 10 ** DECIMALS;

uint256 public immutable PAR = 1;

Math.Rounding public constant ROUNDING = Math.Rounding.Floor;

error PrincipalLessThanScale(uint256 principal, uint256 scale);

constructor(uint256 interestRatePercentage, uint256 frequency) {
INTEREST_RATE_PERCENTAGE = interestRatePercentage;
FREQUENCY = frequency;
}

function calcInterest(uint256 principal, uint256 numTimePeriodsElapsed)
public
view
virtual
returns (uint256 interest)
{
if (principal < SCALE) {
revert PrincipalLessThanScale(principal, SCALE);
}

uint256 interestScaled =
principal.mulDiv(INTEREST_RATE_PERCENTAGE * numTimePeriodsElapsed * SCALE, FREQUENCY * 100, ROUNDING);

console2.log(
string.concat(
"Interest = (IR * P * m) / f = ",
Strings.toString(INTEREST_RATE_PERCENTAGE),
"% * ",
Strings.toString(principal),
" * ",
Strings.toString(numTimePeriodsElapsed),
" / ",
Strings.toString(FREQUENCY),
" = ",
Strings.toString(interestScaled)
)
);

return unscale(interestScaled);
}

function _calcInterestWithScale(uint256 principal, uint256 numTimePeriodsElapsed)
internal
view
returns (uint256 _interestScaled)
{
uint256 interestScaled =
principal.mulDiv(INTEREST_RATE_PERCENTAGE * numTimePeriodsElapsed * SCALE, FREQUENCY * 100, ROUNDING);

console2.log(
string.concat(
"Interest = (IR * P * m) / f = ",
Strings.toString(INTEREST_RATE_PERCENTAGE),
"% * ",
Strings.toString(principal),
" * ",
Strings.toString(numTimePeriodsElapsed),
" / ",
Strings.toString(FREQUENCY),
" = ",
Strings.toString(interestScaled)
)
);

return interestScaled;
}

function calcDiscounted(uint256 principal, uint256 numTimePeriodsElapsed) public view returns (uint256) {
if (principal < SCALE) {
revert PrincipalLessThanScale(principal, SCALE);
}

uint256 discountedScaled = principal * SCALE - _calcInterestWithScale(principal, numTimePeriodsElapsed);

return unscale(discountedScaled);
}

function calcPrincipalFromDiscounted(uint256 discounted, uint256 numTimePeriodsElapsed)
public
view
virtual
returns (uint256)
{
uint256 interestFactor =
INTEREST_RATE_PERCENTAGE.mulDiv(numTimePeriodsElapsed * SCALE, FREQUENCY * 100, ROUNDING);

uint256 scaledPrincipal = discounted.mulDiv(SCALE * SCALE, SCALE - interestFactor, ROUNDING);

console2.log(
string.concat(
"Principal = Discounted / (1 - ((IR * m) / f)) = ",
Strings.toString(discounted),
" / (1 - ((",
Strings.toString(INTEREST_RATE_PERCENTAGE),
" * ",
Strings.toString(numTimePeriodsElapsed),
" ) / ",
Strings.toString(FREQUENCY),
" = ",
Strings.toString(scaledPrincipal)
)
);

return unscale(scaledPrincipal);
}

function unscale(uint256 amount) internal pure returns (uint256) {
return amount / SCALE;
}

function getFrequency() public view returns (uint256 frequency) {
return FREQUENCY;
}

function getInterestInPercentage() public view returns (uint256 interestRateInPercentage) {
return INTEREST_RATE_PERCENTAGE;
}
}
44 changes: 44 additions & 0 deletions packages/contracts/test/src/multi/SimpleInterestTest.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { SimpleInterest } from "./SimpleInterest.s.sol";
import { Frequencies } from "./Frequencies.s.sol";

import { ISimpleInterest } from "./ISimpleInterest.s.sol";
import { InterestTest } from "./InterestTest.t.sol";

contract SimpleInterestTest is InterestTest {
using Math for uint256;

function test__SimpleInterestTest__CheckScale() public {
uint256 apy = 10; // APY in percentage

ISimpleInterest simpleInterest = new SimpleInterest(apy, Frequencies.toValue(Frequencies.Frequency.DAYS_360));

uint256 scaleMinus1 = SCALE - 1;

// expect revert when principal not scaled
vm.expectRevert();
simpleInterest.calcInterest(scaleMinus1, 0);

vm.expectRevert();
simpleInterest.calcDiscounted(scaleMinus1, 0);
}

function test__SimpleInterestTest__Monthly() public {
uint256 apy = 12; // APY in percentage

ISimpleInterest simpleInterest = new SimpleInterest(apy, Frequencies.toValue(Frequencies.Frequency.MONTHLY));

testInterestToMaxPeriods(200 * SCALE, simpleInterest);
}

function test__SimpleInterestTest__Daily360() public {
uint256 apy = 10; // APY in percentage

ISimpleInterest simpleInterest = new SimpleInterest(apy, Frequencies.toValue(Frequencies.Frequency.DAYS_360));

testInterestToMaxPeriods(200 * SCALE, simpleInterest);
}
}
Loading

0 comments on commit 117c8b9

Please sign in to comment.