diff --git a/src/lockers/AaveLocker.sol b/src/lockers/AaveLocker.sol index f9695d2..49351a1 100644 --- a/src/lockers/AaveLocker.sol +++ b/src/lockers/AaveLocker.sol @@ -22,8 +22,9 @@ contract AaveV3Locker is AbstractLocker { CONSTRUCTOR ////////////////////////////////////////////////////////////// */ - constructor(address owner_, address aToken) AbstractLocker(owner_) { + constructor(address owner_, address aToken, address aavePool) AbstractLocker(owner_) { ATOKEN = IAaveToken(aToken); + AAVE_POOL = IAavePool(aavePool); } /* ////////////////////////////////////////////////////////////// diff --git a/src/token/EurB.sol b/src/token/EurB.sol index 75aa116..c58d014 100644 --- a/src/token/EurB.sol +++ b/src/token/EurB.sol @@ -17,14 +17,13 @@ contract EurB is ERC20Wrapper, Ownable, Storage { using SafeERC20 for IERC20; using FixedPointMathLib for uint256; - // note : yield - /* ////////////////////////////////////////////////////////////// ERRORS ////////////////////////////////////////////////////////////// */ error IsNotALocker(); error LengthMismatch(); + error LockerNotPrivate(); error MaxCommissionsDepth(); error MaxRatio(); error MaxYieldInterval(); @@ -58,8 +57,6 @@ contract EurB is ERC20Wrapper, Ownable, Storage { ERC20 LOGIC ////////////////////////////////////////////////////////////// */ - // Note: overwrite depositFor function and add a syncInterest/collateral - /** * @notice Moves an amount of tokens from the caller's account to "to". * @param to The address the tokens are sent to. @@ -137,7 +134,8 @@ contract EurB is ERC20Wrapper, Ownable, Storage { // Check if current idle balance meets target idle balance. uint256 currentIdle = IERC20(underlying_).balanceOf(address(this)); - uint256 targetIdle = totalSupply() - totalInvested; + uint256 totalSupplyExclPrivate = totalSupply() - privateLockersSupply; + uint256 targetIdle = totalSupplyExclPrivate.mulDivDown(idleRatio, BIPS); // If not, withdraw from lockers according to weigths. if (currentIdle < targetIdle) { @@ -147,20 +145,22 @@ contract EurB is ERC20Wrapper, Ownable, Storage { for (uint256 i; i < weights.length; i++) { uint256 proportionalAmount = toWithdraw.mulDivDown(weights[i], BIPS_); // If locker has not enough balance, withdraw max possible. - lockerBalances[i] >= proportionalAmount - ? ILocker(lockers[i]).withdraw(underlying_, proportionalAmount) - : ILocker(lockers[i]).withdraw(underlying_, lockerBalances[i]); + if (lockerBalances[i] >= proportionalAmount) { + try ILocker(lockers[i]).withdraw(underlying_, proportionalAmount) {} catch {} + } else { + try ILocker(lockers[i]).withdraw(underlying_, lockerBalances[i]) {} catch {} + } } } // Get total amount that should be deposited in lockers (non-idle). - // Note : adapt formula for private locker that will have impact on total invested. // Note : check if ok to keep same totalSupply here (think should be ok) - uint256 totalToInvest = totalSupply().mulDivDown(BIPS_ - idleRatio, BIPS_); + uint256 totalToInvest = totalSupplyExclPrivate.mulDivDown(BIPS_ - idleRatio, BIPS_); // We use weights.length as those should always sum to BIPS (see setWeights()). for (uint256 i; i < weights.length; ++i) { uint256 targetBalance = totalToInvest.mulDivDown(weights[i], BIPS_); + // We have to call totalDeposited() again as balance may have updated after withdrawals. uint256 currentBalance = ILocker(lockers[i]).totalDeposited(); if (currentBalance < targetBalance) { @@ -248,8 +248,6 @@ contract EurB is ERC20Wrapper, Ownable, Storage { lockersWeights.pop(); } - function addPrivateLocker(address locker) external onlyOwner {} - // Note : Double check no issue if idle set to max vs lockers function setWeights(uint256[] memory newLockersWeights) external onlyOwner { if (newLockersWeights.length != yieldLockers.length) revert LengthMismatch(); @@ -270,6 +268,25 @@ contract EurB is ERC20Wrapper, Ownable, Storage { if (yieldInterval_ > 30 days) revert MaxYieldInterval(); yieldInterval = yieldInterval_; } - // Note : add a function to remove a locker. - // Note : Do we put a recover function ? + + /* ////////////////////////////////////////////////////////////// + PRIVATE YIELD LOCKERS LOGIC + ////////////////////////////////////////////////////////////// */ + + function addPrivateLocker(address locker) external onlyOwner { + isPrivateLocker[locker] = true; + } + + function depositInPrivateLocker(address locker, uint256 amount) external onlyOwner { + if (isPrivateLocker[locker] == false) revert LockerNotPrivate(); + + privateLockersSupply += amount; + + ILocker(locker).deposit(address(underlying()), amount); + } + + function collectYieldFromPrivateLocker(address locker) external onlyOwner { + if (isPrivateLocker[locker] == false) revert LockerNotPrivate(); + } + // Note : Do we put a recover function (yes with limited withdrawable assets) ? } diff --git a/src/token/Storage.sol b/src/token/Storage.sol index 0e156a5..e3a32c6 100644 --- a/src/token/Storage.sol +++ b/src/token/Storage.sol @@ -19,7 +19,7 @@ contract Storage { STORAGE ////////////////////////////////////////////////////////////// */ - // Would work with a registry here to access hookModules (others added and change in logic) + // The address of the CardFactory contract. ICardFactory public cardFactory; // The address of the treasury address public treasury; @@ -35,4 +35,9 @@ contract Storage { uint256 public lastYieldClaim; // The minimum window between two yield claims. uint256 public yieldInterval; + // The total amount deposited in private lockers. + uint256 public privateLockersSupply; + + // A mapping indicating if a yield locker is a private locker. + mapping(address locker => bool isPrivate) public isPrivateLocker; } diff --git a/src/token/interfaces/ISafe.sol b/src/token/interfaces/ISafe.sol index ed79ef4..fc3df83 100644 --- a/src/token/interfaces/ISafe.sol +++ b/src/token/interfaces/ISafe.sol @@ -2,5 +2,5 @@ pragma solidity ^0.8.22; interface ISafe { - function isModuleEnabled(address module) external returns (bool); + function isModuleEnabled(address module) external view returns (bool); } diff --git a/test/Base.t.sol b/test/Base.t.sol new file mode 100644 index 0000000..4707c30 --- /dev/null +++ b/test/Base.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {CardFactoryMock} from "./utils/mocks/CardFactoryMock.sol"; +import {EurB} from "../src/token/EurB.sol"; +import {Test} from "../lib/forge-std/src/Test.sol"; +import {Users} from "./utils/Types.sol"; + +/// @notice Base test contract with common logic needed by all tests. +abstract contract Base_Test is Test { + /*////////////////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////////////////// + VARIABLES + //////////////////////////////////////////////////////////////////////////*/ + + Users internal users; + + /*////////////////////////////////////////////////////////////////////////// + TEST CONTRACTS + //////////////////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////////////////// + SET-UP FUNCTION + //////////////////////////////////////////////////////////////////////////*/ + + constructor() {} + + function setUp() public virtual { + // Create users for testing. + users = Users({ + dao: createUser("dao"), + treasury: createUser("treasury"), + unprivilegedAddress: createUser("unprivilegedAddress") + }); + } + + /*////////////////////////////////////////////////////////////////////////// + HELPER FUNCTIONS + //////////////////////////////////////////////////////////////////////////*/ + + /// @dev Generates a user, labels its address, and funds it with test assets. + function createUser(string memory name) internal returns (address payable) { + address payable user = payable(makeAddr(name)); + vm.deal({account: user, newBalance: 100 ether}); + return user; + } +} diff --git a/test/fork/Fork.t.sol b/test/fork/Fork.t.sol new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/fork/Fork.t.sol @@ -0,0 +1 @@ + diff --git a/test/fuzz/EurB/Constructor.fuzz.t.sol b/test/fuzz/EurB/Constructor.fuzz.t.sol new file mode 100644 index 0000000..f013205 --- /dev/null +++ b/test/fuzz/EurB/Constructor.fuzz.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {EurB_Fuzz_Test} from "./_EurB.fuzz.t.sol"; + +import {EurB} from "../../../src/token/EurB.sol"; +import {IERC20} from "../../../lib/openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +/** + * @notice Fuzz tests for the function "constructor" of contract "EurB". + */ +contract Constructor_EurB_Fuzz_Test is EurB_Fuzz_Test { + /* /////////////////////////////////////////////////////////////// + SETUP + /////////////////////////////////////////////////////////////// */ + + function setUp() public override { + EurB_Fuzz_Test.setUp(); + } + + /*////////////////////////////////////////////////////////////// + TESTS + //////////////////////////////////////////////////////////////*/ + function testFuzz_Success_deployment(address treasury) public { + // When: Deploying EURB. + vm.prank(users.dao); + EurB eurB = new EurB(IERC20(address(EURE)), treasury, address(CARD_FACTORY)); + + // Then: Correct variables should be set. + assertEq(address(eurB.underlying()), address(EURE)); + assertEq(eurB.treasury(), treasury); + assertEq(eurB.owner(), users.dao); + assertEq(eurB.name(), "EuroBrussels"); + assertEq(eurB.symbol(), "EURB"); + assertEq(address(eurB.cardFactory()), address(CARD_FACTORY)); + } +} diff --git a/test/fuzz/EurB/_EurB.fuzz.t.sol b/test/fuzz/EurB/_EurB.fuzz.t.sol new file mode 100644 index 0000000..0608277 --- /dev/null +++ b/test/fuzz/EurB/_EurB.fuzz.t.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Fuzz_Test} from "../Fuzz.t.sol"; + +/** + * @notice Common logic needed by all "EurB" fuzz tests. + */ +abstract contract EurB_Fuzz_Test is Fuzz_Test { + /* /////////////////////////////////////////////////////////////// + VARIABLES + /////////////////////////////////////////////////////////////// */ + + /* /////////////////////////////////////////////////////////////// + TEST CONTRACTS + /////////////////////////////////////////////////////////////// */ + + /* /////////////////////////////////////////////////////////////// + SETUP + /////////////////////////////////////////////////////////////// */ + + function setUp() public virtual override(Fuzz_Test) { + Fuzz_Test.setUp(); + } + + /* /////////////////////////////////////////////////////////////// + HELPER FUNCTIONS + /////////////////////////////////////////////////////////////// */ +} diff --git a/test/fuzz/Fuzz.t.sol b/test/fuzz/Fuzz.t.sol new file mode 100644 index 0000000..f09acd4 --- /dev/null +++ b/test/fuzz/Fuzz.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Base_Test} from "../Base.t.sol"; +import {CardFactoryMock} from "../utils/mocks/CardFactoryMock.sol"; +import {CommissionModule} from "../../src/modules/CommissionModule.sol"; +import {ERC20Mock} from "../utils/mocks/ERC20Mock.sol"; +import {EurB} from "../../src/token/EurB.sol"; +import {IERC20} from "../../lib/openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {SafeMock} from "../utils/mocks/SafeMock.sol"; + +/** + * @notice Common logic needed by all fuzz tests. + */ +abstract contract Fuzz_Test is Base_Test { + /*////////////////////////////////////////////////////////////////////////// + VARIABLES + //////////////////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////////////////// + TEST CONTRACTS + //////////////////////////////////////////////////////////////////////////*/ + + CardFactoryMock public CARD_FACTORY; + CommissionModule public COMMISSION_MODULE; + ERC20Mock public EURE; + EurB public EURB; + + SafeMock public SAFE1; + SafeMock public SAFE2; + SafeMock public SAFE3; + + /*////////////////////////////////////////////////////////////////////////// + SET-UP FUNCTION + //////////////////////////////////////////////////////////////////////////*/ + + function setUp() public virtual override { + Base_Test.setUp(); + + // Warp to have a timestamp of at least two days old. + vm.warp(2 days); + + // Deploy contracts. + vm.startPrank(users.dao); + COMMISSION_MODULE = new CommissionModule(); + CARD_FACTORY = new CardFactoryMock(address(COMMISSION_MODULE)); + EURE = new ERC20Mock("Monerium EUR", "EURE", 18); + EURB = new EurB(IERC20(address(EURE)), users.treasury, address(CARD_FACTORY)); + + SAFE1 = new SafeMock(); + SAFE2 = new SafeMock(); + SAFE3 = new SafeMock(); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////////////////*/ +} diff --git a/test/utils/Types.sol b/test/utils/Types.sol new file mode 100644 index 0000000..f44b723 --- /dev/null +++ b/test/utils/Types.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +struct Users { + address payable dao; + address payable treasury; + address payable unprivilegedAddress; +} diff --git a/test/utils/mocks/ATokenMock.sol b/test/utils/mocks/ATokenMock.sol new file mode 100644 index 0000000..1e59dad --- /dev/null +++ b/test/utils/mocks/ATokenMock.sol @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; diff --git a/test/utils/mocks/AavePoolMock.sol b/test/utils/mocks/AavePoolMock.sol new file mode 100644 index 0000000..1e59dad --- /dev/null +++ b/test/utils/mocks/AavePoolMock.sol @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; diff --git a/test/utils/mocks/CardFactoryMock.sol b/test/utils/mocks/CardFactoryMock.sol new file mode 100644 index 0000000..b0e486f --- /dev/null +++ b/test/utils/mocks/CardFactoryMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +contract CardFactoryMock { + address public COMMISSION_HOOK_MODULE; + + constructor(address commissionHookModule) { + COMMISSION_HOOK_MODULE = commissionHookModule; + } + + function setCommissionHookModule(address commissionHookModule) public { + COMMISSION_HOOK_MODULE = commissionHookModule; + } +} diff --git a/test/utils/mocks/ERC20Mock.sol b/test/utils/mocks/ERC20Mock.sol new file mode 100644 index 0000000..e761b52 --- /dev/null +++ b/test/utils/mocks/ERC20Mock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {ERC20} from "../../../lib/solmate/src/tokens/ERC20.sol"; + +contract ERC20Mock is ERC20 { + constructor(string memory name_, string memory symbol_, uint8 decimalsInput_) + ERC20(name_, symbol_, decimalsInput_) + {} + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + function burn(uint256 amount) public { + _burn(msg.sender, amount); + } +} diff --git a/test/utils/mocks/SafeMock.sol b/test/utils/mocks/SafeMock.sol new file mode 100644 index 0000000..ca1db33 --- /dev/null +++ b/test/utils/mocks/SafeMock.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +contract SafeMock { + mapping(address module => bool enabled) public moduleEnabled; + + function setModule(address module) external { + moduleEnabled[module] = true; + } + + function removeModule(address module) external { + moduleEnabled[module] = false; + } + + function isModuleEnabled(address module) external view returns (bool enabled) { + enabled = moduleEnabled[module]; + } +}