diff --git a/src/FlatFeeCalculator.sol b/src/FlatFeeCalculator.sol new file mode 100644 index 0000000..121610c --- /dev/null +++ b/src/FlatFeeCalculator.sol @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2024 Toucan Protocol +// +// SPDX-License-Identifier: UNLICENSED + +// If you encounter a vulnerability or an issue, please contact +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +import {IFeeCalculator, FeeDistribution} from "./interfaces/IFeeCalculator.sol"; +import "./interfaces/IPool.sol"; + +/// @title FlatFeeCalculator +/// @author Neutral Labs Inc. & Toucan Protocol +/// @notice This contract calculates deposit and redemption fees for a given pool. +/// @dev It implements the IFeeCalculator interface. +contract FlatFeeCalculator is IFeeCalculator, Ownable { + /// @dev Version-related parameters. VERSION keeps track of production + /// releases. VERSION_RELEASE_CANDIDATE keeps track of iterations + /// of a VERSION in our staging environment. + string public constant VERSION = "1.0.0"; + uint256 public constant VERSION_RELEASE_CANDIDATE = 1; + + uint256 public feeBasisPoints = 300; + + address[] private _recipients; + uint256[] private _shares; + + event FeeBasisPointsUpdated(uint256 feeBasisPoints); + event FeeSetup(address[] recipients, uint256[] shares); + + constructor() Ownable() {} + + /// @notice Sets the fee basis points. + /// @dev Can only be called by the current owner. + /// @param _feeBasisPoints The new fee basis points. + function setFeeBasisPoints(uint256 _feeBasisPoints) external onlyOwner { + require(_feeBasisPoints < 10000, "Fee basis points should be less than 10000"); + + feeBasisPoints = _feeBasisPoints; + emit FeeBasisPointsUpdated(_feeBasisPoints); + } + + /// @notice Sets up the fee distribution among recipients. + /// @dev Can only be called by the current owner. + /// @param recipients The addresses of the fee recipients. + /// @param shares The share of the fee each recipient should receive. + function feeSetup(address[] memory recipients, uint256[] memory shares) external onlyOwner { + require(recipients.length == shares.length, "Recipients and shares arrays must have the same length"); + + uint256 totalShares = sumOf(shares); + require(totalShares == 100, "Total shares must equal 100"); + + _recipients = recipients; + _shares = shares; + emit FeeSetup(recipients, shares); + } + + /// @notice Calculates the deposit fee for a given amount. + /// @param pool The address of the pool. + /// @param tco2 The address of the TCO2 token. + /// @param depositAmount The amount to be deposited. + /// @return feeDistribution How the fee is meant to be + /// distributed among the fee recipients. + function calculateDepositFees(address pool, address tco2, uint256 depositAmount) + external + view + override + returns (FeeDistribution memory feeDistribution) + { + require(depositAmount > 0, "depositAmount must be > 0"); + + feeDistribution = _calculateFee(depositAmount); + } + + /// @notice Calculates the fee shares and recipients based on the total fee. + /// @param totalFee The total fee to be distributed. + /// @return feeDistribution The recipients and the amount of fees each + /// recipient should receive. + function calculateFeeShares(uint256 totalFee) internal view returns (FeeDistribution memory feeDistribution) { + uint256 recipientsLength = _recipients.length; + uint256[] memory shares = new uint256[](recipientsLength); + + uint256 restFee = totalFee; + for (uint256 i = 0; i < recipientsLength; i++) { + shares[i] = (totalFee * _shares[i]) / 100; + restFee -= shares[i]; + } + + // If any fee is left, it is distributed to the first recipient. + // This may happen if any of the shares of the fee to be distributed + // has leftover from the division by 100 above. + shares[0] += restFee; + + feeDistribution.recipients = _recipients; + feeDistribution.shares = shares; + } + + /// @notice Calculates the redemption fees for a given amount. + /// @param pool The address of the pool. + /// @param tco2s The addresses of the TCO2 token. + /// @param redemptionAmounts The amounts to be redeemed. + /// @return feeDistribution How the fee is meant to be + /// distributed among the fee recipients. + function calculateRedemptionFees(address pool, address[] calldata tco2s, uint256[] calldata redemptionAmounts) + external + view + override + returns (FeeDistribution memory feeDistribution) + { + require(tco2s.length == redemptionAmounts.length, "length mismatch"); + + uint256 totalRedemptionAmount = sumOf(redemptionAmounts); + + feeDistribution = _calculateFee(totalRedemptionAmount); + } + + /// @notice Calculates the deposit fee for a given amount of an ERC1155 project. + /// @param pool The address of the pool. + /// @param erc1155 The address of the ERC1155 project + /// @param tokenId The tokenId of the vintage. + /// @param depositAmount The amount to be deposited. + /// @return feeDistribution How the fee is meant to be + /// distributed among the fee recipients. + function calculateDepositFees(address pool, address erc1155, uint256 tokenId, uint256 depositAmount) + external + view + override + returns (FeeDistribution memory feeDistribution) + { + require(depositAmount > 0, "depositAmount must be > 0"); + + feeDistribution = _calculateFee(depositAmount); + } + + /// @notice Calculates the redemption fees for a given amount on ERC1155 projects. + /// @param pool The address of the pool. + /// @param erc1155s The addresses of the ERC1155 projects. + /// @param tokenIds The tokenIds of the project vintages. + /// @param redemptionAmounts The amounts to be redeemed. + /// @return feeDistribution How the fee is meant to be + /// distributed among the fee recipients. + function calculateRedemptionFees( + address pool, + address[] calldata erc1155s, + uint256[] calldata tokenIds, + uint256[] calldata redemptionAmounts + ) external view override returns (FeeDistribution memory feeDistribution) { + require(erc1155s.length == tokenIds.length, "erc1155s/tokenIds length mismatch"); + require(erc1155s.length == redemptionAmounts.length, "erc1155s/redemptionAmounts length mismatch"); + + uint256 totalRedemptionAmount = sumOf(redemptionAmounts); + + feeDistribution = _calculateFee(totalRedemptionAmount); + } + + /// @notice Returns the current fee setup. + /// @return recipients shares The fee recipients and their share of the total fee. + function getFeeSetup() external view returns (address[] memory recipients, uint256[] memory shares) { + recipients = _recipients; + shares = _shares; + } + + /// @notice Calculates the fee for a given amount. + /// @param requestedAmount The amount to be used for the fee calculation. + /// @return feeDistribution How the fee is meant to be + function _calculateFee(uint256 requestedAmount) internal view returns (FeeDistribution memory) { + require(requestedAmount > 0, "requested amount must be > 0"); + + uint256 feeAmount = requestedAmount * feeBasisPoints / 10000; + + require(feeAmount <= requestedAmount, "Fee must be lower or equal to requested amount"); + require(feeAmount > 0, "Fee must be greater than 0"); + + return calculateFeeShares(feeAmount); + } + + function sumOf(uint256[] memory array) private pure returns (uint256) { + uint256 total = 0; + for (uint256 i = 0; i < array.length; i++) { + total += array[i]; + } + return total; + } +} diff --git a/test/FlatFeeCalculatorFuzzy/FlatFeeCalculator.fuzzy.t.sol b/test/FlatFeeCalculatorFuzzy/FlatFeeCalculator.fuzzy.t.sol new file mode 100644 index 0000000..0397b0c --- /dev/null +++ b/test/FlatFeeCalculatorFuzzy/FlatFeeCalculator.fuzzy.t.sol @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2024 Toucan Protocol +// +// SPDX-License-Identifier: UNLICENSED + +// If you encounter a vulnerability or an issue, please contact +pragma solidity ^0.8.13; + +import {Test, console2} from "forge-std/Test.sol"; +import {FeeCalculator} from "../../src/FeeCalculator.sol"; +import {FeeDistribution} from "../../src/interfaces/IFeeCalculator.sol"; +import {FlatFeeCalculator} from "../../src/FlatFeeCalculator.sol"; + +contract FlatFeeCalculatorTestFuzzy is Test { + FlatFeeCalculator public feeCalculator; + address public feeRecipient = 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B; + address public empty = address(0); + + function setUp() public { + feeCalculator = new FlatFeeCalculator(); + address[] memory recipients = new address[](1); + recipients[0] = feeRecipient; + uint256[] memory feeShares = new uint256[](1); + feeShares[0] = 100; + feeCalculator.feeSetup(recipients, feeShares); + } + + function testFeeSetupEmpty() public { + address[] memory recipients = new address[](0); + uint256[] memory feeShares = new uint256[](0); + vm.expectRevert("Total shares must equal 100"); + feeCalculator.feeSetup(recipients, feeShares); + } + + function testGetFeeSetup() public { + address feeRecipient1 = 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B; + address feeRecipient2 = 0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c; + + address[] memory recipients = new address[](2); + recipients[0] = feeRecipient1; + recipients[1] = feeRecipient2; + uint256[] memory feeShares = new uint256[](2); + feeShares[0] = 30; + feeShares[1] = 70; + + feeCalculator.feeSetup(recipients, feeShares); + + (address[] memory _recipients, uint256[] memory _feeShares) = feeCalculator.getFeeSetup(); + assertEq(_recipients[0], feeRecipient1); + assertEq(_recipients[1], feeRecipient2); + assertEq(_feeShares[0], 30); + assertEq(_feeShares[1], 70); + } + + function testSetFeeBasisPoints(uint256 basisPoints) public { + vm.assume(basisPoints > 0); + vm.assume(basisPoints < 10000); + + feeCalculator.setFeeBasisPoints(basisPoints); + + assertEq(feeCalculator.feeBasisPoints(), basisPoints); + } + + function testSetFeeBasisPoints_OutOfRange_Reverts(uint256 basisPoints) public { + vm.assume(basisPoints >= 10000); + + vm.expectRevert("Fee basis points should be less than 10000"); + feeCalculator.setFeeBasisPoints(basisPoints); + } + + function testCalculateDepositFeesNormalCase() public { + // Arrange + // Set up your test data + uint256 depositAmount = 100 * 1e18; + + // Act + FeeDistribution memory feeDistribution = feeCalculator.calculateDepositFees(empty, empty, depositAmount); + address[] memory recipients = feeDistribution.recipients; + uint256[] memory fees = feeDistribution.shares; + + // Assert + assertEq(feeDistribution.recipients.length, feeDistribution.shares.length, "array length mismatch"); + assertEq(recipients[0], feeRecipient); + assertEq(fees[0], 3000000000000000000); + } + + function testCalculateRedemptionFeesNormalCase() public { + // Arrange + // Set up your test data + uint256 redemptionAmount = 100 * 1e18; + address[] memory tco2s = new address[](1); + tco2s[0] = empty; + uint256[] memory redemptionAmounts = new uint256[](1); + redemptionAmounts[0] = redemptionAmount; + + // Act + FeeDistribution memory feeDistribution = feeCalculator.calculateRedemptionFees(empty, tco2s, redemptionAmounts); + address[] memory recipients = feeDistribution.recipients; + uint256[] memory fees = feeDistribution.shares; + + // Assert + assertEq(feeDistribution.recipients.length, feeDistribution.shares.length, "array length mismatch"); + assertEq(recipients[0], feeRecipient); + assertEq(fees[0], 3000000000000000000); + } + + function testCalculateRedemptionFeesMultipleTokensNormalCase() public { + // Arrange + // Set up your test data + uint256 redemptionAmount = 100 * 1e18; + address[] memory tco2s = new address[](3); + tco2s[0] = empty; + tco2s[1] = empty; + tco2s[2] = empty; + uint256[] memory redemptionAmounts = new uint256[](3); + redemptionAmounts[0] = redemptionAmount; + redemptionAmounts[1] = redemptionAmount; + redemptionAmounts[2] = redemptionAmount; + + // Act + FeeDistribution memory feeDistribution = feeCalculator.calculateRedemptionFees(empty, tco2s, redemptionAmounts); + address[] memory recipients = feeDistribution.recipients; + uint256[] memory fees = feeDistribution.shares; + + // Assert + assertEq(feeDistribution.recipients.length, feeDistribution.shares.length, "array length mismatch"); + assertEq(recipients[0], feeRecipient); + + uint256 expected = 3 * redemptionAmount * feeCalculator.feeBasisPoints() / 10000; + assertEq(fees[0], expected); + } + + function testCalculateRedemptionFeesDustAmount_ShouldThrow() public { + // Arrange + // Set up your test data + uint256 depositAmount = 1; + + // Act + vm.expectRevert("Fee must be greater than 0"); + FeeDistribution memory feeDistribution = feeCalculator.calculateDepositFees(empty, empty, depositAmount); + } + + function testCalculateDepositFee_TCO2(uint256 depositAmount) public { + // Arrange + vm.assume(depositAmount > 100); + vm.assume(depositAmount < 1e18 * 1e18); + // Act + FeeDistribution memory feeDistribution = feeCalculator.calculateDepositFees(empty, empty, depositAmount); + + uint256 expected = depositAmount * feeCalculator.feeBasisPoints() / 10000; + + assertEq(feeDistribution.shares[0], expected); + } + + function testCalculateDepositFee_ERC1155(uint256 depositAmount) public { + // Arrange + vm.assume(depositAmount > 100); + vm.assume(depositAmount < 1e18 * 1e18); + // Act + FeeDistribution memory feeDistribution = feeCalculator.calculateDepositFees(empty, empty, 0, depositAmount); + + uint256 expected = depositAmount * feeCalculator.feeBasisPoints() / 10000; + + assertEq(feeDistribution.shares[0], expected); + } + + function testCalculateRedemptionAmount_TCO2( + uint256 redemptionAmount1, + uint256 redemptionAmount2, + uint256 redemptionAmount3 + ) public { + // Arrange + vm.assume(redemptionAmount1 > 100); + vm.assume(redemptionAmount1 < 1e18 * 1e18); + vm.assume(redemptionAmount2 > 100); + vm.assume(redemptionAmount2 < 1e18 * 1e18); + vm.assume(redemptionAmount3 > 100); + vm.assume(redemptionAmount3 < 1e18 * 1e18); + // Act + address[] memory tco2s = new address[](3); + tco2s[0] = empty; + tco2s[1] = empty; + tco2s[2] = empty; + uint256[] memory redemptionAmounts = new uint256[](3); + redemptionAmounts[0] = redemptionAmount1; + redemptionAmounts[1] = redemptionAmount2; + redemptionAmounts[2] = redemptionAmount3; + + FeeDistribution memory feeDistribution = feeCalculator.calculateRedemptionFees(empty, tco2s, redemptionAmounts); + + uint256 expected = + (redemptionAmount1 + redemptionAmount2 + redemptionAmount3) * feeCalculator.feeBasisPoints() / 10000; + + assertEq(feeDistribution.shares[0], expected); + } + + function testCalculateRedemptionAmount_ERC1155( + uint256 redemptionAmount1, + uint256 redemptionAmount2, + uint256 redemptionAmount3 + ) public { + // Arrange + vm.assume(redemptionAmount1 > 100); + vm.assume(redemptionAmount1 < 1e18 * 1e18); + vm.assume(redemptionAmount2 > 100); + vm.assume(redemptionAmount2 < 1e18 * 1e18); + vm.assume(redemptionAmount3 > 100); + vm.assume(redemptionAmount3 < 1e18 * 1e18); + // Act + address[] memory erc1155s = new address[](3); + erc1155s[0] = empty; + erc1155s[1] = empty; + erc1155s[2] = empty; + uint256[] memory tokenIds = new uint256[](3); + tokenIds[0] = 1; + tokenIds[1] = 2; + tokenIds[2] = 3; + uint256[] memory redemptionAmounts = new uint256[](3); + redemptionAmounts[0] = redemptionAmount1; + redemptionAmounts[1] = redemptionAmount2; + redemptionAmounts[2] = redemptionAmount3; + + FeeDistribution memory feeDistribution = + feeCalculator.calculateRedemptionFees(empty, erc1155s, tokenIds, redemptionAmounts); + + uint256 expected = + (redemptionAmount1 + redemptionAmount2 + redemptionAmount3) * feeCalculator.feeBasisPoints() / 10000; + + assertEq(feeDistribution.shares[0], expected); + } +}