// SPDX-License-Identifier: Apache-2.0 // Compatible with OpenZeppelin Contracts ^5.0.0 pragma solidity ^0.8.20; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /// @title Ajo Savings Group /// @author Charlie Andrews-Jubelt /// @custom:security-contact security@valora.xyz contract Ajo { uint256 public roundDurationSeconds; address[] public participants; uint256 public contributionPerRound; uint256 public payout; uint256 public startTime; // epoch seconds mapping(address => uint256) public depositedAmount; mapping(address => uint256) public withdrawnAmount; IERC20 public token; error InsufficientParticipants(); error InvalidStartTime(); error CallerNotParticipant(); error ExcessiveDeposit(); error TransferFailed(); error ExcessiveWithdrawal(); error WithdrawalNotAllowedYet(); error ParticipantNotFound(); constructor(address[] memory _participants, uint256 _contributionPerRound, uint256 _startTime, address _tokenAddress, uint256 _roundDurationSeconds) { if (_participants.length <= 1) revert InsufficientParticipants(); if (_startTime < block.timestamp) revert InvalidStartTime(); participants = _participants; contributionPerRound = _contributionPerRound; payout = contributionPerRound * _participants.length; startTime = _startTime; token = IERC20(_tokenAddress); roundDurationSeconds = _roundDurationSeconds; } event Deposit(address indexed participant, uint256 amount); event Withdraw(address indexed participant, uint256 amount); function deposit(uint256 _amount) public { if (!isParticipant(msg.sender)) revert CallerNotParticipant(); if (depositedAmount[msg.sender] + _amount > payout) revert ExcessiveDeposit(); if (!token.transferFrom(msg.sender, address(this), _amount)) revert TransferFailed(); depositedAmount[msg.sender] += _amount; emit Deposit(msg.sender, _amount); } function withdraw(uint256 _amount) public { if (!isParticipant(msg.sender)) revert CallerNotParticipant(); if (_amount > payout || withdrawnAmount[msg.sender] + _amount > payout) revert ExcessiveWithdrawal(); if (getParticipantIndex(msg.sender) > getCurrentRound()) revert WithdrawalNotAllowedYet(); withdrawnAmount[msg.sender] += _amount; emit Withdraw(msg.sender, _amount); } function isParticipant(address participant) internal view returns (bool) { for (uint256 i = 0; i < participants.length; i++) { if (participants[i] == participant) { return true; } } return false; } function getParticipantIndex(address participant) public view returns (uint256) { for (uint256 i = 0; i < participants.length; i++) { if (participants[i] == participant) { return i; } } revert ParticipantNotFound(); } function getCurrentRound() public view returns (uint256) { uint256 round = (block.timestamp - startTime) / roundDurationSeconds; if (round >= participants.length) { return participants.length - 1; } return round; } }