From 0f5ba5d7c19ab61dea1fa50e34a8f2eb968c3bc5 Mon Sep 17 00:00:00 2001 From: Oighty Date: Wed, 18 Sep 2024 16:38:30 -0500 Subject: [PATCH] test: yield repo and updated heart --- src/test/mocks/MockClearinghouse.sol | 30 + src/test/mocks/MockOhm.sol | 4 + src/test/mocks/MockYieldRepo.sol | 29 + src/test/policies/Heart.t.sol | 37 +- .../policies/YieldRepurchaseFacility.t.sol | 703 ++++++++++++++++++ src/test/sim/RangeSim.sol | 137 ++-- 6 files changed, 890 insertions(+), 50 deletions(-) create mode 100644 src/test/mocks/MockClearinghouse.sol create mode 100644 src/test/mocks/MockYieldRepo.sol create mode 100644 src/test/policies/YieldRepurchaseFacility.t.sol diff --git a/src/test/mocks/MockClearinghouse.sol b/src/test/mocks/MockClearinghouse.sol new file mode 100644 index 00000000..2c39a057 --- /dev/null +++ b/src/test/mocks/MockClearinghouse.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; + +// mock clearinghouse to use with testing the yieldRepo contract +// most functions / variables are omitted +contract MockClearinghouse { + ERC20 public reserve; + ERC4626 public wrappedReserve; + uint256 public principalReceivables; + + constructor(address _reserve, address _wrappedReserve) { + reserve = ERC20(_reserve); + wrappedReserve = ERC4626(_wrappedReserve); + } + + function setPrincipalReceivables(uint256 _principalReceivables) external { + principalReceivables = _principalReceivables; + } + + function withdrawReserve(uint256 _amount) external { + reserve.transfer(msg.sender, _amount); + } + + function withdrawWrappedReserve(uint256 _amount) external { + wrappedReserve.transfer(msg.sender, _amount); + } +} diff --git a/src/test/mocks/MockOhm.sol b/src/test/mocks/MockOhm.sol index edcc8de2..7dad1bc3 100644 --- a/src/test/mocks/MockOhm.sol +++ b/src/test/mocks/MockOhm.sol @@ -14,6 +14,10 @@ contract MockOhm is ERC20 { _mint(to, value); } + function burn(uint256 value) public virtual { + _burn(msg.sender, value); + } + function burnFrom(address from, uint256 value) public virtual { uint256 currentAllowance = allowance[from][msg.sender]; require(currentAllowance >= value, "ERC20: burn amount exceeds allowance"); diff --git a/src/test/mocks/MockYieldRepo.sol b/src/test/mocks/MockYieldRepo.sol new file mode 100644 index 00000000..c8951da2 --- /dev/null +++ b/src/test/mocks/MockYieldRepo.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {IYieldRepo} from "../../policies/interfaces/IYieldRepo.sol"; + +contract MockYieldRepo is IYieldRepo { + uint48 public epoch; + bool public isShutdown; + + function endEpoch() external override { + // do nothing + } + + function shutdown() external { + isShutdown = true; + } + + function getReserveBalance() external pure override returns (uint256) { + return 0; + } + + function getNextYield() external pure override returns (uint256) { + return 0; + } + + function getOhmBalanceAndBacking() external pure override returns (uint256, uint256) { + return (0, 0); + } +} diff --git a/src/test/policies/Heart.t.sol b/src/test/policies/Heart.t.sol index 29073bb5..f3ea5708 100644 --- a/src/test/policies/Heart.t.sol +++ b/src/test/policies/Heart.t.sol @@ -13,15 +13,17 @@ import {ROLESv1} from "modules/ROLES/ROLES.v1.sol"; import {RolesAdmin} from "policies/RolesAdmin.sol"; import {ZeroDistributor} from "policies/Distributor/ZeroDistributor.sol"; import {MockStakingZD} from "test/mocks/MockStakingForZD.sol"; +import {MockYieldRepo} from "test/mocks/MockYieldRepo.sol"; import {FullMath} from "libraries/FullMath.sol"; import "src/Kernel.sol"; -import {OlympusHeart} from "policies/Heart.sol"; +import {OlympusHeart, IHeart} from "policies/Heart.sol"; import {IOperator} from "policies/interfaces/IOperator.sol"; import {IDistributor} from "policies/interfaces/IDistributor.sol"; +import {IYieldRepo} from "policies/interfaces/IYieldRepo.sol"; /** * @notice Mock Operator to test Heart @@ -73,6 +75,8 @@ contract HeartTest is Test { MockStakingZD internal staking; ZeroDistributor internal distributor; + MockYieldRepo internal yieldRepo; + uint48 internal constant PRICE_FREQUENCY = uint48(8 hours); // MINTR @@ -121,11 +125,15 @@ contract HeartTest is Test { distributor = new ZeroDistributor(address(staking)); staking.setDistributor(address(distributor)); + // Deploy mock yieldRepo + yieldRepo = new MockYieldRepo(); + // Deploy heart heart = new OlympusHeart( kernel, IOperator(address(operator)), IDistributor(address(distributor)), + IYieldRepo(address(yieldRepo)), uint256(10e9), // max reward = 10 reward tokens uint48(12 * 50) // auction duration = 5 minutes (50 blocks on ETH mainnet) ); @@ -151,6 +159,9 @@ contract HeartTest is Test { // Heart ROLES rolesAdmin.grantRole("heart_admin", policy); } + + // Do initial beat + heart.beat(); } // ======== SETUP DEPENDENCIES ======= // @@ -169,6 +180,30 @@ contract HeartTest is Test { assertEq(fromKeycode(deps[2]), fromKeycode(expectedDeps[2])); } + function testRevert_configureDependencies_invalidFrequency() public { + // Deploy mock staking with different frequency + staking = new MockStakingZD(7 hours, 0, block.timestamp); + distributor = new ZeroDistributor(address(staking)); + staking.setDistributor(address(distributor)); + + // Deploy heart + heart = new OlympusHeart( + kernel, + IOperator(address(operator)), + IDistributor(address(distributor)), + IYieldRepo(address(yieldRepo)), + uint256(10e9), // max reward = 10 reward tokens + uint48(12 * 50) // auction duration = 5 minutes (50 blocks on ETH mainnet) + ); + + vm.startPrank(address(kernel)); + // Since the staking frequency is different, the call to configureDependencies reverts + bytes memory err = abi.encodeWithSelector(IHeart.Heart_InvalidFrequency.selector); + vm.expectRevert(err); + heart.configureDependencies(); + vm.stopPrank(); + } + function test_requestPermissions() public { Permissions[] memory expectedPerms = new Permissions[](3); diff --git a/src/test/policies/YieldRepurchaseFacility.t.sol b/src/test/policies/YieldRepurchaseFacility.t.sol new file mode 100644 index 00000000..218da8af --- /dev/null +++ b/src/test/policies/YieldRepurchaseFacility.t.sol @@ -0,0 +1,703 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {UserFactory} from "test/lib/UserFactory.sol"; + +import {BondFixedTermSDA} from "test/lib/bonds/BondFixedTermSDA.sol"; +import {BondAggregator} from "test/lib/bonds/BondAggregator.sol"; +import {BondFixedTermTeller} from "test/lib/bonds/BondFixedTermTeller.sol"; +import {RolesAuthority, Authority as SolmateAuthority} from "solmate/auth/authorities/RolesAuthority.sol"; + +import {MockERC20, ERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC4626, ERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; +import {MockPrice} from "test/mocks/MockPrice.sol"; +import {MockOhm} from "test/mocks/MockOhm.sol"; +import {MockClearinghouse} from "test/mocks/MockClearinghouse.sol"; + +import {IBondSDA} from "interfaces/IBondSDA.sol"; +import {IBondAggregator} from "interfaces/IBondAggregator.sol"; + +import {FullMath} from "libraries/FullMath.sol"; + +import "src/Kernel.sol"; +import {OlympusRange} from "modules/RANGE/OlympusRange.sol"; +import {OlympusTreasury} from "modules/TRSRY/OlympusTreasury.sol"; +import {OlympusMinter} from "modules/MINTR/OlympusMinter.sol"; +import {OlympusRoles} from "modules/ROLES/OlympusRoles.sol"; +import {RolesAdmin} from "policies/RolesAdmin.sol"; +import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; +import {Operator} from "policies/Operator.sol"; +import {BondCallback} from "policies/BondCallback.sol"; + +// solhint-disable-next-line max-states-count +contract YieldRepurchaseFacilityTest is Test { + using FullMath for uint256; + + UserFactory public userCreator; + address internal alice; + address internal bob; + address internal guardian; + address internal policy; + address internal heart; + + RolesAuthority internal auth; + BondAggregator internal aggregator; + BondFixedTermTeller internal teller; + BondFixedTermSDA internal auctioneer; + MockOhm internal ohm; + MockERC20 internal reserve; + MockERC4626 internal wrappedReserve; + + Kernel internal kernel; + MockPrice internal PRICE; + OlympusRange internal RANGE; + OlympusTreasury internal TRSRY; + OlympusMinter internal MINTR; + OlympusRoles internal ROLES; + + MockClearinghouse internal clearinghouse; + YieldRepurchaseFacility internal yieldRepo; + RolesAdmin internal rolesAdmin; + BondCallback internal callback; // only used by operator, not by yieldRepo + Operator internal operator; + + uint256 initialReserves = 105_000_000e18; + uint256 initialConversionRate = 1_05e16; + uint256 initialPrincipalReceivables = 100_000_000e18; + uint256 initialYield = 50_000e18 + ((initialPrincipalReceivables * 5) / 1000) / 52; + + function setUp() public { + vm.warp(51 * 365 * 24 * 60 * 60); // Set timestamp at roughly Jan 1, 2021 (51 years since Unix epoch) + userCreator = new UserFactory(); + { + /// Deploy bond system to test against + address[] memory users = userCreator.create(5); + alice = users[0]; + bob = users[1]; + guardian = users[2]; + policy = users[3]; + heart = users[4]; + auth = new RolesAuthority(guardian, SolmateAuthority(address(0))); + + /// Deploy the bond system + aggregator = new BondAggregator(guardian, auth); + teller = new BondFixedTermTeller(guardian, aggregator, guardian, auth); + auctioneer = new BondFixedTermSDA(teller, aggregator, guardian, auth); + + /// Register auctioneer on the bond system + vm.prank(guardian); + aggregator.registerAuctioneer(auctioneer); + } + + { + /// Deploy mock tokens + ohm = new MockOhm("Olympus", "OHM", 9); + reserve = new MockERC20("Reserve", "RSV", 18); + wrappedReserve = new MockERC4626(reserve, "wrappedReserve", "sRSV"); + } + + { + /// Deploy kernel + kernel = new Kernel(); // this contract will be the executor + + /// Deploy modules (some mocks) + PRICE = new MockPrice(kernel, uint48(8 hours), 10 * 1e18); + RANGE = new OlympusRange( + kernel, + ERC20(ohm), + ERC20(reserve), + uint256(100), + [uint256(2000), uint256(2500)], + [uint256(2000), uint256(2500)] + ); + TRSRY = new OlympusTreasury(kernel); + MINTR = new OlympusMinter(kernel, address(ohm)); + ROLES = new OlympusRoles(kernel); + + /// Configure mocks + PRICE.setMovingAverage(10 * 1e18); + PRICE.setLastPrice(10 * 1e18); + PRICE.setDecimals(18); + PRICE.setLastTime(uint48(block.timestamp)); + } + + { + // Deploy mock clearinghouse + clearinghouse = new MockClearinghouse(address(reserve), address(wrappedReserve)); + + /// Deploy bond callback + callback = new BondCallback(kernel, IBondAggregator(address(aggregator)), ohm); + + /// Deploy operator + operator = new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve)], + [ + uint32(2000), // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + uint32(1000), // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); + + /// Deploy protocol loop + yieldRepo = new YieldRepurchaseFacility( + kernel, + address(ohm), + address(reserve), + address(wrappedReserve), + address(teller), + address(auctioneer), + address(clearinghouse) + ); + + /// Deploy ROLES administrator + rolesAdmin = new RolesAdmin(kernel); + } + + { + /// Initialize system and kernel + + /// Install modules + kernel.executeAction(Actions.InstallModule, address(PRICE)); + kernel.executeAction(Actions.InstallModule, address(RANGE)); + kernel.executeAction(Actions.InstallModule, address(TRSRY)); + kernel.executeAction(Actions.InstallModule, address(MINTR)); + kernel.executeAction(Actions.InstallModule, address(ROLES)); + + /// Approve policies + kernel.executeAction(Actions.ActivatePolicy, address(yieldRepo)); + kernel.executeAction(Actions.ActivatePolicy, address(callback)); + kernel.executeAction(Actions.ActivatePolicy, address(operator)); + kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + } + { + /// Configure access control + + /// YieldRepurchaseFacility ROLES + rolesAdmin.grantRole("heart", address(heart)); + rolesAdmin.grantRole("loop_daddy", guardian); + + /// Operator ROLES + rolesAdmin.grantRole("operator_admin", address(guardian)); + } + + // Mint tokens to users, clearinghouse, and TRSRY for testing + uint256 testOhm = 1_000_000 * 1e9; + uint256 testReserve = 1_000_000 * 1e18; + + ohm.mint(alice, testOhm * 20); + + reserve.mint(address(TRSRY), testReserve * 80); + reserve.mint(address(clearinghouse), testReserve * 20); + + // Deposit TRSRY reserves into wrappedReserve + vm.startPrank(address(TRSRY)); + reserve.approve(address(wrappedReserve), testReserve * 80); + wrappedReserve.deposit(testReserve * 80, address(TRSRY)); + vm.stopPrank(); + + // Deposit clearinghouse reserves into wrappedReserve + vm.startPrank(address(clearinghouse)); + reserve.approve(address(wrappedReserve), testReserve * 20); + wrappedReserve.deposit(testReserve * 20, address(clearinghouse)); + vm.stopPrank(); + + // Mint additional reserve to the wrapped reserve to hit the initial conversion rate + reserve.mint(address(wrappedReserve), 5 * testReserve); + + // Approve the bond teller for the tokens to swap + vm.prank(alice); + ohm.approve(address(teller), testOhm * 20); + + // Initialise the operator so that the range prices are set + vm.prank(guardian); + operator.initialize(); + + // Set principal receivables for the clearinghouse + clearinghouse.setPrincipalReceivables(uint256(100_000_000e18)); + + // Initialize the yield repo facility + vm.prank(guardian); + yieldRepo.initialize(initialReserves, initialConversionRate, initialYield); + } + + function _mintYield() internal { + // Get the balance of reserves in the wrappedReserve contract + uint256 wrappedReserveBalance = wrappedReserve.totalAssets(); + + // Calculate the yield to mint (0.01%) + uint256 yield = wrappedReserveBalance / 10000; + + // Mint the yield + reserve.mint(address(wrappedReserve), yield); + } + + // test cases + // [X] setup (contructor + configureDependencies) + // [X] addresses are set correctly + // [X] initial reserve balance is set correctly + // [X] initial conversion rate is set correctly + // [X] initial yield is set correctly + // [X] epoch is set correctly + // [X] endEpoch + // [X] when contract is shutdown + // [X] nothing happens + // [X] when contract is not shutdown + // [X] when epoch is not divisible by 3 + // [X] nothing happens + // [X] when epoch is divisible by 3 + // [X] when epoch == epochLength + // [X] The yield earned on the wrapped reserves over the past 21 epochs is withdrawn from the TRSRY (affecting the balanceInDai and bidAmount) + // [X] OHM in the contract is burned and reserves are added at the backing rate + // [X] a new bond market is created with correct bid amount + // [X] when epoch != epochLength + // [X] OHM in the contract is burned and reserves are added at the backing rate + // [X] a new bond market is created with correct bid amount + // [X] adjustNextYield + // [X] shutdown + // [X] getNextYield + // [X] getReserveBalance + + function test_setup() public { + // addresses are set correctly + assertEq(address(yieldRepo.ohm()), address(ohm)); + assertEq(address(yieldRepo.dai()), address(reserve)); + assertEq(address(yieldRepo.sdai()), address(wrappedReserve)); + assertEq(address(yieldRepo.teller()), address(teller)); + assertEq(address(yieldRepo.auctioneer()), address(auctioneer)); + + // initial reserve balance is set correctly + assertEq(yieldRepo.lastReserveBalance(), initialReserves); + assertEq(yieldRepo.getReserveBalance(), initialReserves); + + // initial conversion rate is set correctly + assertEq(yieldRepo.lastConversionRate(), initialConversionRate); + assertEq((wrappedReserve.totalAssets() * 1e18) / wrappedReserve.totalSupply(), 1_05e16); + + // initial yield is set correctly + assertEq(yieldRepo.nextYield(), initialYield); + + // epoch is set correctly + assertEq(yieldRepo.epoch(), 20); + } + + function test_endEpoch_firstCall() public { + // Mint yield to the wrappedReserve + _mintYield(); + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Cache the TRSRY sDAI balance + uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + + vm.prank(heart); + yieldRepo.endEpoch(); + + // Check that the initial yield was withdrawn from the TRSRY + assertEq( + wrappedReserve.balanceOf(address(TRSRY)), + trsryBalance - wrappedReserve.previewWithdraw(initialYield) + ); + + // Check that the yieldRepo contract has the correct reserve balance + assertEq(reserve.balanceOf(address(yieldRepo)), initialYield / 7); + assertEq( + wrappedReserve.balanceOf(address(yieldRepo)), + wrappedReserve.previewDeposit(initialYield - initialYield / 7) + ); + + // Check that the bond market was created + assertEq(aggregator.marketCounter(), nextBondMarketId + 1); + + // Check that the market params are correct + { + uint256 marketPrice = auctioneer.marketPrice(nextBondMarketId); + ( + address owner, + ERC20 payoutToken, + ERC20 quoteToken, + address callbackAddr, + bool isCapacityInQuote, + uint256 capacity, + , + uint256 minPrice, + uint256 maxPayout, + , + , + uint256 scale + ) = auctioneer.markets(nextBondMarketId); + + assertEq(owner, address(yieldRepo)); + assertEq(address(payoutToken), address(reserve)); + assertEq(address(quoteToken), address(ohm)); + assertEq(callbackAddr, address(0)); + assertEq(isCapacityInQuote, false); + assertEq(capacity, uint256(initialYield) / 7); + assertEq(maxPayout, capacity / 6); + + assertEq(scale, 10 ** uint8(36 + 18 - 9 + 0)); + assertEq( + marketPrice, + ((uint256(1e36) / 10e18) * 10 ** uint8(36 + 1)) / 10 ** uint8(18 + 1) + ); + assertEq( + minPrice, + (((uint256(1e36) / ((10e18 * 120e16) / 1e18))) * 10 ** uint8(36 + 1)) / + 10 ** uint8(18 + 1) + ); + } + + // Check that the epoch has been incremented + assertEq(yieldRepo.epoch(), 0); + } + + function test_endEpoch_isShutdown() public { + // Shutdown the yieldRepo contract + vm.prank(guardian); + yieldRepo.shutdown(new ERC20[](0)); + + // Mint yield to the wrappedReserve + _mintYield(); + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Cache the TRSRY sDAI balance + uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + + vm.prank(heart); + yieldRepo.endEpoch(); + + // Check that the initial yield was not withdrawn from the treasury + assertEq(wrappedReserve.balanceOf(address(TRSRY)), trsryBalance); + + // Check that the yieldRepo contract has not received any funds + assertEq(reserve.balanceOf(address(yieldRepo)), 0); + assertEq(wrappedReserve.balanceOf(address(yieldRepo)), 0); + + // Check that the bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + } + + function test_endEpoch_notDivisBy3() public { + // Mint yield to the wrappedReserve + _mintYield(); + + // Make the initial call to get the epoch counter to reset + vm.prank(heart); + yieldRepo.endEpoch(); + + // Mint yield to the wrappedReserve + _mintYield(); + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Cache the TRSRY sDAI balance + uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + + // Cache the yieldRepo contract reserve balance + uint256 yieldRepoReserveBalance = reserve.balanceOf(address(yieldRepo)); + uint256 yieldRepoWrappedReserveBalance = wrappedReserve.balanceOf(address(yieldRepo)); + + // Call end epoch again + vm.prank(heart); + yieldRepo.endEpoch(); + + // Check that a new bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + + // Check that the treasury balance has not changed + assertEq(wrappedReserve.balanceOf(address(TRSRY)), trsryBalance); + + // Check that the yieldRepo contract reserve balance has not changed + assertEq(reserve.balanceOf(address(yieldRepo)), yieldRepoReserveBalance); + assertEq(wrappedReserve.balanceOf(address(yieldRepo)), yieldRepoWrappedReserveBalance); + + // Check that the epoch has been incremented + assertEq(yieldRepo.epoch(), 1); + } + + function test_endEpoch_divisBy3_notEpochLength() public { + // Mint yield to the wrappedReserve + _mintYield(); + + // Make the initial call to get the epoch counter to reset + vm.prank(heart); + yieldRepo.endEpoch(); + + // Call end epoch twice to setup our test + vm.prank(heart); + yieldRepo.endEpoch(); + vm.prank(heart); + yieldRepo.endEpoch(); + + // Confirm that the epoch is 2 + assertEq(yieldRepo.epoch(), 2); + + // Cache the yieldRepo contract reserve balance before any bonds are issued + uint256 yieldRepoReserveBalance = reserve.balanceOf(address(yieldRepo)); + uint256 yieldRepoWrappedReserveBalance = wrappedReserve.balanceOf(address(yieldRepo)); + + // Purchase a bond from the existing bond market + // So that there is some OHM in the contract to burn + vm.prank(alice); + (uint256 bondPayout, ) = teller.purchase(alice, address(0), 0, 100e9, 0); + + // Confirm that the yieldRepo balance is updated with the bond payout + assertEq(reserve.balanceOf(address(yieldRepo)), yieldRepoReserveBalance - bondPayout); + yieldRepoReserveBalance -= bondPayout; + + // Warp forward a day so that the initial bond market ends + vm.warp(block.timestamp + 1 days); + + // Mint yield to the wrappedReserve + _mintYield(); + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Cache the TRSRY sDAI balance + uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + + // Cache the OHM balance in the yieldRepo contract + uint256 yieldRepoOhmBalance = ohm.balanceOf(address(yieldRepo)); + assertEq(yieldRepoOhmBalance, 100e9); + + // Call end epoch again + vm.prank(heart); + yieldRepo.endEpoch(); + + // Check that a new bond market was created + assertEq(aggregator.marketCounter(), nextBondMarketId + 1); + + // Check that the yieldRepo contract burned the OHM + assertEq(ohm.balanceOf(address(yieldRepo)), 0); + + // Check that the treasury balance has changed by the amount of backing withdrawn for the burnt OHM + uint256 daiFromBurnedOhm = 100e9 * yieldRepo.backingPerToken(); + assertEq( + wrappedReserve.balanceOf(address(TRSRY)), + trsryBalance - wrappedReserve.previewWithdraw(daiFromBurnedOhm) + ); + + // Check that the balance of the yieldRepo contract has changed correctly + uint256 expectedBidAmount = (yieldRepoReserveBalance + + wrappedReserve.previewRedeem(yieldRepoWrappedReserveBalance) + + daiFromBurnedOhm) / 6; + + // Check that the yieldRepo contract reserve balances have changed correctly + assertEq(reserve.balanceOf(address(yieldRepo)), expectedBidAmount); + assertGe( + wrappedReserve.balanceOf(address(yieldRepo)), + yieldRepoWrappedReserveBalance - wrappedReserve.previewWithdraw(expectedBidAmount) + ); + + // Confirm that the bond market has the correct configuration + { + uint256 marketPrice = auctioneer.marketPrice(nextBondMarketId); + ( + , + , + , + , + , + uint256 capacity, + , + uint256 minPrice, + uint256 maxPayout, + , + , + uint256 scale + ) = auctioneer.markets(nextBondMarketId); + + assertEq(capacity, expectedBidAmount); + assertEq(maxPayout, capacity / 6); + + assertEq(scale, 10 ** uint8(36 + 18 - 9 + 0)); + assertEq( + marketPrice, + ((uint256(1e36) / 10e18) * 10 ** uint8(36 + 1)) / 10 ** uint8(18 + 1) + ); + assertEq( + minPrice, + (((uint256(1e36) / ((10e18 * 120e16) / 1e18))) * 10 ** uint8(36 + 1)) / + 10 ** uint8(18 + 1) + ); + } + } + + // error ROLES_RequireRole(bytes32 role_); + + function test_adjustNextYield() public { + // Mint yield to the wrappedReserve + _mintYield(); + + // Call endEpoch to set the next yield + vm.prank(heart); + yieldRepo.endEpoch(); + + // Get the next yield value + uint256 nextYield = yieldRepo.nextYield(); + + // Try to call adjustNextYield with an invalid caller + // Expect it to fail + vm.expectRevert( + abi.encodeWithSignature("ROLES_RequireRole(bytes32)", bytes32("loop_daddy")) + ); + vm.prank(alice); + yieldRepo.adjustNextYield(nextYield); + + // Call adjustNextYield with a value that is too high + // Expect it to fail + uint256 newNextYield = (nextYield * 12) / 10; + + vm.expectRevert(abi.encodePacked("Too much increase")); + vm.prank(guardian); + yieldRepo.adjustNextYield(newNextYield); + + // Call adjustNextYield with a value greater than the current yield but only by 10% + // Expect it to succeed + newNextYield = (nextYield * 11) / 10; + vm.prank(guardian); + yieldRepo.adjustNextYield(newNextYield); + + // Check that the next yield has been adjusted + assertEq(yieldRepo.nextYield(), newNextYield); + + // Call adjustNextYield with a value that is lower than the current yield + // Expect it to succeed + newNextYield = (newNextYield * 9) / 10; + vm.prank(guardian); + yieldRepo.adjustNextYield(newNextYield); + + // Check that the next yield has been adjusted + assertEq(yieldRepo.nextYield(), newNextYield); + + // Call adjustNextYield with a value of zero next yield + // Expect it to succeed + vm.prank(guardian); + yieldRepo.adjustNextYield(0); + + // Check that the next yield has been adjusted + assertEq(yieldRepo.nextYield(), 0); + } + + function test_shutdown() public { + // Try to call shutdown as an invalid caller + // Expect it to fail + vm.expectRevert( + abi.encodeWithSignature("ROLES_RequireRole(bytes32)", bytes32("loop_daddy")) + ); + vm.prank(alice); + yieldRepo.shutdown(new ERC20[](0)); + + // Mint yield + _mintYield(); + + // Call endEpoch initially to get tokens into the contract + vm.prank(heart); + yieldRepo.endEpoch(); + + // Cache the yieldRepo contract reserve balances + uint256 yieldRepoReserveBalance = reserve.balanceOf(address(yieldRepo)); + uint256 yieldRepoWrappedReserveBalance = wrappedReserve.balanceOf(address(yieldRepo)); + + // Cache the treasury balances of the reserve tokens + uint256 trsryReserveBalance = reserve.balanceOf(address(TRSRY)); + uint256 trsryWrappedReserveBalance = wrappedReserve.balanceOf(address(TRSRY)); + + // Setup array of tokens to extract + ERC20[] memory tokens = new ERC20[](2); + tokens[0] = reserve; + tokens[1] = wrappedReserve; + + // Call shutdown with an invalid caller + // Expect it to fail + vm.expectRevert( + abi.encodeWithSignature("ROLES_RequireRole(bytes32)", bytes32("loop_daddy")) + ); + vm.prank(bob); + yieldRepo.shutdown(tokens); + + // Call shutdown with a valid caller + // Expect it to succeed + vm.prank(guardian); + yieldRepo.shutdown(tokens); + + // Check that the contract is shutdown + assertEq(yieldRepo.isShutdown(), true); + + // Check that the yieldRepo contract reserve balances have been transferred to the TRSRY + assertEq(reserve.balanceOf(address(yieldRepo)), 0); + assertEq(wrappedReserve.balanceOf(address(yieldRepo)), 0); + assertEq(reserve.balanceOf(address(TRSRY)), trsryReserveBalance + yieldRepoReserveBalance); + assertEq( + wrappedReserve.balanceOf(address(TRSRY)), + trsryWrappedReserveBalance + yieldRepoWrappedReserveBalance + ); + } + + function test_getReserveBalance() public { + // Mint yield + _mintYield(); + + // Call endEpoch initially to get tokens into the contract + vm.prank(heart); + yieldRepo.endEpoch(); + + // Cache yield earning balances in the clearinghouse and treasury + uint256 clearinghouseWrappedReserveBalance = wrappedReserve.balanceOf( + address(clearinghouse) + ); + uint256 trsryWrappedReserveBalance = wrappedReserve.balanceOf(address(TRSRY)); + + // Calculate the expected yield earning reserve balance, in reserves + uint256 expectedYieldEarningReserveBalance = wrappedReserve.previewRedeem( + clearinghouseWrappedReserveBalance + trsryWrappedReserveBalance + ); + + // Confirm the view function matches + assertEq(yieldRepo.getReserveBalance(), expectedYieldEarningReserveBalance); + } + + function test_getNextYield() public { + // Mint yield + _mintYield(); + + // Call endEpoch initially to get tokens into the contract + vm.prank(heart); + yieldRepo.endEpoch(); + + // Get the "last values" from the yieldRepo contract + uint256 lastReserveBalance = yieldRepo.lastReserveBalance(); + uint256 lastConversionRate = yieldRepo.lastConversionRate(); + + // Get the principal receivables from the clearinghouse + uint256 principalReceivables = clearinghouse.principalReceivables(); + + // Mint additional yield to the wrappedReserve + _mintYield(); + + // Calculate the expected next yield + uint256 expectedNextYield = lastReserveBalance / + 10000 + + (principalReceivables * 5) / + 1000 / + 52; + + // Confirm the view function matches + assertEq(yieldRepo.getNextYield(), expectedNextYield); + } +} diff --git a/src/test/sim/RangeSim.sol b/src/test/sim/RangeSim.sol index 59434884..1a03d2e7 100644 --- a/src/test/sim/RangeSim.sol +++ b/src/test/sim/RangeSim.sol @@ -38,6 +38,7 @@ import {OlympusPriceConfig} from "policies/PriceConfig.sol"; import {MockPriceFeed} from "test/mocks/MockPriceFeed.sol"; import {RolesAdmin} from "policies/RolesAdmin.sol"; import {ZeroDistributor} from "policies/Distributor/ZeroDistributor.sol"; +import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; import {TransferHelper} from "libraries/TransferHelper.sol"; import {FullMath} from "libraries/FullMath.sol"; @@ -113,11 +114,7 @@ library SimIO { uint256 highCushion; } - function writeResults( - uint32 seed, - uint32 key, - Result[] memory results - ) external { + function writeResults(uint32 seed, uint32 key, Result[] memory results) external { string memory path = string( bytes.concat( "./src/test/sim/out/results-", @@ -199,6 +196,7 @@ abstract contract RangeSim is Test { OlympusPriceConfig public priceConfig; RolesAdmin public rolesAdmin; ZeroDistributor public distributor; + YieldRepurchaseFacility public yieldRepo; mapping(uint32 => SimIO.Params) internal params; // map of sim keys to sim params mapping(uint32 => mapping(uint32 => int256)) internal netflows; // map of sim keys to epochs to netflows @@ -365,7 +363,8 @@ abstract contract RangeSim is Test { ); range = new OlympusRange( kernel, - ERC20(ohm), ERC20(reserve), + ERC20(ohm), + ERC20(reserve), vm.envUint("THRESHOLD_FACTOR"), [uint256(_params.cushionSpread), uint256(_params.wallSpread)], [uint256(_params.cushionSpread), uint256(_params.wallSpread)] @@ -377,11 +376,7 @@ abstract contract RangeSim is Test { { /// Deploy bond callback - callback = new BondCallback( - kernel, - IBondAggregator(address(aggregator)), - ohm - ); + callback = new BondCallback(kernel, IBondAggregator(address(aggregator)), ohm); /// Deploy operator operator = new Operator( @@ -404,6 +399,15 @@ abstract contract RangeSim is Test { staking = new MockStakingZD(8 hours, 0, block.timestamp); distributor = new ZeroDistributor(address(staking)); + yieldRepo = new YieldRepurchaseFacility( + kernel, + address(ohm), + address(reserve), + address(wrappedReserve), + address(teller), + address(auctioneer), + address(0) // no clearinghouse + ); // Deploy PriceConfig priceConfig = new OlympusPriceConfig(kernel); @@ -413,6 +417,7 @@ abstract contract RangeSim is Test { kernel, operator, distributor, + yieldRepo, uint256(0), // no keeper rewards for sim uint48(0) // no keeper rewards for sim ); @@ -437,6 +442,7 @@ abstract contract RangeSim is Test { kernel.executeAction(Actions.ActivatePolicy, address(heart)); kernel.executeAction(Actions.ActivatePolicy, address(priceConfig)); kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + kernel.executeAction(Actions.ActivatePolicy, address(yieldRepo)); } { // Configure access control @@ -458,9 +464,16 @@ abstract contract RangeSim is Test { // PriceConfig roles rolesAdmin.grantRole("price_admin", guardian); + + // YieldRepurchaseFacility roles + rolesAdmin.grantRole("loop_daddy", guardian); } { + // Shutdown the yieldRepo + vm.prank(guardian); + yieldRepo.shutdown(new ERC20[](0)); + // Set initial supply and liquidity balances uint256 initialSupply = vm.envUint("SUPPLY"); uint256 liquidityReserves = vm.envUint("LIQUIDITY"); @@ -573,7 +586,7 @@ abstract contract RangeSim is Test { uint256 highCushionPrice = range.price(false, true); uint256 lowWallPrice = range.price(true, false); uint256 lowCushionPrice = range.price(false, false); - uint256 backingPrice = reserve.balanceOf(address(treasury)) * 1e9 / ohm.totalSupply(); + uint256 backingPrice = (reserve.balanceOf(address(treasury)) * 1e9) / ohm.totalSupply(); uint256 threeXPremiumPrice = backingPrice * 3; // Determine rebase adjustment based on price @@ -599,7 +612,7 @@ abstract contract RangeSim is Test { uint256 perc = getRebasePercent(); // Adjust rebase percent if dynamic reward rate is used - if (dynamicRR) perc = perc * getRebaseAdjustment() / 1e6; + if (dynamicRR) perc = (perc * getRebaseAdjustment()) / 1e6; // If percent is zero, do nothing if (perc == 0) return; @@ -633,11 +646,7 @@ abstract contract RangeSim is Test { /// @param reserveIn Whether the reserve token is being sent in (true) or received from (false) the swap /// @param amount Amount of reserves to get in or out (based on reserveIn) /// @dev Ensure tokens are approved on the balancer vault already to avoid allowance errors - function swap( - address sender, - bool reserveIn, - uint256 amount - ) internal { + function swap(address sender, bool reserveIn, uint256 amount) internal { if (reserveIn) { // Swap exact amount of reserves in for amount of OHM we can receive // Create path to swap @@ -685,11 +694,10 @@ abstract contract RangeSim is Test { /// @notice Returns the amount of token in to swap on the liquidity pool to move the price to a target value /// @dev Assumes that the price is in the correct direction for the token being provided. This is to ensure that the units you get back match the token you provide in. - function amountToTargetPrice(ERC20 tokenIn, uint256 targetPrice) - internal - view - returns (uint256 amountIn) - { + function amountToTargetPrice( + ERC20 tokenIn, + uint256 targetPrice + ) internal view returns (uint256 amountIn) { // Get existing data from pool (uint256 reserveBal, uint256 ohmBal, ) = pool.getReserves(); uint256 currentPrice = reserveBal.mulDiv(1e18 * 1e9, ohmBal * 1e18); @@ -709,7 +717,7 @@ abstract contract RangeSim is Test { } // Compute amount to swap in to reach target price (account for LP fee) - amountIn = (newBal - currentBal) * 1000 / 997; + amountIn = ((newBal - currentBal) * 1000) / 997; } function rebalanceLiquidity(uint32 key) internal { @@ -732,13 +740,14 @@ abstract contract RangeSim is Test { if (liquidityRatio < targetRatio) { // Sell reserves into the liquidity pool uint256 amountIn = (reservesInTotal * targetRatio) / 1e4 - reservesInLiquidity; - uint256 maxIn = reservesInTreasury * MAX_OUTFLOW_RATE / 1e4; + uint256 maxIn = (reservesInTreasury * MAX_OUTFLOW_RATE) / 1e4; amountIn = amountIn > maxIn ? maxIn : amountIn; if (amountIn > price.getCurrentPrice() / 1e9) swap(address(treasury), true, amountIn); } else if (liquidityRatio > params[key].maxLiqRatio) { // Buy reserves from the liquidity pool uint256 amountOut = reservesInLiquidity - (reservesInTotal * targetRatio) / 1e4; - if (amountOut > price.getCurrentPrice() / 1e9) swap(address(treasury), false, amountOut); + if (amountOut > price.getCurrentPrice() / 1e9) + swap(address(treasury), false, amountOut); } reservesInTreasury = reserve.balanceOf(address(treasury)); @@ -765,13 +774,14 @@ abstract contract RangeSim is Test { uint256 wallPrice = range.price(true, true); uint256 cushionPrice = range.price(false, true); uint256 currentPrice = price.getCurrentPrice(); - while (flow > currentPrice / 1e9) { // If below this amount, swaps will yield 0 OHM, which errors on the liquidity pool + while (flow > currentPrice / 1e9) { + // If below this amount, swaps will yield 0 OHM, which errors on the liquidity pool console2.log("High", flow); // Check if the RBS side is active, if not, swap all flow into the liquidity pool if (range.active(true)) { // Check price against the upper wall and cushion currentPrice = price.getCurrentPrice(); - uint256 oracleScale = 10**(price.decimals()); + uint256 oracleScale = 10 ** (price.decimals()); // If the market price is above the wall price, swap at the wall up to its capacity if (currentPrice >= wallPrice) { uint256 capacity = range.capacity(true); // Capacity is in OHM units @@ -805,10 +815,13 @@ abstract contract RangeSim is Test { uint256 bondScale = aggregator.marketScale(id); while ( currentPrice >= - aggregator.marketPrice(id).mulDiv(oracleScale, bondScale * 1e9) - && aggregator.isLive(id) + aggregator.marketPrice(id).mulDiv(oracleScale, bondScale * 1e9) && + aggregator.isLive(id) ) { - uint256 maxBond = aggregator.maxAmountAccepted(id, address(treasury)); + uint256 maxBond = aggregator.maxAmountAccepted( + id, + address(treasury) + ); if (maxBond < 1e18) break; if (maxBond > flow) { uint256 minAmountOut = aggregator.payoutFor( @@ -817,7 +830,13 @@ abstract contract RangeSim is Test { address(treasury) ); vm.prank(market); - teller.purchase(market, address(treasury), id, flow, minAmountOut); + teller.purchase( + market, + address(treasury), + id, + flow, + minAmountOut + ); console2.log(" Bonding", flow); flow = 0; break; @@ -885,13 +904,14 @@ abstract contract RangeSim is Test { uint256 wallPrice = range.price(true, false); uint256 cushionPrice = range.price(false, false); uint256 currentPrice = price.getCurrentPrice(); - while (flow > currentPrice / 1e9) { // If below this amount, swaps will yield 0 OHM, which errors on the liquidity pool + while (flow > currentPrice / 1e9) { + // If below this amount, swaps will yield 0 OHM, which errors on the liquidity pool console2.log("Low", flow); // Check if the RBS side is active, if not, swap all flow into the liquidity pool if (range.active(false)) { // Check price against the upper wall and cushion currentPrice = price.getCurrentPrice(); - uint256 oracleScale = 10**uint8(price.decimals()); + uint256 oracleScale = 10 ** uint8(price.decimals()); // If the market price is below the wall price, swap at the wall up to its capacity if (currentPrice <= wallPrice) { @@ -920,15 +940,29 @@ abstract contract RangeSim is Test { if (id != type(uint256).max) { uint256 bondScale = aggregator.marketScale(id); console2.log(" Current Price", currentPrice); - console2.log(" Bond Price", 10**(price.decimals() * 2) / aggregator.marketPrice(id).mulDiv(oracleScale * 1e9, bondScale)); + console2.log( + " Bond Price", + 10 ** (price.decimals() * 2) / + aggregator.marketPrice(id).mulDiv(oracleScale * 1e9, bondScale) + ); while ( currentPrice <= - 10**(price.decimals() * 2) / - aggregator.marketPrice(id).mulDiv(oracleScale * 1e9, bondScale) - && aggregator.isLive(id) + 10 ** (price.decimals() * 2) / + aggregator.marketPrice(id).mulDiv( + oracleScale * 1e9, + bondScale + ) && + aggregator.isLive(id) ) { - uint256 maxBond = aggregator.maxAmountAccepted(id, address(treasury)); // in OHM units - uint256 maxPayout = aggregator.payoutFor(maxBond, id, address(treasury)); // in reserve units + uint256 maxBond = aggregator.maxAmountAccepted( + id, + address(treasury) + ); // in OHM units + uint256 maxPayout = aggregator.payoutFor( + maxBond, + id, + address(treasury) + ); // in reserve units if (maxPayout < 1e18) break; uint256 bondPrice = aggregator.marketPrice(id); if (maxPayout > flow) { @@ -974,7 +1008,10 @@ abstract contract RangeSim is Test { if (flow > currentPrice / 1e9) { // Get amount that can swapped in the liquidity pool to push price to wall price uint256 maxOhmIn = amountToTargetPrice(ohm, wallPrice); - uint256 maxReserveOut = maxOhmIn.mulDiv(wallPrice * 1e18, oracleScale * 1e9); // convert to reserve units + uint256 maxReserveOut = maxOhmIn.mulDiv( + wallPrice * 1e18, + oracleScale * 1e9 + ); // convert to reserve units if (flow > maxReserveOut) { // Swap the max amount in the liquidity pool swap(market, false, maxReserveOut); @@ -991,7 +1028,10 @@ abstract contract RangeSim is Test { // If the market price is below the cushion price, swap into the liquidity pool up to the wall price // Get amount that can swapped in the liquidity pool to push price to wall price uint256 maxOhmIn = amountToTargetPrice(ohm, wallPrice); - uint256 maxReserveOut = maxOhmIn.mulDiv(wallPrice * 1e18, oracleScale * 1e9); // convert to reserve units + uint256 maxReserveOut = maxOhmIn.mulDiv( + wallPrice * 1e18, + oracleScale * 1e9 + ); // convert to reserve units if (flow > maxReserveOut) { // Swap the max amount in the liquidity pool swap(market, false, maxReserveOut); @@ -1014,11 +1054,10 @@ abstract contract RangeSim is Test { } } - function getResult(uint32 epoch, bool rebalanced) - internal - view - returns (SimIO.Result memory result) - { + function getResult( + uint32 epoch, + bool rebalanced + ) internal view returns (SimIO.Result memory result) { // Retrieve data from the contracts on current status uint256 supply = ohm.totalSupply(); uint256 lastPrice = price.getLastPrice(); @@ -1053,7 +1092,7 @@ abstract contract RangeSim is Test { rangeSetup(key); // Initialize variables for tracking status - + uint32 step = 1 hours; uint32 epochs = EPOCHS; // cache uint32 duration = EPOCH_DURATION; // cache @@ -1080,7 +1119,7 @@ abstract contract RangeSim is Test { console2.log("Rebalance liquidity"); rebalanceLiquidity(key); lastRebalance = e; - } + } netflow = netflows[key][e] / int256(uint256(steps)); for (uint32 i; i < steps; ++i) {