diff --git a/.gitmodules b/.gitmodules index 27aa4d9..da3645e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std.git +[submodule "lib/v4-core"] + path = lib/v4-core + url = https://github.com/Uniswap/v4-core +[submodule "lib/v4-periphery"] + path = lib/v4-periphery + url = https://github.com/Uniswap/v4-periphery diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/chamadao-contracts.iml b/.idea/chamadao-contracts.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/chamadao-contracts.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 0000000..2b4031b --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,7 @@ + + + + openzeppelin + + + \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml new file mode 100644 index 0000000..912db82 --- /dev/null +++ b/.idea/discord.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..dee0d33 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..e907ac2 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Chama.sol b/src/Chama.sol index 8c367a3..6a47fd0 100644 --- a/src/Chama.sol +++ b/src/Chama.sol @@ -5,7 +5,7 @@ // Imports // Interfaces, libraries, contracts // Errors -// type declations +// type declarations // State variables // Events // Modifiers @@ -30,7 +30,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract Chama is Ownable { address public factoryAdmin; // The admin of the protocol - IERC20 defaultToken; // This is meant to be usdt/usdc to be decided later + IERC20 public defaultToken; // This is meant to be usdt/usdc to be decided later mapping(string => address) private chamas; mapping(string chamaName => mapping(address chamaAddress => address chamaAdmin)) public chamaAdmin; @@ -59,8 +59,8 @@ contract Chama is Ownable { * @param _name name of the new chama */ - function createChama(address _admin, string memory _name) external returns (address) { - Contributions contributions = new Contributions(_admin, address(defaultToken)); + function createChama(address _admin, string memory _name, uint256 _interestRate) external returns (address) { + Contributions contributions = new Contributions(_admin, address(defaultToken), _interestRate); // Effects chamas[_name] = address(contributions); chamaAdmin[_name][address(contributions)] = _admin; diff --git a/src/Contributions.sol b/src/Contributions.sol index f848ecf..818e101 100644 --- a/src/Contributions.sol +++ b/src/Contributions.sol @@ -2,46 +2,49 @@ pragma solidity 0.8.24; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import {IContributions} from "./interfaces/IContributions.sol"; import {Loans} from "./Loans.sol"; import {Errors} from "./utils/Errors.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -contract Contributions is Loans, Ownable, IContributions, AccessControl { - using SafeERC20 for IERC20; +contract Contributions is Loans, Ownable, IContributions { using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; address public factoryContract; address private chamaAdmin; - IERC20 token; + uint256 public epochPeriod = 30 days; + uint256 public epochEndTime; + uint256 public currentRound; EnumerableSet.AddressSet private members; - // Access Control Roles - bytes32 private constant MEMBER_ROLE = 0x829b824e2329e205435d941c9f13baf578548505283d29261236d8e6596d4636; - bytes32 private constant CHAMA_ADMIN_ROLE = 0xbb46d0af9106a86e3cb61ab45bd36f61bb3b468e4db75bf9d14199a518ba3f9a; - - mapping(address member => uint256 amount) private memberToAmountContributed; mapping(address member => Member) private memberData; mapping(address => bool) private allowedTokens; + mapping(address => uint256) private memberToRoundClaimed; event TokenHasBeenWhitelisted(address token); event MemberHasContributed(address indexed member, uint256 amount, uint256 indexed timestamp); event memberRemovedFromChama(address member); + event memberHasNoContributions(address member); + event RoundClaimed(address indexed member, uint256 indexed timestamp, uint256 round); - constructor(address _admin, address _token) Ownable(msg.sender) { + constructor(address _admin, address _token, uint256 _interestRate) + Ownable(msg.sender) + Loans(_token, _interestRate) + { members.add(_admin); Member memory newMember = Member(_admin, 0, block.timestamp); memberData[_admin] = newMember; factoryContract = msg.sender; chamaAdmin = _admin; - token = IERC20(_token); + currentRound = 0; _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _setRoleAdmin(MEMBER_ROLE, CHAMA_ADMIN_ROLE); _grantRole(CHAMA_ADMIN_ROLE, msg.sender); - grantChamaAdminRole(_admin); + _grantRole(CHAMA_ADMIN_ROLE, _admin); } /*////////////////////////////////////////////////////////////// @@ -62,36 +65,52 @@ contract Contributions is Loans, Ownable, IContributions, AccessControl { function addContribution(uint256 _amount) external override onlyRole(MEMBER_ROLE) { memberToAmountContributed[msg.sender] += _amount; - IERC20(token).safeTransferFrom(msg.sender, address(this), _amount); + token.safeTransferFrom(msg.sender, address(this), _amount); emit MemberHasContributed(msg.sender, _amount, block.timestamp); } - function claimRound(uint256 _amount) external nonReentrant onlyRole(MEMBER_ROLE) { + /** + * + * @notice This allow a member to claim the round for all the members in the chama + * @notice This should be called after the epoch period has ended + * @notice This is a design choice, that we allow one of the members to trigger the claim function for all the members + */ + function claimRound() external nonReentrant onlyRole(MEMBER_ROLE) { // Should check whether the member has contributed and also if they are due to claim their round // Should also check if the member has any penalties // Then allow if all checks pass, allow them to claim their round // q should we clear the member's contributions after they claim their round? - if (_amount == 0) { - revert Errors.Contributions__zeroAmountProvided(); + + if (block.timestamp < epochEndTime) { + revert Errors.Contributions__epochNotOver(); } - uint256 totalContributedAmount = memberToAmountContributed[msg.sender]; - if (_amount > totalContributedAmount) { - revert Errors.Contributions__amountThatCanBeWithdrawnIs(totalContributedAmount); + for (uint256 i = 0; i < members.length(); i++) { + address member = members.at(i); + uint256 contrAmt = memberToAmountContributed[member]; + if (contrAmt == 0) { + emit memberHasNoContributions(member); + continue; + } + uint256 totalContributedAmount = memberToAmountContributed[member]; + uint256 availableAmt = totalContributedAmount - contrAmtFrozen[member]; + token.safeTransfer(msg.sender, availableAmt); } - IERC20(token).safeTransfer(msg.sender, _amount); + epochEndTime = block.timestamp + epochPeriod; + emit RoundClaimed(msg.sender, block.timestamp, currentRound); + currentRound++; } /** * @notice Whitelist a token to be used for contributions * @notice Contract is meant to handle only USDT for now */ - function getContributions(address _member) external view returns (uint256) { - return (memberToAmountContributed[_member]); - } - function calculatePenalties(address _member) external returns (uint256) {} + /*////////////////////////////////////////////////////////////// + ADMIN ROLES + //////////////////////////////////////////////////////////////*/ + function addMemberToChama(address _address) external onlyRole(CHAMA_ADMIN_ROLE) { // Add a member to the chama // Should check if the member is already in the chama @@ -101,7 +120,6 @@ contract Contributions is Loans, Ownable, IContributions, AccessControl { members.add(_address); Member memory newMember = Member(_address, 0, block.timestamp); memberData[_address] = newMember; - grantMemberRole(_address); } function changeAdmin(address _newAdmin) external { @@ -121,6 +139,10 @@ contract Contributions is Loans, Ownable, IContributions, AccessControl { emit TokenHasBeenWhitelisted(_token); } + function setEpochPeriod(uint256 _epochPeriod) external onlyRole(CHAMA_ADMIN_ROLE) { + epochPeriod = _epochPeriod; + } + function removeMemberFromChama(address _member) external onlyRole(CHAMA_ADMIN_ROLE) { // Check if _member is a member of the chama if (!members.contains(_member)) { @@ -159,10 +181,6 @@ contract Contributions is Loans, Ownable, IContributions, AccessControl { _grantRole(CHAMA_ADMIN_ROLE, _admin); } - function grantMemberRole(address _member) public onlyRole(CHAMA_ADMIN_ROLE) { - _grantRole(MEMBER_ROLE, _member); - } - /*////////////////////////////////////////////////////////////// GETTER FUNCTIONS //////////////////////////////////////////////////////////////*/ @@ -182,4 +200,8 @@ contract Contributions is Loans, Ownable, IContributions, AccessControl { function getMemberContributions(address _address) external view returns (Member memory) { return memberData[_address]; } + + function getContributions(address _member) external view returns (uint256) { + return (memberToAmountContributed[_member]); + } } diff --git a/src/Loans.sol b/src/Loans.sol index 723d109..40786b4 100644 --- a/src/Loans.sol +++ b/src/Loans.sol @@ -3,8 +3,98 @@ pragma solidity 0.8.24; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Errors} from "./utils/Errors.sol"; -contract Loans is ReentrancyGuard { -// This contract handles loans logic for the chamas +// handle the loans for a chama +contract Loans is ReentrancyGuard, AccessControl { + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.AddressSet; + + // We are going to use USDC by default + EnumerableSet.AddressSet internal guarantors; + IERC20 internal token; + uint256 loanId; + uint256 currentInterestRate; + + mapping(address member => uint256 amount) internal contrAmtFrozen; + mapping(address member => mapping(address guarantor => uint256 amount)) internal loanAmtAvailable; + mapping(address member => uint256 amount) internal memberToAmountContributed; + mapping(uint256 loanId => Loan loan) internal loanIdToLoanDet; + mapping(address member => uint256 loanId) internal memberToLoanId; + + uint256 internal constant BASIS_POINTS = 10000; + bytes32 internal constant MEMBER_ROLE = keccak256("MEMBER_ROLE"); + bytes32 internal constant CHAMA_ADMIN_ROLE = keccak256("CHAMA_ADMIN_ROLE"); + + struct Loan { + uint256 amount; + uint256 interestRate; + uint256 startTime; + uint256 deadline; + } + + event LoanTaken( + address indexed member, + uint256 amount, + uint256 interestRate, + uint256 startTime, + uint256 deadline, + uint256 indexed loanId + ); + + constructor(address _token, uint256 _interestRate) { + if (_interestRate > BASIS_POINTS) revert Errors.Loans_invalidInterestRate(BASIS_POINTS, _interestRate); + currentInterestRate = _interestRate; + token = IERC20(_token); + loanId = 0; + } + + function guaranteeLoan(address _member, uint256 _amount) external onlyRole(MEMBER_ROLE) { + if (memberToAmountContributed[_member] < _amount) { + revert Errors.Loans__contrAmtLessLoanAmt(memberToAmountContributed[_member], _amount); + } + contrAmtFrozen[msg.sender] += _amount; + loanAmtAvailable[_member][msg.sender] += _amount; + } + + function takeLoan(uint256 _amount, uint256 _deadline, address guarantor) + external + onlyRole(MEMBER_ROLE) + nonReentrant + { + if (_amount > loanAmtAvailable[msg.sender][guarantor]) { + revert Errors.Loans__loanAmtGreaterThanGuaranteedAmt(_amount, loanAmtAvailable[msg.sender][guarantor]); + } + loanIdToLoanDet[loanId] = + Loan({amount: _amount, interestRate: currentInterestRate, startTime: block.timestamp, deadline: _deadline}); + loanAmtAvailable[msg.sender][guarantor] -= _amount; + token.safeTransfer(msg.sender, _amount); + emit LoanTaken(msg.sender, _amount, currentInterestRate, block.timestamp, _deadline, loanId); + loanId++; + } + + function repayLoan(uint256 _amount, uint256 _loanId, address _guarantor) + external + onlyRole(MEMBER_ROLE) + nonReentrant + { + Loan memory loan = loanIdToLoanDet[_loanId]; + // Calculate Interest Rate + uint256 interestAccrued = (loan.amount * loan.interestRate) / BASIS_POINTS; + uint256 totalRepayment = loan.amount + interestAccrued; + if (_amount < totalRepayment) { + loanIdToLoanDet[loanId].amount -= _amount; + } else if (_amount == totalRepayment) { + delete loanIdToLoanDet[loanId]; + } else { + revert Errors.Loans__repayLoanAmountGreaterThanTotalRepayment(_amount, totalRepayment); + } + contrAmtFrozen[_guarantor] -= _amount; + // add some more logic and checks here + token.safeTransferFrom(msg.sender, address(this), _amount); + } } diff --git a/src/interfaces/IContributions.sol b/src/interfaces/IContributions.sol index 12abb1b..d22fa40 100644 --- a/src/interfaces/IContributions.sol +++ b/src/interfaces/IContributions.sol @@ -5,13 +5,13 @@ pragma solidity 0.8.24; interface IContributions { struct Member { address member; - uint256 amount; + uint256 availableAmount; uint256 timestamp; } function addContribution(uint256 _amount) external; - function claimRound(uint256 amount) external; + function claimRound() external; function getContributions(address member) external view returns (uint256); diff --git a/src/utils/Errors.sol b/src/utils/Errors.sol index 1c4cca0..a91bb36 100644 --- a/src/utils/Errors.sol +++ b/src/utils/Errors.sol @@ -3,6 +3,14 @@ pragma solidity ^0.8.24; library Errors { + /*////////////////////////////////////////////////////////////// + LOANS + //////////////////////////////////////////////////////////////*/ + error Loans_invalidInterestRate(uint256 maxInterestRate, uint256 currentInterestRate); + error Loans__contrAmtLessLoanAmt(uint256 contrAmt, uint256 loanAmt); + error Loans__loanAmtGreaterThanGuaranteedAmt(uint256 loanAmt, uint256 guaranteedAmt); + error Loans__repayLoanAmountGreaterThanTotalRepayment(uint256 repaymentAmount, uint256 totalRepaymentAmount); + /*////////////////////////////////////////////////////////////// CHAMA //////////////////////////////////////////////////////////////*/ @@ -22,9 +30,10 @@ library Errors { error Contributions__memberAlreadyInChama(address); error Contributions__zeroAmountProvided(); error Contributions__tokenBalanceMustBeZero(); - error Contributions__amountThatCanBeWithdrawnIs(uint256); + error Contributions__amountNotAvailable(uint256); error Contributions__notMemberInChama(); error Contributions__memberShouldHaveZeroBalance(uint256); error Contributions__zeroAddressProvided(); error Contributions__notFactoryContract(); + error Contributions__epochNotOver(); }