From 6d2994bcaa9597a2dbbbe1c26073c45a2d01bdb5 Mon Sep 17 00:00:00 2001 From: truefibot Date: Mon, 13 Feb 2023 12:06:19 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Update=20Carbon=20contracts=20in?= =?UTF-8?q?=20controllers=20(#669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/carbon/ProtocolConfig.sol | 4 +- contracts/carbon/StructuredPortfolio.sol | 210 +++++++++-------- contracts/carbon/TrancheVault.sol | 153 +++++-------- contracts/carbon/echidna/FuzzingBorrower.sol | 31 +++ contracts/carbon/echidna/FuzzingLender.sol | 22 ++ .../StructuredPortfolioFuzzingInit.sol | 216 ++++++++++++++++++ ...StructuredPortfolioFuzzingInteractions.sol | 89 ++++++++ .../StructuredPortfolioFuzzingInvariants.sol | 108 +++++++++ .../carbon/harnesses/TrancheVaultHarness.sol | 12 + .../interfaces/IStructuredPortfolio.sol | 4 + contracts/carbon/interfaces/ITrancheVault.sol | 3 +- .../mocks/StructuredPortfolioFactoryMock.sol | 187 +++++++++++++++ .../carbon/test/FixedInterestOnlyLoans.sol | 2 +- .../test/FixedInterestOnlyLoansTest.sol | 21 ++ contracts/carbon/test/ProtocolConfigTest.sol | 25 ++ .../carbon/test/StructuredPortfolioTest.sol | 4 - .../carbon/test/StructuredPortfolioTest2.sol | 39 ++++ contracts/carbon/test/TrancheVaultTest.sol | 8 - contracts/carbon/test/TrancheVaultTest2.sol | 47 ++++ 19 files changed, 986 insertions(+), 199 deletions(-) create mode 100644 contracts/carbon/echidna/FuzzingBorrower.sol create mode 100644 contracts/carbon/echidna/FuzzingLender.sol create mode 100644 contracts/carbon/echidna/StructuredPortfolioFuzzingInit.sol create mode 100644 contracts/carbon/echidna/StructuredPortfolioFuzzingInteractions.sol create mode 100644 contracts/carbon/echidna/StructuredPortfolioFuzzingInvariants.sol create mode 100644 contracts/carbon/mocks/StructuredPortfolioFactoryMock.sol create mode 100644 contracts/carbon/test/FixedInterestOnlyLoansTest.sol create mode 100644 contracts/carbon/test/ProtocolConfigTest.sol create mode 100644 contracts/carbon/test/StructuredPortfolioTest2.sol create mode 100644 contracts/carbon/test/TrancheVaultTest2.sol diff --git a/contracts/carbon/ProtocolConfig.sol b/contracts/carbon/ProtocolConfig.sol index ce8f1d7..0ddeae8 100644 --- a/contracts/carbon/ProtocolConfig.sol +++ b/contracts/carbon/ProtocolConfig.sol @@ -32,8 +32,8 @@ contract ProtocolConfig is Upgradeable, IProtocolConfig { address _protocolAdmin, address _protocolTreasury, address _pauserAddress - ) external initializer { - __Upgradeable_init(msg.sender, msg.sender); + ) public initializer { + __Upgradeable_init(msg.sender, _pauserAddress); defaultProtocolFeeRate = _defaultProtocolFeeRate; protocolAdmin = _protocolAdmin; protocolTreasury = _protocolTreasury; diff --git a/contracts/carbon/StructuredPortfolio.sol b/contracts/carbon/StructuredPortfolio.sol index a6ee720..c0a8163 100644 --- a/contracts/carbon/StructuredPortfolio.sol +++ b/contracts/carbon/StructuredPortfolio.sol @@ -11,6 +11,7 @@ pragma solidity ^0.8.16; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {Upgradeable} from "./proxy/Upgradeable.sol"; @@ -52,8 +53,8 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable IProtocolConfig _protocolConfig, PortfolioParams memory portfolioParams, TrancheInitData[] memory tranchesInitData, - ExpectedEquityRate calldata _expectedEquityRate - ) external initializer { + ExpectedEquityRate memory _expectedEquityRate + ) public initializer { _initialize(_fixedInterestOnlyLoans, underlyingToken); __Upgradeable_init(_protocolConfig.protocolAdmin(), _protocolConfig.pauserAddress()); _grantRole(MANAGER_ROLE, manager); @@ -101,48 +102,37 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable } function updateCheckpoints() public whenNotPaused { - require(status == Status.Live, "SP: Portfolio is not live"); - + require(status != Status.CapitalFormation, "SP: No checkpoints before start"); + (uint256[] memory _totalAssetsAfter, uint256[] memory pendingFees) = _calculateWaterfall(virtualTokenBalance + loansValue()); + LoansDeficitCheckpoint[] memory deficits = _calculateLoansDeficit(_totalAssetsAfter, pendingFees); + for (uint256 i = 0; i < _totalAssetsAfter.length; i++) { + tranches[i].updateCheckpointFromPortfolio(_totalAssetsAfter[i], deficits[i].deficit); + } if (someLoansDefaulted) { - _updateCheckpointsAndLoansDeficit(); - } else { - _updateCheckpoints(); + for (uint256 i = 1; i < deficits.length; i++) { + tranchesData[i].loansDeficitCheckpoint = deficits[i]; + } } } - function _updateCheckpointsAndLoansDeficit() internal { - uint256[] memory _totalAssetsBefore = new uint256[](tranches.length); - for (uint256 i = 0; i < tranches.length; i++) { - _totalAssetsBefore[i] = tranches[i].getCheckpoint().totalAssets; + function _calculateLoansDeficit(uint256[] memory realTotalAssets, uint256[] memory pendingFees) + internal + view + returns (LoansDeficitCheckpoint[] memory) + { + LoansDeficitCheckpoint[] memory deficits = new LoansDeficitCheckpoint[](realTotalAssets.length); + if (!someLoansDefaulted) { + return deficits; } - - uint256[] memory _totalAssetsAfter = _updateCheckpoints(); - uint256 timestamp = _limitedBlockTimestamp(); - for (uint256 i = 1; i < _totalAssetsAfter.length; i++) { - uint256 currentDeficit = _defaultedLoansDeficit(tranchesData[i], timestamp); - - int256 deficitDelta = SafeCast.toInt256(_totalAssetsBefore[i]) - SafeCast.toInt256(_totalAssetsAfter[i]); - uint256 newDeficit = _getNewLoansDeficit(currentDeficit, deficitDelta); - - tranchesData[i].loansDeficitCheckpoint = LoansDeficitCheckpoint({deficit: newDeficit, timestamp: timestamp}); - } - } - - function _updateCheckpoints() internal returns (uint256[] memory) { - uint256[] memory _totalAssetsAfter = calculateWaterfall(); - for (uint256 i = 0; i < _totalAssetsAfter.length; i++) { - tranches[i].updateCheckpointFromPortfolio(_totalAssetsAfter[i]); - } - return _totalAssetsAfter; - } - - function _getNewLoansDeficit(uint256 currentDeficit, int256 delta) internal pure returns (uint256) { - require(delta > type(int256).min, "SP: Delta out of range"); - if (delta < 0 && currentDeficit < uint256(-delta)) { - return 0; + for (uint256 i = 1; i < realTotalAssets.length; i++) { + Checkpoint memory checkpoint = tranches[i].getCheckpoint(); + uint256 assumedTotalAssets = _assumedTrancheValue(i, timestamp); + uint256 assumedTotalAssetsAfterFees = _saturatingSub(assumedTotalAssets, Math.max(pendingFees[i], checkpoint.unpaidFees)); + uint256 newDeficit = _saturatingSub(assumedTotalAssetsAfterFees, realTotalAssets[i]); + deficits[i] = LoansDeficitCheckpoint({deficit: newDeficit, timestamp: timestamp}); } - return _addSigned(currentDeficit, delta); + return deficits; } function increaseVirtualTokenBalance(uint256 increment) external { @@ -157,16 +147,18 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable uint256 tranchesCount = tranches.length; for (uint256 i = 0; i < tranchesCount; i++) { if (msg.sender == address(tranches[i])) { - virtualTokenBalance = _addSigned(virtualTokenBalance, delta); + virtualTokenBalance = delta < 0 ? virtualTokenBalance - uint256(-delta) : virtualTokenBalance + uint256(delta); return; } } revert("SP: Not a tranche"); } - function totalAssets() public view returns (uint256) { + function totalAssets() external view returns (uint256) { if (status == Status.Live) { - return liquidAssets() + loansValue(); + uint256 _totalPendingFees = totalPendingFees(); + uint256 totalAssetsBeforeFees = virtualTokenBalance + loansValue(); + return _saturatingSub(totalAssetsBeforeFees, _totalPendingFees); } return _sum(_tranchesTotalAssets()); } @@ -244,6 +236,53 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable _checkTranchesRatios(_tranchesTotalAssets()); } + function maxTrancheValueComplyingWithRatio(uint256 trancheIdx) external view returns (uint256) { + if (status != Status.Live || trancheIdx == 0) { + return type(uint256).max; + } + + uint256[] memory waterfallValues = calculateWaterfall(); + + uint256 subordinateValue = 0; + for (uint256 i = 0; i < trancheIdx; i++) { + subordinateValue += waterfallValues[i]; + } + + uint256 minSubordinateRatio = tranchesData[trancheIdx].minSubordinateRatio; + if (minSubordinateRatio == 0) { + return type(uint256).max; + } + + return (subordinateValue * BASIS_PRECISION) / minSubordinateRatio; + } + + function minTrancheValueComplyingWithRatio(uint256 trancheIdx) external view returns (uint256) { + if (status != Status.Live) { + return 0; + } + + uint256[] memory trancheValues = calculateWaterfall(); + uint256 tranchesCount = trancheValues.length; + if (trancheIdx == tranchesCount - 1) { + return 0; + } + + uint256 subordinateValueWithoutTranche = 0; + uint256 maxThreshold = 0; + for (uint256 i = 0; i < tranchesCount - 1; i++) { + uint256 trancheValue = trancheValues[i]; + if (i != trancheIdx) { + subordinateValueWithoutTranche += trancheValue; + } + if (i >= trancheIdx) { + uint256 lowerBound = (trancheValues[i + 1] * tranchesData[i + 1].minSubordinateRatio) / BASIS_PRECISION; + uint256 minTrancheValue = _saturatingSub(lowerBound, subordinateValueWithoutTranche); + maxThreshold = Math.max(minTrancheValue, maxThreshold); + } + } + return maxThreshold; + } + function _tranchesTotalAssets() internal view returns (uint256[] memory) { if (status == Status.Live) { return calculateWaterfall(); @@ -290,6 +329,7 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable } _changePortfolioStatus(Status.Closed); + updateCheckpoints(); if (!isAfterEndDate) { endDate = block.timestamp; @@ -297,29 +337,16 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable } function _closeTranches() internal { + updateCheckpoints(); uint256 limitedBlockTimestamp = _limitedBlockTimestamp(); + (uint256[] memory waterfall, ) = _calculateWaterfall(virtualTokenBalance); - uint256[] memory waterfallTranchesAssets = calculateWaterfall(); - uint256[] memory distributedAssets = new uint256[](waterfallTranchesAssets.length); - uint256 assetsLeft = virtualTokenBalance; - - for (uint256 i = waterfallTranchesAssets.length - 1; i > 0; i--) { - tranchesData[i].maxValueOnClose = _assumedTrancheValue(i, limitedBlockTimestamp); - - uint256 trancheDistributedAssets = _min(waterfallTranchesAssets[i], assetsLeft); - distributedAssets[i] = trancheDistributedAssets; - tranches[i].updateCheckpointFromPortfolio(trancheDistributedAssets); - - assetsLeft -= trancheDistributedAssets; - } - - distributedAssets[0] = _min(waterfallTranchesAssets[0], assetsLeft); - tranches[0].updateCheckpointFromPortfolio(distributedAssets[0]); - - for (uint256 i = 0; i < distributedAssets.length; i++) { - uint256 trancheDistributedAssets = distributedAssets[i]; - tranchesData[i].distributedAssets = trancheDistributedAssets; - _transfer(tranches[i], trancheDistributedAssets); + for (uint256 i = 0; i < waterfall.length; i++) { + if (i != 0) { + tranchesData[i].maxValueOnClose = _assumedTrancheValue(i, limitedBlockTimestamp); + } + tranchesData[i].distributedAssets = waterfall[i]; + _transfer(tranches[i], waterfall[i]); } } @@ -340,21 +367,34 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable } function calculateWaterfall() public view returns (uint256[] memory) { - uint256[] memory waterfall = calculateWaterfallWithoutFees(); + (uint256[] memory waterfall, ) = _calculateWaterfall(virtualTokenBalance + loansValue()); + return waterfall; + } + + function _calculateWaterfall(uint256 assetsLeft) internal view returns (uint256[] memory, uint256[] memory) { + uint256[] memory waterfall = _calculateWaterfallWithoutFees(assetsLeft); + uint256[] memory fees = new uint256[](tranches.length); for (uint256 i = 0; i < waterfall.length; i++) { uint256 pendingFees = tranches[i].totalPendingFeesForAssets(waterfall[i]); waterfall[i] = _saturatingSub(waterfall[i], pendingFees); + fees[i] = pendingFees; } - return waterfall; + return (waterfall, fees); } function calculateWaterfallWithoutFees() public view returns (uint256[] memory) { + return _calculateWaterfallWithoutFees(virtualTokenBalance + loansValue()); + } + + function _calculateWaterfallWithoutFees(uint256 assetsLeft) internal view returns (uint256[] memory) { uint256[] memory waterfall = new uint256[](tranches.length); if (status != Status.Live) { + for (uint256 i = 0; i < waterfall.length; i++) { + waterfall[i] = tranches[i].totalAssetsBeforeFees(); + } return waterfall; } - uint256 assetsLeft = virtualTokenBalance + loansValue(); uint256 limitedBlockTimestamp = _limitedBlockTimestamp(); for (uint256 i = waterfall.length - 1; i > 0; i--) { @@ -377,28 +417,20 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable function _assumedTrancheValue(uint256 trancheIdx, uint256 timestamp) internal view returns (uint256) { Checkpoint memory checkpoint = tranches[trancheIdx].getCheckpoint(); TrancheData memory trancheData = tranchesData[trancheIdx]; + uint256 targetApy = trancheData.targetApy; - uint256 assumedTotalAssets = _assumedTotalAssets(checkpoint, trancheData, timestamp); - uint256 defaultedLoansDeficit = _defaultedLoansDeficit(trancheData, timestamp); + uint256 timePassedSinceCheckpoint = _saturatingSub(timestamp, checkpoint.timestamp); + uint256 assumedTotalAssets = _withInterest(checkpoint.totalAssets, targetApy, timePassedSinceCheckpoint) + + checkpoint.unpaidFees; - return assumedTotalAssets + defaultedLoansDeficit; - } - - function _assumedTotalAssets( - Checkpoint memory checkpoint, - TrancheData memory trancheData, - uint256 timestamp - ) internal pure returns (uint256) { - uint256 timePassed = _saturatingSub(timestamp, checkpoint.timestamp); - return _withInterest(checkpoint.totalAssets, trancheData.targetApy, timePassed); - } - - function _defaultedLoansDeficit(TrancheData memory trancheData, uint256 timestamp) internal pure returns (uint256) { - if (trancheData.loansDeficitCheckpoint.deficit == 0) { - return 0; + uint256 defaultedLoansDeficit; + uint256 checkpointDeficit = trancheData.loansDeficitCheckpoint.deficit; + if (checkpointDeficit != 0) { + uint256 timePassed = _saturatingSub(timestamp, trancheData.loansDeficitCheckpoint.timestamp); + defaultedLoansDeficit = _withInterest(checkpointDeficit, targetApy, timePassed); } - uint256 timePassed = _saturatingSub(timestamp, trancheData.loansDeficitCheckpoint.timestamp); - return _withInterest(trancheData.loansDeficitCheckpoint.deficit, trancheData.targetApy, timePassed); + + return assumedTotalAssets + defaultedLoansDeficit; } function _withInterest( @@ -456,7 +488,7 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable continue; } - uint256 trancheShare = _min(trancheFreeCapacity, undistributedAssets); + uint256 trancheShare = Math.min(trancheFreeCapacity, undistributedAssets); undistributedAssets -= trancheShare; _repayInClosed(i, trancheShare); } @@ -504,18 +536,10 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable emit PortfolioStatusChanged(newStatus); } - function _min(uint256 x, uint256 y) internal pure returns (uint256) { - return x > y ? y : x; - } - function _saturatingSub(uint256 x, uint256 y) internal pure returns (uint256) { return x > y ? x - y : 0; } - function _addSigned(uint256 x, int256 y) internal pure returns (uint256) { - return y < 0 ? x - uint256(-y) : x + uint256(y); - } - function _sum(uint256[] memory components) internal pure returns (uint256) { uint256 sum; for (uint256 i = 0; i < components.length; i++) { @@ -525,7 +549,7 @@ contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable } function _limitedBlockTimestamp() internal view returns (uint256) { - return _min(block.timestamp, endDate); + return Math.min(block.timestamp, endDate); } function _requireManagerRole() internal view { diff --git a/contracts/carbon/TrancheVault.sol b/contracts/carbon/TrancheVault.sol index 401ae0c..acfd0f2 100644 --- a/contracts/carbon/TrancheVault.sol +++ b/contracts/carbon/TrancheVault.sol @@ -74,7 +74,7 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { uint256 _waterfallIndex, address manager, uint256 _managerFeeRate - ) external initializer { + ) public initializer { __ERC20_init(_name, _symbol); __Upgradeable_init(_protocolConfig.protocolAdmin(), address(0)); @@ -106,7 +106,7 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { } uint256 balance = totalAssetsBeforeFees(); uint256 pendingFees = totalPendingFeesForAssets(balance); - return balance > pendingFees ? balance - pendingFees : 0; + return _saturatingSub(balance, pendingFees); } function totalAssetsBeforeFees() public view returns (uint256) { @@ -162,7 +162,7 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { } function maxDeposit(address receiver) external view returns (uint256) { - return _min(_maxDeposit(receiver), _maxDepositComplyingWithRatio()); + return Math.min(_maxDeposit(receiver), _maxDepositComplyingWithRatio()); } function previewDeposit(uint256 assets) public view returns (uint256) { @@ -214,7 +214,7 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { if (maxRatioLimit == type(uint256).max) { return _maxMint(receiver); } - return _min(_maxMint(receiver), previewDeposit(maxRatioLimit)); + return Math.min(_maxMint(receiver), previewDeposit(maxRatioLimit)); } function previewMint(uint256 shares) public view returns (uint256) { @@ -240,7 +240,7 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { } function maxWithdraw(address owner) external view returns (uint256) { - return _min(_maxWithdraw(owner), _maxWithdrawComplyingWithRatio()); + return Math.min(_maxWithdraw(owner), _maxWithdrawComplyingWithRatio()); } function previewWithdraw(uint256 assets) public view returns (uint256) { @@ -295,7 +295,7 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { } function maxRedeem(address owner) external view returns (uint256) { - return _min(_maxRedeem(owner), convertToShares(_maxWithdrawComplyingWithRatio())); + return Math.min(_maxRedeem(owner), convertToShares(_maxWithdrawComplyingWithRatio())); } function previewRedeem(uint256 shares) public view returns (uint256) { @@ -388,108 +388,67 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { _updateCheckpoint(totalAssets()); } - function updateCheckpointFromPortfolio(uint256 newTotalAssets) external { + function updateCheckpointFromPortfolio(uint256 newTotalAssets, uint256 newDeficit) external { _requirePortfolio(); - _updateCheckpoint(newTotalAssets); - } - - function _maxTrancheValueComplyingWithRatio() internal view returns (uint256) { - if (portfolio.status() != Status.Live) { - return type(uint256).max; - } - - if (waterfallIndex == 0) { - return type(uint256).max; - } - - uint256[] memory waterfallValues = portfolio.calculateWaterfall(); - - uint256 subordinateValue = 0; - for (uint256 i = 0; i < waterfallIndex; i++) { - subordinateValue += waterfallValues[i]; - } - - uint256 minSubordinateRatio = portfolio.getTrancheData(waterfallIndex).minSubordinateRatio; - if (minSubordinateRatio == 0) { - return type(uint256).max; - } - - return (subordinateValue * BASIS_PRECISION) / minSubordinateRatio; + _updateCheckpoint(newTotalAssets, newDeficit); } function _maxDepositComplyingWithRatio() internal view returns (uint256) { - uint256 maxTrancheValueComplyingWithRatio = _maxTrancheValueComplyingWithRatio(); + uint256 maxTrancheValueComplyingWithRatio = portfolio.maxTrancheValueComplyingWithRatio(waterfallIndex); if (maxTrancheValueComplyingWithRatio == type(uint256).max) { return type(uint256).max; } return _saturatingSub(maxTrancheValueComplyingWithRatio, totalAssets()); } - function _minTrancheValueComplyingWithRatio() internal view returns (uint256) { - if (portfolio.status() != Status.Live) { - return 0; - } - - uint256[] memory waterfallValues = portfolio.calculateWaterfall(); - uint256 tranchesCount = waterfallValues.length; - if (waterfallIndex == tranchesCount - 1) { - return 0; - } - - uint256 subordinateValueWithoutTranche = 0; - uint256 maxThreshold = 0; - for (uint256 i = 0; i < tranchesCount - 1; i++) { - uint256 trancheValue = waterfallValues[i]; - if (i != waterfallIndex) { - subordinateValueWithoutTranche += trancheValue; - } - if (i >= waterfallIndex) { - uint256 lowerBound = (waterfallValues[i + 1] * portfolio.getTrancheData(i + 1).minSubordinateRatio) / BASIS_PRECISION; - uint256 minTrancheValue = _saturatingSub(lowerBound, subordinateValueWithoutTranche); - maxThreshold = _max(minTrancheValue, maxThreshold); - } - } - return maxThreshold; - } - function _maxWithdrawComplyingWithRatio() internal view returns (uint256) { - uint256 minTrancheValueComplyingWithRatio = _minTrancheValueComplyingWithRatio(); + uint256 minTrancheValueComplyingWithRatio = portfolio.minTrancheValueComplyingWithRatio(waterfallIndex); return _saturatingSub(totalAssets(), minTrancheValueComplyingWithRatio); } + function _updateCheckpoint(uint256 newTotalAssets) internal { + return _updateCheckpoint(newTotalAssets, 0); + } + /** * @param newTotalAssets Total assets value to save in checkpoint with fees deducted */ - function _updateCheckpoint(uint256 newTotalAssets) internal { + function _updateCheckpoint(uint256 newTotalAssets, uint256 newDeficit) internal { if (portfolio.status() == Status.CapitalFormation) { return; } uint256 _totalAssetsBeforeFees = totalAssetsBeforeFees(); - _payProtocolFee(_totalAssetsBeforeFees); - _payManagerFee(_totalAssetsBeforeFees); + uint256 _protocolFee = _payProtocolFee(_totalAssetsBeforeFees, newDeficit); + _payManagerFee(_totalAssetsBeforeFees, _protocolFee, newDeficit); uint256 protocolFeeRate = protocolConfig.protocolFeeRate(); - uint256 newTotalAssetsWithUnpaidFees = newTotalAssets + unpaidManagerFee + unpaidProtocolFee; checkpoint = Checkpoint({ - totalAssets: newTotalAssetsWithUnpaidFees, + totalAssets: newTotalAssets, protocolFeeRate: protocolFeeRate, - timestamp: block.timestamp + timestamp: block.timestamp, + unpaidFees: unpaidManagerFee + unpaidProtocolFee }); emit CheckpointUpdated(newTotalAssets, protocolFeeRate); } - function _payProtocolFee(uint256 _totalAssetsBeforeFees) internal { - uint256 pendingFee = _pendingProtocolFee(_totalAssetsBeforeFees); + function _payProtocolFee(uint256 _totalAssetsBeforeFees, uint256 newDeficit) internal returns (uint256) { + uint256 pendingFee = _pendingProtocolFee(_totalAssetsBeforeFees, newDeficit); address protocolAddress = protocolConfig.protocolTreasury(); (uint256 paidProtocolFee, uint256 _unpaidProtocolFee) = _payFee(pendingFee, protocolAddress); unpaidProtocolFee = _unpaidProtocolFee; emit ProtocolFeePaid(protocolAddress, paidProtocolFee); + + return paidProtocolFee + _unpaidProtocolFee; } - function _payManagerFee(uint256 _totalAssetsBeforeFees) internal { - uint256 pendingFee = _pendingManagerFee(_totalAssetsBeforeFees); + function _payManagerFee( + uint256 _totalAssetsBeforeFees, + uint256 protocolFee, + uint256 newDeficit + ) internal { + uint256 pendingFee = _pendingManagerFee(_totalAssetsBeforeFees, protocolFee, newDeficit); (uint256 paidManagerFee, uint256 _unpaidManagerFee) = _payFee(pendingFee, managerFeeBeneficiary); unpaidManagerFee = _unpaidManagerFee; emit ManagerFeePaid(managerFeeBeneficiary, paidManagerFee); @@ -547,7 +506,8 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { } function totalPendingFeesForAssets(uint256 _totalAssetsBeforeFees) public view returns (uint256) { - return _pendingProtocolFee(_totalAssetsBeforeFees) + _pendingManagerFee(_totalAssetsBeforeFees); + uint256 _protocolFee = _pendingProtocolFee(_totalAssetsBeforeFees); + return _protocolFee + _pendingManagerFee(_totalAssetsBeforeFees, _protocolFee); } function pendingProtocolFee() external view returns (uint256) { @@ -555,26 +515,47 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { } function _pendingProtocolFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { - return _accruedProtocolFee(_totalAssetsBeforeFees) + unpaidProtocolFee; + return _pendingProtocolFee(_totalAssetsBeforeFees, portfolio.getTrancheData(waterfallIndex).loansDeficitCheckpoint.deficit); } - function pendingManagerFee() external view returns (uint256) { - return _pendingManagerFee(totalAssetsBeforeFees()); + function _pendingProtocolFee(uint256 _totalAssetsBeforeFees, uint256 newDeficit) internal view returns (uint256) { + uint256 totalProtocolFee = _accruedFee(checkpoint.protocolFeeRate, _totalAssetsBeforeFees) + unpaidProtocolFee; + uint256 maxTrancheValue = _totalAssetsBeforeFees + newDeficit; + + return _capFee(totalProtocolFee, maxTrancheValue); } - function _pendingManagerFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { - return _accruedManagerFee(_totalAssetsBeforeFees) + unpaidManagerFee; + function pendingManagerFee() external view returns (uint256) { + return _pendingManagerFee(totalAssetsBeforeFees(), 0); } - function _accruedProtocolFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { - return _accruedFee(checkpoint.protocolFeeRate, _totalAssetsBeforeFees); + function _pendingManagerFee(uint256 _totalAssetsBeforeFees, uint256 _protocolFees) internal view returns (uint256) { + return + _pendingManagerFee( + _totalAssetsBeforeFees, + _protocolFees, + portfolio.getTrancheData(waterfallIndex).loansDeficitCheckpoint.deficit + ); } - function _accruedManagerFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { + function _pendingManagerFee( + uint256 _totalAssetsBeforeFees, + uint256 _protocolFees, + uint256 newDeficit + ) internal view returns (uint256) { if (portfolio.status() != Status.Live) { - return 0; + return unpaidManagerFee; } - return _accruedFee(managerFeeRate, _totalAssetsBeforeFees); + uint256 totalManagerFee = _accruedFee(managerFeeRate, _totalAssetsBeforeFees) + unpaidManagerFee; + uint256 maxTrancheValue = _saturatingSub(_totalAssetsBeforeFees + newDeficit, _protocolFees); + return _capFee(totalManagerFee, maxTrancheValue); + } + + function _capFee(uint256 fee, uint256 availableAssets) internal view returns (uint256) { + if (waterfallIndex != 0 && portfolio.status() == Status.Live) { + return Math.min(fee, availableAssets); + } + return fee; } function _accruedFee(uint256 feeRate, uint256 _totalAssetsBeforeFees) internal view returns (uint256) { @@ -659,14 +640,6 @@ contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { _approve(owner, msg.sender, sharesAllowance - shares); } - function _max(uint256 x, uint256 y) internal pure returns (uint256) { - return x < y ? y : x; - } - - function _min(uint256 x, uint256 y) internal pure returns (uint256) { - return x > y ? y : x; - } - function _saturatingSub(uint256 x, uint256 y) internal pure returns (uint256) { return x > y ? x - y : 0; } diff --git a/contracts/carbon/echidna/FuzzingBorrower.sol b/contracts/carbon/echidna/FuzzingBorrower.sol new file mode 100644 index 0000000..2d8127a --- /dev/null +++ b/contracts/carbon/echidna/FuzzingBorrower.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {FixedInterestOnlyLoans} from "../test/FixedInterestOnlyLoans.sol"; +import {IStructuredPortfolio} from "../StructuredPortfolio.sol"; + +contract FuzzingBorrower { + function acceptLoan(FixedInterestOnlyLoans fixedInterestOnlyLoans, uint256 loanId) external { + fixedInterestOnlyLoans.acceptLoan(loanId); + } + + function repayLoan( + IStructuredPortfolio portfolio, + FixedInterestOnlyLoans fixedInterestOnlyLoans, + uint256 loanId + ) external { + uint256 amount = fixedInterestOnlyLoans.expectedRepaymentAmount(loanId); + fixedInterestOnlyLoans.asset(loanId).approve(address(portfolio), amount); + portfolio.repayLoan(loanId); + } +} diff --git a/contracts/carbon/echidna/FuzzingLender.sol b/contracts/carbon/echidna/FuzzingLender.sol new file mode 100644 index 0000000..94b1d73 --- /dev/null +++ b/contracts/carbon/echidna/FuzzingLender.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {ITrancheVault, Checkpoint} from "../interfaces/ITrancheVault.sol"; + +contract FuzzingLender { + function deposit(ITrancheVault tranche, uint256 amount) external { + IERC20WithDecimals(tranche.asset()).approve(address(tranche), amount); + tranche.deposit(amount, address(this)); + } +} diff --git a/contracts/carbon/echidna/StructuredPortfolioFuzzingInit.sol b/contracts/carbon/echidna/StructuredPortfolioFuzzingInit.sol new file mode 100644 index 0000000..487182b --- /dev/null +++ b/contracts/carbon/echidna/StructuredPortfolioFuzzingInit.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {FixedInterestOnlyLoans} from "../test/FixedInterestOnlyLoans.sol"; +import {FixedInterestOnlyLoansTest} from "../test/FixedInterestOnlyLoansTest.sol"; +import {TrancheVault} from "../TrancheVault.sol"; +import {TrancheVaultTest2} from "../test/TrancheVaultTest2.sol"; +import {ProtocolConfig} from "../ProtocolConfig.sol"; +import {ProtocolConfigTest} from "../test/ProtocolConfigTest.sol"; +import {AllowAllLenderVerifier} from "../lenderVerifiers/AllowAllLenderVerifier.sol"; +import {DepositController} from "../controllers/DepositController.sol"; +import {WithdrawController} from "../controllers/WithdrawController.sol"; +import {TransferController} from "../controllers/TransferController.sol"; +import {MockToken} from "../mocks/MockToken.sol"; +import {TrancheInitData, PortfolioParams, ExpectedEquityRate, YEAR} from "../interfaces/IStructuredPortfolio.sol"; +import {StructuredPortfolio, Status} from "../StructuredPortfolio.sol"; +import {StructuredPortfolioTest2} from "../test/StructuredPortfolioTest2.sol"; +import {AddLoanParams} from "../LoansManager.sol"; +import {StructuredPortfolioTest} from "../test/StructuredPortfolioTest.sol"; +import {FuzzingBorrower} from "./FuzzingBorrower.sol"; +import {FuzzingLender} from "./FuzzingLender.sol"; + +uint256 constant DAY = 1 days; + +contract StructuredPortfolioFuzzingInit { + MockToken public token; + ProtocolConfig public protocolConfig; + FixedInterestOnlyLoans public fixedInterestOnlyLoans; + AllowAllLenderVerifier public lenderVerifier; + TrancheVault public equityTranche; + TrancheVault public juniorTranche; + TrancheVault public seniorTranche; + StructuredPortfolio public structuredPortfolio; + FuzzingBorrower public borrower; + FuzzingLender public lender; + + uint256 internal activeLoansCount; + + constructor() { + _initializeToken(); + _initializeProtocolConfig(); + _initializeFixedInterestOnlyLoans(); + _initializeLenderVerifier(); + equityTranche = _initializeTranche("Equity Tranche", "EQT", 0, 10**9 * 10**6); + juniorTranche = _initializeTranche("Junior Tranche", "JNT", 1, 10**9 * 10**6); + seniorTranche = _initializeTranche("Senior Tranche", "SNT", 2, 10**9 * 10**6); + _initializePortfolio(); + + _initializeLender(); + _initializeBorrower(); + + _fillTranches(); + _startPortfolio(); + _createAndFundLoans(); + } + + function _initializeToken() internal { + token = new MockToken( + 6 /* decimals */ + ); + token.mint(address(this), 10**12); + } + + function _initializeProtocolConfig() internal { + protocolConfig = new ProtocolConfigTest( + 50, /* _defaultProtocolFeeRate */ + address(this), /* _protocolAdmin */ + address(this), /* _protocolTreasury */ + address(this) /* _pauserAddress */ + ); + } + + function _initializeFixedInterestOnlyLoans() internal { + fixedInterestOnlyLoans = new FixedInterestOnlyLoansTest(protocolConfig); + } + + function _initializeLenderVerifier() internal { + lenderVerifier = new AllowAllLenderVerifier(); + } + + function _initializeTranche( + string memory name, + string memory symbol, + uint256 waterfallIndex, + uint256 ceiling + ) internal returns (TrancheVault) { + DepositController depositController = new DepositController(); + depositController.initialize( + address(this), /* manager */ + address(lenderVerifier), + 50, /* _depositFeeRate */ + ceiling + ); + depositController.setDepositAllowed(true, Status.Live); + WithdrawController withdrawController = new WithdrawController(); + withdrawController.initialize( + address(this), /* manager */ + 50, /* _withdrawFeeRate */ + 10**6 /* _floor */ + ); + withdrawController.setWithdrawAllowed(true, Status.Live); + TransferController transferController = new TransferController(); + + TrancheVault tranche = new TrancheVaultTest2( + name, + symbol, + IERC20WithDecimals(address(token)), + depositController, + withdrawController, + transferController, + protocolConfig, + waterfallIndex, + address(this), /* manager */ + 50 /* _managerFeeRate */ + ); + + return tranche; + } + + function _initializePortfolio() internal { + PortfolioParams memory portfolioParams = PortfolioParams( + "Portfolio", + 2 * YEAR, /* duration */ + 90 * DAY, /* capitalFormationPeriod */ + 0 /* minimumSize */ + ); + + TrancheInitData[] memory tranchesInitData = new TrancheInitData[](3); + tranchesInitData[0] = TrancheInitData( + equityTranche, + 0, /* targetApy */ + 0 /* minSubordinateRatio */ + ); + tranchesInitData[1] = TrancheInitData( + juniorTranche, + 500, /* targetApy */ + 0 /* minSubordinateRatio */ + ); + tranchesInitData[2] = TrancheInitData( + seniorTranche, + 300, /* targetApy */ + 0 /* minSubordinateRatio */ + ); + + structuredPortfolio = new StructuredPortfolioTest2( + address(this), /* manager */ + IERC20WithDecimals(address(token)), + fixedInterestOnlyLoans, + protocolConfig, + portfolioParams, + tranchesInitData, + ExpectedEquityRate(200, 2000) + ); + } + + function _initializeLender() internal { + lender = new FuzzingLender(); + token.mint(address(lender), 1e10 * 10**6); + } + + function _initializeBorrower() internal { + borrower = new FuzzingBorrower(); + token.mint(address(lender), 1e10 * 10**6); + } + + function _fillTranches() internal { + lender.deposit(equityTranche, 2e6 * 10**6); + lender.deposit(juniorTranche, 3e6 * 10**6); + lender.deposit(seniorTranche, 5e6 * 10**6); + } + + function _startPortfolio() internal { + structuredPortfolio.start(); + } + + function _createAndFundLoans() internal { + AddLoanParams memory params1 = AddLoanParams( + 3e6 * 10**6, /* principal */ + 3, /* periodCount */ + 2e4 * 10**6, /* periodPayment */ + uint32(DAY), /* periodDuration */ + address(borrower), /* recipient */ + uint32(DAY), /* gracePeriod */ + true /* canBeRepaidAfterDefault */ + ); + structuredPortfolio.addLoan(params1); + borrower.acceptLoan(fixedInterestOnlyLoans, 0); + activeLoansCount += 1; + structuredPortfolio.fundLoan(0); + + AddLoanParams memory params2 = AddLoanParams( + 6e6 * 10**6, /* principal */ + 10, /* periodCount */ + 2e4 * 10**6, /* periodPayment */ + uint32(DAY), /* periodDuration */ + address(borrower), /* recipient */ + uint32(DAY), /* gracePeriod */ + true /* canBeRepaidAfterDefault */ + ); + structuredPortfolio.addLoan(params2); + borrower.acceptLoan(fixedInterestOnlyLoans, 1); + activeLoansCount += 1; + structuredPortfolio.fundLoan(1); + } +} diff --git a/contracts/carbon/echidna/StructuredPortfolioFuzzingInteractions.sol b/contracts/carbon/echidna/StructuredPortfolioFuzzingInteractions.sol new file mode 100644 index 0000000..442b382 --- /dev/null +++ b/contracts/carbon/echidna/StructuredPortfolioFuzzingInteractions.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {FixedInterestOnlyLoans} from "../test/FixedInterestOnlyLoans.sol"; +import {Status, TrancheData} from "../interfaces/IStructuredPortfolio.sol"; +import {StructuredPortfolio} from "../StructuredPortfolio.sol"; +import {StructuredPortfolioFuzzingInit} from "./StructuredPortfolioFuzzingInit.sol"; +import {ITrancheVault, Checkpoint} from "../interfaces/ITrancheVault.sol"; +import {AddLoanParams} from "../interfaces/ILoansManager.sol"; + +uint256 constant DAY = 1 days; + +contract StructuredPortfolioFuzzingInteractions is StructuredPortfolioFuzzingInit { + uint256 internal previousTotalAssets; + bool internal anyDefaultedLoans = false; + + function updateTotalAssets() public { + previousTotalAssets = structuredPortfolio.totalAssets(); + } + + function markLoanAsDefaulted(uint256 rawLoanId) public { + uint256 loanId = rawLoanId % structuredPortfolio.getActiveLoans().length; + structuredPortfolio.markLoanAsDefaulted(loanId); + anyDefaultedLoans = true; + } + + function deposit(uint256 rawAmount, uint8 rawTrancheId) public { + uint256 trancheId = rawTrancheId % 3; + uint256 amount = rawAmount % token.balanceOf(address(lender)); + ITrancheVault tranche; + if (trancheId == 0) { + tranche = equityTranche; + } else if (trancheId == 1) { + tranche = juniorTranche; + } else { + tranche = seniorTranche; + } + + lender.deposit(tranche, amount); + } + + function addLoan(AddLoanParams calldata rawParams) external { + AddLoanParams memory params = AddLoanParams( + rawParams.principal % structuredPortfolio.virtualTokenBalance(), + rawParams.periodCount % 10, + rawParams.periodPayment % (structuredPortfolio.virtualTokenBalance() / 10), + rawParams.periodDuration % uint32(7 * DAY), + address(borrower), /* recipient */ + uint32(DAY), /* gracePeriod */ + true /* canBeRepaidAfterDefault */ + ); + + structuredPortfolio.addLoan(params); + } + + function acceptLoan(uint256 rawLoanId) external { + uint256 loanId = rawLoanId % 5; + borrower.acceptLoan(fixedInterestOnlyLoans, loanId); + activeLoansCount += 1; + } + + function fundLoan(uint256 rawLoanId) external { + uint256 loanId = rawLoanId % 5; + structuredPortfolio.fundLoan(loanId); + } + + function repayLoan(uint256 rawLoanId) external { + uint256 loanId = rawLoanId % 5; + borrower.repayLoan(structuredPortfolio, fixedInterestOnlyLoans, loanId); + } + + function close() public { + structuredPortfolio.close(); + } + + function updateCheckpoints() public { + structuredPortfolio.updateCheckpoints(); + } +} diff --git a/contracts/carbon/echidna/StructuredPortfolioFuzzingInvariants.sol b/contracts/carbon/echidna/StructuredPortfolioFuzzingInvariants.sol new file mode 100644 index 0000000..cd942d3 --- /dev/null +++ b/contracts/carbon/echidna/StructuredPortfolioFuzzingInvariants.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {FixedInterestOnlyLoans} from "../test/FixedInterestOnlyLoans.sol"; +import {Status, TrancheData} from "../interfaces/IStructuredPortfolio.sol"; +import {StructuredPortfolio} from "../StructuredPortfolio.sol"; +import {StructuredPortfolioFuzzingInteractions} from "./StructuredPortfolioFuzzingInteractions.sol"; +import {ITrancheVault, Checkpoint} from "../interfaces/ITrancheVault.sol"; +import {AddLoanParams} from "../interfaces/ILoansManager.sol"; + +uint256 constant DAY = 1 days; + +contract StructuredPortfolioFuzzingInvariants is StructuredPortfolioFuzzingInteractions { + function echidna_check_statusIsNotCapitalFormation() public view returns (bool) { + return structuredPortfolio.status() != Status.CapitalFormation; + } + + bool public echidna_check_totalAssetsIncreases = true; + + function _echidna_check_totalAssetsIncreases() public { + require(structuredPortfolio.loansValue() > structuredPortfolio.totalAssets() / 2); + require(!anyDefaultedLoans); + require(!_anyOverdueLoans()); + + echidna_check_totalAssetsIncreases = structuredPortfolio.totalAssets() >= previousTotalAssets; + } + + bool public echidna_check_updateCheckpointsContinuous = true; + + function _echidna_check_updateCheckpointsContinuous() public { + uint256[] memory waterfall_old = structuredPortfolio.calculateWaterfall(); + structuredPortfolio.updateCheckpoints(); + TrancheData[] memory trancheData_old = _getTranchesData(); + Checkpoint[] memory trancheCheckpoints_old = _getTrancheCheckpoints(); + structuredPortfolio.updateCheckpoints(); + uint256[] memory waterfall_new = structuredPortfolio.calculateWaterfall(); + TrancheData[] memory trancheData_new = _getTranchesData(); + Checkpoint[] memory trancheCheckpoints_new = _getTrancheCheckpoints(); + + for (uint256 i = 0; i < waterfall_old.length; i++) { + if (waterfall_new[i] != waterfall_old[i]) { + echidna_check_updateCheckpointsContinuous = false; + } + + if ( + trancheData_new[i].loansDeficitCheckpoint.deficit != trancheData_old[i].loansDeficitCheckpoint.deficit || + trancheData_new[i].loansDeficitCheckpoint.timestamp != trancheData_old[i].loansDeficitCheckpoint.timestamp + ) { + echidna_check_updateCheckpointsContinuous = false; + } + + if ( + trancheCheckpoints_new[i].totalAssets != trancheCheckpoints_old[i].totalAssets || + trancheCheckpoints_new[i].protocolFeeRate != trancheCheckpoints_old[i].protocolFeeRate || + trancheCheckpoints_new[i].timestamp != trancheCheckpoints_old[i].timestamp || + trancheCheckpoints_new[i].unpaidFees != trancheCheckpoints_old[i].unpaidFees + ) { + echidna_check_updateCheckpointsContinuous = false; + } + } + } + + function echidna_check_virtualTokenBalanceEqualsTokenBalance() public view returns (bool) { + return structuredPortfolio.virtualTokenBalance() == token.balanceOf(address(structuredPortfolio)); + } + + function _anyOverdueLoans() internal view returns (bool) { + for (uint256 i = 0; i < activeLoansCount; i++) { + uint256 activeLoanId = structuredPortfolio.activeLoanIds(i); + if (fixedInterestOnlyLoans.currentPeriodEndDate(activeLoanId) < block.timestamp) { + return true; + } + } + + return false; + } + + function _getTranchesData() internal view returns (TrancheData[] memory) { + ITrancheVault[] memory trancheVaults = structuredPortfolio.getTranches(); + TrancheData[] memory tranchesData = new TrancheData[](trancheVaults.length); + + for (uint256 i = 0; i < trancheVaults.length; i++) { + tranchesData[i] = structuredPortfolio.getTrancheData(i); + } + + return tranchesData; + } + + function _getTrancheCheckpoints() internal view returns (Checkpoint[] memory) { + ITrancheVault[] memory trancheVaults = structuredPortfolio.getTranches(); + Checkpoint[] memory trancheCheckpoints = new Checkpoint[](trancheVaults.length); + for (uint256 i = 0; i < trancheVaults.length; i++) { + trancheCheckpoints[i] = trancheVaults[i].getCheckpoint(); + } + + return trancheCheckpoints; + } +} diff --git a/contracts/carbon/harnesses/TrancheVaultHarness.sol b/contracts/carbon/harnesses/TrancheVaultHarness.sol index e159590..906a2fb 100644 --- a/contracts/carbon/harnesses/TrancheVaultHarness.sol +++ b/contracts/carbon/harnesses/TrancheVaultHarness.sol @@ -26,4 +26,16 @@ contract TrancheVaultHarness is TrancheVault { require(from != address(this) && from != address(portfolio)); token.safeTransferFrom(from, to, amount); } + + function setCustomProtocolFeeRateHarness(address contractAddress, uint16 newFeeRate) public { + protocolConfig.setCustomProtocolFeeRate(contractAddress, newFeeRate); + } + + function removeCustomProtocolFeeRateHarness(address contractAddress) public { + protocolConfig.removeCustomProtocolFeeRate(contractAddress); + } + + function setDefaultProtocolFeeRate(uint256 newFeeRate) public { + protocolConfig.setDefaultProtocolFeeRate(newFeeRate); + } } diff --git a/contracts/carbon/interfaces/IStructuredPortfolio.sol b/contracts/carbon/interfaces/IStructuredPortfolio.sol index f74107f..adf200f 100644 --- a/contracts/carbon/interfaces/IStructuredPortfolio.sol +++ b/contracts/carbon/interfaces/IStructuredPortfolio.sol @@ -295,4 +295,8 @@ interface IStructuredPortfolio is IAccessControlUpgradeable { * Is ignored if not called by tranche */ function checkTranchesRatiosFromTranche(uint256 newTotalAssets) external view; + + function maxTrancheValueComplyingWithRatio(uint256 trancheIdx) external view returns (uint256); + + function minTrancheValueComplyingWithRatio(uint256 trancheIdx) external view returns (uint256); } diff --git a/contracts/carbon/interfaces/ITrancheVault.sol b/contracts/carbon/interfaces/ITrancheVault.sol index 34dc58c..d4819f5 100644 --- a/contracts/carbon/interfaces/ITrancheVault.sol +++ b/contracts/carbon/interfaces/ITrancheVault.sol @@ -29,6 +29,7 @@ struct Checkpoint { uint256 totalAssets; uint256 protocolFeeRate; uint256 timestamp; + uint256 unpaidFees; } struct Configuration { @@ -202,7 +203,7 @@ interface ITrancheVault is IERC4626Upgradeable, IERC165 { * - is used by StructuredPortfolio only in Live portfolio status * @param _totalAssets Total assets amount to save in the checkpoint */ - function updateCheckpointFromPortfolio(uint256 _totalAssets) external; + function updateCheckpointFromPortfolio(uint256 _totalAssets, uint256 deficit) external; /// @return Total tranche assets including accrued but yet not paid fees function totalAssetsBeforeFees() external view returns (uint256); diff --git a/contracts/carbon/mocks/StructuredPortfolioFactoryMock.sol b/contracts/carbon/mocks/StructuredPortfolioFactoryMock.sol new file mode 100644 index 0000000..d182f15 --- /dev/null +++ b/contracts/carbon/mocks/StructuredPortfolioFactoryMock.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT +pragma solidity ^0.8.16; + +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {IFixedInterestOnlyLoans} from "../interfaces/IFixedInterestOnlyLoans.sol"; +import {IProtocolConfig} from "../interfaces/IProtocolConfig.sol"; +import {IStructuredPortfolio, TrancheInitData, PortfolioParams, ExpectedEquityRate} from "../interfaces/IStructuredPortfolio.sol"; +import {ITrancheVault} from "../interfaces/ITrancheVault.sol"; +import {ProxyWrapper} from "../proxy/ProxyWrapper.sol"; +import {IDepositController} from "../interfaces/IDepositController.sol"; +import {IWithdrawController} from "../interfaces/IWithdrawController.sol"; +import {ITransferController} from "../interfaces/ITransferController.sol"; + +struct TrancheData { + string name; + string symbol; + /// @dev Implementation of the controller applied when calling deposit-related functions + address depositControllerImplementation; + /// @dev Encoded args with initialize method selector from deposit controller + bytes depositControllerInitData; + /// @dev Implementation of the controller applied when calling withdraw-related functions + address withdrawControllerImplementation; + /// @dev Encoded args with initialize method selector from withdraw controller + bytes withdrawControllerInitData; + /// @dev Implementation of the controller used when calling transfer-related functions + address transferControllerImplementation; + /// @dev Encoded args with initialize method selector from transfer controller + bytes transferControllerInitData; + /// @dev The APY expected to be granted at the end of the portfolio + uint128 targetApy; + /// @dev The minimum ratio of funds obtained in a tranche vault to its subordinate tranches + uint128 minSubordinateRatio; + /// @dev Manager fee expressed in BPS + uint256 managerFeeRate; +} + +/** + * @title A factory for deploying Structured Portfolios + * @dev Only whitelisted users can create portfolios + */ +contract StructuredPortfolioFactoryMock is AccessControlEnumerable { + using Address for address; + bytes32 public constant WHITELISTED_MANAGER_ROLE = keccak256("WHITELISTED_MANAGER_ROLE"); + + IStructuredPortfolio[] public portfolios; + address public trancheImplementation; + address public portfolioImplementation; + IProtocolConfig public protocolConfig; + + /** + * @notice Event fired on portfolio creation + * @param newPortfolio Address of the newly created portfolio + * @param manager Address of the portfolio manager + * @param tranches List of adressess of tranche vaults deployed to store assets + */ + event PortfolioCreated(IStructuredPortfolio indexed newPortfolio, address indexed manager, ITrancheVault[] tranches); + + /** + * @dev Grants admin role to message sender, allowing for whitelisting of portfolio managers + * @dev Portfolio and tranche implementation addresses are stored in order to create proxies + * @param _portfolioImplementation Portfolio implementation address + * @param _trancheImplementation Tranche vault implementation address + * @param _protocolConfig Protocol config address + */ + constructor( + address _portfolioImplementation, + address _trancheImplementation, + IProtocolConfig _protocolConfig + ) { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + portfolioImplementation = _portfolioImplementation; + trancheImplementation = _trancheImplementation; + protocolConfig = _protocolConfig; + } + + /** + * @notice Creates a portfolio alongside with its tranche vaults + * @dev Tranche vaults are ordered from the most volatile to the most stable + * @param fixedInterestOnlyLoans Address of a Fixed Intereset Only Loans used for managing loans + * @param portfolioParams Parameters used for portfolio deployment + * @param tranchesData Data used for tranche vaults deployment + */ + function createPortfolio( + IERC20WithDecimals underlyingToken, + IFixedInterestOnlyLoans fixedInterestOnlyLoans, + PortfolioParams calldata portfolioParams, + TrancheData[] calldata tranchesData, + ExpectedEquityRate calldata expectedEquityRate + ) external { + require(hasRole(WHITELISTED_MANAGER_ROLE, msg.sender), "SPF: Only whitelisted manager"); + + (TrancheInitData[] memory tranchesInitData, ITrancheVault[] memory tranches) = _deployTranches(underlyingToken, tranchesData); + + IStructuredPortfolio newPortfolio = IStructuredPortfolio( + address( + new ProxyWrapper( + portfolioImplementation, + abi.encodeWithSelector( + IStructuredPortfolio.initialize.selector, + msg.sender, + underlyingToken, + fixedInterestOnlyLoans, + protocolConfig, + portfolioParams, + tranchesInitData, + expectedEquityRate + ) + ) + ) + ); + portfolios.push(newPortfolio); + + emit PortfolioCreated(newPortfolio, msg.sender, tranches); + } + + /** + * @notice Deploys all tranche vaults for a portfolio + * @param underlyingToken Token used as an underlying asset + * @param tranchesData Data used for tranche vaults deployment + */ + function _deployTranches(IERC20WithDecimals underlyingToken, TrancheData[] calldata tranchesData) + internal + returns (TrancheInitData[] memory trancheInitData, ITrancheVault[] memory tranches) + { + uint256 tranchesCount = tranchesData.length; + trancheInitData = new TrancheInitData[](tranchesCount); + tranches = new ITrancheVault[](tranchesCount); + + for (uint256 i = 0; i < tranchesCount; i++) { + TrancheData memory trancheData = tranchesData[i]; + + IDepositController depositController = IDepositController( + address(new ProxyWrapper(trancheData.depositControllerImplementation, trancheData.depositControllerInitData)) + ); + + IWithdrawController withdrawController = IWithdrawController( + address(new ProxyWrapper(trancheData.withdrawControllerImplementation, trancheData.withdrawControllerInitData)) + ); + + ITransferController transferController = ITransferController( + address(new ProxyWrapper(trancheData.transferControllerImplementation, trancheData.transferControllerInitData)) + ); + + ITrancheVault tranche = ITrancheVault( + address( + new ProxyWrapper( + trancheImplementation, + abi.encodeWithSelector( + ITrancheVault.initialize.selector, + trancheData.name, + trancheData.symbol, + underlyingToken, + depositController, + withdrawController, + transferController, + protocolConfig, + i, + msg.sender, + trancheData.managerFeeRate + ) + ) + ) + ); + + trancheInitData[i] = TrancheInitData(tranche, trancheData.targetApy, trancheData.minSubordinateRatio); + + tranches[i] = tranche; + } + } + + /// @return All created portfolios + function getPortfolios() external view returns (IStructuredPortfolio[] memory) { + return portfolios; + } +} diff --git a/contracts/carbon/test/FixedInterestOnlyLoans.sol b/contracts/carbon/test/FixedInterestOnlyLoans.sol index e08191c..146db8c 100644 --- a/contracts/carbon/test/FixedInterestOnlyLoans.sol +++ b/contracts/carbon/test/FixedInterestOnlyLoans.sol @@ -39,7 +39,7 @@ contract FixedInterestOnlyLoans is ERC721Upgradeable, Upgradeable, IFixedInteres _; } - function initialize(IProtocolConfig _protocolConfig) external initializer { + function initialize(IProtocolConfig _protocolConfig) public initializer { __Upgradeable_init(msg.sender, _protocolConfig.pauserAddress()); __ERC721_init("FixedInterestOnlyLoans", "FIOL"); } diff --git a/contracts/carbon/test/FixedInterestOnlyLoansTest.sol b/contracts/carbon/test/FixedInterestOnlyLoansTest.sol new file mode 100644 index 0000000..57a33f7 --- /dev/null +++ b/contracts/carbon/test/FixedInterestOnlyLoansTest.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {FixedInterestOnlyLoans} from "./FixedInterestOnlyLoans.sol"; +import {ProtocolConfig} from "../ProtocolConfig.sol"; + +contract FixedInterestOnlyLoansTest is FixedInterestOnlyLoans { + constructor(ProtocolConfig protocolConfig) { + initialize(protocolConfig); + } +} diff --git a/contracts/carbon/test/ProtocolConfigTest.sol b/contracts/carbon/test/ProtocolConfigTest.sol new file mode 100644 index 0000000..55c40e9 --- /dev/null +++ b/contracts/carbon/test/ProtocolConfigTest.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {ProtocolConfig} from "../ProtocolConfig.sol"; + +contract ProtocolConfigTest is ProtocolConfig { + constructor( + uint256 _defaultProtocolFeeRate, + address _protocolAdmin, + address _protocolTreasury, + address _pauserAddress + ) { + initialize(_defaultProtocolFeeRate, _protocolAdmin, _protocolTreasury, _pauserAddress); + } +} diff --git a/contracts/carbon/test/StructuredPortfolioTest.sol b/contracts/carbon/test/StructuredPortfolioTest.sol index d78aec1..c015bd8 100644 --- a/contracts/carbon/test/StructuredPortfolioTest.sol +++ b/contracts/carbon/test/StructuredPortfolioTest.sol @@ -39,10 +39,6 @@ contract StructuredPortfolioTest is StructuredPortfolio { virtualTokenBalance -= decrement; } - function getNewLoansDeficit(uint256 currentDeficit, int256 delta) external pure returns (uint256) { - return _getNewLoansDeficit(currentDeficit, delta); - } - function someLoansDefaultedTest() external view returns (bool) { return someLoansDefaulted; } diff --git a/contracts/carbon/test/StructuredPortfolioTest2.sol b/contracts/carbon/test/StructuredPortfolioTest2.sol new file mode 100644 index 0000000..8fed5a7 --- /dev/null +++ b/contracts/carbon/test/StructuredPortfolioTest2.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {StructuredPortfolio, PortfolioParams, TrancheInitData, ExpectedEquityRate} from "../StructuredPortfolio.sol"; +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {IProtocolConfig} from "../interfaces/IProtocolConfig.sol"; +import {IFixedInterestOnlyLoans} from "../interfaces/IFixedInterestOnlyLoans.sol"; + +contract StructuredPortfolioTest2 is StructuredPortfolio { + constructor( + address manager, + IERC20WithDecimals underlyingToken, + IFixedInterestOnlyLoans _fixedInterestOnlyLoans, + IProtocolConfig _protocolConfig, + PortfolioParams memory portfolioParams, + TrancheInitData[] memory tranchesInitData, + ExpectedEquityRate memory _expectedEquityRate + ) { + initialize( + manager, + underlyingToken, + _fixedInterestOnlyLoans, + _protocolConfig, + portfolioParams, + tranchesInitData, + _expectedEquityRate + ); + } +} diff --git a/contracts/carbon/test/TrancheVaultTest.sol b/contracts/carbon/test/TrancheVaultTest.sol index da019e5..421aecd 100644 --- a/contracts/carbon/test/TrancheVaultTest.sol +++ b/contracts/carbon/test/TrancheVaultTest.sol @@ -21,12 +21,4 @@ contract TrancheVaultTest is TrancheVault { function mockDecreaseVirtualTokenBalance(uint256 amount) external { virtualTokenBalance -= amount; } - - function maxTrancheValueComplyingWithRatio() external view returns (uint256) { - return _maxTrancheValueComplyingWithRatio(); - } - - function minTrancheValueComplyingWithRatio() external view returns (uint256) { - return _minTrancheValueComplyingWithRatio(); - } } diff --git a/contracts/carbon/test/TrancheVaultTest2.sol b/contracts/carbon/test/TrancheVaultTest2.sol new file mode 100644 index 0000000..d81fcf4 --- /dev/null +++ b/contracts/carbon/test/TrancheVaultTest2.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Business Source License 1.1 +// License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. "Business Source License" is a trademark of MariaDB Corporation Ab. + +// Parameters +// Licensor: TrueFi Foundation Ltd. +// Licensed Work: Structured Credit Vaults. The Licensed Work is (c) 2022 TrueFi Foundation Ltd. +// Additional Use Grant: Any uses listed and defined at this [LICENSE](https://github.com/trusttoken/contracts-carbon/license.md) +// Change Date: December 31, 2025 +// Change License: MIT + +pragma solidity ^0.8.16; + +import {TrancheVault} from "../TrancheVault.sol"; +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {IDepositController} from "../interfaces/IDepositController.sol"; +import {IWithdrawController} from "../interfaces/IWithdrawController.sol"; +import {ITransferController} from "../interfaces/ITransferController.sol"; +import {IProtocolConfig} from "../interfaces/IProtocolConfig.sol"; + +contract TrancheVaultTest2 is TrancheVault { + constructor( + string memory _name, + string memory _symbol, + IERC20WithDecimals _token, + IDepositController _depositController, + IWithdrawController _withdrawController, + ITransferController _transferController, + IProtocolConfig _protocolConfig, + uint256 _waterfallIndex, + address manager, + uint256 _managerFeeRate + ) { + initialize( + _name, + _symbol, + _token, + _depositController, + _withdrawController, + _transferController, + _protocolConfig, + _waterfallIndex, + manager, + _managerFeeRate + ); + } +}