diff --git a/.compiler.json b/.compiler.json new file mode 100644 index 0000000..2d91616 --- /dev/null +++ b/.compiler.json @@ -0,0 +1,9 @@ +{ + "version": "0.8.16", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + } + } +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d3a0983 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `eslint-config-archblock` + extends: ['archblock/contracts'], +} diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..e0519da --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,8 @@ +{ + "require": ["ts-node/register/transpile-only", "tsconfig-paths/register"], + "extension": ["ts"], + "target": "esnext", + "timeout": 40000, + "watch-files": ["test"], + "exit": true +} diff --git a/.solhint.json b/.solhint.json new file mode 100644 index 0000000..7d9a2e4 --- /dev/null +++ b/.solhint.json @@ -0,0 +1,27 @@ +{ + "extends": "solhint:recommended", + "rules": { + "avoid-suicide": "error", + "compiler-version": [ + "error", + ">=0.8.16" + ], + "state-visibility": "off", + "func-name-mixedcase": "off", + "var-name-mixedcase": "off", + "no-empty-blocks": "off", + "no-inline-assembly": "off", + "not-rely-on-time": "off", + "max-states-count": "off", + "reason-string": "off", + "const-name-snakecase": "off", + "avoid-tx-origin": "off", + "func-visibility": [ + "warn", + { + "ignoreConstructors": true, + "reentrancy": true + } + ] + } +} diff --git a/README.md b/README.md index e4da4b3..c2a3c3f 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -## 🥞 Minimum deposit controller +# 🦐 Minimum deposit controller diff --git a/abi-exporter.js b/abi-exporter.js new file mode 100644 index 0000000..5e16099 --- /dev/null +++ b/abi-exporter.js @@ -0,0 +1,70 @@ +const fs = require('fs') +const path = require('path') +const { extendConfig } = require('hardhat/config') + +const { HardhatPluginError } = require('hardhat/plugins') + +const { + TASK_COMPILE, +} = require('hardhat/builtin-tasks/task-names') + +extendConfig(function (config, userConfig) { + config.abiExporter = Object.assign( + { + path: './abi', + clear: false, + flat: false, + only: [], + except: [], + spacing: 2, + }, + userConfig.abiExporter, + ) +}) + +task(TASK_COMPILE, async function (args, hre, runSuper) { + const config = hre.config.abiExporter + + await runSuper() + + const outputDirectory = path.resolve(hre.config.paths.root, config.path) + + if (!outputDirectory.startsWith(hre.config.paths.root)) { + throw new HardhatPluginError('resolved path must be inside of project directory') + } + + if (outputDirectory === hre.config.paths.root) { + throw new HardhatPluginError('resolved path must not be root directory') + } + + if (config.clear) { + if (fs.existsSync(outputDirectory)) { + fs.rmdirSync(outputDirectory, { recursive: true }) + } + } + + if (!fs.existsSync(outputDirectory)) { + fs.mkdirSync(outputDirectory, { recursive: true }) + } + + for (const fullName of await hre.artifacts.getAllFullyQualifiedNames()) { + if (config.only.length && !config.only.some(m => fullName.match(m))) continue + if (config.except.length && config.except.some(m => fullName.match(m))) continue + + const { abi, sourceName, contractName, bytecode, deployedBytecode } = await hre.artifacts.readArtifact(fullName) + + if (!abi.length) continue + + const destination = path.resolve( + outputDirectory, + config.flat ? '' : sourceName, + contractName, + ) + '.json' + + if (!fs.existsSync(path.dirname(destination))) { + fs.mkdirSync(path.dirname(destination), { recursive: true }) + } + + fs.writeFileSync(destination, `${JSON.stringify({ abi, bytecode, deployedBytecode }, null, config.spacing)}\n`, { flag: 'w' }) + } +}) diff --git a/contracts/MinimumDepositController/IDepositController.sol b/contracts/MinimumDepositController/IDepositController.sol new file mode 100644 index 0000000..2af8648 --- /dev/null +++ b/contracts/MinimumDepositController/IDepositController.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.16; + +import {ILenderVerifier} from "../carbon/interfaces/ILenderVerifier.sol"; +import {Status} from "../carbon/interfaces/IStructuredPortfolio.sol"; +import {DepositAllowed} from "../carbon/interfaces/IDepositController.sol"; + +/** + * @title Contract for managing deposit related settings + * @dev Used by TrancheVault contract + */ +interface IDepositController { + /** + * @notice Event emitted when new ceiling is set + * @param newCeiling New ceiling value + */ + event CeilingChanged(uint256 newCeiling); + + /** + * @notice Event emitted when deposits are disabled or enabled for a specific StructuredPortfolio status + * @param newDepositAllowed Value indicating whether deposits should be enabled or disabled + * @param portfolioStatus StructuredPortfolio status for which changes are applied + */ + event DepositAllowedChanged(bool newDepositAllowed, Status portfolioStatus); + + /** + * @notice Event emitted when deposit fee rate is switched + * @param newFeeRate New deposit fee rate value (in BPS) + */ + event DepositFeeRateChanged(uint256 newFeeRate); + + /** + * @notice Event emitted when minimum deposit is changed + * @param newMinimumDeposit New minimum deposit value (in assets) + */ + event MinimumDepositChanged(uint256 newMinimumDeposit); + + /** + * @notice Event emitted when lender verifier is switched + * @param newLenderVerifier New lender verifier contract address + */ + event LenderVerifierChanged(ILenderVerifier indexed newLenderVerifier); + + /// @return DepositController manager role used for access control + function MANAGER_ROLE() external view returns (bytes32); + + /// @return Address of contract used for checking whether given address is allowed to put funds into an instrument according to implemented strategy + function lenderVerifier() external view returns (ILenderVerifier); + + /// @return Max asset capacity defined for TrancheVaults interacting with DepositController + function ceiling() external view returns (uint256); + + /// @return Rate (in BPS) of the fee applied to the deposit amount + function depositFeeRate() external view returns (uint256); + + /// @return Value indicating whether deposits are allowed when related StructuredPortfolio is in given status + /// @param status StructuredPortfolio status + function depositAllowed(Status status) external view returns (bool); + + /** + * @notice Setup contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param manager Address to which MANAGER_ROLE should be granted + * @param _lenderVerifier Address of LenderVerifier contract + * @param _depositFeeRate Deposit fee rate (in BPS) + * @param _minimumDeposit Minimum deposit value (in assets) + * @param _ceiling Ceiling value + */ + function initialize( + address manager, + address _lenderVerifier, + uint256 _depositFeeRate, + uint256 _minimumDeposit, + uint256 _ceiling + ) external; + + /** + * @return assets Max assets amount that can be deposited with TrancheVault shares minted to given receiver + * @param receiver Shares receiver address + */ + function maxDeposit(address receiver) external view returns (uint256 assets); + + /** + * @return shares Max TrancheVault shares amount given address can receive + * @param receiver Shares receiver address + */ + function maxMint(address receiver) external view returns (uint256 shares); + + /** + * @notice Simulates deposit assets conversion including fees + * @return shares Shares amount that can be obtained from the given assets amount + * @param assets Tested assets amount + */ + function previewDeposit(uint256 assets) external view returns (uint256 shares); + + /** + * @notice Simulates mint shares conversion including fees + * @return assets Assets amount that needs to be deposited to obtain given shares amount + * @param shares Tested shares amount + */ + function previewMint(uint256 shares) external view returns (uint256 assets); + + /** + * @notice Simulates deposit result + * @return shares Shares amount that can be obtained from the deposit with given params + * @return depositFee Fee for a deposit with given params + * @param sender Supposed deposit transaction sender address + * @param assets Supposed assets amount + * @param receiver Supposed shares receiver address + */ + function onDeposit( + address sender, + uint256 assets, + address receiver + ) external returns (uint256 shares, uint256 depositFee); + + /** + * @notice Simulates mint result + * @return assets Assets amount that needs to be provided to execute mint with given params + * @return mintFee Fee for a mint with given params + * @param sender Supposed mint transaction sender address + * @param shares Supposed shares amount + * @param receiver Supposed shares receiver address + */ + function onMint( + address sender, + uint256 shares, + address receiver + ) external returns (uint256 assets, uint256 mintFee); + + /** + * @notice Ceiling setter + * @param newCeiling New ceiling value + */ + function setCeiling(uint256 newCeiling) external; + + /** + * @notice Deposit allowed setter + * @param newDepositAllowed Value indicating whether deposits should be allowed when related StructuredPortfolio is in given status + * @param portfolioStatus StructuredPortfolio status for which changes are applied + */ + function setDepositAllowed(bool newDepositAllowed, Status portfolioStatus) external; + + /** + * @notice Deposit fee rate setter + * @param newFeeRate New deposit fee rate (in BPS) + */ + function setDepositFeeRate(uint256 newFeeRate) external; + + /** + * @notice Lender verifier setter + * @param newLenderVerifier New LenderVerifier contract address + */ + function setLenderVerifier(ILenderVerifier newLenderVerifier) external; + + /** + * @notice Allows to change ceiling, deposit fee rate, minimum deposit, lender verifier and enable or disable deposits at once + * @param newCeiling New ceiling value + * @param newFeeRate New deposit fee rate (in BPS) + * @param newMinimumDeposit New minimum deposit value (in assets) + * @param newLenderVerifier New LenderVerifier contract address + * @param newDepositAllowed New deposit allowed settings + */ + function configure( + uint256 newCeiling, + uint256 newFeeRate, + uint256 newMinimumDeposit, + ILenderVerifier newLenderVerifier, + DepositAllowed memory newDepositAllowed + ) external; +} diff --git a/contracts/MinimumDepositController/MinimumDepositController.sol b/contracts/MinimumDepositController/MinimumDepositController.sol new file mode 100644 index 0000000..620aab1 --- /dev/null +++ b/contracts/MinimumDepositController/MinimumDepositController.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.16; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {ILenderVerifier, Status, DepositAllowed} from "../carbon/interfaces/IDepositController.sol"; +import {ITrancheVault} from "../carbon/interfaces/ITrancheVault.sol"; +import {IDepositController} from "./IDepositController.sol"; + +uint256 constant BASIS_PRECISION = 10000; + +contract MinimumDepositController is IDepositController, Initializable, AccessControlEnumerable { + /// @dev Manager role used for access control + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + ILenderVerifier public lenderVerifier; + uint256 public ceiling; + uint256 public depositFeeRate; + uint256 public minimumDeposit; + + mapping(Status => bool) public depositAllowed; + + constructor() {} + + function initialize( + address manager, + address _lenderVerifier, + uint256 _depositFeeRate, + uint256 _minimumDeposit, + uint256 _ceiling + ) external initializer { + _grantRole(MANAGER_ROLE, manager); + lenderVerifier = ILenderVerifier(_lenderVerifier); + depositFeeRate = _depositFeeRate; + minimumDeposit = _minimumDeposit; + depositAllowed[Status.CapitalFormation] = true; + + ceiling = _ceiling; + } + + function maxDeposit(address receiver) public view returns (uint256) { + if (!lenderVerifier.isAllowed(receiver)) { + return 0; + } + + ITrancheVault tranche = ITrancheVault(msg.sender); + if (!depositAllowed[tranche.portfolio().status()]) { + return 0; + } + + uint256 totalAssets = tranche.totalAssets(); + if (ceiling <= totalAssets) { + return 0; + } + return ceiling - totalAssets; + } + + function maxMint(address receiver) external view returns (uint256) { + return previewDeposit(maxDeposit(receiver)); + } + + function onDeposit( + address, + uint256 assets, + address + ) external view returns (uint256, uint256) { + _requireMinimumDeposit(assets); + uint256 depositFee = _getDepositFee(assets); + return (previewDeposit(assets), depositFee); + } + + function onMint( + address, + uint256 shares, + address + ) external view returns (uint256, uint256) { + uint256 assets = ITrancheVault(msg.sender).convertToAssetsCeil(shares); + _requireMinimumDeposit(assets); + uint256 depositFee = _getDepositFee(assets); + return (assets, depositFee); + } + + function previewDeposit(uint256 assets) public view returns (uint256 shares) { + uint256 depositFee = _getDepositFee(assets); + return ITrancheVault(msg.sender).convertToShares(assets - depositFee); + } + + function previewMint(uint256 shares) public view returns (uint256) { + uint256 assets = ITrancheVault(msg.sender).convertToAssetsCeil(shares); + uint256 depositFee = _getDepositFee(assets); + return assets + depositFee; + } + + function setCeiling(uint256 newCeiling) public { + _requireManagerRole(); + ceiling = newCeiling; + emit CeilingChanged(newCeiling); + } + + function setDepositAllowed(bool newDepositAllowed, Status portfolioStatus) public { + _requireManagerRole(); + require(portfolioStatus == Status.CapitalFormation || portfolioStatus == Status.Live, "MDC: No custom value in Closed"); + depositAllowed[portfolioStatus] = newDepositAllowed; + emit DepositAllowedChanged(newDepositAllowed, portfolioStatus); + } + + function setDepositFeeRate(uint256 newFeeRate) public { + _requireManagerRole(); + depositFeeRate = newFeeRate; + emit DepositFeeRateChanged(newFeeRate); + } + + function setMinimumDeposit(uint256 newMinimumDeposit) public { + _requireManagerRole(); + minimumDeposit = newMinimumDeposit; + emit MinimumDepositChanged(newMinimumDeposit); + } + + function setLenderVerifier(ILenderVerifier newLenderVerifier) public { + _requireManagerRole(); + lenderVerifier = newLenderVerifier; + emit LenderVerifierChanged(newLenderVerifier); + } + + function configure( + uint256 newCeiling, + uint256 newFeeRate, + uint256 newMinimumDeposit, + ILenderVerifier newLenderVerifier, + DepositAllowed memory newDepositAllowed + ) external { + if (ceiling != newCeiling) { + setCeiling(newCeiling); + } + if (depositFeeRate != newFeeRate) { + setDepositFeeRate(newFeeRate); + } + if (newMinimumDeposit != minimumDeposit) { + setMinimumDeposit(newMinimumDeposit); + } + if (lenderVerifier != newLenderVerifier) { + setLenderVerifier(newLenderVerifier); + } + if (depositAllowed[newDepositAllowed.status] != newDepositAllowed.value) { + setDepositAllowed(newDepositAllowed.value, newDepositAllowed.status); + } + } + + function _getDepositFee(uint256 assets) internal view returns (uint256) { + return (assets * depositFeeRate) / BASIS_PRECISION; + } + + function _requireManagerRole() internal view { + require(hasRole(MANAGER_ROLE, msg.sender), "MDC: Only manager"); + } + + function _requireMinimumDeposit(uint256 assets) internal view { + require(assets >= minimumDeposit, "MDC: Assets below minimum deposit"); + } +} diff --git a/contracts/carbon/LoansManager.sol b/contracts/carbon/LoansManager.sol new file mode 100644 index 0000000..8405620 --- /dev/null +++ b/contracts/carbon/LoansManager.sol @@ -0,0 +1,147 @@ +// 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 {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import {IERC20WithDecimals} from "./interfaces/IERC20WithDecimals.sol"; +import {IFixedInterestOnlyLoans, FixedInterestOnlyLoanStatus} from "./interfaces/IFixedInterestOnlyLoans.sol"; +import {IERC20WithDecimals} from "./interfaces/IERC20WithDecimals.sol"; +import {ILoansManager, AddLoanParams} from "./interfaces/ILoansManager.sol"; + +/// @title Manager of portfolio's active loans +abstract contract LoansManager is ILoansManager { + using SafeERC20 for IERC20WithDecimals; + + IFixedInterestOnlyLoans public fixedInterestOnlyLoans; + IERC20WithDecimals public asset; + + uint256[] public activeLoanIds; + mapping(uint256 => bool) public issuedLoanIds; + + function _initialize(IFixedInterestOnlyLoans _fixedInterestOnlyLoans, IERC20WithDecimals _asset) internal { + fixedInterestOnlyLoans = _fixedInterestOnlyLoans; + asset = _asset; + } + + function _markLoanAsDefaulted(uint256 loanId) internal { + fixedInterestOnlyLoans.markAsDefaulted(loanId); + _tryToExcludeLoan(loanId); + emit LoanDefaulted(loanId); + } + + function _addLoan(AddLoanParams calldata params) internal { + uint256 loanId = fixedInterestOnlyLoans.issueLoan( + IERC20WithDecimals(address(asset)), + params.principal, + params.periodCount, + params.periodPayment, + params.periodDuration, + params.recipient, + params.gracePeriod, + params.canBeRepaidAfterDefault + ); + + issuedLoanIds[loanId] = true; + + emit LoanAdded(loanId); + } + + function _fundLoan(uint256 loanId) internal returns (uint256 principal) { + require(issuedLoanIds[loanId], "LM: Not issued by this contract"); + + principal = fixedInterestOnlyLoans.principal(loanId); + require(asset.balanceOf(address(this)) >= principal, "LM: Insufficient funds"); + + fixedInterestOnlyLoans.start(loanId); + activeLoanIds.push(loanId); + address borrower = fixedInterestOnlyLoans.recipient(loanId); + asset.safeTransfer(borrower, principal); + + emit LoanFunded(loanId); + } + + function _repayLoan(uint256 loanId) internal returns (uint256 amount) { + amount = _repayFixedInterestOnlyLoan(loanId); + asset.safeTransferFrom(msg.sender, address(this), amount); + emit LoanRepaid(loanId, amount); + } + + function _updateLoanGracePeriod(uint256 loanId, uint32 newGracePeriod) internal { + fixedInterestOnlyLoans.updateInstrument(loanId, newGracePeriod); + emit LoanGracePeriodUpdated(loanId, newGracePeriod); + } + + function _cancelLoan(uint256 loanId) internal { + fixedInterestOnlyLoans.cancel(loanId); + emit LoanCancelled(loanId); + } + + function _repayFixedInterestOnlyLoan(uint256 loanId) internal returns (uint256) { + require(issuedLoanIds[loanId], "LM: Not issued by this contract"); + require(fixedInterestOnlyLoans.recipient(loanId) == msg.sender, "LM: Not an instrument recipient"); + + uint256 amount = fixedInterestOnlyLoans.expectedRepaymentAmount(loanId); + fixedInterestOnlyLoans.repay(loanId, amount); + _tryToExcludeLoan(loanId); + + return amount; + } + + function _tryToExcludeLoan(uint256 loanId) internal { + FixedInterestOnlyLoanStatus loanStatus = fixedInterestOnlyLoans.status(loanId); + + if ( + loanStatus == FixedInterestOnlyLoanStatus.Started || + loanStatus == FixedInterestOnlyLoanStatus.Accepted || + loanStatus == FixedInterestOnlyLoanStatus.Created + ) { + return; + } + + uint256 loansLength = activeLoanIds.length; + for (uint256 i = 0; i < loansLength; i++) { + if (activeLoanIds[i] == loanId) { + if (i < loansLength - 1) { + activeLoanIds[i] = activeLoanIds[loansLength - 1]; + } + activeLoanIds.pop(); + emit ActiveLoanRemoved(loanId); + return; + } + } + } + + function _calculateAccruedInterest( + uint256 periodPayment, + uint256 periodDuration, + uint256 periodCount, + uint256 loanEndDate + ) internal view returns (uint256) { + uint256 fullInterest = periodPayment * periodCount; + if (block.timestamp >= loanEndDate) { + return fullInterest; + } + + uint256 loanDuration = (periodDuration * periodCount); + uint256 passed = block.timestamp + loanDuration - loanEndDate; + + return (fullInterest * passed) / loanDuration; + } + + function onERC721Received( + address, + address, + uint256, + bytes calldata + ) external pure returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } +} diff --git a/contracts/carbon/ProtocolConfig.sol b/contracts/carbon/ProtocolConfig.sol new file mode 100644 index 0000000..ce8f1d7 --- /dev/null +++ b/contracts/carbon/ProtocolConfig.sol @@ -0,0 +1,105 @@ +// 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 {IProtocolConfig} from "./interfaces/IProtocolConfig.sol"; +import {Upgradeable} from "./proxy/Upgradeable.sol"; + +struct CustomFeeRate { + bool isSet; + uint16 feeRate; +} + +contract ProtocolConfig is Upgradeable, IProtocolConfig { + uint256 public defaultProtocolFeeRate; + address public protocolAdmin; + address public protocolTreasury; + address public pauserAddress; + + mapping(address => CustomFeeRate) internal customFeeRates; + + function initialize( + uint256 _defaultProtocolFeeRate, + address _protocolAdmin, + address _protocolTreasury, + address _pauserAddress + ) external initializer { + __Upgradeable_init(msg.sender, msg.sender); + defaultProtocolFeeRate = _defaultProtocolFeeRate; + protocolAdmin = _protocolAdmin; + protocolTreasury = _protocolTreasury; + pauserAddress = _pauserAddress; + } + + function protocolFeeRate() external view returns (uint256) { + return _protocolFeeRate(msg.sender); + } + + function protocolFeeRate(address contractAddress) external view returns (uint256) { + return _protocolFeeRate(contractAddress); + } + + function _protocolFeeRate(address contractAddress) internal view returns (uint256) { + CustomFeeRate memory customFeeRate = customFeeRates[contractAddress]; + return customFeeRate.isSet ? customFeeRate.feeRate : defaultProtocolFeeRate; + } + + function setCustomProtocolFeeRate(address contractAddress, uint16 newFeeRate) external { + _requireDefaultAdminRole(); + + CustomFeeRate memory customFeeRate = customFeeRates[contractAddress]; + require(!customFeeRate.isSet || newFeeRate != customFeeRate.feeRate, "PC: Fee already set"); + + customFeeRates[contractAddress] = CustomFeeRate({isSet: true, feeRate: newFeeRate}); + + emit CustomProtocolFeeRateChanged(contractAddress, newFeeRate); + } + + function removeCustomProtocolFeeRate(address contractAddress) external { + _requireDefaultAdminRole(); + require(customFeeRates[contractAddress].isSet, "PC: No fee rate to remove"); + customFeeRates[contractAddress] = CustomFeeRate({isSet: false, feeRate: 0}); + emit CustomProtocolFeeRateRemoved(contractAddress); + } + + function setDefaultProtocolFeeRate(uint256 newFeeRate) external { + _requireDefaultAdminRole(); + require(newFeeRate != defaultProtocolFeeRate, "PC: Fee already set"); + defaultProtocolFeeRate = newFeeRate; + emit DefaultProtocolFeeRateChanged(newFeeRate); + } + + function setProtocolAdmin(address newProtocolAdmin) external { + _requireDefaultAdminRole(); + require(newProtocolAdmin != protocolAdmin, "PC: Admin already set"); + protocolAdmin = newProtocolAdmin; + emit ProtocolAdminChanged(newProtocolAdmin); + } + + function setProtocolTreasury(address newProtocolTreasury) external { + _requireDefaultAdminRole(); + require(newProtocolTreasury != protocolTreasury, "PC: Treasury already set"); + protocolTreasury = newProtocolTreasury; + emit ProtocolTreasuryChanged(newProtocolTreasury); + } + + function setPauserAddress(address newPauserAddress) external { + _requireDefaultAdminRole(); + require(newPauserAddress != pauserAddress, "PC: Pauser already set"); + pauserAddress = newPauserAddress; + emit PauserAddressChanged(newPauserAddress); + } + + function _requireDefaultAdminRole() internal view { + require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "PC: Only default admin"); + } +} diff --git a/contracts/carbon/StructuredPortfolio.sol b/contracts/carbon/StructuredPortfolio.sol new file mode 100644 index 0000000..a6ee720 --- /dev/null +++ b/contracts/carbon/StructuredPortfolio.sol @@ -0,0 +1,534 @@ +// 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 {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Upgradeable} from "./proxy/Upgradeable.sol"; +import {IERC20WithDecimals} from "./interfaces/IERC20WithDecimals.sol"; +import {IFixedInterestOnlyLoans, FixedInterestOnlyLoanStatus} from "./interfaces/IFixedInterestOnlyLoans.sol"; +import {ITrancheVault, Checkpoint} from "./interfaces/ITrancheVault.sol"; +import {IProtocolConfig} from "./interfaces/IProtocolConfig.sol"; +import {IDepositController} from "./interfaces/IDepositController.sol"; +import {IWithdrawController} from "./interfaces/IWithdrawController.sol"; +import {IStructuredPortfolio, Status, TrancheData, TrancheInitData, PortfolioParams, ExpectedEquityRate, LoansDeficitCheckpoint, BASIS_PRECISION, YEAR} from "./interfaces/IStructuredPortfolio.sol"; +import {LoansManager, AddLoanParams} from "./LoansManager.sol"; + +contract StructuredPortfolio is IStructuredPortfolio, LoansManager, Upgradeable { + using SafeERC20 for IERC20WithDecimals; + + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); // 0x241ecf16d79d0f8dbfb92cbc07fe17840425976cf0667f022fe9877caa831b08 + + IProtocolConfig public protocolConfig; + + Status public status; + string public name; + uint256 public endDate; + uint256 public startDate; + uint256 public startDeadline; + uint256 public minimumSize; + uint256 public virtualTokenBalance; + uint256 public portfolioDuration; + + ITrancheVault[] public tranches; + TrancheData[] public tranchesData; + ExpectedEquityRate public expectedEquityRate; + + bool internal someLoansDefaulted; + + function initialize( + address manager, + IERC20WithDecimals underlyingToken, + IFixedInterestOnlyLoans _fixedInterestOnlyLoans, + IProtocolConfig _protocolConfig, + PortfolioParams memory portfolioParams, + TrancheInitData[] memory tranchesInitData, + ExpectedEquityRate calldata _expectedEquityRate + ) external initializer { + _initialize(_fixedInterestOnlyLoans, underlyingToken); + __Upgradeable_init(_protocolConfig.protocolAdmin(), _protocolConfig.pauserAddress()); + _grantRole(MANAGER_ROLE, manager); + + require(portfolioParams.duration > 0, "SP: Duration cannot be zero"); + + uint256 tranchesCount = tranchesInitData.length; + + uint8 tokenDecimals = underlyingToken.decimals(); + for (uint256 i = 0; i < tranchesCount; i++) { + require(tokenDecimals == tranchesInitData[i].tranche.decimals(), "SP: Decimals mismatched"); + } + + require(tranchesInitData[0].targetApy == 0, "SP: Target APY in tranche 0"); + require(tranchesInitData[0].minSubordinateRatio == 0, "SP: Min sub ratio in tranche 0"); + + protocolConfig = _protocolConfig; + + asset = underlyingToken; + name = portfolioParams.name; + portfolioDuration = portfolioParams.duration; + startDeadline = block.timestamp + portfolioParams.capitalFormationPeriod; + minimumSize = portfolioParams.minimumSize; + expectedEquityRate = _expectedEquityRate; + + LoansDeficitCheckpoint memory emptyLoansDeficitCheckpoint = LoansDeficitCheckpoint({deficit: 0, timestamp: 0}); + + for (uint256 i = 0; i < tranchesCount; i++) { + TrancheInitData memory initData = tranchesInitData[i]; + tranches.push(initData.tranche); + initData.tranche.setPortfolio(this); + underlyingToken.safeApprove(address(initData.tranche), type(uint256).max); + tranchesData.push(TrancheData(initData.targetApy, initData.minSubordinateRatio, 0, 0, emptyLoansDeficitCheckpoint)); + } + + emit PortfolioInitialized(tranches); + } + + function getTranches() external view returns (ITrancheVault[] memory) { + return tranches; + } + + function getTrancheData(uint256 i) external view returns (TrancheData memory) { + return tranchesData[i]; + } + + function updateCheckpoints() public whenNotPaused { + require(status == Status.Live, "SP: Portfolio is not live"); + + if (someLoansDefaulted) { + _updateCheckpointsAndLoansDeficit(); + } else { + _updateCheckpoints(); + } + } + + function _updateCheckpointsAndLoansDeficit() internal { + uint256[] memory _totalAssetsBefore = new uint256[](tranches.length); + for (uint256 i = 0; i < tranches.length; i++) { + _totalAssetsBefore[i] = tranches[i].getCheckpoint().totalAssets; + } + + 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; + } + return _addSigned(currentDeficit, delta); + } + + function increaseVirtualTokenBalance(uint256 increment) external { + _changeVirtualTokenBalance(SafeCast.toInt256(increment)); + } + + function decreaseVirtualTokenBalance(uint256 decrement) external { + _changeVirtualTokenBalance(-SafeCast.toInt256(decrement)); + } + + function _changeVirtualTokenBalance(int256 delta) internal { + uint256 tranchesCount = tranches.length; + for (uint256 i = 0; i < tranchesCount; i++) { + if (msg.sender == address(tranches[i])) { + virtualTokenBalance = _addSigned(virtualTokenBalance, delta); + return; + } + } + revert("SP: Not a tranche"); + } + + function totalAssets() public view returns (uint256) { + if (status == Status.Live) { + return liquidAssets() + loansValue(); + } + return _sum(_tranchesTotalAssets()); + } + + function liquidAssets() public view returns (uint256) { + uint256 _totalPendingFees = totalPendingFees(); + return _saturatingSub(virtualTokenBalance, _totalPendingFees); + } + + function totalPendingFees() public view returns (uint256) { + uint256 sum = 0; + uint256 tranchesCount = tranches.length; + uint256[] memory _totalAssets = calculateWaterfallWithoutFees(); + + for (uint256 i = 0; i < tranchesCount; i++) { + sum += tranches[i].totalPendingFeesForAssets(_totalAssets[i]); + } + + return sum; + } + + function loansValue() public view returns (uint256) { + uint256[] memory _loans = activeLoanIds; + + uint256 _value = 0; + for (uint256 i = 0; i < _loans.length; i++) { + _value += _calculateLoanValue(_loans[i]); + } + + return _value; + } + + function _calculateLoanValue(uint256 instrumentId) internal view returns (uint256) { + IFixedInterestOnlyLoans.LoanMetadata memory loan = fixedInterestOnlyLoans.loanData(instrumentId); + + uint256 accruedInterest = _calculateAccruedInterest(loan.periodPayment, loan.periodDuration, loan.periodCount, loan.endDate); + uint256 interestPaidSoFar = loan.periodsRepaid * loan.periodPayment; + + if (loan.principal + accruedInterest <= interestPaidSoFar) { + return 0; + } else { + return loan.principal + accruedInterest - interestPaidSoFar; + } + } + + function start() external whenNotPaused { + _requireManagerRole(); + require(status == Status.CapitalFormation, "SP: Portfolio is not in capital formation"); + uint256[] memory _totalAssets = _tranchesTotalAssets(); + _checkTranchesRatios(_totalAssets); + require(_sum(_totalAssets) >= minimumSize, "SP: Portfolio minimum size not reached"); + + _changePortfolioStatus(Status.Live); + + startDate = block.timestamp; + endDate = block.timestamp + portfolioDuration; + + uint256 tranchesCount = tranches.length; + for (uint256 i = 0; i < tranchesCount; i++) { + tranches[i].onPortfolioStart(); + } + } + + function checkTranchesRatiosFromTranche(uint256 newTotalAssets) external view { + uint256[] memory _totalAssets = calculateWaterfall(); + for (uint256 i = 0; i < _totalAssets.length; i++) { + if (msg.sender == address(tranches[i])) { + _totalAssets[i] = newTotalAssets; + } + } + _checkTranchesRatios(_totalAssets); + } + + function checkTranchesRatios() external view { + _checkTranchesRatios(_tranchesTotalAssets()); + } + + function _tranchesTotalAssets() internal view returns (uint256[] memory) { + if (status == Status.Live) { + return calculateWaterfall(); + } + + uint256[] memory _totalAssets = new uint256[](tranches.length); + for (uint256 i = 0; i < _totalAssets.length; i++) { + _totalAssets[i] = tranches[i].totalAssets(); + } + return _totalAssets; + } + + function _checkTranchesRatios(uint256[] memory _totalAssets) internal view { + uint256 subordinateValue = _totalAssets[0]; + + for (uint256 i = 1; i < _totalAssets.length; i++) { + uint256 minSubordinateRatio = tranchesData[i].minSubordinateRatio; + uint256 trancheValue = _totalAssets[i]; + + bool isMinRatioRequired = minSubordinateRatio != 0; + if (isMinRatioRequired) { + uint256 subordinateValueInBps = subordinateValue * BASIS_PRECISION; + uint256 lowerBound = trancheValue * minSubordinateRatio; + bool isMinRatioSatisfied = subordinateValueInBps >= lowerBound; + require(isMinRatioSatisfied, "SP: Tranche min ratio not met"); + } + + subordinateValue += trancheValue; + } + } + + function close() external whenNotPaused { + require(status != Status.Closed, "SP: Portfolio already closed"); + bool isAfterEndDate = block.timestamp > endDate; + require(isAfterEndDate || activeLoanIds.length == 0, "SP: Active loans exist"); + + bool isManager = hasRole(MANAGER_ROLE, msg.sender); + + if (status == Status.Live) { + require(isManager || isAfterEndDate, "SP: Cannot close before end date"); + _closeTranches(); + } else { + require(isManager || block.timestamp >= startDeadline, "SP: Cannot close before end date"); + } + + _changePortfolioStatus(Status.Closed); + + if (!isAfterEndDate) { + endDate = block.timestamp; + } + } + + function _closeTranches() internal { + uint256 limitedBlockTimestamp = _limitedBlockTimestamp(); + + 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); + } + } + + function _transfer(ITrancheVault tranche, uint256 amount) internal { + asset.safeTransfer(address(tranche), amount); + tranche.onTransfer(amount); + virtualTokenBalance -= amount; + } + + function calculateWaterfallForTranche(uint256 trancheIdx) external view returns (uint256) { + require(trancheIdx < tranches.length, "SP: Tranche index out of bounds"); + return calculateWaterfall()[trancheIdx]; + } + + function calculateWaterfallForTrancheWithoutFee(uint256 trancheIdx) external view returns (uint256) { + require(trancheIdx < tranches.length, "SP: Tranche index out of bounds"); + return calculateWaterfallWithoutFees()[trancheIdx]; + } + + function calculateWaterfall() public view returns (uint256[] memory) { + uint256[] memory waterfall = calculateWaterfallWithoutFees(); + for (uint256 i = 0; i < waterfall.length; i++) { + uint256 pendingFees = tranches[i].totalPendingFeesForAssets(waterfall[i]); + waterfall[i] = _saturatingSub(waterfall[i], pendingFees); + } + return waterfall; + } + + function calculateWaterfallWithoutFees() public view returns (uint256[] memory) { + uint256[] memory waterfall = new uint256[](tranches.length); + if (status != Status.Live) { + return waterfall; + } + + uint256 assetsLeft = virtualTokenBalance + loansValue(); + uint256 limitedBlockTimestamp = _limitedBlockTimestamp(); + + for (uint256 i = waterfall.length - 1; i > 0; i--) { + uint256 assumedTrancheValue = _assumedTrancheValue(i, limitedBlockTimestamp); + + if (assumedTrancheValue >= assetsLeft) { + waterfall[i] = assetsLeft; + return waterfall; + } + + waterfall[i] = assumedTrancheValue; + assetsLeft -= assumedTrancheValue; + } + + waterfall[0] = assetsLeft; + + return waterfall; + } + + function _assumedTrancheValue(uint256 trancheIdx, uint256 timestamp) internal view returns (uint256) { + Checkpoint memory checkpoint = tranches[trancheIdx].getCheckpoint(); + TrancheData memory trancheData = tranchesData[trancheIdx]; + + uint256 assumedTotalAssets = _assumedTotalAssets(checkpoint, trancheData, timestamp); + uint256 defaultedLoansDeficit = _defaultedLoansDeficit(trancheData, timestamp); + + 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 timePassed = _saturatingSub(timestamp, trancheData.loansDeficitCheckpoint.timestamp); + return _withInterest(trancheData.loansDeficitCheckpoint.deficit, trancheData.targetApy, timePassed); + } + + function _withInterest( + uint256 initialValue, + uint256 targetApy, + uint256 timePassed + ) internal pure returns (uint256) { + uint256 interest = (initialValue * targetApy * timePassed) / YEAR / BASIS_PRECISION; + return initialValue + interest; + } + + function addLoan(AddLoanParams calldata params) external whenNotPaused { + _requireManagerRole(); + require(status == Status.Live, "SP: Portfolio is not live"); + _addLoan(params); + } + + function fundLoan(uint256 loanId) external whenNotPaused { + _requireManagerRole(); + updateCheckpoints(); + + uint256 principal = _fundLoan(loanId); + require(virtualTokenBalance >= principal, "SP: Principal exceeds balance"); + virtualTokenBalance -= principal; + updateCheckpoints(); + } + + function repayLoan(uint256 loanId) external whenNotPaused { + require(status != Status.CapitalFormation, "SP: Cannot repay in capital formation"); + if (status == Status.Closed) { + _repayLoanInClosed(loanId); + } else { + _repayLoanInLive(loanId); + } + } + + function _repayLoanInLive(uint256 loanId) internal { + updateCheckpoints(); + uint256 repayAmount = _repayLoan(loanId); + virtualTokenBalance += repayAmount; + updateCheckpoints(); + } + + function _repayLoanInClosed(uint256 loanId) internal { + uint256 undistributedAssets = _repayFixedInterestOnlyLoan(loanId); + + for (uint256 i = tranches.length - 1; i > 0; i--) { + if (undistributedAssets == 0) { + return; + } + + TrancheData memory trancheData = tranchesData[i]; + uint256 trancheFreeCapacity = trancheData.maxValueOnClose - trancheData.distributedAssets; + if (trancheFreeCapacity == 0) { + continue; + } + + uint256 trancheShare = _min(trancheFreeCapacity, undistributedAssets); + undistributedAssets -= trancheShare; + _repayInClosed(i, trancheShare); + } + + if (undistributedAssets == 0) { + return; + } + + _repayInClosed(0, undistributedAssets); + } + + function _repayInClosed(uint256 trancheIdx, uint256 amount) internal { + ITrancheVault tranche = tranches[trancheIdx]; + tranchesData[trancheIdx].distributedAssets += amount; + asset.safeTransferFrom(msg.sender, address(tranche), amount); + tranche.onTransfer(amount); + tranche.updateCheckpoint(); + } + + function updateLoanGracePeriod(uint256 loanId, uint32 newGracePeriod) external { + _requireManagerRole(); + _updateLoanGracePeriod(loanId, newGracePeriod); + } + + function cancelLoan(uint256 loanId) external { + _requireManagerRole(); + _cancelLoan(loanId); + } + + function markLoanAsDefaulted(uint256 loanId) external whenNotPaused { + _requireManagerRole(); + + updateCheckpoints(); + someLoansDefaulted = true; + _markLoanAsDefaulted(loanId); + updateCheckpoints(); + } + + function getActiveLoans() external view returns (uint256[] memory) { + return activeLoanIds; + } + + function _changePortfolioStatus(Status newStatus) internal { + status = newStatus; + 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++) { + sum += components[i]; + } + return sum; + } + + function _limitedBlockTimestamp() internal view returns (uint256) { + return _min(block.timestamp, endDate); + } + + function _requireManagerRole() internal view { + require(hasRole(MANAGER_ROLE, msg.sender), "SP: Only manager"); + } +} diff --git a/contracts/carbon/StructuredPortfolioFactory.sol b/contracts/carbon/StructuredPortfolioFactory.sol new file mode 100644 index 0000000..0e220c9 --- /dev/null +++ b/contracts/carbon/StructuredPortfolioFactory.sol @@ -0,0 +1,127 @@ +// 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 {IStructuredPortfolioFactory, TrancheData, ITrancheVault, IERC20WithDecimals, IStructuredPortfolio, IProtocolConfig, IFixedInterestOnlyLoans, PortfolioParams, ExpectedEquityRate, TrancheInitData} from "./interfaces/IStructuredPortfolioFactory.sol"; +import {ProxyWrapper} from "./proxy/ProxyWrapper.sol"; + +contract StructuredPortfolioFactory is IStructuredPortfolioFactory, AccessControlEnumerable { + using Address for address; + bytes32 public constant WHITELISTED_MANAGER_ROLE = keccak256("WHITELISTED_MANAGER_ROLE"); + + IStructuredPortfolio[] public portfolios; + address public immutable trancheImplementation; + address public immutable portfolioImplementation; + IProtocolConfig public immutable protocolConfig; + + constructor( + address _portfolioImplementation, + address _trancheImplementation, + IProtocolConfig _protocolConfig + ) { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + portfolioImplementation = _portfolioImplementation; + trancheImplementation = _trancheImplementation; + protocolConfig = _protocolConfig; + } + + 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]; + + address depositController = Clones.clone(trancheData.depositControllerImplementation); + depositController.functionCall(trancheData.depositControllerInitData); + + address withdrawController = Clones.clone(trancheData.withdrawControllerImplementation); + withdrawController.functionCall(trancheData.withdrawControllerInitData); + + address transferController = Clones.clone(trancheData.transferControllerImplementation); + transferController.functionCall(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; + } + } + + function getPortfolios() external view returns (IStructuredPortfolio[] memory) { + return portfolios; + } +} diff --git a/contracts/carbon/TrancheVault.sol b/contracts/carbon/TrancheVault.sol new file mode 100644 index 0000000..401ae0c --- /dev/null +++ b/contracts/carbon/TrancheVault.sol @@ -0,0 +1,673 @@ +// 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 {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import {IERC20MetadataUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {AccessControlEnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import {IDepositController} from "./interfaces/IDepositController.sol"; +import {IWithdrawController} from "./interfaces/IWithdrawController.sol"; +import {ITrancheVault, SizeRange, Checkpoint, Configuration, IProtocolConfig} from "./interfaces/ITrancheVault.sol"; +import {ITransferController} from "./interfaces/ITransferController.sol"; +import {IERC20WithDecimals} from "./interfaces/IERC20WithDecimals.sol"; +import {IStructuredPortfolio, Status, BASIS_PRECISION, YEAR} from "./interfaces/IStructuredPortfolio.sol"; +import {IPausable} from "./interfaces/IPausable.sol"; +import {Upgradeable} from "./proxy/Upgradeable.sol"; + +contract TrancheVault is ITrancheVault, ERC20Upgradeable, Upgradeable { + using SafeERC20 for IERC20WithDecimals; + + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); // 0x241ecf16d79d0f8dbfb92cbc07fe17840425976cf0667f022fe9877caa831b08 + + bytes32 public constant TRANCHE_CONTROLLER_OWNER_ROLE = keccak256("TRANCHE_CONTROLLER_OWNER_ROLE"); // 0x5b4e632df2edce09667a379f949ff4559a6f6e163b09e2e961c6950a280403b4 + + IERC20WithDecimals internal token; + Checkpoint internal checkpoint; + IStructuredPortfolio public portfolio; + IDepositController public depositController; + IWithdrawController public withdrawController; + ITransferController public transferController; + IProtocolConfig public protocolConfig; + uint256 public waterfallIndex; + uint256 public unpaidProtocolFee; + uint256 public unpaidManagerFee; + address public managerFeeBeneficiary; + uint256 public managerFeeRate; + uint256 public virtualTokenBalance; + uint256 internal totalAssetsCache; + + modifier portfolioNotPaused() { + require(!IPausable(address(portfolio)).paused(), "TV: Portfolio is paused"); + _; + } + + constructor() {} + + modifier cacheTotalAssets() { + totalAssetsCache = totalAssets(); + _; + totalAssetsCache = 0; + } + + function initialize( + string memory _name, + string memory _symbol, + IERC20WithDecimals _token, + IDepositController _depositController, + IWithdrawController _withdrawController, + ITransferController _transferController, + IProtocolConfig _protocolConfig, + uint256 _waterfallIndex, + address manager, + uint256 _managerFeeRate + ) external initializer { + __ERC20_init(_name, _symbol); + __Upgradeable_init(_protocolConfig.protocolAdmin(), address(0)); + + _grantRole(TRANCHE_CONTROLLER_OWNER_ROLE, manager); + _grantRole(MANAGER_ROLE, manager); + + token = _token; + depositController = _depositController; + withdrawController = _withdrawController; + transferController = _transferController; + protocolConfig = _protocolConfig; + waterfallIndex = _waterfallIndex; + managerFeeBeneficiary = manager; + managerFeeRate = _managerFeeRate; + } + + // -- ERC4626 methods -- + function decimals() public view virtual override(ERC20Upgradeable, IERC20MetadataUpgradeable) returns (uint8) { + return token.decimals(); + } + + function asset() external view returns (address) { + return address(token); + } + + function totalAssets() public view virtual override returns (uint256) { + if (totalAssetsCache != 0) { + return totalAssetsCache; + } + uint256 balance = totalAssetsBeforeFees(); + uint256 pendingFees = totalPendingFeesForAssets(balance); + return balance > pendingFees ? balance - pendingFees : 0; + } + + function totalAssetsBeforeFees() public view returns (uint256) { + if (portfolio.status() == Status.Live) { + return portfolio.calculateWaterfallForTrancheWithoutFee(waterfallIndex); + } + + return virtualTokenBalance; + } + + function convertToShares(uint256 assets) public view returns (uint256) { + uint256 _totalAssets = totalAssets(); + if (portfolio.status() == Status.CapitalFormation || _totalAssets == 0) { + return assets; + } + return (assets * totalSupply()) / _totalAssets; + } + + function convertToSharesCeil(uint256 assets) public view returns (uint256) { + uint256 _totalAssets = totalAssets(); + if (portfolio.status() == Status.CapitalFormation || _totalAssets == 0) { + return assets; + } + return Math.ceilDiv(assets * totalSupply(), _totalAssets); + } + + function convertToAssets(uint256 shares) public view returns (uint256) { + uint256 _totalSupply = totalSupply(); + if (portfolio.status() == Status.CapitalFormation || _totalSupply == 0) { + return shares; + } + + return (shares * totalAssets()) / _totalSupply; + } + + function convertToAssetsCeil(uint256 shares) public view returns (uint256) { + uint256 _totalSupply = totalSupply(); + if (portfolio.status() == Status.CapitalFormation || _totalSupply == 0) { + return shares; + } + + return Math.ceilDiv(shares * totalAssets(), _totalSupply); + } + + function _maxDeposit(address receiver) internal view returns (uint256) { + if (portfolio.status() == Status.Live) { + if (totalSupply() != 0 && totalAssets() == 0) { + return 0; + } + } + + return depositController.maxDeposit(receiver); + } + + function maxDeposit(address receiver) external view returns (uint256) { + return _min(_maxDeposit(receiver), _maxDepositComplyingWithRatio()); + } + + function previewDeposit(uint256 assets) public view returns (uint256) { + return depositController.previewDeposit(assets); + } + + function deposit(uint256 amount, address receiver) external cacheTotalAssets portfolioNotPaused returns (uint256) { + require(amount <= _maxDeposit(receiver), "TV: Amount exceeds max deposit"); + (uint256 shares, uint256 depositFee) = depositController.onDeposit(msg.sender, amount, receiver); + + _payDepositFee(depositFee); + _depositAssets(amount - depositFee, shares, receiver); + + return shares; + } + + function _depositAssets( + uint256 amount, + uint256 shares, + address receiver + ) internal { + assert(msg.sender != address(this)); + assert(msg.sender != address(portfolio)); + require(amount > 0 && shares > 0, "TV: Amount cannot be zero"); + Status status = portfolio.status(); + + if (status == Status.Live) { + uint256 newTotalAssets = totalAssets() + amount; + portfolio.checkTranchesRatiosFromTranche(newTotalAssets); + _updateCheckpoint(newTotalAssets); + token.safeTransferFrom(msg.sender, address(portfolio), amount); + portfolio.increaseVirtualTokenBalance(amount); + } else { + token.safeTransferFrom(msg.sender, address(this), amount); + virtualTokenBalance += amount; + } + + _mint(receiver, shares); + + emit Deposit(msg.sender, receiver, amount, shares); + } + + function _maxMint(address receiver) internal view returns (uint256) { + return depositController.maxMint(receiver); + } + + function maxMint(address receiver) external view returns (uint256) { + uint256 maxRatioLimit = _maxDepositComplyingWithRatio(); + if (maxRatioLimit == type(uint256).max) { + return _maxMint(receiver); + } + return _min(_maxMint(receiver), previewDeposit(maxRatioLimit)); + } + + function previewMint(uint256 shares) public view returns (uint256) { + return depositController.previewMint(shares); + } + + function mint(uint256 shares, address receiver) external cacheTotalAssets portfolioNotPaused returns (uint256) { + require(shares <= _maxMint(receiver), "TV: Amount exceeds max mint"); + (uint256 assetAmount, uint256 depositFee) = depositController.onMint(msg.sender, shares, receiver); + + _payDepositFee(depositFee); + _depositAssets(assetAmount, shares, receiver); + + return assetAmount; + } + + function _maxWithdraw(address owner) public view returns (uint256) { + if (totalAssets() == 0) { + return 0; + } + + return withdrawController.maxWithdraw(owner); + } + + function maxWithdraw(address owner) external view returns (uint256) { + return _min(_maxWithdraw(owner), _maxWithdrawComplyingWithRatio()); + } + + function previewWithdraw(uint256 assets) public view returns (uint256) { + return withdrawController.previewWithdraw(assets); + } + + function withdraw( + uint256 assets, + address receiver, + address owner + ) external cacheTotalAssets portfolioNotPaused returns (uint256) { + require(assets <= _maxWithdraw(owner), "TV: Amount exceeds max withdraw"); + + (uint256 shares, uint256 withdrawFee) = withdrawController.onWithdraw(msg.sender, assets, receiver, owner); + + _payWithdrawFee(withdrawFee); + _withdrawAssets(assets, shares, owner, receiver); + + return shares; + } + + function _withdrawAssets( + uint256 assets, + uint256 shares, + address owner, + address receiver + ) internal { + require(assets > 0 && shares > 0, "TV: Amount cannot be zero"); + _safeBurn(owner, shares); + + Status status = portfolio.status(); + + if (status != Status.CapitalFormation) { + uint256 newTotalAssets = totalAssets() - assets; + if (status == Status.Live) { + portfolio.checkTranchesRatiosFromTranche(newTotalAssets); + } + _updateCheckpoint(newTotalAssets); + } + + _transferAssets(receiver, assets); + + emit Withdraw(msg.sender, receiver, owner, assets, shares); + } + + function _maxRedeem(address owner) public view returns (uint256) { + if (totalAssets() == 0) { + return 0; + } + + return withdrawController.maxRedeem(owner); + } + + function maxRedeem(address owner) external view returns (uint256) { + return _min(_maxRedeem(owner), convertToShares(_maxWithdrawComplyingWithRatio())); + } + + function previewRedeem(uint256 shares) public view returns (uint256) { + return withdrawController.previewRedeem(shares); + } + + function redeem( + uint256 shares, + address receiver, + address owner + ) external cacheTotalAssets portfolioNotPaused returns (uint256) { + require(shares <= _maxRedeem(owner), "TV: Amount exceeds max redeem"); + (uint256 assets, uint256 withdrawFee) = withdrawController.onRedeem(msg.sender, shares, receiver, owner); + + _payWithdrawFee(withdrawFee); + _withdrawAssets(assets, shares, owner, receiver); + + return assets; + } + + // -- ERC20 methods -- + function _transfer( + address sender, + address recipient, + uint256 amount + ) internal override portfolioNotPaused { + bool canTransfer = transferController.onTransfer(sender, sender, recipient, amount); + require(canTransfer, "TV: Transfer not allowed"); + super._transfer(sender, recipient, amount); + } + + function _approve( + address owner, + address spender, + uint256 amount + ) internal override portfolioNotPaused { + super._approve(owner, spender, amount); + } + + // -- ERC165 methods -- + function supportsInterface(bytes4 interfaceID) public view override(AccessControlEnumerableUpgradeable, IERC165) returns (bool) { + return (interfaceID == type(IERC165).interfaceId || + interfaceID == type(IERC20).interfaceId || + interfaceID == type(IERC4626).interfaceId || + super.supportsInterface(interfaceID)); + } + + // -- non ERC methods -- + function getCheckpoint() external view returns (Checkpoint memory) { + return checkpoint; + } + + function configure(Configuration memory newConfiguration) external { + if (newConfiguration.managerFeeRate != managerFeeRate) { + setManagerFeeRate(newConfiguration.managerFeeRate); + } + if (newConfiguration.managerFeeBeneficiary != managerFeeBeneficiary) { + setManagerFeeBeneficiary(newConfiguration.managerFeeBeneficiary); + } + if (newConfiguration.depositController != depositController) { + setDepositController(newConfiguration.depositController); + } + if (newConfiguration.withdrawController != withdrawController) { + setWithdrawController(newConfiguration.withdrawController); + } + if (newConfiguration.transferController != transferController) { + setTransferController(newConfiguration.transferController); + } + } + + function onPortfolioStart() external { + assert(address(this) != address(portfolio)); + _requirePortfolio(); + + uint256 balance = virtualTokenBalance; + virtualTokenBalance = 0; + + portfolio.increaseVirtualTokenBalance(balance); + token.safeTransfer(address(portfolio), balance); + _updateCheckpoint(balance); + } + + function onTransfer(uint256 assets) external { + _requirePortfolio(); + virtualTokenBalance += assets; + } + + function updateCheckpoint() external portfolioNotPaused { + require(portfolio.status() == Status.Closed, "TV: Only in Closed status"); + _updateCheckpoint(totalAssets()); + } + + function updateCheckpointFromPortfolio(uint256 newTotalAssets) 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; + } + + function _maxDepositComplyingWithRatio() internal view returns (uint256) { + uint256 maxTrancheValueComplyingWithRatio = _maxTrancheValueComplyingWithRatio(); + 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(); + return _saturatingSub(totalAssets(), minTrancheValueComplyingWithRatio); + } + + /** + * @param newTotalAssets Total assets value to save in checkpoint with fees deducted + */ + function _updateCheckpoint(uint256 newTotalAssets) internal { + if (portfolio.status() == Status.CapitalFormation) { + return; + } + + uint256 _totalAssetsBeforeFees = totalAssetsBeforeFees(); + _payProtocolFee(_totalAssetsBeforeFees); + _payManagerFee(_totalAssetsBeforeFees); + + uint256 protocolFeeRate = protocolConfig.protocolFeeRate(); + uint256 newTotalAssetsWithUnpaidFees = newTotalAssets + unpaidManagerFee + unpaidProtocolFee; + checkpoint = Checkpoint({ + totalAssets: newTotalAssetsWithUnpaidFees, + protocolFeeRate: protocolFeeRate, + timestamp: block.timestamp + }); + + emit CheckpointUpdated(newTotalAssets, protocolFeeRate); + } + + function _payProtocolFee(uint256 _totalAssetsBeforeFees) internal { + uint256 pendingFee = _pendingProtocolFee(_totalAssetsBeforeFees); + address protocolAddress = protocolConfig.protocolTreasury(); + (uint256 paidProtocolFee, uint256 _unpaidProtocolFee) = _payFee(pendingFee, protocolAddress); + unpaidProtocolFee = _unpaidProtocolFee; + emit ProtocolFeePaid(protocolAddress, paidProtocolFee); + } + + function _payManagerFee(uint256 _totalAssetsBeforeFees) internal { + uint256 pendingFee = _pendingManagerFee(_totalAssetsBeforeFees); + (uint256 paidManagerFee, uint256 _unpaidManagerFee) = _payFee(pendingFee, managerFeeBeneficiary); + unpaidManagerFee = _unpaidManagerFee; + emit ManagerFeePaid(managerFeeBeneficiary, paidManagerFee); + } + + function _payDepositFee(uint256 fee) internal { + if (fee == 0) { + return; + } + token.safeTransferFrom(msg.sender, managerFeeBeneficiary, fee); + emit ManagerFeePaid(managerFeeBeneficiary, fee); + } + + function _payWithdrawFee(uint256 fee) internal { + if (fee == 0) { + return; + } + _transferAssets(managerFeeBeneficiary, fee); + emit ManagerFeePaid(managerFeeBeneficiary, fee); + } + + function _transferAssets(address to, uint256 assets) internal { + require(to != address(this), "TV: Token transfer to TV"); + require(to != address(portfolio), "TV: Token transfer to SP"); + + if (portfolio.status() == Status.Live) { + portfolio.decreaseVirtualTokenBalance(assets); + token.safeTransferFrom(address(portfolio), to, assets); + } else { + virtualTokenBalance -= assets; + token.safeTransfer(to, assets); + } + } + + function _payFee(uint256 fee, address recipient) internal returns (uint256 paidFee, uint256 unpaidFee) { + if (fee == 0) { + return (0, 0); + } + + uint256 balance = portfolio.status() == Status.Live ? portfolio.virtualTokenBalance() : virtualTokenBalance; + + if (fee > balance) { + paidFee = balance; + unpaidFee = fee - balance; + } else { + paidFee = fee; + unpaidFee = 0; + } + + _transferAssets(recipient, paidFee); + } + + function totalPendingFees() external view returns (uint256) { + return totalPendingFeesForAssets(totalAssetsBeforeFees()); + } + + function totalPendingFeesForAssets(uint256 _totalAssetsBeforeFees) public view returns (uint256) { + return _pendingProtocolFee(_totalAssetsBeforeFees) + _pendingManagerFee(_totalAssetsBeforeFees); + } + + function pendingProtocolFee() external view returns (uint256) { + return _pendingProtocolFee(totalAssetsBeforeFees()); + } + + function _pendingProtocolFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { + return _accruedProtocolFee(_totalAssetsBeforeFees) + unpaidProtocolFee; + } + + function pendingManagerFee() external view returns (uint256) { + return _pendingManagerFee(totalAssetsBeforeFees()); + } + + function _pendingManagerFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { + return _accruedManagerFee(_totalAssetsBeforeFees) + unpaidManagerFee; + } + + function _accruedProtocolFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { + return _accruedFee(checkpoint.protocolFeeRate, _totalAssetsBeforeFees); + } + + function _accruedManagerFee(uint256 _totalAssetsBeforeFees) internal view returns (uint256) { + if (portfolio.status() != Status.Live) { + return 0; + } + return _accruedFee(managerFeeRate, _totalAssetsBeforeFees); + } + + function _accruedFee(uint256 feeRate, uint256 _totalAssetsBeforeFees) internal view returns (uint256) { + if (checkpoint.timestamp == 0) { + return 0; + } + uint256 adjustedTotalAssets = (block.timestamp - checkpoint.timestamp) * _totalAssetsBeforeFees; + return (adjustedTotalAssets * feeRate) / YEAR / BASIS_PRECISION; + } + + function setDepositController(IDepositController newController) public { + _requireTrancheControllerOwnerRole(); + _requireNonZeroAddress(address(newController)); + depositController = newController; + emit DepositControllerChanged(newController); + } + + function setWithdrawController(IWithdrawController newController) public { + _requireTrancheControllerOwnerRole(); + _requireNonZeroAddress(address(newController)); + withdrawController = newController; + emit WithdrawControllerChanged(newController); + } + + function setTransferController(ITransferController newController) public { + _requireTrancheControllerOwnerRole(); + _requireNonZeroAddress(address(newController)); + transferController = newController; + emit TransferControllerChanged(newController); + } + + function setManagerFeeRate(uint256 _managerFeeRate) public { + _requireManagerRole(); + _updateCheckpoint(totalAssets()); + + managerFeeRate = _managerFeeRate; + + emit ManagerFeeRateChanged(_managerFeeRate); + } + + function setManagerFeeBeneficiary(address _managerFeeBeneficiary) public { + _requireManagerRole(); + _requireNonZeroAddress(_managerFeeBeneficiary); + require(address(this) != _managerFeeBeneficiary, "TV: Cannot be TV address"); + + managerFeeBeneficiary = _managerFeeBeneficiary; + _updateCheckpoint(totalAssets()); + + emit ManagerFeeBeneficiaryChanged(_managerFeeBeneficiary); + } + + function setPortfolio(IStructuredPortfolio _portfolio) external { + require(address(portfolio) == address(0), "TV: Portfolio already set"); + portfolio = _portfolio; + } + + function _requireNonZeroAddress(address _address) internal pure { + require(_address != address(0), "TV: Cannot be zero address"); + } + + function _requireManagerRole() internal view { + require(hasRole(MANAGER_ROLE, msg.sender), "TV: Only manager"); + } + + function _requireTrancheControllerOwnerRole() internal view { + require(hasRole(TRANCHE_CONTROLLER_OWNER_ROLE, msg.sender), "TV: Only tranche controller owner"); + } + + function _requirePortfolio() internal view { + require(msg.sender == address(portfolio), "TV: Sender is not portfolio"); + } + + function _safeBurn(address owner, uint256 shares) internal { + if (owner == msg.sender) { + _burn(owner, shares); + return; + } + + uint256 sharesAllowance = allowance(owner, msg.sender); + require(sharesAllowance >= shares, "TV: Insufficient allowance"); + _burn(owner, shares); + _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/controllers/DepositController.sol b/contracts/carbon/controllers/DepositController.sol new file mode 100644 index 0000000..ed33cc2 --- /dev/null +++ b/contracts/carbon/controllers/DepositController.sol @@ -0,0 +1,150 @@ +// 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 {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {IDepositController, ILenderVerifier, Status, DepositAllowed} from "../interfaces/IDepositController.sol"; +import {ITrancheVault} from "../interfaces/ITrancheVault.sol"; + +uint256 constant BASIS_PRECISION = 10000; + +contract DepositController is IDepositController, Initializable, AccessControlEnumerable { + /// @dev Manager role used for access control + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + ILenderVerifier public lenderVerifier; + uint256 public ceiling; + uint256 public depositFeeRate; + + mapping(Status => bool) public depositAllowed; + + constructor() {} + + function initialize( + address manager, + address _lenderVerifier, + uint256 _depositFeeRate, + uint256 _ceiling + ) external initializer { + _grantRole(MANAGER_ROLE, manager); + lenderVerifier = ILenderVerifier(_lenderVerifier); + depositFeeRate = _depositFeeRate; + depositAllowed[Status.CapitalFormation] = true; + + ceiling = _ceiling; + } + + function maxDeposit(address receiver) public view returns (uint256) { + if (!lenderVerifier.isAllowed(receiver)) { + return 0; + } + + ITrancheVault tranche = ITrancheVault(msg.sender); + if (!depositAllowed[tranche.portfolio().status()]) { + return 0; + } + + uint256 totalAssets = tranche.totalAssets(); + if (ceiling <= totalAssets) { + return 0; + } + return ceiling - totalAssets; + } + + function maxMint(address receiver) external view returns (uint256) { + return previewDeposit(maxDeposit(receiver)); + } + + function onDeposit( + address, + uint256 assets, + address + ) external view returns (uint256, uint256) { + uint256 depositFee = _getDepositFee(assets); + return (previewDeposit(assets), depositFee); + } + + function onMint( + address, + uint256 shares, + address + ) external view returns (uint256, uint256) { + uint256 assets = ITrancheVault(msg.sender).convertToAssetsCeil(shares); + uint256 depositFee = _getDepositFee(assets); + return (assets, depositFee); + } + + function previewDeposit(uint256 assets) public view returns (uint256 shares) { + uint256 depositFee = _getDepositFee(assets); + return ITrancheVault(msg.sender).convertToShares(assets - depositFee); + } + + function previewMint(uint256 shares) public view returns (uint256) { + uint256 assets = ITrancheVault(msg.sender).convertToAssetsCeil(shares); + uint256 depositFee = _getDepositFee(assets); + return assets + depositFee; + } + + function setCeiling(uint256 newCeiling) public { + _requireManagerRole(); + ceiling = newCeiling; + emit CeilingChanged(newCeiling); + } + + function setDepositAllowed(bool newDepositAllowed, Status portfolioStatus) public { + _requireManagerRole(); + require(portfolioStatus == Status.CapitalFormation || portfolioStatus == Status.Live, "DC: No custom value in Closed"); + depositAllowed[portfolioStatus] = newDepositAllowed; + emit DepositAllowedChanged(newDepositAllowed, portfolioStatus); + } + + function setDepositFeeRate(uint256 newFeeRate) public { + _requireManagerRole(); + depositFeeRate = newFeeRate; + emit DepositFeeRateChanged(newFeeRate); + } + + function setLenderVerifier(ILenderVerifier newLenderVerifier) public { + _requireManagerRole(); + lenderVerifier = newLenderVerifier; + emit LenderVerifierChanged(newLenderVerifier); + } + + function configure( + uint256 newCeiling, + uint256 newFeeRate, + ILenderVerifier newLenderVerifier, + DepositAllowed memory newDepositAllowed + ) external { + if (ceiling != newCeiling) { + setCeiling(newCeiling); + } + if (depositFeeRate != newFeeRate) { + setDepositFeeRate(newFeeRate); + } + if (lenderVerifier != newLenderVerifier) { + setLenderVerifier(newLenderVerifier); + } + if (depositAllowed[newDepositAllowed.status] != newDepositAllowed.value) { + setDepositAllowed(newDepositAllowed.value, newDepositAllowed.status); + } + } + + function _getDepositFee(uint256 assets) internal view returns (uint256) { + return (assets * depositFeeRate) / BASIS_PRECISION; + } + + function _requireManagerRole() internal view { + require(hasRole(MANAGER_ROLE, msg.sender), "DC: Only manager"); + } +} diff --git a/contracts/carbon/controllers/TransferController.sol b/contracts/carbon/controllers/TransferController.sol new file mode 100644 index 0000000..22ed52c --- /dev/null +++ b/contracts/carbon/controllers/TransferController.sol @@ -0,0 +1,28 @@ +// 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 {ITransferController} from "../interfaces/ITransferController.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; + +contract TransferController is ITransferController, Initializable { + function initialize(address) external initializer {} + + function onTransfer( + address, + address, + address, + uint256 + ) external pure returns (bool isTransferAllowed) { + return true; + } +} diff --git a/contracts/carbon/controllers/WithdrawController.sol b/contracts/carbon/controllers/WithdrawController.sol new file mode 100644 index 0000000..cdc6431 --- /dev/null +++ b/contracts/carbon/controllers/WithdrawController.sol @@ -0,0 +1,166 @@ +// 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 {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {ITrancheVault} from "../interfaces/ITrancheVault.sol"; +import {IStructuredPortfolio} from "../interfaces/IStructuredPortfolio.sol"; +import {IWithdrawController, Status, WithdrawAllowed} from "../interfaces/IWithdrawController.sol"; + +uint256 constant BASIS_PRECISION = 10000; + +contract WithdrawController is IWithdrawController, Initializable, AccessControlEnumerable { + /// @dev Manager role used for access control + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + uint256 public floor; + uint256 public withdrawFeeRate; + mapping(Status => bool) public withdrawAllowed; + + constructor() {} + + function initialize( + address manager, + uint256 _withdrawFeeRate, + uint256 _floor + ) external initializer { + withdrawFeeRate = _withdrawFeeRate; + _grantRole(MANAGER_ROLE, manager); + withdrawAllowed[Status.Closed] = true; + + floor = _floor; + } + + function maxWithdraw(address owner) public view returns (uint256) { + ITrancheVault vault = ITrancheVault(msg.sender); + Status status = vault.portfolio().status(); + if (!withdrawAllowed[status]) { + return 0; + } + + uint256 ownerShares = vault.balanceOf(owner); + uint256 userMaxWithdraw = vault.convertToAssets(ownerShares); + if (status == Status.Closed) { + return userMaxWithdraw; + } + + uint256 globalMaxWithdraw = _globalMaxWithdraw(vault, status); + + return Math.min(userMaxWithdraw, globalMaxWithdraw); + } + + function maxRedeem(address owner) external view returns (uint256) { + ITrancheVault vault = ITrancheVault(msg.sender); + Status status = vault.portfolio().status(); + if (!withdrawAllowed[status]) { + return 0; + } + + uint256 userMaxRedeem = vault.balanceOf(owner); + if (status == Status.Closed) { + return userMaxRedeem; + } + + uint256 globalMaxWithdraw = _globalMaxWithdraw(vault, status); + uint256 globalMaxRedeem = vault.convertToShares(globalMaxWithdraw); + + return Math.min(userMaxRedeem, globalMaxRedeem); + } + + function _globalMaxWithdraw(ITrancheVault vault, Status status) internal view returns (uint256) { + uint256 totalWithdrawableAssets = vault.totalAssets(); + IStructuredPortfolio portfolio = vault.portfolio(); + if (status == Status.Live) { + uint256 virtualTokenBalance = portfolio.virtualTokenBalance(); + if (virtualTokenBalance < totalWithdrawableAssets) { + totalWithdrawableAssets = virtualTokenBalance; + } + } + return totalWithdrawableAssets > floor ? totalWithdrawableAssets - floor : 0; + } + + function onWithdraw( + address, + uint256 assets, + address, + address + ) external view returns (uint256, uint256) { + uint256 withdrawFee = _getWithdrawFee(assets); + return (previewWithdraw(assets), withdrawFee); + } + + function onRedeem( + address, + uint256 shares, + address, + address + ) external view returns (uint256, uint256) { + uint256 assets = ITrancheVault(msg.sender).convertToAssets(shares); + uint256 withdrawFee = _getWithdrawFee(assets); + return (assets - withdrawFee, withdrawFee); + } + + function previewRedeem(uint256 shares) public view returns (uint256) { + uint256 assets = ITrancheVault(msg.sender).convertToAssets(shares); + uint256 withdrawFee = _getWithdrawFee(assets); + return assets - withdrawFee; + } + + function previewWithdraw(uint256 assets) public view returns (uint256) { + uint256 withdrawFee = _getWithdrawFee(assets); + return ITrancheVault(msg.sender).convertToSharesCeil(assets + withdrawFee); + } + + function setFloor(uint256 newFloor) public { + _requireManagerRole(); + floor = newFloor; + emit FloorChanged(newFloor); + } + + function setWithdrawAllowed(bool newWithdrawAllowed, Status portfolioStatus) public { + _requireManagerRole(); + require(portfolioStatus == Status.CapitalFormation || portfolioStatus == Status.Live, "WC: No custom value in Closed"); + withdrawAllowed[portfolioStatus] = newWithdrawAllowed; + emit WithdrawAllowedChanged(newWithdrawAllowed, portfolioStatus); + } + + function setWithdrawFeeRate(uint256 newFeeRate) public { + _requireManagerRole(); + withdrawFeeRate = newFeeRate; + emit WithdrawFeeRateChanged(newFeeRate); + } + + function configure( + uint256 newFloor, + uint256 newFeeRate, + WithdrawAllowed memory newWithdrawAllowed + ) external { + if (floor != newFloor) { + setFloor(newFloor); + } + if (withdrawFeeRate != newFeeRate) { + setWithdrawFeeRate(newFeeRate); + } + if (withdrawAllowed[newWithdrawAllowed.status] != newWithdrawAllowed.value) { + setWithdrawAllowed(newWithdrawAllowed.value, newWithdrawAllowed.status); + } + } + + function _getWithdrawFee(uint256 assets) internal view returns (uint256) { + return (assets * withdrawFeeRate) / BASIS_PRECISION; + } + + function _requireManagerRole() internal view { + require(hasRole(MANAGER_ROLE, msg.sender), "WC: Only manager"); + } +} diff --git a/contracts/carbon/harnesses/TrancheVaultHarness.sol b/contracts/carbon/harnesses/TrancheVaultHarness.sol new file mode 100644 index 0000000..e159590 --- /dev/null +++ b/contracts/carbon/harnesses/TrancheVaultHarness.sol @@ -0,0 +1,29 @@ +// 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 {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {TrancheVault} from "../TrancheVault.sol"; + +contract TrancheVaultHarness is TrancheVault { + using SafeERC20 for IERC20WithDecimals; + + function tokenTransferHarness( + address from, + address to, + uint256 amount + ) external { + require(from != address(this) && from != address(portfolio)); + token.safeTransferFrom(from, to, amount); + } +} diff --git a/contracts/carbon/interfaces/IDebtInstrument.sol b/contracts/carbon/interfaces/IDebtInstrument.sol new file mode 100644 index 0000000..8e0beaa --- /dev/null +++ b/contracts/carbon/interfaces/IDebtInstrument.sol @@ -0,0 +1,30 @@ +// 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 {IFinancialInstrument} from "./IFinancialInstrument.sol"; + +interface IDebtInstrument is IFinancialInstrument { + function endDate(uint256 instrumentId) external view returns (uint256); + + function repay(uint256 instrumentId, uint256 amount) external returns (uint256 principalRepaid, uint256 interestRepaid); + + function start(uint256 instrumentId) external; + + function cancel(uint256 instrumentId) external; + + function markAsDefaulted(uint256 instrumentId) external; + + function issueInstrumentSelector() external pure returns (bytes4); + + function updateInstrumentSelector() external pure returns (bytes4); +} diff --git a/contracts/carbon/interfaces/IDepositController.sol b/contracts/carbon/interfaces/IDepositController.sol new file mode 100644 index 0000000..81d5120 --- /dev/null +++ b/contracts/carbon/interfaces/IDepositController.sol @@ -0,0 +1,177 @@ +// 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 {ILenderVerifier} from "../interfaces/ILenderVerifier.sol"; +import {Status} from "../interfaces/IStructuredPortfolio.sol"; + +struct DepositAllowed { + /// @dev StructuredPortfolio status for which deposits should be enabled or disabled + Status status; + /// @dev Value indicating whether deposits should be enabled or disabled + bool value; +} + +/** + * @title Contract for managing deposit related settings + * @dev Used by TrancheVault contract + */ +interface IDepositController { + /** + * @notice Event emitted when new ceiling is set + * @param newCeiling New ceiling value + */ + event CeilingChanged(uint256 newCeiling); + + /** + * @notice Event emitted when deposits are disabled or enabled for a specific StructuredPortfolio status + * @param newDepositAllowed Value indicating whether deposits should be enabled or disabled + * @param portfolioStatus StructuredPortfolio status for which changes are applied + */ + event DepositAllowedChanged(bool newDepositAllowed, Status portfolioStatus); + + /** + * @notice Event emitted when deposit fee rate is switched + * @param newFeeRate New deposit fee rate value (in BPS) + */ + event DepositFeeRateChanged(uint256 newFeeRate); + + /** + * @notice Event emitted when lender verifier is switched + * @param newLenderVerifier New lender verifier contract address + */ + event LenderVerifierChanged(ILenderVerifier indexed newLenderVerifier); + + /// @return DepositController manager role used for access control + function MANAGER_ROLE() external view returns (bytes32); + + /// @return Address of contract used for checking whether given address is allowed to put funds into an instrument according to implemented strategy + function lenderVerifier() external view returns (ILenderVerifier); + + /// @return Max asset capacity defined for TrancheVaults interracting with DepositController + function ceiling() external view returns (uint256); + + /// @return Rate (in BPS) of the fee applied to the deposit amount + function depositFeeRate() external view returns (uint256); + + /// @return Value indicating whether deposits are allowed when related StructuredPortfolio is in given status + /// @param status StructuredPortfolio status + function depositAllowed(Status status) external view returns (bool); + + /** + * @notice Setup contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param manager Address to which MANAGER_ROLE should be granted + * @param lenderVerfier Address of LenderVerifier contract + * @param _depositFeeRate Deposit fee rate (in BPS) + * @param ceiling Ceiling value + */ + function initialize( + address manager, + address lenderVerfier, + uint256 _depositFeeRate, + uint256 ceiling + ) external; + + /** + * @return assets Max assets amount that can be deposited with TrancheVault shares minted to given receiver + * @param receiver Shares receiver address + */ + function maxDeposit(address receiver) external view returns (uint256 assets); + + /** + * @return shares Max TrancheVault shares amount given address can receive + * @param receiver Shares receiver address + */ + function maxMint(address receiver) external view returns (uint256 shares); + + /** + * @notice Simulates deposit assets conversion including fees + * @return shares Shares amount that can be obtained from the given assets amount + * @param assets Tested assets amount + */ + function previewDeposit(uint256 assets) external view returns (uint256 shares); + + /** + * @notice Simulates mint shares conversion including fees + * @return assets Assets amount that needs to be deposited to obtain given shares amount + * @param shares Tested shares amount + */ + function previewMint(uint256 shares) external view returns (uint256 assets); + + /** + * @notice Simulates deposit result + * @return shares Shares amount that can be obtained from the deposit with given params + * @return depositFee Fee for a deposit with given params + * @param sender Supposed deposit transaction sender address + * @param assets Supposed assets amount + * @param receiver Supposed shares receiver address + */ + function onDeposit( + address sender, + uint256 assets, + address receiver + ) external returns (uint256 shares, uint256 depositFee); + + /** + * @notice Simulates mint result + * @return assets Assets amount that needs to be provided to execute mint with given params + * @return mintFee Fee for a mint with given params + * @param sender Supposed mint transaction sender address + * @param shares Supposed shares amount + * @param receiver Supposed shares receiver address + */ + function onMint( + address sender, + uint256 shares, + address receiver + ) external returns (uint256 assets, uint256 mintFee); + + /** + * @notice Ceiling setter + * @param newCeiling New ceiling value + */ + function setCeiling(uint256 newCeiling) external; + + /** + * @notice Deposit allowed setter + * @param newDepositAllowed Value indicating whether deposits should be allowed when related StructuredPortfolio is in given status + * @param portfolioStatus StructuredPortfolio status for which changes are applied + */ + function setDepositAllowed(bool newDepositAllowed, Status portfolioStatus) external; + + /** + * @notice Deposit fee rate setter + * @param newFeeRate New deposit fee rate (in BPS) + */ + function setDepositFeeRate(uint256 newFeeRate) external; + + /** + * @notice Lender verifier setter + * @param newLenderVerifier New LenderVerifer contract address + */ + function setLenderVerifier(ILenderVerifier newLenderVerifier) external; + + /** + * @notice Allows to change ceiling, deposit fee rate, lender verifier and enable or disable deposits at once + * @param newCeiling New ceiling value + * @param newFeeRate New deposit fee rate (in BPS) + * @param newLenderVerifier New LenderVerifier contract address + * @param newDepositAllowed New deposit allowed settings + */ + function configure( + uint256 newCeiling, + uint256 newFeeRate, + ILenderVerifier newLenderVerifier, + DepositAllowed memory newDepositAllowed + ) external; +} diff --git a/contracts/carbon/interfaces/IERC20WithDecimals.sol b/contracts/carbon/interfaces/IERC20WithDecimals.sol new file mode 100644 index 0000000..c870a0b --- /dev/null +++ b/contracts/carbon/interfaces/IERC20WithDecimals.sol @@ -0,0 +1,18 @@ +// 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 {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IERC20WithDecimals is IERC20 { + function decimals() external view returns (uint8); +} diff --git a/contracts/carbon/interfaces/IFinancialInstrument.sol b/contracts/carbon/interfaces/IFinancialInstrument.sol new file mode 100644 index 0000000..a3ee559 --- /dev/null +++ b/contracts/carbon/interfaces/IFinancialInstrument.sol @@ -0,0 +1,23 @@ +// 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 {IERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import {IERC20WithDecimals} from "./IERC20WithDecimals.sol"; + +interface IFinancialInstrument is IERC721Upgradeable { + function principal(uint256 instrumentId) external view returns (uint256); + + function asset(uint256 instrumentId) external view returns (IERC20WithDecimals); + + function recipient(uint256 instrumentId) external view returns (address); +} diff --git a/contracts/carbon/interfaces/IFixedInterestOnlyLoans.sol b/contracts/carbon/interfaces/IFixedInterestOnlyLoans.sol new file mode 100644 index 0000000..ac95146 --- /dev/null +++ b/contracts/carbon/interfaces/IFixedInterestOnlyLoans.sol @@ -0,0 +1,60 @@ +// 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 {IDebtInstrument} from "./IDebtInstrument.sol"; +import {IERC20WithDecimals} from "./IERC20WithDecimals.sol"; + +enum FixedInterestOnlyLoanStatus { + Created, + Accepted, + Started, + Repaid, + Canceled, + Defaulted +} + +interface IFixedInterestOnlyLoans is IDebtInstrument { + struct LoanMetadata { + uint256 principal; + uint256 periodPayment; + FixedInterestOnlyLoanStatus status; + uint16 periodCount; + uint32 periodDuration; + uint40 currentPeriodEndDate; + address recipient; + bool canBeRepaidAfterDefault; + uint16 periodsRepaid; + uint32 gracePeriod; + uint40 endDate; + IERC20WithDecimals asset; + } + + function issueLoan( + IERC20WithDecimals _asset, + uint256 _principal, + uint16 _periodCount, + uint256 _periodPayment, + uint32 _periodDuration, + address _recipient, + uint32 _gracePeriod, + bool _canBeRepaidAfterDefault + ) external returns (uint256); + + function loanData(uint256 instrumentId) external view returns (LoanMetadata memory); + + function updateInstrument(uint256 _instrumentId, uint32 _gracePeriod) external; + + function status(uint256 instrumentId) external view returns (FixedInterestOnlyLoanStatus); + + function expectedRepaymentAmount(uint256 instrumentId) external view returns (uint256); +} diff --git a/contracts/carbon/interfaces/ILenderVerifier.sol b/contracts/carbon/interfaces/ILenderVerifier.sol new file mode 100644 index 0000000..bba925a --- /dev/null +++ b/contracts/carbon/interfaces/ILenderVerifier.sol @@ -0,0 +1,24 @@ +// 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; + +/** + * @title Contract used for checking whether given address is allowed to put funds into an instrument according to implemented strategy + * @dev Used by DepositController + */ +interface ILenderVerifier { + /** + * @param lender Address of lender to verify + * @return Value indicating whether given lender address is allowed to put funds into an instrument or not + */ + function isAllowed(address lender) external view returns (bool); +} diff --git a/contracts/carbon/interfaces/ILoansManager.sol b/contracts/carbon/interfaces/ILoansManager.sol new file mode 100644 index 0000000..1c7bbf6 --- /dev/null +++ b/contracts/carbon/interfaces/ILoansManager.sol @@ -0,0 +1,91 @@ +// 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 {IAccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import {IFixedInterestOnlyLoans} from "./IFixedInterestOnlyLoans.sol"; +import {IERC20WithDecimals} from "./IERC20WithDecimals.sol"; + +struct AddLoanParams { + uint256 principal; + uint16 periodCount; + uint256 periodPayment; + uint32 periodDuration; + address recipient; + uint32 gracePeriod; + bool canBeRepaidAfterDefault; +} + +/// @title Manager of a Structured Portfolio's active loans +interface ILoansManager { + /** + * @notice Event emitted when the loan is added + * @param loanId Loan id + */ + event LoanAdded(uint256 indexed loanId); + + /** + * @notice Event emitted when the loan is funded + * @param loanId Loan id + */ + event LoanFunded(uint256 indexed loanId); + + /** + * @notice Event emitted when the loan is repaid + * @param loanId Loan id + * @param amount Repaid amount + */ + event LoanRepaid(uint256 indexed loanId, uint256 amount); + + /** + * @notice Event emitted when the loan is marked as defaulted + * @param loanId Loan id + */ + event LoanDefaulted(uint256 indexed loanId); + + /** + * @notice Event emitted when the loan grace period is updated + * @param loanId Loan id + * @param newGracePeriod New loan grace period + */ + event LoanGracePeriodUpdated(uint256 indexed loanId, uint32 newGracePeriod); + + /** + * @notice Event emitted when the loan is cancelled + * @param loanId Loan id + */ + event LoanCancelled(uint256 indexed loanId); + + /** + * @notice Event emitted when the loan is fully repaid, cancelled or defaulted + * @param loanId Loan id + */ + event ActiveLoanRemoved(uint256 indexed loanId); + + /// @return FixedInterestOnlyLoans contract address + function fixedInterestOnlyLoans() external view returns (IFixedInterestOnlyLoans); + + /// @return Underlying asset address + function asset() external view returns (IERC20WithDecimals); + + /** + * @param index Index of loan in array + * @return Loan id + */ + function activeLoanIds(uint256 index) external view returns (uint256); + + /** + * @param loanId Loan id + * @return Value indicating whether loan with given id was issued by this contract + */ + function issuedLoanIds(uint256 loanId) external view returns (bool); +} diff --git a/contracts/carbon/interfaces/IPausable.sol b/contracts/carbon/interfaces/IPausable.sol new file mode 100644 index 0000000..5ba6c39 --- /dev/null +++ b/contracts/carbon/interfaces/IPausable.sol @@ -0,0 +1,16 @@ +// 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; + +interface IPausable { + function paused() external returns (bool); +} diff --git a/contracts/carbon/interfaces/IProtocolConfig.sol b/contracts/carbon/interfaces/IProtocolConfig.sol new file mode 100644 index 0000000..4ea48b2 --- /dev/null +++ b/contracts/carbon/interfaces/IProtocolConfig.sol @@ -0,0 +1,124 @@ +// 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; + +interface IProtocolConfig { + /** + * @notice Event emitted when new defaultProtocolFeeRate is set + * @param newProtocolFeeRate Newly set protocol fee rate (in BPS) + */ + event DefaultProtocolFeeRateChanged(uint256 newProtocolFeeRate); + + /** + * @notice Event emitted when new custom fee rate for a specific address is set + * @param contractAddress Address of the contract for which new custom fee rate has been set + * @param newProtocolFeeRate Newly set custom protocol fee rate (in BPS) + */ + event CustomProtocolFeeRateChanged(address contractAddress, uint16 newProtocolFeeRate); + + /** + * @notice Event emitted when custom fee rate for a specific address is unset + * @param contractAddress Address of the contract for which custom fee rate has been unset + */ + event CustomProtocolFeeRateRemoved(address contractAddress); + + /** + * @notice Event emitted when new protocolAdmin address is set + * @param newProtocolAdmin Newly set protocolAdmin address + */ + event ProtocolAdminChanged(address indexed newProtocolAdmin); + + /** + * @notice Event emitted when new protocolTreasury address is set + * @param newProtocolTreasury Newly set protocolTreasury address + */ + event ProtocolTreasuryChanged(address indexed newProtocolTreasury); + + /** + * @notice Event emitted when new pauser address is set + * @param newPauserAddress Newly set pauser address + */ + event PauserAddressChanged(address indexed newPauserAddress); + + /** + * @notice Setups the contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param _defaultProtocolFeeRate Default fee rate valid for every contract except those with custom fee rate set + * @param _protocolAdmin Address of the account/contract that should be able to upgrade Upgradeable contracts + * @param _protocolTreasury Address of the account/contract to which collected fee should be transferred + * @param _pauserAddress Address of the account/contract that should be grnated PAUSER role on TrueFi Pausable contracts + */ + function initialize( + uint256 _defaultProtocolFeeRate, + address _protocolAdmin, + address _protocolTreasury, + address _pauserAddress + ) external; + + /// @return Protocol fee rate valid for the message sender + function protocolFeeRate() external view returns (uint256); + + /** + * @return Protocol fee rate valid for the given address + * @param contractAddress Address of contract queried for it's protocol fee rate + */ + function protocolFeeRate(address contractAddress) external view returns (uint256); + + /// @return Default fee rate valid for every contract except those with custom fee rate set + function defaultProtocolFeeRate() external view returns (uint256); + + /// @return Address of the account/contract that should be able to upgrade Upgradeable contracts + function protocolAdmin() external view returns (address); + + /// @return Address of the account/contract to which collected fee should be transferred + function protocolTreasury() external view returns (address); + + /// @return Address of the account/contract that should be grnated PAUSER role on TrueFi Pausable contracts + function pauserAddress() external view returns (address); + + /** + * @notice Custom protocol fee rate setter + * @param contractAddress Address of the contract for which new custom fee rate should be set + * @param newFeeRate Custom protocol fee rate (in BPS) which should be set for the given address + */ + function setCustomProtocolFeeRate(address contractAddress, uint16 newFeeRate) external; + + /** + * @notice Removes custom protocol fee rate from the given contract address + * @param contractAddress Address of the contract for which custom fee rate should be unset + */ + function removeCustomProtocolFeeRate(address contractAddress) external; + + /** + * @notice Default protocol fee rate setter + * @param newFeeRate New protocol fee rate (in BPS) to set + */ + function setDefaultProtocolFeeRate(uint256 newFeeRate) external; + + /** + * @notice Protocol admin address setter + * @param newProtocolAdmin New protocol admin address to set + */ + function setProtocolAdmin(address newProtocolAdmin) external; + + /** + * @notice Protocol treasury address setter + * @param newProtocolTreasury New protocol treasury address to set + */ + function setProtocolTreasury(address newProtocolTreasury) external; + + /** + * @notice TrueFi contracts pauser address setter + * @param newPauserAddress New pauser address to set + */ + function setPauserAddress(address newPauserAddress) external; +} diff --git a/contracts/carbon/interfaces/IStructuredPortfolio.sol b/contracts/carbon/interfaces/IStructuredPortfolio.sol new file mode 100644 index 0000000..f74107f --- /dev/null +++ b/contracts/carbon/interfaces/IStructuredPortfolio.sol @@ -0,0 +1,298 @@ +// 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 {IAccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/IAccessControlUpgradeable.sol"; +import {ITrancheVault} from "./ITrancheVault.sol"; +import {ILoansManager, AddLoanParams} from "./ILoansManager.sol"; +import {IFixedInterestOnlyLoans} from "./IFixedInterestOnlyLoans.sol"; +import {IERC20WithDecimals} from "./IERC20WithDecimals.sol"; +import {IProtocolConfig} from "./IProtocolConfig.sol"; + +uint256 constant BASIS_PRECISION = 10000; +uint256 constant YEAR = 365 days; + +enum Status { + CapitalFormation, + Live, + Closed +} + +struct LoansDeficitCheckpoint { + /// @dev Tranche missing funds due to defaulted loans + uint256 deficit; + /// @dev Timestamp of checkpoint + uint256 timestamp; +} + +struct TrancheData { + /// @dev The APY expected to be granted at the end of the portfolio Live phase (in BPS) + uint128 targetApy; + /// @dev The minimum required ratio of the sum of subordinate tranches assets to the tranche assets (in BPS) + uint128 minSubordinateRatio; + /// @dev The amount of assets transferred to the tranche after close() was called + uint256 distributedAssets; + /// @dev The potential maximum amount of tranche assets available for withdraw after close() was called + uint256 maxValueOnClose; + /// @dev Checkpoint tracking how many assets should be returned to the tranche due to defaulted loans + LoansDeficitCheckpoint loansDeficitCheckpoint; +} + +struct TrancheInitData { + /// @dev Address of the tranche vault + ITrancheVault tranche; + /// @dev The APY expected to be granted at the end of the portfolio Live phase (in BPS) + uint128 targetApy; + /// @dev The minimum ratio of the sum of subordinate tranches assets to the tranche assets (in BPS) + uint128 minSubordinateRatio; +} + +struct PortfolioParams { + /// @dev Portfolio name + string name; + /// @dev Portfolio duration in seconds + uint256 duration; + /// @dev Capital formation period in seconds, used to calculate portfolio start deadline + uint256 capitalFormationPeriod; + /// @dev Minimum deposited amount needed to start the portfolio + uint256 minimumSize; +} + +struct ExpectedEquityRate { + /// @dev Minimum expected APY on tranche 0 (expressed in bps) + uint256 from; + /// @dev Maximum expected APY on tranche 0 (expressed in bps) + uint256 to; +} + +/** + * @title Structured Portfolio used for obtaining funds and managing loans + * @notice Portfolio consists of multiple tranches, each offering a different yield for the lender + * based on the respective risk. + */ + +interface IStructuredPortfolio is IAccessControlUpgradeable { + /** + * @notice Event emitted when portfolio is initialized + * @param tranches Array of tranches addresses + */ + event PortfolioInitialized(ITrancheVault[] tranches); + + /** + * @notice Event emitted when portfolio status is changed + * @param newStatus Portfolio status set + */ + event PortfolioStatusChanged(Status newStatus); + + /** + * @notice Event emitted when tranches checkpoint is changed + * @param totalAssets New values of tranches + * @param protocolFeeRates New protocol fee rates for each tranche + */ + event CheckpointUpdated(uint256[] totalAssets, uint256[] protocolFeeRates); + + /// @return Portfolio manager role used for access control + function MANAGER_ROLE() external view returns (bytes32); + + /// @return Name of the StructuredPortfolio + function name() external view returns (string memory); + + /// @return Current portfolio status + function status() external view returns (Status); + + /// @return Timestamp of block in which StructuredPortfolio was switched to Live phase + function startDate() external view returns (uint256); + + /** + * @dev Returns expected end date or actual end date if portfolio was closed prematurely. + * @return The date by which the manager is supposed to close the portfolio. + */ + function endDate() external view returns (uint256); + + /** + * @dev Timestamp after which anyone can close the portfolio if it's in capital formation. + * @return The date by which the manager is supposed to launch the portfolio. + */ + function startDeadline() external view returns (uint256); + + /// @return Minimum sum of all tranches assets required to be met to switch StructuredPortfolio to Live phase + function minimumSize() external view returns (uint256); + + /** + * @notice Launches the portfolio making it possible to issue loans. + * @dev + * - reverts if tranches ratios and portfolio min size are not met, + * - changes status to `Live`, + * - sets `startDate` and `endDate`, + * - transfers assets obtained in tranches to the portfolio. + */ + function start() external; + + /** + * @notice Closes the portfolio, making it possible to withdraw funds from tranche vaults. + * @dev + * - reverts if there are any active loans before end date, + * - changes status to `Closed`, + * - calculates waterfall values for tranches and transfers the funds to the vaults, + * - updates `endDate`. + */ + function close() external; + + /** + * @notice Distributes portfolio value among tranches respecting their target apys and fees. + * Returns zeros for CapitalFormation and Closed portfolio status. + * @return Array of current tranche values + */ + function calculateWaterfall() external view returns (uint256[] memory); + + /** + * @notice Distributes portfolio value among tranches respecting their target apys, but not fees. + * Returns zeros for CapitalFormation and Closed portfolio status. + * @return Array of current tranche values (with pending fees not deducted) + */ + function calculateWaterfallWithoutFees() external view returns (uint256[] memory); + + /** + * @param trancheIndex Index of tranche + * @return Current value of tranche in Live status, 0 for other statuses + */ + function calculateWaterfallForTranche(uint256 trancheIndex) external view returns (uint256); + + /** + * @param trancheIndex Index of tranche + * @return Current value of tranche (with pending fees not deducted) in Live status, 0 for other statuses + */ + function calculateWaterfallForTrancheWithoutFee(uint256 trancheIndex) external view returns (uint256); + + /** + * @notice Setup contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param manager Address on which MANAGER_ROLE is granted + * @param underlyingToken Address of ERC20 token used by portfolio + * @param fixedInterestOnlyLoans Address of FixedInterestOnlyLoans contract + * @param _protocolConfig Address of ProtocolConfig contract + * @param portfolioParams Parameters to configure portfolio + * @param tranchesInitData Parameters to configure tranches + * @param _expectedEquityRate APY range that is expected to be reached by Equity tranche + */ + function initialize( + address manager, + IERC20WithDecimals underlyingToken, + IFixedInterestOnlyLoans fixedInterestOnlyLoans, + IProtocolConfig _protocolConfig, + PortfolioParams memory portfolioParams, + TrancheInitData[] memory tranchesInitData, + ExpectedEquityRate memory _expectedEquityRate + ) external; + + /// @return Array of portfolio's tranches addresses + function getTranches() external view returns (ITrancheVault[] memory); + + /** + * @return i-th tranche data + */ + function getTrancheData(uint256) external view returns (TrancheData memory); + + /** + * @notice Updates checkpoints on each tranche and pay pending fees + * @dev Can be executed only in Live status + */ + function updateCheckpoints() external; + + /// @return Total value locked in the contract including yield from outstanding loans + function totalAssets() external view returns (uint256); + + /// @return Underlying token balance of portfolio reduced by pending fees + function liquidAssets() external view returns (uint256); + + /// @return Sum of current values of all active loans + function loansValue() external view returns (uint256); + + /// @return Sum of all unsettled fees that tranches should pay + function totalPendingFees() external view returns (uint256); + + /// @return Array of all active loans' ids + function getActiveLoans() external view returns (uint256[] memory); + + /** + * @notice Creates a loan that should be accepted next by the loan recipient + * @dev + * - can be executed only by StructuredPortfolio manager + * - can be executed only in Live status + */ + function addLoan(AddLoanParams calldata params) external; + + /** + * @notice Starts a loan with given id and transfers assets to loan recipient + * @dev + * - can be executed only by StructuredPortfolio manager + * - can be executed only in Live status + * @param loanId Id of the loan that should be started + */ + function fundLoan(uint256 loanId) external; + + /** + * @notice Allows sender to repay a loan with given id + * @dev + * - cannot be executed in CapitalFormation + * - can be executed only by loan recipient + * - automatically calculates amount to repay based on data stored in FixedInterestOnlyLoans contract + * @param loanId Id of the loan that should be repaid + */ + function repayLoan(uint256 loanId) external; + + /** + * @notice Cancels the loan with provided loan id + * @dev Can be executed only by StructuredPortfolio manager + * @param loanId Id of the loan to cancel + */ + function cancelLoan(uint256 loanId) external; + + /** + * @notice Sets the status of a loan with given id to Defaulted and excludes it from active loans array + * @dev Can be executed only by StructuredPortfolio manager + * @param loanId Id of the loan that should be defaulted + */ + function markLoanAsDefaulted(uint256 loanId) external; + + /** + * @notice Sets new grace period for the existing loan + * @dev Can be executed only by StructuredPortfolio manager + * @param loanId Id of the loan which grace period should be updated + * @param newGracePeriod New grace period to set (in seconds) + */ + function updateLoanGracePeriod(uint256 loanId, uint32 newGracePeriod) external; + + /** + * @notice Virtual value of the portfolio + */ + function virtualTokenBalance() external view returns (uint256); + + /** + * @notice Increase virtual portfolio value + * @dev Must be called by a tranche + */ + function increaseVirtualTokenBalance(uint256 delta) external; + + /** + * @notice Decrease virtual portfolio value + * @dev Must be called by a tranche + */ + function decreaseVirtualTokenBalance(uint256 delta) external; + + /** + * @notice Reverts if tranche ratios are not met + * @param newTotalAssets new total assets value of the tranche calling this function. + * Is ignored if not called by tranche + */ + function checkTranchesRatiosFromTranche(uint256 newTotalAssets) external view; +} diff --git a/contracts/carbon/interfaces/IStructuredPortfolioFactory.sol b/contracts/carbon/interfaces/IStructuredPortfolioFactory.sol new file mode 100644 index 0000000..de3fca0 --- /dev/null +++ b/contracts/carbon/interfaces/IStructuredPortfolioFactory.sol @@ -0,0 +1,94 @@ +// 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 {IAccessControlEnumerable} from "@openzeppelin/contracts/access/IAccessControlEnumerable.sol"; +import {IStructuredPortfolio} from "./IStructuredPortfolio.sol"; +import {IProtocolConfig} from "./IProtocolConfig.sol"; +import {ITrancheVault} from "./ITrancheVault.sol"; +import {IERC20WithDecimals} from "./IERC20WithDecimals.sol"; +import {IFixedInterestOnlyLoans} from "./IFixedInterestOnlyLoans.sol"; +import {IProtocolConfig} from "./IProtocolConfig.sol"; +import {IStructuredPortfolio, TrancheInitData, PortfolioParams, ExpectedEquityRate} from "./IStructuredPortfolio.sol"; + +struct TrancheData { + /// @dev Tranche name + string name; + /// @dev Tranche symbol + 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 + */ +interface IStructuredPortfolioFactory is IAccessControlEnumerable { + /// @return Whitelisted manager role used for access control, allowing user with this role too create StructuredPortfolio + function WHITELISTED_MANAGER_ROLE() external view returns (bytes32); + + /// @param portfolioId Id of the portfolio created with this StructuredPortfolioFactory + /// @return Address of the StructuredPortfolio with given portfolio id + function portfolios(uint256 portfolioId) external view returns (IStructuredPortfolio); + + /// @return Address of the Tranche contract implementation used for portfolio deployment + function trancheImplementation() external view returns (address); + + /// @return Address of the StructuredPortfolio contract implementation used for portfolio deployment + function portfolioImplementation() external view returns (address); + + /// @return Address of the ProtocolConfig + function protocolConfig() external view returns (IProtocolConfig); + + /** + * @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); + + /** + * @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; + + /// @return All created portfolios + function getPortfolios() external view returns (IStructuredPortfolio[] memory); +} diff --git a/contracts/carbon/interfaces/ITrancheVault.sol b/contracts/carbon/interfaces/ITrancheVault.sol new file mode 100644 index 0000000..34dc58c --- /dev/null +++ b/contracts/carbon/interfaces/ITrancheVault.sol @@ -0,0 +1,266 @@ +// 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 {IERC4626Upgradeable} from "@openzeppelin/contracts-upgradeable/interfaces/IERC4626Upgradeable.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IDepositController} from "./IDepositController.sol"; +import {IWithdrawController} from "./IWithdrawController.sol"; +import {ITransferController} from "./ITransferController.sol"; +import {IStructuredPortfolio} from "./IStructuredPortfolio.sol"; +import {IProtocolConfig} from "./IProtocolConfig.sol"; +import {IERC20WithDecimals} from "./IERC20WithDecimals.sol"; + +struct SizeRange { + uint256 floor; + uint256 ceiling; +} + +struct Checkpoint { + uint256 totalAssets; + uint256 protocolFeeRate; + uint256 timestamp; +} + +struct Configuration { + uint256 managerFeeRate; + address managerFeeBeneficiary; + IDepositController depositController; + IWithdrawController withdrawController; + ITransferController transferController; +} + +interface ITrancheVault is IERC4626Upgradeable, IERC165 { + /** + * @notice Event emitted when checkpoint is changed + * @param totalAssets Tranche total assets at the moment of checkpoint creation + * @param protocolFeeRate Protocol fee rate at the moment of checkpoint creation + */ + event CheckpointUpdated(uint256 totalAssets, uint256 protocolFeeRate); + + /** + * @notice Event emitted when fee is transfered to protocol + * @param protocolAddress Address to which protocol fees are transfered + * @param fee Fee amount paid to protocol + */ + event ProtocolFeePaid(address indexed protocolAddress, uint256 fee); + + /** + * @notice Event emitted when fee is transfered to manager + * @param managerFeeBeneficiary Address to which manager fees are transfered + * @param fee Fee amount paid to protocol + */ + event ManagerFeePaid(address indexed managerFeeBeneficiary, uint256 fee); + + /** + * @notice Event emitted when manager fee rate is changed by the manager + * @param newManagerFeeRate New fee rate + */ + event ManagerFeeRateChanged(uint256 newManagerFeeRate); + + /** + * @notice Event emitted when manager fee beneficiary is changed by the manager + * @param newManagerFeeBeneficiary New beneficiary address to which manager fee will be transferred + */ + event ManagerFeeBeneficiaryChanged(address newManagerFeeBeneficiary); + + /** + * @notice Event emitted when new DepositController address is set + * @param newController New DepositController address + */ + event DepositControllerChanged(IDepositController indexed newController); + + /** + * @notice Event emitted when new WithdrawController address is set + * @param newController New WithdrawController address + */ + event WithdrawControllerChanged(IWithdrawController indexed newController); + + /** + * @notice Event emitted when new TransferController address is set + * @param newController New TransferController address + */ + event TransferControllerChanged(ITransferController indexed newController); + + /// @notice Tranche manager role used for access control + function MANAGER_ROLE() external view returns (bytes32); + + /// @notice Role used to access tranche controllers setters + function TRANCHE_CONTROLLER_OWNER_ROLE() external view returns (bytes32); + + /// @return Associated StructuredPortfolio address + function portfolio() external view returns (IStructuredPortfolio); + + /// @return Address of DepositController contract responsible for deposit-related operations on TrancheVault + function depositController() external view returns (IDepositController); + + /// @return Address of WithdrawController contract responsible for withdraw-related operations on TrancheVault + function withdrawController() external view returns (IWithdrawController); + + /// @return Address of TransferController contract deducing whether a specific transfer is allowed or not + function transferController() external view returns (ITransferController); + + /// @return TrancheVault index in StructuredPortfolio tranches order + function waterfallIndex() external view returns (uint256); + + /// @return Annual rate of continuous fee accrued on every block on the top of checkpoint tranche total assets (expressed in bps) + function managerFeeRate() external view returns (uint256); + + /// @return Address to which manager fee should be transferred + function managerFeeBeneficiary() external view returns (address); + + /// @return Address of ProtocolConfig contract used to collect protocol fee + function protocolConfig() external view returns (IProtocolConfig); + + /** + * @notice DepositController address setter + * @dev Can be executed only by TrancheVault manager + * @param newController New DepositController address + */ + function setDepositController(IDepositController newController) external; + + /** + * @notice WithdrawController address setter + * @dev Can be executed only by TrancheVault manager + * @param newController New WithdrawController address + */ + function setWithdrawController(IWithdrawController newController) external; + + /** + * @notice TransferController address setter + * @dev Can be executed only by TrancheVault manager + * @param newController New TransferController address + */ + function setTransferController(ITransferController newController) external; + + /** + * @notice Sets address of StructuredPortfolio associated with TrancheVault + * @dev Can be executed only once + * @param _portfolio StructuredPortfolio address + */ + function setPortfolio(IStructuredPortfolio _portfolio) external; + + /** + * @notice Manager fee rate setter + * @dev Can be executed only by TrancheVault manager + * @param newFeeRate New manager fee rate (expressed in bps) + */ + function setManagerFeeRate(uint256 newFeeRate) external; + + /** + * @notice Manager fee beneficiary setter + * @dev Can be executed only by TrancheVault manager + * @param newBeneficiary New manager fee beneficiary address + */ + function setManagerFeeBeneficiary(address newBeneficiary) external; + + /** + * @notice Setup contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param _name Contract name + * @param _symbol Contract symbol + * @param _token Address of ERC20 token used by TrancheVault + * @param _depositController Address of DepositController contract responsible for deposit-related operations on TrancheVault + * @param _withdrawController Address of WithdrawController contract responsible for withdraw-related operations on TrancheVault + * @param _transferController Address of TransferController contract deducing whether a specific transfer is allowed or not + * @param _protocolConfig Address of ProtocolConfig contract storing TrueFi protocol-related data + * @param _waterfallIndex TrancheVault index in StructuredPortfolio tranches order + * @param manager Address on which MANAGER_ROLE is granted + * @param _managerFeeRate Annual rate of continuous fee accrued on every block on the top of checkpoint tranche total assets (expressed in bps) + */ + function initialize( + string memory _name, + string memory _symbol, + IERC20WithDecimals _token, + IDepositController _depositController, + IWithdrawController _withdrawController, + ITransferController _transferController, + IProtocolConfig _protocolConfig, + uint256 _waterfallIndex, + address manager, + uint256 _managerFeeRate + ) external; + + /** + * @notice Updates TrancheVault checkpoint with current total assets and pays pending fees + */ + function updateCheckpoint() external; + + /** + * @notice Updates TrancheVault checkpoint with total assets value calculated in StructuredPortfolio waterfall + * @dev + * - can be executed only by associated StructuredPortfolio + * - is used by StructuredPortfolio only in Live portfolio status + * @param _totalAssets Total assets amount to save in the checkpoint + */ + function updateCheckpointFromPortfolio(uint256 _totalAssets) external; + + /// @return Total tranche assets including accrued but yet not paid fees + function totalAssetsBeforeFees() external view returns (uint256); + + /// @return Sum of all unpaid fees and fees accrued since last checkpoint update + function totalPendingFees() external view returns (uint256); + + /** + * @return Sum of all unpaid fees and fees accrued on the given amount since last checkpoint update + * @param amount Asset amount with which fees should be calculated + */ + function totalPendingFeesForAssets(uint256 amount) external view returns (uint256); + + /// @return Sum of unpaid protocol fees and protocol fees accrued since last checkpoint update + function pendingProtocolFee() external view returns (uint256); + + /// @return Sum of unpaid manager fees and manager fees accrued since last checkpoint update + function pendingManagerFee() external view returns (uint256); + + /// @return checkpoint Checkpoint tracking info about TrancheVault total assets and protocol fee rate at last checkpoint update, and timestamp of that update + function getCheckpoint() external view returns (Checkpoint memory checkpoint); + + /// @return protocolFee Remembered value of fee unpaid to protocol due to insufficient TrancheVault funds at the moment of transfer + function unpaidProtocolFee() external view returns (uint256 protocolFee); + + /// @return managerFee Remembered value of fee unpaid to manager due to insufficient TrancheVault funds at the moment of transfer + function unpaidManagerFee() external view returns (uint256); + + /** + * @notice Initializes TrancheVault checkpoint and transfers all TrancheVault assets to associated StructuredPortfolio + * @dev + * - can be executed only by associated StructuredPortfolio + * - called by associated StructuredPortfolio on transition to Live status + */ + function onPortfolioStart() external; + + /** + * @notice Updates virtualTokenBalance and checkpoint after transferring assets from StructuredPortfolio to TrancheVault + * @dev Can be executed only by associated StructuredPortfolio + * @param assets Transferred assets amount + */ + function onTransfer(uint256 assets) external; + + /** + * @notice Converts given amount of token assets to TrancheVault LP tokens at the current price, without respecting fees + * @param assets Amount of assets to convert + */ + function convertToSharesCeil(uint256 assets) external view returns (uint256); + + /** + * @notice Converts given amount of TrancheVault LP tokens to token assets at the current price, without respecting fees + * @param shares Amount of TrancheVault LP tokens to convert + */ + function convertToAssetsCeil(uint256 shares) external view returns (uint256); + + /** + * @notice Allows to change managerFeeRate, managerFeeBeneficiary, depositController and withdrawController + * @dev Can be executed only by TrancheVault manager + */ + function configure(Configuration memory newConfiguration) external; +} diff --git a/contracts/carbon/interfaces/ITransferController.sol b/contracts/carbon/interfaces/ITransferController.sol new file mode 100644 index 0000000..04b42bd --- /dev/null +++ b/contracts/carbon/interfaces/ITransferController.sol @@ -0,0 +1,36 @@ +// 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; + +interface ITransferController { + /** + * @notice Setup contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param manager Address to which MANAGER_ROLE should be granted + */ + function initialize(address manager) external; + + /** + * @notice Verifies TrancheVault shares transfers + * @return isTransferAllowed Value indicating whether TrancheVault shares transfer with given params is allowed + * @param sender Transfer transaction sender address + * @param from Transferred funds owner address + * @param to Transferred funds recipient address + * @param value Transferred assets amount + */ + function onTransfer( + address sender, + address from, + address to, + uint256 value + ) external view returns (bool isTransferAllowed); +} diff --git a/contracts/carbon/interfaces/IWithdrawController.sol b/contracts/carbon/interfaces/IWithdrawController.sol new file mode 100644 index 0000000..6e1a9de --- /dev/null +++ b/contracts/carbon/interfaces/IWithdrawController.sol @@ -0,0 +1,161 @@ +// 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 {Status} from "../interfaces/IStructuredPortfolio.sol"; + +struct WithdrawAllowed { + /// @dev StructuredPortfolio status for which withdrawals should be enabled or disabled + Status status; + /// @dev Value indicating whether withdrawals should be enabled or disabled + bool value; +} + +/** + * @title Contract for managing withdraw related settings + * @dev Used by TrancheVault contract + */ +interface IWithdrawController { + /** + * @notice Event emitted when new floor is set + * @param newFloor New floor value + */ + event FloorChanged(uint256 newFloor); + + /** + * @notice Event emitted when withdrawals are disabled or enabled for a specific StructuredPortfolio status + * @param newWithdrawAllowed Value indicating whether withdrawals should be enabled or disabled + * @param portfolioStatus StructuredPortfolio status for which changes are applied + */ + event WithdrawAllowedChanged(bool newWithdrawAllowed, Status portfolioStatus); + + /** + * @notice Event emitted when withdraw fee rate is switched + * @param newFeeRate New withdraw fee rate value (in BPS) + */ + event WithdrawFeeRateChanged(uint256 newFeeRate); + + /// @return WithdrawController manager role used for access control + function MANAGER_ROLE() external view returns (bytes32); + + /// @return Min assets amount that needs to stay in TrancheVault interracting with WithdrawController when related StructuredPortfolio is not in Closed state + function floor() external view returns (uint256); + + /// @return Rate (in BPS) of the fee applied to the withdraw amount + function withdrawFeeRate() external view returns (uint256); + + /// @return Value indicating whether withdrawals are allowed when related StructuredPortfolio is in given status + /// @param status StructuredPortfolio status + function withdrawAllowed(Status status) external view returns (bool); + + /** + * @notice Setup contract with given params + * @dev Used by Initializable contract (can be called only once) + * @param manager Address to which MANAGER_ROLE should be granted + * @param withdrawFeeRate Withdraw fee rate (in BPS) + * @param floor Floor value + */ + function initialize( + address manager, + uint256 withdrawFeeRate, + uint256 floor + ) external; + + /** + * @return assets Max assets amount that can be withdrawn from TrancheVault for shares of given owner + * @param owner Shares owner address + */ + function maxWithdraw(address owner) external view returns (uint256 assets); + + /** + * @return shares Max TrancheVault shares amount given owner can burn to withdraw assets + * @param owner Shares owner address + */ + function maxRedeem(address owner) external view returns (uint256 shares); + + /** + * @notice Simulates withdraw assets conversion including fees + * @return shares Shares amount that needs to be burnt to obtain given assets amount + * @param assets Tested assets amount + */ + function previewWithdraw(uint256 assets) external view returns (uint256 shares); + + /** + * @notice Simulates redeem shares conversion including fees + * @return assets Assets amount that will be obtained from the given shares burnt + * @param shares Tested shares amount + */ + function previewRedeem(uint256 shares) external view returns (uint256 assets); + + /** + * @notice Simulates withdraw result + * @return shares Shares amount that needs to be burnt to make a withdrawal with given params + * @return withdrawFee Fee for a withdrawal with given params + * @param sender Supposed withdraw transaction sender address + * @param assets Supposed assets amount + * @param receiver Supposed assets receiver address + * @param owner Supposed shares owner + */ + function onWithdraw( + address sender, + uint256 assets, + address receiver, + address owner + ) external returns (uint256 shares, uint256 withdrawFee); + + /** + * @notice Simulates redeem result + * @return assets Assets amount that will be obtained from the redeem with given params + * @return redeemFee Fee for a redeem with given params + * @param sender Supposed redeem transaction sender address + * @param shares Supposed shares amount + * @param receiver Supposed assets receiver address + * @param owner Supposed shares owner + */ + function onRedeem( + address sender, + uint256 shares, + address receiver, + address owner + ) external returns (uint256 assets, uint256 redeemFee); + + /** + * @notice Floor setter + * @param newFloor New floor value + */ + function setFloor(uint256 newFloor) external; + + /** + * @notice Withdraw allowed setter + * @param newWithdrawAllowed Value indicating whether withdrawals should be allowed when related StructuredPortfolio is in given status + * @param portfolioStatus StructuredPortfolio status for which changes are applied + */ + function setWithdrawAllowed(bool newWithdrawAllowed, Status portfolioStatus) external; + + /** + * @notice Withdraw fee rate setter + * @param newFeeRate New withdraw fee rate (in BPS) + */ + function setWithdrawFeeRate(uint256 newFeeRate) external; + + /** + * @notice Allows to change floor, withdraw fee rate and enable or disable withdrawals at once + * @param newFloor New floor value + * @param newFeeRate New withdraw fee rate (in BPS) + * @param newWithdrawAllowed New withdraw allowed settings + */ + function configure( + uint256 newFloor, + uint256 newFeeRate, + WithdrawAllowed memory newWithdrawAllowed + ) external; +} diff --git a/contracts/carbon/lenderVerifiers/AllowAllLenderVerifier.sol b/contracts/carbon/lenderVerifiers/AllowAllLenderVerifier.sol new file mode 100644 index 0000000..d9183a9 --- /dev/null +++ b/contracts/carbon/lenderVerifiers/AllowAllLenderVerifier.sol @@ -0,0 +1,20 @@ +// 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 {ILenderVerifier} from "../interfaces/ILenderVerifier.sol"; + +contract AllowAllLenderVerifier is ILenderVerifier { + function isAllowed(address) external pure returns (bool) { + return true; + } +} diff --git a/contracts/carbon/mocks/FVMockTokens/MockMissingReturnToken.sol b/contracts/carbon/mocks/FVMockTokens/MockMissingReturnToken.sol new file mode 100644 index 0000000..5f46526 --- /dev/null +++ b/contracts/carbon/mocks/FVMockTokens/MockMissingReturnToken.sol @@ -0,0 +1,58 @@ +// Copyright (C) 2017, 2018, 2019, 2020 dbrock, rain, mrchico, d-xo +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.16; + +contract MockToken { + // --- ERC20 Data --- + string public constant name = "Token"; + string public constant symbol = "TKN"; + uint8 public constant decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Approval(address indexed src, address indexed guy, uint256 wad); + event Transfer(address indexed src, address indexed dst, uint256 wad); + + // --- Math --- + function add(uint256 x, uint256 y) internal pure returns (uint256 z) { + require((z = x + y) >= x); + } + + function sub(uint256 x, uint256 y) internal pure returns (uint256 z) { + require((z = x - y) <= x); + } + + // --- Init --- + constructor(uint256 _totalSupply) { + totalSupply = _totalSupply; + balanceOf[msg.sender] = _totalSupply; + emit Transfer(address(0), msg.sender, _totalSupply); + } + + // --- Token --- + function transfer(address dst, uint256 wad) external { + transferFrom(msg.sender, dst, wad); + } + + function transferFrom( + address src, + address dst, + uint256 wad + ) public { + require(balanceOf[src] >= wad, "insufficient-balance"); + if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) { + require(allowance[src][msg.sender] >= wad, "insufficient-allowance"); + allowance[src][msg.sender] = sub(allowance[src][msg.sender], wad); + } + balanceOf[src] = sub(balanceOf[src], wad); + balanceOf[dst] = add(balanceOf[dst], wad); + emit Transfer(src, dst, wad); + } + + function approve(address usr, uint256 wad) external { + allowance[msg.sender][usr] = wad; + emit Approval(msg.sender, usr, wad); + } +} diff --git a/contracts/carbon/mocks/FVMockTokens/MockNonRevertingToken.sol b/contracts/carbon/mocks/FVMockTokens/MockNonRevertingToken.sol new file mode 100644 index 0000000..5f9de52 --- /dev/null +++ b/contracts/carbon/mocks/FVMockTokens/MockNonRevertingToken.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockToken is ERC20 { + constructor() ERC20("MockToken", "MT") {} + + function decimals() public view virtual override returns (uint8) { + return 6; + } + + function singleToken() external view returns (uint256) { + return 10**decimals(); + } + + function transfer(address recipient, uint256 amount) public virtual override returns (bool) { + if (balanceOf(_msgSender()) < amount) { + return false; + } + _transfer(_msgSender(), recipient, amount); + return true; + } + + function transferFrom( + address sender, + address recipient, + uint256 amount + ) public virtual override returns (bool) { + uint256 currentAllowance = allowance(sender, _msgSender()); + if (balanceOf(sender) < amount || currentAllowance < amount) { + return false; + } + + _transfer(sender, recipient, amount); + unchecked { + _approve(sender, _msgSender(), currentAllowance - amount); + } + + return true; + } +} diff --git a/contracts/carbon/mocks/FVMockTokens/MockRegularToken.sol b/contracts/carbon/mocks/FVMockTokens/MockRegularToken.sol new file mode 100644 index 0000000..ce92a5c --- /dev/null +++ b/contracts/carbon/mocks/FVMockTokens/MockRegularToken.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockToken is ERC20 { + constructor() ERC20("MockToken", "MT") {} + + function decimals() public view virtual override returns (uint8) { + return 6; + } + + function mint(address receiver, uint256 amount) external { + _mint(receiver, amount); + } +} diff --git a/contracts/carbon/mocks/MockLenderVerifier.sol b/contracts/carbon/mocks/MockLenderVerifier.sol new file mode 100644 index 0000000..39a3cd7 --- /dev/null +++ b/contracts/carbon/mocks/MockLenderVerifier.sol @@ -0,0 +1,26 @@ +// 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 {ILenderVerifier} from "../interfaces/ILenderVerifier.sol"; + +contract MockLenderVerifier is ILenderVerifier { + mapping(address => bool) public isBlacklisted; + + function isAllowed(address receiver) external view returns (bool) { + return !isBlacklisted[receiver]; + } + + function setIsBlacklisted(address receiver, bool _isBlacklisted) external { + isBlacklisted[receiver] = _isBlacklisted; + } +} diff --git a/contracts/carbon/mocks/MockToken.sol b/contracts/carbon/mocks/MockToken.sol new file mode 100644 index 0000000..4829470 --- /dev/null +++ b/contracts/carbon/mocks/MockToken.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockToken is ERC20 { + uint8 public _decimals; + mapping(address => bool) private _failTransfers; + + constructor(uint8 __decimals) ERC20("MockToken", "MT") { + _decimals = __decimals; + } + + function decimals() public view virtual override returns (uint8) { + return _decimals; + } + + function mint(address receiver, uint256 amount) external { + _mint(receiver, amount); + } + + function burn(address account, uint256 amount) external { + _burn(account, amount); + } + + function failTransfers(address recipient, bool newValue) external { + _failTransfers[recipient] = newValue; + } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (_failTransfers[to]) { + revert("MockToken: Transfer failed"); + } else { + return super.transfer(to, amount); + } + } + + function transferFrom( + address from, + address to, + uint256 amount + ) public override returns (bool) { + if (_failTransfers[to]) { + revert("MockToken: Transfer failed"); + } else { + return super.transferFrom(from, to, amount); + } + } +} diff --git a/contracts/carbon/proxy/ProxyWrapper.sol b/contracts/carbon/proxy/ProxyWrapper.sol new file mode 100644 index 0000000..4baafb8 --- /dev/null +++ b/contracts/carbon/proxy/ProxyWrapper.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +// Needed for Etherscan verification +contract ProxyWrapper is ERC1967Proxy { + constructor(address _logic, bytes memory _data) payable ERC1967Proxy(_logic, _data) {} +} diff --git a/contracts/carbon/proxy/Upgradeable.sol b/contracts/carbon/proxy/Upgradeable.sol new file mode 100644 index 0000000..e0f7502 --- /dev/null +++ b/contracts/carbon/proxy/Upgradeable.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {AccessControlEnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; + +abstract contract Upgradeable is AccessControlEnumerableUpgradeable, UUPSUpgradeable, PausableUpgradeable { + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + constructor() initializer {} + + function __Upgradeable_init(address admin, address pauser) internal onlyInitializing { + AccessControlEnumerableUpgradeable.__AccessControlEnumerable_init(); + __Pausable_init(); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(PAUSER_ROLE, pauser); + } + + function _authorizeUpgrade(address) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} + + function pause() external onlyRole(PAUSER_ROLE) { + super._pause(); + } + + function unpause() external onlyRole(PAUSER_ROLE) { + super._unpause(); + } +} diff --git a/contracts/carbon/test/FixedInterestOnlyLoans.sol b/contracts/carbon/test/FixedInterestOnlyLoans.sol new file mode 100644 index 0000000..e08191c --- /dev/null +++ b/contracts/carbon/test/FixedInterestOnlyLoans.sol @@ -0,0 +1,280 @@ +// 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 {AccessControlEnumerableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import {IERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/interfaces/IERC165Upgradeable.sol"; +import {IERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/interfaces/IERC721Upgradeable.sol"; +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {Upgradeable} from "../proxy/Upgradeable.sol"; +import {IFixedInterestOnlyLoans, FixedInterestOnlyLoanStatus} from "../interfaces/IFixedInterestOnlyLoans.sol"; +import {IProtocolConfig} from "../interfaces/IProtocolConfig.sol"; + +contract FixedInterestOnlyLoans is ERC721Upgradeable, Upgradeable, IFixedInterestOnlyLoans { + LoanMetadata[] internal loans; + + event LoanIssued(uint256 indexed instrumentId); + event LoanStatusChanged(uint256 indexed instrumentId, FixedInterestOnlyLoanStatus newStatus); + event GracePeriodUpdated(uint256 indexed instrumentId, uint32 newGracePeriod); + event Repaid(uint256 indexed instrumentId, uint256 amount); + event Canceled(uint256 indexed instrumentId); + + modifier onlyLoanOwner(uint256 instrumentId) { + require(msg.sender == ownerOf(instrumentId), "FixedInterestOnlyLoans: Not a loan owner"); + _; + } + + modifier onlyLoanStatus(uint256 instrumentId, FixedInterestOnlyLoanStatus _status) { + require(loans[instrumentId].status == _status, "FixedInterestOnlyLoans: Unexpected loan status"); + _; + } + + function initialize(IProtocolConfig _protocolConfig) external initializer { + __Upgradeable_init(msg.sender, _protocolConfig.pauserAddress()); + __ERC721_init("FixedInterestOnlyLoans", "FIOL"); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(IERC165Upgradeable, ERC721Upgradeable, AccessControlEnumerableUpgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function principal(uint256 instrumentId) external view returns (uint256) { + return loans[instrumentId].principal; + } + + function asset(uint256 instrumentId) external view returns (IERC20WithDecimals) { + return loans[instrumentId].asset; + } + + function recipient(uint256 instrumentId) external view returns (address) { + return loans[instrumentId].recipient; + } + + function canBeRepaidAfterDefault(uint256 instrumentId) external view returns (bool) { + return loans[instrumentId].canBeRepaidAfterDefault; + } + + function status(uint256 instrumentId) external view returns (FixedInterestOnlyLoanStatus) { + return loans[instrumentId].status; + } + + function periodPayment(uint256 instrumentId) external view returns (uint256) { + return loans[instrumentId].periodPayment; + } + + function periodCount(uint256 instrumentId) external view returns (uint16) { + return loans[instrumentId].periodCount; + } + + function periodDuration(uint256 instrumentId) external view returns (uint32) { + return loans[instrumentId].periodDuration; + } + + function endDate(uint256 instrumentId) external view returns (uint256) { + return loans[instrumentId].endDate; + } + + function gracePeriod(uint256 instrumentId) external view returns (uint256) { + return loans[instrumentId].gracePeriod; + } + + function issueInstrumentSelector() external pure returns (bytes4) { + return this.issueLoan.selector; + } + + function updateInstrumentSelector() external pure returns (bytes4) { + return this.updateInstrument.selector; + } + + function currentPeriodEndDate(uint256 instrumentId) external view returns (uint40) { + return loans[instrumentId].currentPeriodEndDate; + } + + function periodsRepaid(uint256 instrumentId) external view returns (uint256) { + return loans[instrumentId].periodsRepaid; + } + + function loanData(uint256 instrumentId) external view returns (LoanMetadata memory) { + return loans[instrumentId]; + } + + function issueLoan( + IERC20WithDecimals _asset, + uint256 _principal, + uint16 _periodCount, + uint256 _periodPayment, + uint32 _periodDuration, + address _recipient, + uint32 _gracePeriod, + bool _canBeRepaidAfterDefault + ) public virtual whenNotPaused returns (uint256) { + require(_recipient != address(0), "FixedInterestOnlyLoans: recipient cannot be the zero address"); + + uint32 loanDuration = _periodCount * _periodDuration; + require(loanDuration > 0, "FixedInterestOnlyLoans: Loan duration must be greater than 0"); + + uint256 _totalInterest = _periodCount * _periodPayment; + require(_totalInterest > 0, "FixedInterestOnlyLoans: Total interest must be greater than 0"); + + uint256 id = loans.length; + loans.push( + LoanMetadata( + _principal, + _periodPayment, + FixedInterestOnlyLoanStatus.Created, + _periodCount, + _periodDuration, + 0, // currentPeriodEndDate + _recipient, + _canBeRepaidAfterDefault, + 0, // periodsRepaid + _gracePeriod, + 0, // endDate, + _asset + ) + ); + + _safeMint(msg.sender, id); + + emit LoanIssued(id); + return id; + } + + function acceptLoan(uint256 instrumentId) + public + virtual + onlyLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Created) + whenNotPaused + { + require(msg.sender == loans[instrumentId].recipient, "FixedInterestOnlyLoans: Not a borrower"); + _changeLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Accepted); + } + + function start(uint256 instrumentId) + external + onlyLoanOwner(instrumentId) + onlyLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Accepted) + whenNotPaused + { + LoanMetadata storage loan = loans[instrumentId]; + _changeLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Started); + + uint32 _periodDuration = loan.periodDuration; + uint40 loanDuration = loan.periodCount * _periodDuration; + loan.endDate = uint40(block.timestamp) + loanDuration; + loan.currentPeriodEndDate = uint40(block.timestamp + _periodDuration); + } + + function _changeLoanStatus(uint256 instrumentId, FixedInterestOnlyLoanStatus _status) private { + loans[instrumentId].status = _status; + emit LoanStatusChanged(instrumentId, _status); + } + + function repay(uint256 instrumentId, uint256 amount) + public + virtual + onlyLoanOwner(instrumentId) + whenNotPaused + returns (uint256 principalRepaid, uint256 interestRepaid) + { + require(_canBeRepaid(instrumentId), "FixedInterestOnlyLoans: This loan cannot be repaid"); + LoanMetadata storage loan = loans[instrumentId]; + uint16 _periodsRepaid = loan.periodsRepaid; + uint16 _periodCount = loan.periodCount; + + interestRepaid = loan.periodPayment; + if (_periodsRepaid == _periodCount - 1) { + principalRepaid = loan.principal; + _changeLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Repaid); + } + require(amount == interestRepaid + principalRepaid, "FixedInterestOnlyLoans: Unexpected repayment amount"); + + loan.periodsRepaid = _periodsRepaid + 1; + loan.currentPeriodEndDate += loan.periodDuration; + + emit Repaid(instrumentId, amount); + + return (principalRepaid, interestRepaid); + } + + function expectedRepaymentAmount(uint256 instrumentId) external view returns (uint256) { + LoanMetadata storage loan = loans[instrumentId]; + uint256 amount = loan.periodPayment; + if (loan.periodsRepaid == loan.periodCount - 1) { + amount += loan.principal; + } + return amount; + } + + function cancel(uint256 instrumentId) external onlyLoanOwner(instrumentId) whenNotPaused { + FixedInterestOnlyLoanStatus _status = loans[instrumentId].status; + require( + _status == FixedInterestOnlyLoanStatus.Created || _status == FixedInterestOnlyLoanStatus.Accepted, + "FixedInterestOnlyLoans: Unexpected loan status" + ); + _changeLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Canceled); + } + + function markAsDefaulted(uint256 instrumentId) + external + onlyLoanOwner(instrumentId) + onlyLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Started) + whenNotPaused + { + require( + loans[instrumentId].currentPeriodEndDate + loans[instrumentId].gracePeriod < block.timestamp, + "FixedInterestOnlyLoans: This loan cannot be defaulted" + ); + _changeLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Defaulted); + } + + function updateInstrument(uint256 instrumentId, uint32 newGracePeriod) + external + onlyLoanOwner(instrumentId) + onlyLoanStatus(instrumentId, FixedInterestOnlyLoanStatus.Started) + whenNotPaused + { + require(newGracePeriod > loans[instrumentId].gracePeriod, "FixedInterestOnlyLoans: Grace period can only be extended"); + loans[instrumentId].gracePeriod = newGracePeriod; + emit GracePeriodUpdated(instrumentId, newGracePeriod); + } + + function _canBeRepaid(uint256 instrumentId) internal view returns (bool) { + LoanMetadata storage loan = loans[instrumentId]; + + if (loan.status == FixedInterestOnlyLoanStatus.Started) { + return true; + } else if (loan.status == FixedInterestOnlyLoanStatus.Defaulted && loan.canBeRepaidAfterDefault) { + return true; + } else { + return false; + } + } + + function _transfer( + address from, + address to, + uint256 tokenID + ) internal virtual override whenNotPaused { + super._transfer(from, to, tokenID); + } + + function _approve(address to, uint256 tokenID) internal virtual override whenNotPaused { + super._approve(to, tokenID); + } +} diff --git a/contracts/carbon/test/LoansManagerTest.sol b/contracts/carbon/test/LoansManagerTest.sol new file mode 100644 index 0000000..2ca5cd9 --- /dev/null +++ b/contracts/carbon/test/LoansManagerTest.sol @@ -0,0 +1,64 @@ +// 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 {LoansManager, IFixedInterestOnlyLoans, AddLoanParams} from "../LoansManager.sol"; +import {IERC20WithDecimals} from "../interfaces/IERC20WithDecimals.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract LoansManagerTest is LoansManager { + using SafeERC20 for IERC20WithDecimals; + + function initialize(IFixedInterestOnlyLoans _fixedInterestOnlyLoans, IERC20WithDecimals _asset) external { + _initialize(_fixedInterestOnlyLoans, _asset); + } + + function addLoan(AddLoanParams calldata params) external { + _addLoan(params); + } + + function fundLoan(uint256 loanId) external { + _fundLoan(loanId); + } + + function repayLoan(uint256 loanId) external { + _repayLoan(loanId); + } + + function cancelLoan(uint256 loanId) external { + _cancelLoan(loanId); + } + + function updateLoanGracePeriod(uint256 loanId, uint32 newGracePeriod) external { + _updateLoanGracePeriod(loanId, newGracePeriod); + } + + function transferAllAssets(address to) external { + asset.safeTransfer(to, asset.balanceOf(address(this))); + } + + function tryToExcludeLoan(uint256 loanId) external { + _tryToExcludeLoan(loanId); + } + + function markLoanAsDefaulted(uint256 loanId) external { + _markLoanAsDefaulted(loanId); + } + + function setLoanAsDefaulted(uint256 loanId) external { + fixedInterestOnlyLoans.markAsDefaulted(loanId); + } + + function getActiveLoans() external view returns (uint256[] memory) { + return activeLoanIds; + } +} diff --git a/contracts/carbon/test/StructuredPortfolioTest.sol b/contracts/carbon/test/StructuredPortfolioTest.sol new file mode 100644 index 0000000..d78aec1 --- /dev/null +++ b/contracts/carbon/test/StructuredPortfolioTest.sol @@ -0,0 +1,49 @@ +// 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.sol"; + +/** + * @dev This contract is used to test the StructuredPortfolio contract. + * The intention is to easily set non-settable values and have access to private methods. + * Please don't override any StructuredPortfolio methods in this contract. + */ +contract StructuredPortfolioTest is StructuredPortfolio { + function setTrancheMinSubordinateRatio(uint256 trancheIdx, uint128 ratio) external { + tranchesData[trancheIdx].minSubordinateRatio = ratio; + } + + function onPortfolioStart(ITrancheVault tranche) external { + tranche.onPortfolioStart(); + } + + function setMinimumSize(uint256 newSize) external { + minimumSize = newSize; + } + + function mockIncreaseVirtualTokenBalance(uint256 increment) external { + virtualTokenBalance += increment; + } + + function mockDecreaseVirtualTokenBalance(uint256 decrement) external { + 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/TrancheVaultTest.sol b/contracts/carbon/test/TrancheVaultTest.sol new file mode 100644 index 0000000..da019e5 --- /dev/null +++ b/contracts/carbon/test/TrancheVaultTest.sol @@ -0,0 +1,32 @@ +// 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"; + +contract TrancheVaultTest is TrancheVault { + function mockIncreaseVirtualTokenBalance(uint256 amount) external { + virtualTokenBalance += amount; + } + + 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/hardhat.config.ts b/hardhat.config.ts new file mode 100644 index 0000000..f5ee3d6 --- /dev/null +++ b/hardhat.config.ts @@ -0,0 +1,43 @@ +import '@typechain/hardhat' +import 'hardhat-waffle-dev' +import 'solidity-coverage' +import './abi-exporter' +import 'tsconfig-paths/register' +import 'hardhat-gas-reporter' + +import mocharc from './.mocharc.json' +import compiler from './.compiler.json' + +module.exports = { + paths: { + sources: './contracts', + artifacts: './build', + cache: './cache', + }, + abiExporter: { + path: './build', + flat: true, + spacing: 2, + }, + networks: { + hardhat: { + initialDate: '2020-01-01T00:00:00Z', + allowUnlimitedContractSize: true, + }, + }, + typechain: { + outDir: 'build/types', + target: 'ethers-v5', + }, + solidity: { + compilers: [compiler], + }, + mocha: { + ...mocharc, + timeout: 400000, + }, + waffle: { + skipEstimateGas: '0xB71B00', + injectCallHistory: true, + }, +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..33ebbb9 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "@aip/minimum-deposit-controller", + "version": "0.0.1", + "private": true, + "scripts": { + "preinstall": "npx only-allow pnpm", + "build:hardhat": "hardhat compile", + "build:typechain": "typechain --target ethers-v5 --out-dir build/types 'build/*.json'", + "build": "pnpm clean && pnpm build:hardhat && pnpm build:typechain", + "clean": "rm -rf ./build && hardhat clean", + "lint:prettier": "prettier './{contracts,test,features}/**/*.{ts,sol}'", + "lint:sol": "solhint 'contracts/**/*.sol'", + "lint:ts": "eslint '{test,features}/**/*.ts' --cache", + "lint:fix": "pnpm run lint:prettier --write --loglevel error && pnpm run lint:sol --fix && pnpm run lint:ts --fix", + "lint": "pnpm run lint:sol && pnpm run lint:prettier --check --loglevel error && pnpm run lint:ts", + "test:unit": "mocha 'test/**/*.test.ts'", + "test": "pnpm test:unit", + "test:ci": "pnpm run test" + }, + "prettier": "prettier-config-archblock/contracts.json", + "dependencies": { + "@openzeppelin/contracts": "4.7.3", + "@openzeppelin/contracts-upgradeable": "4.7.3" + }, + "devDependencies": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/providers": "^5.7.1", + "@nomiclabs/hardhat-ethers": "^2.1.1", + "@typechain/ethers-v5": "^10.1.0", + "@typechain/hardhat": "^6.1.3", + "@types/mocha": "^10.0.0", + "@types/node": "^17.0.12", + "chai": "^4.3.6", + "eslint-config-archblock": "workspace:*", + "ethereum-waffle": "4.0.7", + "ethers": "^5.7.1", + "hardhat": "^2.11.2", + "hardhat-gas-reporter": "^1.0.9", + "hardhat-waffle-dev": "2.0.3-dev.c5b5c29", + "mocha": "^10.0.0", + "prettier-config-archblock": "workspace:*", + "solc": "^0.8.17", + "solhint": "^3.3.7", + "solidity-coverage": "^0.8.2", + "ts-node": "^10.9.1", + "tsconfig": "workspace:*", + "tsconfig-paths": "^4.1.0", + "typechain": "^8.1.0", + "typescript": "^4.9.4" + } +} diff --git a/test/contracts/configure.test.ts b/test/contracts/configure.test.ts new file mode 100644 index 0000000..e313ff5 --- /dev/null +++ b/test/contracts/configure.test.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai' +import { Wallet } from 'ethers' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { PortfolioStatus } from 'fixtures/structuredPortfolioFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.configure', () => { + const loadFixture = setupFixtureLoader() + const ceiling = 400 + const depositFeeRate = 100 + const minimumDeposit = 200 + const lenderVerifierAddress = Wallet.createRandom().address + + it('changes multiple values', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const status = PortfolioStatus.Live + const depositAllowed = false + + await depositController.configure(ceiling, depositFeeRate, minimumDeposit, lenderVerifierAddress, { + status, + value: depositAllowed, + }) + + expect(await depositController.depositAllowed(status)).to.eq(depositAllowed) + expect(await depositController.ceiling()).to.eq(ceiling) + expect(await depositController.depositFeeRate()).to.eq(depositFeeRate) + expect(await depositController.minimumDeposit()).to.eq(minimumDeposit) + expect(await depositController.lenderVerifier()).to.eq(lenderVerifierAddress) + }) + + it("can be used in closed if deposit allowed doesn't change", async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const status = PortfolioStatus.Closed + const depositAllowed = await depositController.depositAllowed(status) + + await depositController.configure(ceiling, depositFeeRate, minimumDeposit, lenderVerifierAddress, { + status, + value: depositAllowed, + }) + + expect(await depositController.depositAllowed(status)).to.eq(depositAllowed) + expect(await depositController.ceiling()).to.eq(ceiling) + expect(await depositController.depositFeeRate()).to.eq(depositFeeRate) + expect(await depositController.minimumDeposit()).to.eq(minimumDeposit) + expect(await depositController.lenderVerifier()).to.eq(lenderVerifierAddress) + }) +}) diff --git a/test/contracts/initialize.test.ts b/test/contracts/initialize.test.ts new file mode 100644 index 0000000..8f9f1e3 --- /dev/null +++ b/test/contracts/initialize.test.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { PortfolioStatus } from 'fixtures/structuredPortfolioFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.initialize', () => { + const loadFixture = setupFixtureLoader() + + it('grants manager role', async () => { + const { depositController, wallet } = await loadFixture(minimumDepositControllerFixture) + const managerRole = await depositController.MANAGER_ROLE() + expect(await depositController.hasRole(managerRole, wallet.address)).to.be.true + }) + + it('sets deposit fee rate', async () => { + const { depositController, depositFeeRate } = await loadFixture(minimumDepositControllerFixture) + expect(await depositController.depositFeeRate()).to.eq(depositFeeRate) + }) + + it('sets minimum deposit', async () => { + const { depositController, minimumDeposit } = await loadFixture(minimumDepositControllerFixture) + expect(await depositController.minimumDeposit()).to.eq(minimumDeposit) + }) + + it('sets lender verifier', async () => { + const { depositController, lenderVerifier } = await loadFixture(minimumDepositControllerFixture) + expect(await depositController.lenderVerifier()).to.eq(lenderVerifier.address) + }) + + it('sets default deposit allowed', async () => { + const TEST_DATA = [ + { + status: PortfolioStatus.CapitalFormation, + defaultValue: true, + }, + { + status: PortfolioStatus.Live, + defaultValue: false, + }, + { + status: PortfolioStatus.Closed, + defaultValue: false, + }, + ] + + const { depositController } = await loadFixture(minimumDepositControllerFixture) + + for (const { status, defaultValue } of TEST_DATA) { + expect(await depositController.depositAllowed(status)).to.deep.eq(defaultValue) + } + }) +}) diff --git a/test/contracts/onDeposit.test.ts b/test/contracts/onDeposit.test.ts new file mode 100644 index 0000000..533155b --- /dev/null +++ b/test/contracts/onDeposit.test.ts @@ -0,0 +1,28 @@ +import { setupFixtureLoader } from 'test/setup' +import { structuredPortfolioFixture } from 'fixtures/structuredPortfolioFixture' +import { parseUSDC } from 'utils' +import { expect } from 'chai' + +describe('MinimumDepositController.onDeposit', () => { + const loadFixture = setupFixtureLoader() + const minimumDeposit = parseUSDC(100) + + it('cannot deposit below minimum deposit value', async () => { + const { equityTranche, depositToTranche } = await loadFixture(structuredPortfolioFixture(minimumDeposit)) + await expect(depositToTranche(equityTranche, parseUSDC(50))).to.be.revertedWith('MDC: Assets below minimum deposit') + }) + + it('makes deposit with minimum deposit value', async () => { + const { equityTranche, depositToTranche, token } = await loadFixture(structuredPortfolioFixture(minimumDeposit)) + await depositToTranche(equityTranche, minimumDeposit) + expect(await token.balanceOf(equityTranche.address)).to.eq(minimumDeposit) + }) + + it('makes deposit with value greater than minimum deposit', async () => { + const { equityTranche, depositToTranche, token } = await loadFixture(structuredPortfolioFixture(minimumDeposit)) + const amount = parseUSDC(150) + + await depositToTranche(equityTranche, amount) + expect(await token.balanceOf(equityTranche.address)).to.eq(amount) + }) +}) diff --git a/test/contracts/onMint.test.ts b/test/contracts/onMint.test.ts new file mode 100644 index 0000000..5c6038a --- /dev/null +++ b/test/contracts/onMint.test.ts @@ -0,0 +1,30 @@ +import { setupFixtureLoader } from 'test/setup' +import { structuredPortfolioFixture } from 'fixtures/structuredPortfolioFixture' +import { parseUSDC } from 'utils' +import { expect } from 'chai' + +describe('MinimumDepositController.onMint', () => { + const loadFixture = setupFixtureLoader() + const minimumDeposit = parseUSDC(100) + + it('cannot mint below minimum deposit value', async () => { + const { equityTranche, mintToTranche } = await loadFixture(structuredPortfolioFixture(minimumDeposit)) + await expect(mintToTranche(equityTranche, parseUSDC(10))).to.be.revertedWith('MDC: Assets below minimum deposit') + }) + + it('mints shares equal to minimum deposit value', async () => { + const { equityTranche, mintToTranche, token } = await loadFixture(structuredPortfolioFixture(minimumDeposit)) + const amount = await equityTranche.convertToShares(minimumDeposit) + + await mintToTranche(equityTranche, amount) + expect(await token.balanceOf(equityTranche.address)).to.eq(amount) + }) + + it('mints shares for share amount above minimum deposit value', async () => { + const { equityTranche, mintToTranche, token } = await loadFixture(structuredPortfolioFixture(minimumDeposit)) + const amount = await equityTranche.convertToShares(parseUSDC(150)) + + await mintToTranche(equityTranche, amount) + expect(await token.balanceOf(equityTranche.address)).to.eq(amount) + }) +}) diff --git a/test/contracts/setCeiling.test.ts b/test/contracts/setCeiling.test.ts new file mode 100644 index 0000000..636f3fd --- /dev/null +++ b/test/contracts/setCeiling.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.setCeiling', () => { + const loadFixture = setupFixtureLoader() + + it('only manager', async () => { + const { depositController, other } = await loadFixture(minimumDepositControllerFixture) + await expect(depositController.connect(other).setCeiling(1000)).to.be.revertedWith('MDC: Only manager') + }) + + it('sets new value', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const ceiling = 1000 + await depositController.setCeiling(ceiling) + expect(await depositController.ceiling()).to.eq(ceiling) + }) + + it('emits event', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const ceiling = 1000 + await expect(depositController.setCeiling(ceiling)).to.emit(depositController, 'CeilingChanged').withArgs(ceiling) + }) +}) diff --git a/test/contracts/setDepositAllowed.test.ts b/test/contracts/setDepositAllowed.test.ts new file mode 100644 index 0000000..1f7e522 --- /dev/null +++ b/test/contracts/setDepositAllowed.test.ts @@ -0,0 +1,43 @@ +import { expect } from 'chai' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { PortfolioStatus } from 'fixtures/structuredPortfolioFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.setDepositAllowed', () => { + const loadFixture = setupFixtureLoader() + + it('emits event', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const newValue = true + const status = PortfolioStatus.CapitalFormation + await expect(depositController.setDepositAllowed(newValue, status)) + .to.emit(depositController, 'DepositAllowedChanged') + .withArgs(newValue, status) + }) + + it('only manager', async () => { + const { depositController, other } = await loadFixture(minimumDepositControllerFixture) + await expect( + depositController.connect(other).setDepositAllowed(true, PortfolioStatus.CapitalFormation), + ).to.be.revertedWith('MDC: Only manager') + }) + + it('no custom value in closed', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + await expect(depositController.setDepositAllowed(true, PortfolioStatus.Closed)).to.be.revertedWith( + 'MDC: No custom value in Closed', + ) + }) + + it('changes value', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const newValue = true + const status = PortfolioStatus.Live + + await depositController.setDepositAllowed(newValue, status) + + const depositAllowed = await depositController.depositAllowed(status) + + expect(depositAllowed).to.eq(newValue) + }) +}) diff --git a/test/contracts/setDepositFeeRate.test.ts b/test/contracts/setDepositFeeRate.test.ts new file mode 100644 index 0000000..3a955dc --- /dev/null +++ b/test/contracts/setDepositFeeRate.test.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.setDepositFeeRate', () => { + const loadFixture = setupFixtureLoader() + + it('only manager', async () => { + const { depositController, other } = await loadFixture(minimumDepositControllerFixture) + await expect(depositController.connect(other).setDepositFeeRate(100)).to.be.revertedWith('MDC: Only manager') + }) + + it('sets new deposit fee rate', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const depositFeeRate = 100 + await depositController.setDepositFeeRate(depositFeeRate) + expect(await depositController.depositFeeRate()).to.eq(depositFeeRate) + }) + + it('emits event', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const depositFeeRate = 100 + await expect(depositController.setDepositFeeRate(depositFeeRate)) + .to.emit(depositController, 'DepositFeeRateChanged') + .withArgs(depositFeeRate) + }) +}) diff --git a/test/contracts/setLenderVerifier.test.ts b/test/contracts/setLenderVerifier.test.ts new file mode 100644 index 0000000..23f8135 --- /dev/null +++ b/test/contracts/setLenderVerifier.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai' +import { Wallet } from 'ethers' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.setLenderVerifier', () => { + const loadFixture = setupFixtureLoader() + + const lenderVerifierAddress = Wallet.createRandom().address + + it('only manager', async () => { + const { depositController, other } = await loadFixture(minimumDepositControllerFixture) + await expect(depositController.connect(other).setLenderVerifier(lenderVerifierAddress)).to.be.revertedWith( + 'MDC: Only manager', + ) + }) + + it('sets new lender verifier', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + await depositController.setLenderVerifier(lenderVerifierAddress) + expect(await depositController.lenderVerifier()).to.eq(lenderVerifierAddress) + }) + + it('emits event', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + await expect(depositController.setLenderVerifier(lenderVerifierAddress)) + .to.emit(depositController, 'LenderVerifierChanged') + .withArgs(lenderVerifierAddress) + }) +}) diff --git a/test/contracts/setMinimumDeposit.test.ts b/test/contracts/setMinimumDeposit.test.ts new file mode 100644 index 0000000..4503f9f --- /dev/null +++ b/test/contracts/setMinimumDeposit.test.ts @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { minimumDepositControllerFixture } from 'fixtures/minimumDepositControllerFixture' +import { setupFixtureLoader } from 'test/setup' + +describe('MinimumDepositController.setMinimumDeposit', () => { + const loadFixture = setupFixtureLoader() + + it('only manager', async () => { + const { depositController, other } = await loadFixture(minimumDepositControllerFixture) + await expect(depositController.connect(other).setMinimumDeposit(1000)).to.be.revertedWith('MDC: Only manager') + }) + + it('sets new value', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const minimumDeposit = 1000 + await depositController.setMinimumDeposit(minimumDeposit) + expect(await depositController.minimumDeposit()).to.eq(minimumDeposit) + }) + + it('emits event', async () => { + const { depositController } = await loadFixture(minimumDepositControllerFixture) + const minimumDeposit = 1000 + await expect(depositController.setMinimumDeposit(minimumDeposit)) + .to.emit(depositController, 'MinimumDepositChanged') + .withArgs(minimumDeposit) + }) +}) diff --git a/test/fixtures/deployControllers.ts b/test/fixtures/deployControllers.ts new file mode 100644 index 0000000..9ad3dd4 --- /dev/null +++ b/test/fixtures/deployControllers.ts @@ -0,0 +1,11 @@ +import { MinimumDepositController__factory, WithdrawController__factory } from 'build/types' +import { TransferController__factory } from 'build/types/factories/TransferController__factory' +import { Wallet } from 'ethers' + +export async function deployControllers(wallet: Wallet) { + const depositController = await new MinimumDepositController__factory(wallet).deploy() + const withdrawController = await new WithdrawController__factory(wallet).deploy() + const transferController = await new TransferController__factory(wallet).deploy() + + return { depositController, withdrawController, transferController } +} diff --git a/test/fixtures/deployFixedInterestOnlyLoans.ts b/test/fixtures/deployFixedInterestOnlyLoans.ts new file mode 100644 index 0000000..13e23ea --- /dev/null +++ b/test/fixtures/deployFixedInterestOnlyLoans.ts @@ -0,0 +1,13 @@ +import { FixedInterestOnlyLoans__factory } from 'build/types' +import { Wallet } from 'ethers' +import { deployBehindProxy } from 'utils/deployBehindProxy' +import { deployProtocolConfig } from './deployProtocolConfig' + +export async function deployFixedInterestOnlyLoans([wallet]: Wallet[]) { + const { protocolConfig } = await deployProtocolConfig(wallet) + const fixedInterestOnlyLoans = await deployBehindProxy( + new FixedInterestOnlyLoans__factory(wallet), + protocolConfig.address, + ) + return { fixedInterestOnlyLoans, protocolConfig } +} diff --git a/test/fixtures/deployProtocolConfig.ts b/test/fixtures/deployProtocolConfig.ts new file mode 100644 index 0000000..085c136 --- /dev/null +++ b/test/fixtures/deployProtocolConfig.ts @@ -0,0 +1,34 @@ +import { ProtocolConfig__factory } from 'build/types/factories/ProtocolConfig__factory' +import { Wallet } from 'ethers' +import { deployBehindProxy } from 'utils/deployBehindProxy' + +export interface ProtocolConfigParams { + defaultProtocolFeeRate: number + protocolAdmin: string + protocolTreasury: string + pauserAddress: string +} + +export async function deployProtocolConfig(wallet: Wallet) { + const defaultProtocolFeeRate = 0 + const protocolAdmin = wallet.address + const protocolTreasury = Wallet.createRandom().address + const pauserAddress = wallet.address + + const protocolConfigParams: ProtocolConfigParams = { + defaultProtocolFeeRate, + protocolAdmin, + protocolTreasury, + pauserAddress, + } + + const protocolConfig = await deployBehindProxy( + new ProtocolConfig__factory(wallet), + defaultProtocolFeeRate, + protocolAdmin, + protocolTreasury, + pauserAddress, + ) + + return { protocolConfig: protocolConfig, protocolConfigParams: protocolConfigParams } +} diff --git a/test/fixtures/minimumDepositControllerFixture.ts b/test/fixtures/minimumDepositControllerFixture.ts new file mode 100644 index 0000000..f8a044f --- /dev/null +++ b/test/fixtures/minimumDepositControllerFixture.ts @@ -0,0 +1,12 @@ +import { MinimumDepositController__factory } from 'build/types' +import { AllowAllLenderVerifier__factory } from 'build/types/factories/AllowAllLenderVerifier__factory' +import { Wallet } from 'ethers' + +export async function minimumDepositControllerFixture([wallet]: Wallet[]) { + const lenderVerifier = await new AllowAllLenderVerifier__factory(wallet).deploy() + const depositController = await new MinimumDepositController__factory(wallet).deploy() + const depositFeeRate = 500 + const minimumDeposit = 100 + await depositController.initialize(wallet.address, lenderVerifier.address, depositFeeRate, minimumDeposit, 0) + return { depositController, depositFeeRate, minimumDeposit, lenderVerifier } +} diff --git a/test/fixtures/setupLoansManagerHelpers.ts b/test/fixtures/setupLoansManagerHelpers.ts new file mode 100644 index 0000000..5e34e85 --- /dev/null +++ b/test/fixtures/setupLoansManagerHelpers.ts @@ -0,0 +1,67 @@ +import { FixedInterestOnlyLoans, LoansManagerTest, StructuredPortfolio, MockToken } from 'build/types' +import { Wallet, BigNumber, constants } from 'ethers' +import { DAY } from 'utils/constants' +import { extractEventArgFromTx } from 'utils/extractEventArgFromTx' +import { utils } from 'ethers' + +export interface Loan { + principal: BigNumber + periodCount: number + periodPayment: BigNumber + periodDuration: number + recipient: string + gracePeriod: number + canBeRepaidAfterDefault: boolean +} + +export async function setupLoansManagerHelpers( + loansManager: LoansManagerTest | StructuredPortfolio, + fixedInterestOnlyLoans: FixedInterestOnlyLoans, + borrower: Wallet, + token: MockToken, +) { + const basicLoan: Loan = { + principal: utils.parseUnits('100000', await token.decimals()), + periodCount: 1, + periodPayment: utils.parseUnits('100', await token.decimals()), + periodDuration: DAY, + recipient: borrower.address, + gracePeriod: DAY, + canBeRepaidAfterDefault: true, + } + + async function addLoan(loan: Loan = basicLoan) { + const tx = await loansManager.addLoan(loan) + const loanId: BigNumber = await extractEventArgFromTx(tx, [loansManager.address, 'LoanAdded', 'loanId']) + return loanId + } + + async function addAndAcceptLoan(loan: Loan = basicLoan) { + const loanId = await addLoan(loan) + await fixedInterestOnlyLoans.connect(borrower).acceptLoan(loanId) + return loanId + } + + async function addAndFundLoan(loan: Loan = basicLoan) { + const loanId = await addAndAcceptLoan(loan) + await loansManager.fundLoan(loanId) + return loanId + } + + function getLoan(loan: Partial) { + return { ...basicLoan, ...loan } + } + + async function repayLoanInFull(loanId: BigNumber, loan: Loan = basicLoan) { + await token.connect(borrower).approve(loansManager.address, constants.MaxUint256) + for (let i = 0; i < loan.periodCount; i++) { + await loansManager.connect(borrower).repayLoan(loanId) + } + } + + function getFullRepayAmount(loan: Loan = basicLoan) { + return loan.principal.add(loan.periodPayment.mul(loan.periodCount)) + } + + return { loan: basicLoan, addLoan, addAndAcceptLoan, addAndFundLoan, getLoan, repayLoanInFull, getFullRepayAmount } +} diff --git a/test/fixtures/structuredPortfolioFactoryFixture.ts b/test/fixtures/structuredPortfolioFactoryFixture.ts new file mode 100644 index 0000000..9516a7e --- /dev/null +++ b/test/fixtures/structuredPortfolioFactoryFixture.ts @@ -0,0 +1,287 @@ +import { + MinimumDepositController, + MinimumDepositController__factory, + MockToken__factory, + StructuredPortfolioFactory__factory, + StructuredPortfolioTest__factory, + TrancheVaultTest, + TrancheVaultTest__factory, + WithdrawController, + WithdrawController__factory, +} from 'build/types' +import { BigNumberish, BytesLike, constants, Contract, ContractTransaction, utils, Wallet } from 'ethers' +import { DAY, YEAR } from 'utils/constants' +import { extractEventArgFromTx } from 'utils/extractEventArgFromTx' +import { deployFixedInterestOnlyLoans } from './deployFixedInterestOnlyLoans' +import { deployControllers } from 'fixtures/deployControllers' +import { deployProtocolConfig } from './deployProtocolConfig' +import { AllowAllLenderVerifier__factory } from 'build/types/factories/AllowAllLenderVerifier__factory' + +export interface TrancheInitData { + name: string + symbol: string + depositControllerImplementation: string + depositControllerInitData: BytesLike + withdrawControllerImplementation: string + withdrawControllerInitData: BytesLike + transferControllerImplementation: string + transferControllerInitData: BytesLike + targetApy: number + minSubordinateRatio: number + managerFeeRate: number +} + +export interface TrancheData extends TrancheInitData { + depositController: MinimumDepositController + withdrawController: WithdrawController +} + +export interface PortfolioParams { + name: string + duration: number + capitalFormationPeriod: number + minimumSize: number +} + +export const getStructuredPortfolioFactoryFixture = (tokenDecimals: number, minimumDeposit: BigNumberish = 0) => { + return async ([wallet, other]: Wallet[]) => { + const token = await new MockToken__factory(wallet).deploy(tokenDecimals) + + const parseTokenUnits = (amount: string | number) => utils.parseUnits(amount.toString(), tokenDecimals) + + await token.mint(wallet.address, parseTokenUnits(1e12)) + await token.mint(other.address, parseTokenUnits(1e10)) + + const structuredPortfolioImplementation = await new StructuredPortfolioTest__factory(wallet).deploy() + const trancheVaultImplementation = await new TrancheVaultTest__factory(wallet).deploy() + + const { protocolConfig, protocolConfigParams } = await deployProtocolConfig(wallet) + + const structuredPortfolioFactory = await new StructuredPortfolioFactory__factory(wallet).deploy( + structuredPortfolioImplementation.address, + trancheVaultImplementation.address, + protocolConfig.address, + ) + + const whitelistedManagerRole = await structuredPortfolioFactory.WHITELISTED_MANAGER_ROLE() + await structuredPortfolioFactory.grantRole(whitelistedManagerRole, wallet.address) + + const { fixedInterestOnlyLoans } = await deployFixedInterestOnlyLoans([wallet]) + + const { depositController, withdrawController, transferController } = await deployControllers(wallet) + + const lenderVerifier = await new AllowAllLenderVerifier__factory(wallet).deploy() + + const sizes = [ + { floor: constants.Zero, ceiling: parseTokenUnits(5e9) }, + { floor: constants.Zero, ceiling: parseTokenUnits(5e9) }, + { floor: constants.Zero, ceiling: parseTokenUnits(1e10) }, + ] + + const equityTrancheInitData: TrancheInitData = { + name: 'Equity Tranche', + symbol: 'EQT', + depositControllerImplementation: depositController.address, + depositControllerInitData: depositController.interface.encodeFunctionData('initialize', [ + wallet.address, + lenderVerifier.address, + 0, + minimumDeposit, + parseTokenUnits(1e10), + ]), + withdrawControllerImplementation: withdrawController.address, + withdrawControllerInitData: withdrawController.interface.encodeFunctionData('initialize', [wallet.address, 0, 1]), + transferControllerImplementation: transferController.address, + transferControllerInitData: transferController.interface.encodeFunctionData('initialize', [wallet.address]), + targetApy: 0, + minSubordinateRatio: 0, + managerFeeRate: 0, + } + + const juniorTrancheInitData: TrancheInitData = { + name: 'Junior Tranche', + symbol: 'JNT', + depositControllerImplementation: depositController.address, + depositControllerInitData: depositController.interface.encodeFunctionData('initialize', [ + wallet.address, + lenderVerifier.address, + 0, + minimumDeposit, + parseTokenUnits(1e10), + ]), + withdrawControllerImplementation: withdrawController.address, + withdrawControllerInitData: withdrawController.interface.encodeFunctionData('initialize', [wallet.address, 0, 1]), + transferControllerImplementation: transferController.address, + transferControllerInitData: transferController.interface.encodeFunctionData('initialize', [wallet.address]), + targetApy: 500, + minSubordinateRatio: 0, + managerFeeRate: 0, + } + + const seniorTrancheInitData: TrancheInitData = { + name: 'Senior Tranche', + symbol: 'SNT', + depositControllerImplementation: depositController.address, + depositControllerInitData: depositController.interface.encodeFunctionData('initialize', [ + wallet.address, + lenderVerifier.address, + 0, + minimumDeposit, + parseTokenUnits(1e10), + ]), + withdrawControllerImplementation: withdrawController.address, + withdrawControllerInitData: withdrawController.interface.encodeFunctionData('initialize', [wallet.address, 0, 1]), + transferControllerImplementation: transferController.address, + transferControllerInitData: transferController.interface.encodeFunctionData('initialize', [wallet.address]), + targetApy: 300, + minSubordinateRatio: 0, + managerFeeRate: 0, + } + + const tranchesInitData = [equityTrancheInitData, juniorTrancheInitData, seniorTrancheInitData] + + const portfolioDuration = 2 * YEAR + + const portfolioParams: PortfolioParams = { + name: 'Portfolio', + duration: portfolioDuration, + capitalFormationPeriod: 90 * DAY, + minimumSize: 0, + } + + const expectedEquityRate = { from: 200, to: 2000 } + + async function createPortfolio( + params: Partial<{ + token: Wallet | Contract + fixedInterestOnlyLoans: Wallet | Contract + portfolioParams: PortfolioParams + tranchesInitData: TrancheInitData[] + expectedEquityRate: { from: number; to: number } + }> = {}, + ) { + const args = { + token, + fixedInterestOnlyLoans, + portfolioParams, + tranchesInitData, + expectedEquityRate, + ...params, + } + const createPortfolioTx = await structuredPortfolioFactory.createPortfolio( + args.token.address, + args.fixedInterestOnlyLoans.address, + args.portfolioParams, + args.tranchesInitData, + args.expectedEquityRate, + ) + const portfolio = await getPortfolioFromTx(createPortfolioTx) + + return { portfolio, createPortfolioTx } + } + + async function createPortfolioAndSetupControllers(...args: Parameters) { + const { portfolio, createPortfolioTx } = await createPortfolio(...args) + const tranches = await getTranchesFromTx(createPortfolioTx) + const controllers: { depositController: MinimumDepositController; withdrawController: WithdrawController }[] = [] + for (let i = 0; i < tranches.length; i++) { + const depositControllerAddress = await tranches[i].depositController() + const withdrawControllerAddress = await tranches[i].withdrawController() + const depositController = MinimumDepositController__factory.connect(depositControllerAddress, wallet) + const withdrawController = WithdrawController__factory.connect(withdrawControllerAddress, wallet) + controllers.push({ depositController, withdrawController }) + } + return { portfolio, tranches, createPortfolioTx, controllers } + } + + const { createPortfolioTx } = await createPortfolio() + + const { timestamp: now } = await wallet.provider.getBlock('latest') + const maxCapitalFormationDuration = 90 * DAY + const startDeadline = now + maxCapitalFormationDuration + + const tranches = await getTranchesFromTx(createPortfolioTx) + + const tranchesData: TrancheData[] = [] + for (let i = 0; i < tranches.length; i++) { + const depositControllerAddress = await tranches[i].depositController() + const withdrawControllerAddress = await tranches[i].withdrawController() + const depositController = MinimumDepositController__factory.connect(depositControllerAddress, wallet) + const withdrawController = WithdrawController__factory.connect(withdrawControllerAddress, wallet) + tranchesData.push({ + ...tranchesInitData[i], + depositController, + withdrawController, + }) + + await depositController.setCeiling(sizes[i].ceiling) + await withdrawController.setFloor(sizes[i].floor) + } + + async function depositToTranche(tranche: TrancheVaultTest, amount: BigNumberish, receiver = wallet) { + await token.mint(receiver.address, amount) + await token.connect(receiver).approve(tranche.address, amount) + return tranche.connect(receiver).deposit(amount, receiver.address) + } + + async function depositAndApproveToTranche(tranche: TrancheVaultTest, amount: BigNumberish, receiver = wallet) { + await depositToTranche(tranche, amount, receiver) + await tranche.connect(receiver).approve(tranchesData[0].withdrawController.address, amount) + } + + async function mintToTranche(tranche: TrancheVaultTest, shares: BigNumberish, receiver = wallet.address) { + await token.approve(tranche.address, constants.MaxUint256) + return tranche.mint(shares, receiver) + } + + async function getPortfolioFromTx(tx: ContractTransaction = createPortfolioTx) { + const portfolioAddress: string = await extractEventArgFromTx(tx, [ + structuredPortfolioFactory.address, + 'PortfolioCreated', + 'newPortfolio', + ]) + return new StructuredPortfolioTest__factory(wallet).attach(portfolioAddress) + } + + async function getTranchesFromTx(tx: ContractTransaction = createPortfolioTx) { + const tranchesAddresses: string[] = await extractEventArgFromTx(tx, [ + structuredPortfolioFactory.address, + 'PortfolioCreated', + 'tranches', + ]) + return tranchesAddresses.map((address) => new TrancheVaultTest__factory(wallet).attach(address)) + } + + return { + structuredPortfolioFactory, + tranchesData, + tranches, + token, + fixedInterestOnlyLoans, + portfolioDuration, + portfolioParams, + createPortfolioTx, + parseTokenUnits, + depositToTranche, + depositAndApproveToTranche, + mintToTranche, + getPortfolioFromTx, + getTranchesFromTx, + startDeadline, + maxCapitalFormationDuration, + equityTrancheData: tranchesData[0], + juniorTrancheData: tranchesData[1], + seniorTrancheData: tranchesData[2], + whitelistedManagerRole, + protocolConfig, + protocolConfigParams, + expectedEquityRate, + tranchesInitData, + createPortfolio, + createPortfolioAndSetupControllers, + lenderVerifier, + } + } +} + +export const structuredPortfolioFactoryFixture = getStructuredPortfolioFactoryFixture(6) diff --git a/test/fixtures/structuredPortfolioFixture.ts b/test/fixtures/structuredPortfolioFixture.ts new file mode 100644 index 0000000..5b0ecbf --- /dev/null +++ b/test/fixtures/structuredPortfolioFixture.ts @@ -0,0 +1,201 @@ +import { MinimumDepositController, TrancheVaultTest, WithdrawController } from 'build/types' +import { BigNumber, BigNumberish, constants, Wallet } from 'ethers' +import { getStructuredPortfolioFactoryFixture, TrancheData } from './structuredPortfolioFactoryFixture' +import { setupLoansManagerHelpers } from './setupLoansManagerHelpers' +import { YEAR } from 'utils/constants' +import { timeTravel } from 'utils/timeTravel' +import { getTxTimestamp } from 'utils/getTxTimestamp' +import { sum } from 'utils/sum' +import { MockProvider } from 'ethereum-waffle' +import { parseBPS } from 'utils' + +interface FixtureTrancheData extends TrancheData { + initialDeposit: BigNumber + trancheIdx: number + calculateTargetValue: (yearDivider?: number) => BigNumber +} + +export enum PortfolioStatus { + CapitalFormation, + Live, + Closed, +} + +const getStructuredPortfolioFixture = (tokenDecimals: number, minimumDeposit?: BigNumberish) => { + return async ([wallet, other, ...rest]: Wallet[], provider: MockProvider) => { + const factoryFixtureResult = await getStructuredPortfolioFactoryFixture( + tokenDecimals, + minimumDeposit, + )([wallet, other, ...rest]) + const { portfolioDuration, getPortfolioFromTx, tranches, tranchesData, fixedInterestOnlyLoans, token } = + factoryFixtureResult + + const structuredPortfolio = await getPortfolioFromTx() + + function withdrawFromTranche( + tranche: TrancheVaultTest, + amount: BigNumberish, + owner = wallet.address, + receiver = wallet.address, + ) { + return tranche.withdraw(amount, receiver, owner) + } + + function redeemFromTranche( + tranche: TrancheVaultTest, + amount: BigNumberish, + owner = wallet.address, + receiver = wallet.address, + ) { + return tranche.redeem(amount, receiver, owner) + } + + async function setDepositAllowed( + controller: MinimumDepositController, + value: boolean, + portfolio = structuredPortfolio, + ) { + await controller.setDepositAllowed(value, await portfolio.status()) + } + + async function setWithdrawAllowed(controller: WithdrawController, value: boolean, portfolio = structuredPortfolio) { + await controller.setWithdrawAllowed(value, await portfolio.status()) + } + + async function startPortfolioAndEnableLiveActions() { + const tx = await structuredPortfolio.start() + for (const trancheData of tranchesData) { + await setDepositAllowed(trancheData.depositController, true) + // await setWithdrawAllowed(trancheData.withdrawController, true) + } + return tx + } + + async function startAndClosePortfolio() { + await structuredPortfolio.start() + await timeTravel(provider, portfolioDuration) + await structuredPortfolio.close() + } + + async function mintToPortfolio(amount: BigNumberish, portfolio = structuredPortfolio) { + await token.mint(portfolio.address, amount) + await portfolio.mockIncreaseVirtualTokenBalance(amount) + } + + async function burnFromPortfolio(amount: BigNumberish, portfolio = structuredPortfolio) { + await token.burn(portfolio.address, amount) + await portfolio.mockDecreaseVirtualTokenBalance(amount) + } + + async function increaseAssetsInTranche(tranche: TrancheVaultTest, amount: BigNumberish) { + await token.mint(tranche.address, amount) + await tranche.mockIncreaseVirtualTokenBalance(amount) + } + + async function decreaseAssetsInTranche(tranche: TrancheVaultTest, amount: BigNumberish) { + await token.burn(tranche.address, amount) + await tranche.mockDecreaseVirtualTokenBalance(amount) + } + + function withInterest(initialAmount: BigNumberish, apy: number, period: number) { + const initialAmountBN = BigNumber.from(initialAmount) + const yearlyInterest = initialAmountBN.mul(apy).div(parseBPS(100)) + const periodInterest = yearlyInterest.mul(period).div(YEAR) + return initialAmountBN.add(periodInterest) + } + + const [equityTranche, juniorTranche, seniorTranche] = tranches + + const loansManagerHelpers = await setupLoansManagerHelpers( + structuredPortfolio, + fixedInterestOnlyLoans, + other, + token, + ) + + return { + structuredPortfolio, + ...loansManagerHelpers, + PortfolioStatus, + withdrawFromTranche, + redeemFromTranche, + startAndClosePortfolio, + startPortfolioAndEnableLiveActions, + ...factoryFixtureResult, + equityTranche, + juniorTranche, + seniorTranche, + withInterest, + setDepositAllowed, + setWithdrawAllowed, + mintToPortfolio, + burnFromPortfolio, + increaseAssetsInTranche, + decreaseAssetsInTranche, + } + } +} + +export const structuredPortfolioFixture = (minimumDeposit?: BigNumberish) => + getStructuredPortfolioFixture(6, minimumDeposit) + +export const getStructuredPortfolioLiveFixture = (tokenDecimals: number, minimumDeposit: BigNumberish) => { + return async ([wallet, borrower, ...rest]: Wallet[], provider: MockProvider) => { + const portfolioFixtureResult = await getStructuredPortfolioFixture(tokenDecimals, minimumDeposit)( + [wallet, borrower, ...rest], + provider, + ) + const { + tranches, + depositToTranche, + parseTokenUnits, + tranchesData, + withInterest, + startPortfolioAndEnableLiveActions, + } = portfolioFixtureResult + + const initialDeposits = [2e6, 3e6, 5e6].map(parseTokenUnits) + const totalDeposit = sum(...initialDeposits) + for (let i = 0; i < tranches.length; i++) { + await depositToTranche(tranches[i], initialDeposits[i]) + } + + const portfolioStartTx = await startPortfolioAndEnableLiveActions() + const portfolioStartTimestamp = await getTxTimestamp(portfolioStartTx, provider) + + function calculateTargetTrancheValue(trancheIdx: number, yearDivider = 1) { + if (trancheIdx === 0) { + return constants.Zero + } + const { targetApy } = tranchesData[trancheIdx] + const initialDeposit = initialDeposits[trancheIdx] + return withInterest(initialDeposit, targetApy, YEAR / yearDivider) + } + + const getTrancheData = (trancheIdx: number): FixtureTrancheData => ({ + ...tranchesData[trancheIdx], + initialDeposit: initialDeposits[trancheIdx], + trancheIdx, + calculateTargetValue: (yearDivider?: number) => calculateTargetTrancheValue(trancheIdx, yearDivider), + }) + + const senior = getTrancheData(2) + const junior = getTrancheData(1) + const equity = getTrancheData(0) + + return { + ...portfolioFixtureResult, + calculateTargetTrancheValue, + withInterest, + initialDeposits, + senior, + junior, + equity, + portfolioStartTx, + portfolioStartTimestamp, + totalDeposit, + } + } +} + +export const structuredPortfolioLiveFixture = getStructuredPortfolioLiveFixture(6) diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..01c96ab --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,49 @@ +import { MockProvider, Fixture } from 'ethereum-waffle' +import { Wallet } from 'ethers' +import { waffle } from 'hardhat' + +type FixtureLoader = ReturnType +export interface FixtureReturns { + provider: MockProvider + wallet: Wallet + other: Wallet + another: Wallet +} + +let loadFixture: ReturnType | undefined +export function setupFixtureLoader() { + if (!loadFixture) { + loadFixture = setupOnce() + } + return loadFixture +} + +type CurrentLoader = { loader: FixtureLoader; returns: FixtureReturns; fixture: Fixture } + +function setupOnce() { + let currentLoader: CurrentLoader = { + loader: {} as FixtureLoader, + returns: {} as FixtureReturns, + fixture: {} as Fixture, + } + + async function makeLoader(): Promise<{ loader: FixtureLoader; returns: FixtureReturns }> { + const provider = waffle.provider + await provider.send('hardhat_reset', []) + const [wallet, other, another, ...rest] = provider.getWallets() + const loader = waffle.createFixtureLoader([wallet, other, another, ...rest], provider) + const returns = { provider, wallet, other, another } + return { loader, returns } + } + + async function loadFixture(fixture: Fixture): Promise { + // This function creates a new provider for each fixture, because of bugs + // in ganache that clear contract code on evm_revert + const { loader, returns } = currentLoader.fixture === fixture ? currentLoader : await makeLoader() + currentLoader = { fixture, loader, returns } + const result = await loader(fixture) + return { ...returns, ...result } + } + + return loadFixture +} diff --git a/test/utils/constants.ts b/test/utils/constants.ts new file mode 100644 index 0000000..eab6c91 --- /dev/null +++ b/test/utils/constants.ts @@ -0,0 +1,23 @@ +import { constants } from 'ethers' + +export const BYTES_32_ZERO = `0x${'0'.repeat(64)}` + +export const AddressOne = '0x0000000000000000000000000000000000000001' +export const BURN_AMOUNT_MULTIPLIER = 12_441_000 +export const MAX_BURN_BOUND = constants.MaxUint256.sub(constants.MaxUint256.mod(BURN_AMOUNT_MULTIPLIER)) + +export const MAX_APY = 100_000 +export const SECOND = 1000 +export const DAY = 60 * 60 * 24 +export const WEEK = 7 * DAY +export const YEAR = 365 * DAY + +export const ONE_PERCENT = 100 +export const ONE_HUNDRED_PERCENT = 10_000 + +export const MAGIC_VALUE = '0x1626ba7e' + +export const MANAGED_PORTFOLIO_NAME = 'Managed Portfolio' +export const MANAGED_PORTFOLIO_SYMBOL = 'MPS' + +export const MAX_BYTECODE_SIZE = 24576 diff --git a/test/utils/deployBehindProxy.ts b/test/utils/deployBehindProxy.ts new file mode 100644 index 0000000..d80b3d3 --- /dev/null +++ b/test/utils/deployBehindProxy.ts @@ -0,0 +1,17 @@ +import type { ContractFactory } from 'ethers' +import { ERC1967Proxy__factory } from 'contracts' +import { defineReadOnly } from 'ethers/lib/utils' + +type UnpackPromise = T extends Promise ? U : T + +export async function deployBehindProxy( + factory: T, + ...args: Parameters>['initialize']> +): Promise> { + const impl = await factory.deploy() + const init = (await impl.populateTransaction.initialize(...args)).data + const proxy = await new ERC1967Proxy__factory(impl.signer).deploy(impl.address, init ?? []) + const instance = factory.attach(proxy.address) + defineReadOnly(instance, 'deployTransaction', proxy.deployTransaction) + return instance as ReturnType +} diff --git a/test/utils/extractArgFromTx.ts b/test/utils/extractArgFromTx.ts new file mode 100644 index 0000000..80065d4 --- /dev/null +++ b/test/utils/extractArgFromTx.ts @@ -0,0 +1,19 @@ +import { ContractTransaction } from 'ethers' + +type MaybePromise = T | Promise + +export const extractArgFromTx = async ( + pendingTx: MaybePromise, + ...argFilters: [string, string, string][] +): Promise => { + const tx = await pendingTx + const receipt = await tx.wait() + const events = receipt.events + for (const filter of argFilters) { + const [_address, _event, _argName] = filter + const eventLookup = events?.filter(({ address }) => address === _address).find(({ event }) => event === _event) + if (eventLookup !== undefined) { + return eventLookup.args?.[_argName] + } + } +} diff --git a/test/utils/extractEventArgFromTx.ts b/test/utils/extractEventArgFromTx.ts new file mode 100644 index 0000000..2e54776 --- /dev/null +++ b/test/utils/extractEventArgFromTx.ts @@ -0,0 +1,19 @@ +import { ContractTransaction } from 'ethers' + +type MaybePromise = T | Promise + +export const extractEventArgFromTx = async ( + pendingTx: MaybePromise, + ...argFilters: [address: string, event: string, argName: string][] +): Promise => { + const tx = await pendingTx + const receipt = await tx.wait() + const events = receipt.events + for (const filter of argFilters) { + const [_address, _event, _argName] = filter + const eventLookup = events?.filter(({ address }) => address === _address).find(({ event }) => event === _event) + if (eventLookup !== undefined) { + return eventLookup.args?.[_argName] + } + } +} diff --git a/test/utils/getTxTimestamp.ts b/test/utils/getTxTimestamp.ts new file mode 100644 index 0000000..fc936b3 --- /dev/null +++ b/test/utils/getTxTimestamp.ts @@ -0,0 +1,7 @@ +import { MockProvider } from 'ethereum-waffle' +import { ContractTransaction } from 'ethers' + +export async function getTxTimestamp(tx: ContractTransaction, provider: MockProvider): Promise { + const txReceipt = await tx.wait() + return (await provider.getBlock(txReceipt.blockHash)).timestamp +} diff --git a/test/utils/index.ts b/test/utils/index.ts new file mode 100644 index 0000000..7216d5b --- /dev/null +++ b/test/utils/index.ts @@ -0,0 +1,10 @@ +export * from './constants' +export * from './extractArgFromTx' +export * from './parseUSDC' +export * from './parseBPS' +export * from './parseEth' +export * from './getTxTimestamp' +export * from './timeTravel' +export * from './time' +export * from './mockDepositController' +export * from './mockWithdrawController' diff --git a/test/utils/mockDepositController.ts b/test/utils/mockDepositController.ts new file mode 100644 index 0000000..10dc86b --- /dev/null +++ b/test/utils/mockDepositController.ts @@ -0,0 +1,38 @@ +import { IDepositController__factory } from 'build/types' +import { deployMockContract } from 'ethereum-waffle' +import { BigNumber, BigNumberish, ContractTransaction, Wallet } from 'ethers' + +export interface MockDepositControllerConfig { + onDeposit?: { shares: BigNumberish; fee?: BigNumberish } + depositLimit?: BigNumberish + onMint?: { assets: BigNumberish; fee?: BigNumberish } + mintLimit?: BigNumberish +} + +export async function mockDepositController( + wallet: Wallet, + setDepositController: (address: string) => Promise, + { onDeposit, depositLimit, onMint, mintLimit }: MockDepositControllerConfig, +) { + const mockContract = await deployMockContract(wallet, IDepositController__factory.abi) + + if (onDeposit) { + await mockContract.mock.onDeposit.returns(onDeposit.shares, onDeposit?.fee ?? 0) + await mockContract.mock.previewDeposit.returns(onDeposit.shares ?? 0) + } + if (depositLimit) { + await mockContract.mock.maxDeposit.returns(depositLimit) + } + + if (onMint) { + await mockContract.mock.onMint.returns(onMint.assets, onMint?.fee ?? 0) + await mockContract.mock.previewMint.returns(BigNumber.from(onMint.assets).add(onMint?.fee ?? 0)) + } + + if (mintLimit) { + await mockContract.mock.maxMint.returns(mintLimit) + } + + await setDepositController(mockContract.address) + return mockContract +} diff --git a/test/utils/mockWithdrawController.ts b/test/utils/mockWithdrawController.ts new file mode 100644 index 0000000..39d6535 --- /dev/null +++ b/test/utils/mockWithdrawController.ts @@ -0,0 +1,39 @@ +import { deployMockContract } from 'ethereum-waffle' +import { IWithdrawController__factory } from 'contracts' +import { BigNumberish, ContractTransaction, Wallet } from 'ethers' + +export interface MockWithdrawControllerConfig { + onWithdraw?: { shares: BigNumberish; fee?: BigNumberish } + withdrawLimit?: BigNumberish + onRedeem?: { assets: BigNumberish; fee?: BigNumberish } + redeemLimit?: BigNumberish +} + +export async function mockWithdrawController( + wallet: Wallet, + setWithdrawController: (address: string) => Promise, + { onWithdraw, withdrawLimit, onRedeem, redeemLimit }: MockWithdrawControllerConfig, +) { + const mockContract = await deployMockContract(wallet, IWithdrawController__factory.abi) + + if (onWithdraw) { + await mockContract.mock.onWithdraw.returns(onWithdraw.shares, onWithdraw?.fee ?? 0) + await mockContract.mock.previewWithdraw.returns(onWithdraw.shares) + } + + if (onRedeem) { + await mockContract.mock.onRedeem.returns(onRedeem.assets, onRedeem?.fee ?? 0) + await mockContract.mock.previewRedeem.returns(onRedeem.assets) + } + + if (withdrawLimit) { + await mockContract.mock.maxWithdraw.returns(withdrawLimit) + } + + if (redeemLimit) { + await mockContract.mock.maxRedeem.returns(redeemLimit) + } + + await setWithdrawController(mockContract.address) + return mockContract +} diff --git a/test/utils/parseBPS.ts b/test/utils/parseBPS.ts new file mode 100644 index 0000000..696fb6d --- /dev/null +++ b/test/utils/parseBPS.ts @@ -0,0 +1,3 @@ +import { BigNumber, BigNumberish } from 'ethers' + +export const parseBPS = (amount: BigNumberish) => BigNumber.from(amount).mul(100) diff --git a/test/utils/parseEth.ts b/test/utils/parseEth.ts new file mode 100644 index 0000000..7f8b09c --- /dev/null +++ b/test/utils/parseEth.ts @@ -0,0 +1,4 @@ +import { BigNumberish } from 'ethers' +import { parseEther } from 'ethers/lib/utils' + +export const parseEth = (amount: BigNumberish) => parseEther(amount.toString()) diff --git a/test/utils/parseUSDC.ts b/test/utils/parseUSDC.ts new file mode 100644 index 0000000..1c695e6 --- /dev/null +++ b/test/utils/parseUSDC.ts @@ -0,0 +1,4 @@ +import { BigNumberish } from 'ethers' +import { parseUnits } from 'ethers/lib/utils' + +export const parseUSDC = (amount: BigNumberish) => parseUnits(amount.toString(), 6) diff --git a/test/utils/sum.ts b/test/utils/sum.ts new file mode 100644 index 0000000..763b174 --- /dev/null +++ b/test/utils/sum.ts @@ -0,0 +1,5 @@ +import { BigNumber, constants } from 'ethers' + +export function sum(...values: BigNumber[]) { + return values.reduce((sum, value) => sum.add(value), constants.Zero) +} diff --git a/test/utils/time.ts b/test/utils/time.ts new file mode 100644 index 0000000..419734f --- /dev/null +++ b/test/utils/time.ts @@ -0,0 +1,3 @@ +import { SECOND } from 'utils/constants' + +export const toUnixTimestamp = (timestamp: number) => Math.floor(timestamp / SECOND) diff --git a/test/utils/timeTravel.ts b/test/utils/timeTravel.ts new file mode 100644 index 0000000..6d8d185 --- /dev/null +++ b/test/utils/timeTravel.ts @@ -0,0 +1,26 @@ +import { ContractTransaction, providers } from 'ethers' +import { getTxTimestamp } from './getTxTimestamp' +import { MockProvider } from 'ethereum-waffle' + +export const timeTravel = async (provider: providers.JsonRpcProvider, time: number) => { + await provider.send('evm_increaseTime', [time]) + await provider.send('evm_mine', []) +} + +export const timeTravelTo = async (provider: providers.JsonRpcProvider, timestamp: number) => { + await provider.send('evm_mine', [timestamp]) +} + +export const setNextBlockTimestamp = async (provider: providers.JsonRpcProvider, timestamp: number) => { + await provider.send('evm_setNextBlockTimestamp', [timestamp]) +} + +export const executeAndSetNextTimestamp = async ( + provider: MockProvider, + contractFunction: Promise, + timestamp: number, +) => { + const tx = await contractFunction + const txTimestamp = await getTxTimestamp(tx, provider) + await setNextBlockTimestamp(provider, txTimestamp + timestamp) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d0e6e8c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "esnext", + "sourceMap": true, + "baseUrl": ".", + "paths": { + "build/*": ["build/*"], + "build": ["build"], + "fixtures/*": ["test/fixtures/*"], + "fixtures": ["test/fixtures"], + "utils/*": ["test/utils/*"], + "utils": ["test/utils"], + "contracts/*": ["build/types/*"], + "contracts": ["build/types", "build/types/factories"], + "config": ["test/config"], + "config/*": ["test/config/*"] + } + }, + "include": [ + "**/*.ts", + "contracts", + "test" + ] +} diff --git a/waffle.json b/waffle.json new file mode 100644 index 0000000..8d5f555 --- /dev/null +++ b/waffle.json @@ -0,0 +1,12 @@ +{ + "compilerType": "solcjs", + "compilerVersion": "v0.8.16+commit.07a7930e", + "compilerOptions": { + "optimizer": { + "enabled": true, + "runs": 200 + } + }, + "sourceDirectory": "./contracts", + "outputDirectory": "./build" +}