From 6196121ef17325a380269c47d82be38d431d17e6 Mon Sep 17 00:00:00 2001 From: "clement.l" Date: Fri, 15 Nov 2024 18:50:54 +0700 Subject: [PATCH] feat: update project --- script/Counter.s.sol | 19 --- src/Challenge.sol | 117 +++++++++++++++++++ src/ChallengeManager.sol | 146 +++++++++++++++++++++++ src/Counter.sol | 14 --- src/DripProfile.sol | 156 +++++++++++++++++++++++++ src/DripVault.sol | 8 ++ src/interfaces/IChallenge.sol | 30 +++++ src/interfaces/IChallengeManager.sol | 50 ++++++++ src/interfaces/IDripProfile.sol | 34 ++++++ src/libraries/ErrorsLib.sol | 33 ++++++ src/libraries/TimeLib.sol | 31 +++++ src/libraries/Types.sol | 94 +++++++++++++++ src/mocks/ERC20Mock.sol | 13 +++ test/Challenge.t.sol | 69 +++++++++++ test/ChallengeManager.t.sol | 76 ++++++++++++ test/Counter.t.sol | 24 ---- test/DripProfile.t.sol | 169 +++++++++++++++++++++++++++ test/helpers/CommonTest.sol | 71 +++++++++++ test/helpers/SetUp.sol | 20 ++++ 19 files changed, 1117 insertions(+), 57 deletions(-) delete mode 100644 script/Counter.s.sol create mode 100644 src/Challenge.sol create mode 100644 src/ChallengeManager.sol delete mode 100644 src/Counter.sol create mode 100644 src/DripProfile.sol create mode 100644 src/DripVault.sol create mode 100644 src/interfaces/IChallenge.sol create mode 100644 src/interfaces/IChallengeManager.sol create mode 100644 src/interfaces/IDripProfile.sol create mode 100644 src/libraries/ErrorsLib.sol create mode 100644 src/libraries/TimeLib.sol create mode 100644 src/libraries/Types.sol create mode 100644 src/mocks/ERC20Mock.sol create mode 100644 test/Challenge.t.sol create mode 100644 test/ChallengeManager.t.sol delete mode 100644 test/Counter.t.sol create mode 100644 test/DripProfile.t.sol create mode 100644 test/helpers/CommonTest.sol create mode 100644 test/helpers/SetUp.sol diff --git a/script/Counter.s.sol b/script/Counter.s.sol deleted file mode 100644 index cdc1fe9..0000000 --- a/script/Counter.s.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Script, console} from "forge-std/Script.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterScript is Script { - Counter public counter; - - function setUp() public {} - - function run() public { - vm.startBroadcast(); - - counter = new Counter(); - - vm.stopBroadcast(); - } -} diff --git a/src/Challenge.sol b/src/Challenge.sol new file mode 100644 index 0000000..88ddcab --- /dev/null +++ b/src/Challenge.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {ERC20} from "../lib/solmate/src/tokens/ERC20.sol"; +import {SafeTransferLib} from "../lib/solmate/src/utils/SafeTransferLib.sol"; +import {IChallengeManager} from "./interfaces/IChallengeManager.sol"; +import {IChallenge} from "./interfaces/IChallenge.sol"; +import {Types} from "./libraries/Types.sol"; +import {ErrorsLib} from "./libraries/ErrorsLib.sol"; +import {TimeLib} from "./libraries/TimeLib.sol"; +import {DripVault} from "./DripVault.sol"; + +contract Challenge is ERC721, IChallenge { + using Math for uint256; + using SafeTransferLib for ERC20; + + uint256 private _tokenId; // Token ID for each challenge; starts at 0 + address public profileContract; + address public managerContract; + + mapping(uint256 => Types.Challenge) private _challenges; // tokenId => challenge + + constructor() ERC721("Drip Challenge", "DCH") {} + + modifier onlyProfile(address profileOwner) { + // Only the profile contract or the owner can call this function + require(msg.sender == profileContract || msg.sender == profileOwner, ErrorsLib.NOT_OWNER); + _; + } + + function setContracts(address _profileContract, address _managerContract) external { + // Can only be set once + require(profileContract == address(0), ErrorsLib.PROFILE_CONTRACT_ALREADY_SET); + profileContract = _profileContract; + managerContract = _managerContract; + } + + function createChallenge(Types.CreateChallengeParams calldata params) + external + onlyProfile(params.owner) + returns (uint256 challengeId) + { + require(params.durationInDays <= 365, ErrorsLib.INVALID_DURATION); + + uint256 epochId = IChallengeManager(managerContract).getCurrentEpochId(); + (Types.Epoch memory epoch, Types.EpochStatus status) = IChallengeManager(managerContract).getEpochInfo(epochId); + require(status == Types.EpochStatus.Active, ErrorsLib.INVALID_EPOCH_STATUS); + + // Transfer epoch asset from user to challenge manager + ERC20(epoch.asset).safeTransferFrom(params.owner, managerContract, params.depositAmount); + + // Calculate duration and end time + uint256 startTime = block.timestamp; + uint256 endTime = TimeLib.addDaysToTimestamp(startTime, params.durationInDays); + + Types.Challenge storage challenge = _challenges[_tokenId]; + challenge.id = _tokenId; + challenge.title = params.title; + challenge.description = params.description; + challenge.owner = params.owner; + challenge.startTime = startTime; + challenge.endTime = endTime; + challenge.depositAmount = params.depositAmount; + challenge.depositToken = address(0); // TODO: Get from epoch + challenge.durationInDays = params.durationInDays; + challenge.epochId = params.epochId; + challenge.dailyCompletionTimestamps = new uint256[](params.durationInDays); + + // Mint and emit the challenge event + _safeMint(params.owner, _tokenId); + emit ChallengeCreated(_tokenId, params.owner); + + // Add the challenge to the epoch + IChallengeManager(managerContract).addChallengeToEpoch( + params.epochId, _tokenId, params.owner, params.depositAmount + ); + + // Return challenge ID and increment the token counter + challengeId = _tokenId; + ++_tokenId; + } + + function getChallenge(uint256 challengeId) external view returns (Types.Challenge memory) { + return _challenges[challengeId]; + } + + function getChallengesByEpoch(uint256 epochId) external view returns (Types.Challenge[] memory) { + uint256[] memory challengeIds = IChallengeManager(managerContract).getEpochChallenges(epochId); + uint256 length = challengeIds.length; + Types.Challenge[] memory challenges = new Types.Challenge[](length); + + for (uint256 i = 0; i < length;) { + challenges[i] = _challenges[challengeIds[i]]; + unchecked { + ++i; + } + } + return challenges; + } + + function submitDailyCompletion(address owner, uint256 tokenId, uint16 day) external onlyProfile(owner) { + Types.Challenge storage challenge = _challenges[tokenId]; + require(day < challenge.durationInDays, ErrorsLib.EXCEED_DURATION); + require(challenge.dailyCompletionTimestamps[day] == 0, ErrorsLib.ALREADY_COMPLETED); + + uint256 dayStartTime = TimeLib.addDaysToTimestamp(challenge.startTime, day); + uint256 dayEndTime = TimeLib.addDaysToTimestamp(dayStartTime, 1); + + require(block.timestamp >= dayStartTime && block.timestamp <= dayEndTime, ErrorsLib.NOT_IN_TIME_RANGE); + + challenge.dailyCompletionTimestamps[day] = block.timestamp; + emit DailyCompletionSubmitted(tokenId, day); + } +} diff --git a/src/ChallengeManager.sol b/src/ChallengeManager.sol new file mode 100644 index 0000000..f2aa051 --- /dev/null +++ b/src/ChallengeManager.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IChallengeManager} from "./interfaces/IChallengeManager.sol"; +import {IChallenge} from "./interfaces/IChallenge.sol"; +import {ErrorsLib} from "./libraries/ErrorsLib.sol"; +import {TimeLib} from "./libraries/TimeLib.sol"; +import {Types} from "./libraries/Types.sol"; +import {DripVault} from "./DripVault.sol"; + +contract ChallengeManager is Ownable, IChallengeManager { + using Math for uint256; + using EnumerableSet for EnumerableSet.AddressSet; + + IChallenge public challenge; + + // keccak256(abi.encode(uint256(keccak256("drip.ChallengeManager")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 private constant ChallengeManagerStorageLocation = + 0x2e5d4946837baa00c2caecc5c5227a1c2b19f4bab88cdee66eaa65badd325500; + + modifier onlyChallenge() { + require(msg.sender == address(challenge), ErrorsLib.NOT_AUTHORIZED); + _; + } + + constructor(address _challenge) Ownable(msg.sender) { + challenge = IChallenge(_challenge); + } + + /// @notice Returns the current epoch ID. + /// @return ID of the current epoch. + function getCurrentEpochId() external view returns (uint256) { + return _getChallengeManagerStorage().currentEpoch.id; + } + + /// @notice Returns the information of an epoch. + /// @param epochId The ID of the epoch. + /// @return Information of the epoch. + function getEpochInfo(uint256 epochId) external view returns (Types.Epoch memory, Types.EpochStatus) { + Types.ChallengeManagerStorage storage $ = _getChallengeManagerStorage(); + return ($.epochs[epochId], getEpochStatus(epochId)); + } + + /// @notice Returns the status of an epoch. + /// @param epochId The ID of the epoch. + /// @return Status of the epoch. + function getEpochStatus(uint256 epochId) public view returns (Types.EpochStatus) { + Types.Epoch memory epoch = _getChallengeManagerStorage().epochs[epochId]; + if (block.timestamp < epoch.startTimestamp) { + return Types.EpochStatus.Pending; + } else if (block.timestamp < epoch.endTimestamp) { + return Types.EpochStatus.Active; + } else if (block.timestamp < epoch.endTimestamp + 7 days) { + return Types.EpochStatus.Ended; + } else { + return Types.EpochStatus.Closed; + } + } + + /// @notice Starts a new epoch. + /// @param description The description. + /// @param start The start timestamp. + /// @param durationInDays The duration of the epoch in days. + /// @param asset The asset to be deposited in the epoch. + /// @return id of the new epoch. + function startEpoch(string calldata description, uint256 start, uint16 durationInDays, address asset) + external + onlyOwner + returns (uint256 id) + { + require(asset != address(0), ErrorsLib.ZERO_ADDRESS); + Types.ChallengeManagerStorage storage $ = _getChallengeManagerStorage(); + require(getEpochStatus($.currentEpoch.id) >= Types.EpochStatus.Ended, ErrorsLib.INVALID_EPOCH_STATUS); + + id = $.nextEpochId; + + uint256 endTimestamp = TimeLib.addDaysToTimestamp(start, durationInDays); + + DripVault vault = new DripVault(asset); + + Types.Epoch memory epoch = Types.Epoch({ + id: id, + description: description, + durationInDays: durationInDays, + startTimestamp: start, + endTimestamp: endTimestamp, + vault: address(vault), + asset: asset, + participantCount: 0, + totalDeposits: 0 + }); + $.epochs[id] = epoch; + $.currentEpoch = epoch; + $.nextEpochId++; + emit EpochStarted(id, start, endTimestamp); + } + + function addChallengeToEpoch(uint256 epochId, uint256 challengeId, address challengeOwner, uint256 depositAmount) + external + onlyChallenge + { + Types.ChallengeManagerStorage storage $ = _getChallengeManagerStorage(); + IERC20($.epochs[epochId].asset).approve($.epochs[epochId].vault, type(uint256).max); + IERC4626($.epochs[epochId].vault).deposit(depositAmount, challengeOwner); + + $.epochChallenges[epochId].push(challengeId); + EnumerableSet.AddressSet storage participants = $.epochParticipants[epochId]; + participants.add(challengeOwner); + // Update epoch info + $.epochs[epochId].totalDeposits = IERC4626($.epochs[epochId].vault).totalAssets(); + $.epochs[epochId].participantCount = participants.length(); + } + + function getEpochChallenges(uint256 epochId) external view returns (uint256[] memory) { + return _getChallengeManagerStorage().epochChallenges[epochId]; + } + + /// @notice Returns whether a token is whitelisted. + /// @notice This is not used yet. + /// @param token The address of the token to check. + /// @return Whether the token is whitelisted. + function isWhiteListedToken(address token) external view returns (bool) { + return _getChallengeManagerStorage().isWhitelistedToken[token]; + } + + /// @notice DEMO ONLY + /// @notice Sets the epoch. + function setEpoch(uint256 epochId, Types.Epoch memory epoch) external onlyOwner { + Types.ChallengeManagerStorage storage $ = _getChallengeManagerStorage(); + $.epochs[epochId] = epoch; + $.currentEpoch = epoch; + $.nextEpochId++; + } + + /// @dev Returns the storage struct of ChallengeManager. + function _getChallengeManagerStorage() private pure returns (Types.ChallengeManagerStorage storage $) { + assembly { + $.slot := ChallengeManagerStorageLocation + } + } +} diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index aded799..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/DripProfile.sol b/src/DripProfile.sol new file mode 100644 index 0000000..73d9a55 --- /dev/null +++ b/src/DripProfile.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {ErrorsLib} from "./libraries/ErrorsLib.sol"; +import {Types} from "./libraries/Types.sol"; +import {IDripProfile} from "./interfaces/IDripProfile.sol"; +import {Challenge} from "./Challenge.sol"; +import {ChallengeManager} from "./ChallengeManager.sol"; + +contract DripProfile is ERC721, IDripProfile { + uint256 private _nextProfileId; // Token ID for each profile; starts at 1 + + mapping(address => uint256) private _ownerToProfileId; // Maps address to profileId + mapping(uint256 => Types.Profile) private _profileIdToProfile; // Maps profileId to Profile struct + mapping(uint256 => mapping(uint256 => uint256[])) private _profileIdToEpochIdToChallengeIds; // Maps profileId to epoch Id to associated challenges + + Challenge private challenge; + ChallengeManager private challengeManager; + + constructor(address challengeAddress, address challengeManagerAddress) ERC721("Drip Profile", "DP") { + challenge = Challenge(challengeAddress); + challengeManager = ChallengeManager(challengeManagerAddress); + challenge.setContracts(address(this), challengeManagerAddress); + } + + /** + * @dev Modifier to check that the caller is the owner of the profile. + * @param profileId The ID of the profile to check ownership. + */ + modifier onlyProfileOwner(uint256 profileId) { + require(ownerOf(profileId) == msg.sender, ErrorsLib.NOT_OWNER); + _; + } + + /** + * @dev Creates a new profile for the caller. + * @param owner The owner of the profile + * @param handle The user handle + * @param avatar The user avatar data + * @return profileId The ID of the created profile. + */ + function createProfile(address owner, string calldata handle, uint32[] calldata avatar) + external + returns (uint256) + { + require(owner != address(0), ErrorsLib.ZERO_ADDRESS); + require(bytes(handle).length > 0, ErrorsLib.INVALID_HANDLE); + require(avatar.length == 5, ErrorsLib.INVALID_AVATAR_LENGTH); + + uint256 profileId = ++_nextProfileId; + _safeMint(owner, profileId); + _ownerToProfileId[owner] = profileId; + _profileIdToProfile[profileId] = Types.Profile(profileId, owner, handle, avatar); + emit ProfileCreated(profileId, owner); + return profileId; + } + + /** + * @dev Retrieves profile data by profile ID. + * @param profileId The ID of the profile to retrieve. + * @return profile The profile data + */ + function getProfileById(uint256 profileId) external view returns (Types.Profile memory) { + return _profileIdToProfile[profileId]; + } + + /** + * @dev Retrieves profile ID by owner. + * @param owner The owner of the profile + * @return profileId The ID of the profile + */ + function getProfileByOwner(address owner) external view returns (Types.Profile memory) { + uint256 profileId = _ownerToProfileId[owner]; + require(profileId != 0, ErrorsLib.PROFILE_NOT_EXISTS); + return _profileIdToProfile[profileId]; + } + + /** + * @dev Updates the user handle of an existing profile. + * @param profileId The ID of the profile to update. + * @param handle The new handle of the profile + */ + function updateHandle(uint256 profileId, string calldata handle) external onlyProfileOwner(profileId) { + require(profileId != 0, ErrorsLib.PROFILE_NOT_EXISTS); + require(bytes(handle).length > 0, ErrorsLib.INVALID_HANDLE); + _profileIdToProfile[profileId].handle = handle; + } + + /** + * @dev Creates a new challenge associated with a profile. + * @param profileId The ID of the profile to associate with the challenge. + * @param depositAmount The amount to be deposited in the challenge. + * @param durationInDays The duration of the challenge in days. + * @return challengeId The ID of the created challenge. + */ + function createChallenge( + uint256 profileId, + string calldata name, + string calldata description, + uint256 depositAmount, + uint16 durationInDays + ) external onlyProfileOwner(profileId) returns (uint256) { + require(profileId != 0, ErrorsLib.PROFILE_NOT_EXISTS); + + uint256 epochId = challengeManager.getCurrentEpochId(); + Types.CreateChallengeParams memory params = + Types.CreateChallengeParams(msg.sender, depositAmount, epochId, durationInDays, name, description); + uint256 challengeId = challenge.createChallenge(params); + _profileIdToEpochIdToChallengeIds[profileId][epochId].push(challengeId); + return challengeId; + } + + /** + * @dev Retrieves the challenges associated with a profile. + * @param profileId The ID of the profile to retrieve the challenges. + * @return challenges The challenges associated with the profile. + */ + function getChallenges(uint256 profileId, uint256 epochId) external view returns (Types.Challenge[] memory) { + require(profileId != 0, ErrorsLib.PROFILE_NOT_EXISTS); + + uint256[] storage challengeIds = _profileIdToEpochIdToChallengeIds[profileId][epochId]; + uint256 challengesLength = challengeIds.length; + + Types.Challenge[] memory challenges = new Types.Challenge[](challengesLength); + + for (uint256 i = 0; i < challengesLength;) { + challenges[i] = challenge.getChallenge(challengeIds[i]); + + unchecked { + i++; + } + } + return challenges; + } + + /** + * @dev Submits a daily completion for a challenge associated with a profile. + * @param profileId The ID of the profile associated with the challenge. + * @param challengeId The ID of the challenge to submit the daily completion. + * @param day The day to submit the completion for. + */ + function submitDailyCompletion(uint256 profileId, uint256 challengeId, uint16 day) + external + onlyProfileOwner(profileId) + { + challenge.submitDailyCompletion(msg.sender, challengeId, day); + } + + function _update(address to, uint256 tokenId, address auth) internal override returns (address) { + address from = super._update(to, tokenId, auth); + require(from == address(0), ErrorsLib.NOT_TRANSFERABLE); + return from; + } +} diff --git a/src/DripVault.sol b/src/DripVault.sol new file mode 100644 index 0000000..bec6426 --- /dev/null +++ b/src/DripVault.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC4626, IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; + +contract DripVault is ERC4626 { + constructor(address asset) ERC4626(IERC20(asset)) ERC20("Drip Vault Token", "DRIPVLT") {} +} diff --git a/src/interfaces/IChallenge.sol b/src/interfaces/IChallenge.sol new file mode 100644 index 0000000..887c29a --- /dev/null +++ b/src/interfaces/IChallenge.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Types} from "../libraries/Types.sol"; + +/** + * @title IChallenge + * @notice Interface for the Challenge contract + */ +interface IChallenge { + event ChallengeCreated(uint256 indexed tokenId, address indexed owner); + + event DailyCompletionSubmitted(uint256 indexed tokenId, uint256 indexed day); + + event ChallengeCompleted(uint256 indexed tokenId, address indexed learner); + + event RewardClaimed(uint256 indexed tokenId, address indexed learner, uint256 amount); + + /// @notice Creates a challenge + function createChallenge(Types.CreateChallengeParams calldata params) external returns (uint256); + + /// @notice Returns the challenge for a given ID + function getChallenge(uint256 challengeId) external view returns (Types.Challenge memory); + + /// @notice Submits a daily completion + function submitDailyCompletion(address owner, uint256 tokenId, uint16 day) external; + + /// @notice Returns the challenges for an epoch + function getChallengesByEpoch(uint256 epochId) external view returns (Types.Challenge[] memory); +} diff --git a/src/interfaces/IChallengeManager.sol b/src/interfaces/IChallengeManager.sol new file mode 100644 index 0000000..af59298 --- /dev/null +++ b/src/interfaces/IChallengeManager.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Types} from "../libraries/Types.sol"; + +/** + * @title IChallengeManager + * @notice Interface for the ChallengeManager contract + */ +interface IChallengeManager { + event EpochStarted(uint256 id, uint256 startTimestamp, uint256 endTimestamp); + + /** + * @dev Returns the current epoch ID + */ + function getCurrentEpochId() external view returns (uint256); + + /** + * @dev Returns the epoch info for a given epoch ID + */ + function getEpochInfo(uint256 epochId) external view returns (Types.Epoch memory, Types.EpochStatus); + + /** + * @dev Returns the status of an epoch + */ + function getEpochStatus(uint256 epochId) external view returns (Types.EpochStatus); + + /** + * @dev Starts a new epoch + */ + function startEpoch(string calldata description, uint256 start, uint16 durationInDays, address depositToken) + external + returns (uint256); + + /** + * @dev Adds a challenge to an epoch + */ + function addChallengeToEpoch(uint256 epochId, uint256 challengeId, address challengeOwner, uint256 depositAmount) + external; + + /** + * @dev Returns the challenges for an epoch + */ + function getEpochChallenges(uint256 epochId) external view returns (uint256[] memory); + + /** + * @dev Returns if a token is whitelisted for depositing when creating a challenge + */ + function isWhiteListedToken(address token) external view returns (bool isWhitelisted); +} diff --git a/src/interfaces/IDripProfile.sol b/src/interfaces/IDripProfile.sol new file mode 100644 index 0000000..1138be6 --- /dev/null +++ b/src/interfaces/IDripProfile.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Types} from "../libraries/Types.sol"; + +/** + * @title IDripProfile + * @notice Interface for the DripProfile contract + */ +interface IDripProfile { + event ProfileCreated(uint256 indexed profileId, address indexed owner); + + function createProfile(address owner, string calldata handle, uint32[] calldata avatar) + external + returns (uint256); + + function getProfileById(uint256 profileId) external view returns (Types.Profile memory); + + function getProfileByOwner(address owner) external view returns (Types.Profile memory); + + function updateHandle(uint256 profileId, string calldata handle) external; + + function createChallenge( + uint256 profileId, + string calldata name, + string calldata description, + uint256 depositAmount, + uint16 durationInDays + ) external returns (uint256); + + function getChallenges(uint256 profileId, uint256 epochId) external view returns (Types.Challenge[] memory); + + function submitDailyCompletion(uint256 profileId, uint256 challengeId, uint16 day) external; +} diff --git a/src/libraries/ErrorsLib.sol b/src/libraries/ErrorsLib.sol new file mode 100644 index 0000000..396896e --- /dev/null +++ b/src/libraries/ErrorsLib.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @title ErrorsLib +/// @notice Library exposing error messages. +library ErrorsLib { + /// @dev Thrown when the address is zero. + string internal constant ZERO_ADDRESS = "zero address"; + /// @dev Thrown when the caller is not the owner of the token. + string internal constant NOT_OWNER = "not owner"; + /// @dev Thrown when the caller is not from the authorized address. + string internal constant NOT_AUTHORIZED = "not authorized"; + /// @dev Thrown when the profile is attempt to be transferred. + string internal constant NOT_TRANSFERABLE = "not transferable"; + /// @dev Thrown when the profile contract is already set. + string internal constant PROFILE_CONTRACT_ALREADY_SET = "profile contract already set"; + /// @dev Thrown when the profile is not exists. + string internal constant PROFILE_NOT_EXISTS = "profile not exists"; + /// @dev Thrown when the epoch status is invalid. + string internal constant INVALID_EPOCH_STATUS = "invalid epoch status"; + /// @dev Thrown when the avatar length is invalid. + string internal constant INVALID_AVATAR_LENGTH = "invalid avatar length"; + /// @dev Thrown when the handle is invalid. + string internal constant INVALID_HANDLE = "invalid handle"; + /// @dev Thrown when the duration is invalid. + string internal constant INVALID_DURATION = "invalid duration"; + /// @dev Thrown when the day exceeds the duration. + string internal constant EXCEED_DURATION = "exceed duration"; + /// @dev Thrown when the day is not in the time range. + string internal constant NOT_IN_TIME_RANGE = "not in time range"; + /// @dev Thrown when the day is already completed. + string internal constant ALREADY_COMPLETED = "already completed"; +} diff --git a/src/libraries/TimeLib.sol b/src/libraries/TimeLib.sol new file mode 100644 index 0000000..7d2bc5c --- /dev/null +++ b/src/libraries/TimeLib.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +library TimeLib { + using Math for uint256; + + /** + * @dev Returns the timestamp after a specified number of days from the start time. + * @param startTime The initial timestamp. + * @param daysOffset The number of days to add. + * @return Timestamp of the result. + */ + function addDaysToTimestamp(uint256 startTime, uint16 daysOffset) internal pure returns (uint256) { + uint256 dayStartInSeconds = _daysToSeconds(daysOffset); + return _addSeconds(startTime, dayStartInSeconds); + } + + function _daysToSeconds(uint16 day) private pure returns (uint256) { + (bool success, uint256 s) = uint256(day).tryMul(86400); + require(success, "Multiplication overflow"); + return s; + } + + function _addSeconds(uint256 timestamp, uint256 secondsToAdd) private pure returns (uint256) { + (bool success, uint256 newTimestamp) = timestamp.tryAdd(secondsToAdd); + require(success, "Addition overflow"); + return newTimestamp; + } +} diff --git a/src/libraries/Types.sol b/src/libraries/Types.sol new file mode 100644 index 0000000..97a154d --- /dev/null +++ b/src/libraries/Types.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +library Types { + /* ENUMS */ + + /// @notice Represents the current state of an epoch + enum EpochStatus { + // 0 - The epoch has not started yet. + Pending, + // 1 - The epoch is active and users can create challenges. + Active, + // 2 - The Epoch has ended. Users can no longer create challenges and have a 7-day window to claim rewards. + Ended, + // 3 - The epoch is fully closed. No more actions can be performed. + Closed + } + + /* STRUCTS */ + + /// @notice User profile + struct Profile { + uint256 id; + address owner; + string handle; + uint32[] avatar; // Avatar: [0-background, 1-body, 2-accessory, 3-head, 4-glasses] + } + + /// @notice Epoch details + struct Epoch { + uint256 id; + uint256 startTimestamp; + uint256 endTimestamp; + // Number of participants in the epoch + uint256 participantCount; + uint256 totalDeposits; + // The vault to be used for the epoch + address vault; + // The asset to be deposited in the epoch + address asset; + uint16 durationInDays; + string description; + } + + /// @notice Challenge details + struct Challenge { + uint256 id; + uint256 epochId; + uint256 startTime; + uint256 endTime; + uint256 depositAmount; + address owner; + address depositToken; + uint16 durationInDays; + string title; + string description; + uint256[] dailyCompletionTimestamps; + } + + /// @notice Parameters for creating a challenge + struct CreateChallengeParams { + address owner; + uint256 depositAmount; + uint256 epochId; + uint16 durationInDays; + string title; + string description; + } + + /// @notice Challenge manager storage + struct ChallengeManagerStorage { + // Next epoch ID + uint256 nextEpochId; + // Current epoch + Types.Epoch currentEpoch; + // Epoch ID => Epoch + mapping(uint256 => Epoch) epochs; + // Epoch ID => challenge IDs + mapping(uint256 => uint256[]) epochChallenges; + // Epoch ID => participants + mapping(uint256 => EnumerableSet.AddressSet) epochParticipants; + // Token address => is whitelisted + mapping(address => bool) isWhitelistedToken; + } + + struct VaultStorage { + // Epoch ID => total deposits + mapping(uint256 => uint256) epochTotalDeposits; + // Epoch ID => failed deposits + mapping(uint256 => uint256) epochFailedDeposits; + } +} diff --git a/src/mocks/ERC20Mock.sol b/src/mocks/ERC20Mock.sol new file mode 100644 index 0000000..a81bbf9 --- /dev/null +++ b/src/mocks/ERC20Mock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract ERC20Mock is ERC20 { + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {} + + function setBalance(address account, uint256 amount) external { + _burn(account, balanceOf(account)); + _mint(account, amount); + } +} diff --git a/test/Challenge.t.sol b/test/Challenge.t.sol new file mode 100644 index 0000000..63d0440 --- /dev/null +++ b/test/Challenge.t.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./helpers/SetUp.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IChallenge} from "../src/interfaces/IChallenge.sol"; +import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; +import {Types} from "../src/libraries/Types.sol"; + +contract ChallengeTest is SetUp { + function setUp() public override { + super.setUp(); + + vm.startPrank(DRIP); + _setUpEpoch(0, block.timestamp, "Test Epoch"); + vm.stopPrank(); + } + + function testCreateChallenge(uint256 amount, uint16 durationInDays) public { + amount = bound(amount, 1 ether, 100 ether); + durationInDays = uint16(bound(durationInDays, 1, 365)); + + vm.startPrank(USER1); + _approveToken(address(mockToken), address(challenge), amount); + + vm.expectEmit(true, true, false, false); + emit IChallenge.ChallengeCreated(0, USER1); + + uint256 challengeId = + challenge.createChallenge(Types.CreateChallengeParams(USER1, amount, 0, durationInDays, "title", "desc")); + vm.stopPrank(); + + assertEq(challengeId, 0); + // Get challenge + assertEq(challenge.getChallenge(challengeId).owner, USER1); + assertEq(challenge.getChallenge(challengeId).title, "title"); + assertEq(challenge.getChallenge(challengeId).description, "desc"); + + // Get challenges by epoch + Types.Challenge[] memory challenges = challenge.getChallengesByEpoch(0); + assertEq(challenges.length, 1); + // Check challenge details + assertEq(challenges[0].owner, USER1); + } + + function testCreateChallenge_InvalidOwner() public { + vm.prank(USER1); + vm.expectRevert(bytes(ErrorsLib.NOT_OWNER)); + challenge.createChallenge(Types.CreateChallengeParams(USER2, 1 ether, 0, 7, "test", "test")); + } + + function testCreateChallenge_InvalidDuration() public { + vm.prank(USER1); + vm.expectRevert(bytes(ErrorsLib.INVALID_DURATION)); + challenge.createChallenge(Types.CreateChallengeParams(USER1, 1 ether, 0, 366, "test", "test")); + } + + function testGetChallenge() public { + vm.startPrank(USER1); + _approveToken(address(mockToken), address(challenge), 1 ether); + challenge.createChallenge(Types.CreateChallengeParams(USER1, 1 ether, 0, 3, "test", "test")); + vm.stopPrank(); + + Types.Challenge memory challenge = challenge.getChallenge(0); + assertEq(challenge.owner, USER1); + assertEq(challenge.depositAmount, 1 ether); + assertEq(challenge.durationInDays, 3); + } +} diff --git a/test/ChallengeManager.t.sol b/test/ChallengeManager.t.sol new file mode 100644 index 0000000..171e364 --- /dev/null +++ b/test/ChallengeManager.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./helpers/SetUp.sol"; +import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; +import {Types} from "../src/libraries/Types.sol"; + +contract ChallengeManagerTest is SetUp { + function testStartEpoch() public { + // Start first epoch + uint256 startTimestamp = block.timestamp; + vm.prank(DRIP); + uint256 epochId1 = challengeManager.startEpoch("test1", startTimestamp, 3, address(mockToken)); + assertEq(epochId1, 0); + assertEq(challengeManager.getCurrentEpochId(), 0); + + (Types.Epoch memory epoch1, Types.EpochStatus status1) = challengeManager.getEpochInfo(0); + assertEq(epoch1.durationInDays, 3); + assertEq(epoch1.startTimestamp, startTimestamp); + assertEq(epoch1.endTimestamp, startTimestamp + 3 days); + assertEq(uint256(status1), uint256(Types.EpochStatus.Active)); + + // End first epoch + uint256 timeElapsed = startTimestamp + 3 days; + vm.warp(timeElapsed); + assertEq(uint256(challengeManager.getEpochStatus(0)), uint256(Types.EpochStatus.Ended), "Epoch1 ended"); + + // Start next epoch + vm.prank(DRIP); + uint256 epochId2 = challengeManager.startEpoch("test2", timeElapsed, 3, address(mockToken)); + assertEq(epochId2, 1); + assertEq(challengeManager.getCurrentEpochId(), 1); + + (Types.Epoch memory epoch2, Types.EpochStatus status2) = challengeManager.getEpochInfo(1); + assertEq(epoch2.durationInDays, 3); + assertEq(epoch2.startTimestamp, timeElapsed); + assertEq(epoch2.endTimestamp, timeElapsed + 3 days); + assertEq(uint256(status2), uint256(Types.EpochStatus.Active), "Epoch2 is active"); + } + + function testStartEpoch_NotOwner() public { + vm.expectRevert(); + vm.prank(USER1); + challengeManager.startEpoch("test", block.timestamp, 7, address(mockToken)); + } + + function testStartEpoch_InvalidStatus() public { + vm.prank(DRIP); + challengeManager.startEpoch("test1", block.timestamp, 7, address(mockToken)); + + vm.expectRevert(bytes(ErrorsLib.INVALID_EPOCH_STATUS)); + vm.prank(DRIP); + challengeManager.startEpoch("test2", block.timestamp, 7, address(mockToken)); + } + + function testEpochStatus() public { + uint256 current = block.timestamp; + vm.prank(DRIP); + challengeManager.startEpoch("test", current + 30 seconds, 3, address(mockToken)); + + uint256 status = uint256(challengeManager.getEpochStatus(0)); + assertEq(status, uint256(Types.EpochStatus.Pending)); + + vm.warp(current + 30 seconds); + status = uint256(challengeManager.getEpochStatus(0)); + assertEq(status, uint256(Types.EpochStatus.Active)); + + vm.warp(current + 3 days + 30 seconds); + status = uint256(challengeManager.getEpochStatus(0)); + assertEq(status, uint256(Types.EpochStatus.Ended)); + + vm.warp(current + 7 days + 3 days + 30 seconds); + status = uint256(challengeManager.getEpochStatus(0)); + assertEq(status, uint256(Types.EpochStatus.Closed)); + } +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 54b724f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/DripProfile.t.sol b/test/DripProfile.t.sol new file mode 100644 index 0000000..842f775 --- /dev/null +++ b/test/DripProfile.t.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./helpers/SetUp.sol"; + +import {IDripProfile} from "../src/interfaces/IDripProfile.sol"; +import {ErrorsLib} from "../src/libraries/ErrorsLib.sol"; +import {Types} from "../src/libraries/Types.sol"; + +contract DripProfileTest is SetUp { + function setUp() public override { + super.setUp(); + + vm.startPrank(DRIP); + _setUpEpoch(0, block.timestamp, "Test Epoch"); + vm.stopPrank(); + } + + function testCreateProfile(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + vm.expectEmit(true, true, false, false); + emit IDripProfile.ProfileCreated(1, USER1); + uint256 profileId = _createProfile(USER1, handle); + assertEq(profileId, 1); + assertEq(profile.ownerOf(profileId), USER1); + } + + function testCreateProfile_NotTransferable(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + vm.expectEmit(true, true, false, false); + emit IDripProfile.ProfileCreated(1, USER1); + uint256 profileId = _createProfile(USER1, handle); + assertEq(profileId, 1); + assertEq(profile.ownerOf(profileId), USER1); + + vm.prank(USER1); + vm.expectRevert(bytes(ErrorsLib.NOT_TRANSFERABLE)); + profile.safeTransferFrom(USER1, USER2, profileId); + } + + function testCreateProfile_InvalidHandle() public { + vm.expectRevert(bytes(ErrorsLib.INVALID_HANDLE)); + profile.createProfile(USER1, "", _createAvatar(5)); + } + + function testCreateProfile_InvalidAvatarLength(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint32[] memory avatar = _createAvatar(4); + vm.prank(DRIP); + vm.expectRevert(bytes(ErrorsLib.INVALID_AVATAR_LENGTH)); + profile.createProfile(USER1, handle, avatar); + } + + function testGetProfileById(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + Types.Profile memory profile = profile.getProfileById(profileId); + assertEq(profile.owner, USER1); + assertEq(profile.id, 1); + assertEq(profile.handle, handle); + } + + function testGetProfileByOwner(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + _createProfile(USER1, handle); + Types.Profile memory profile = profile.getProfileByOwner(USER1); + assertEq(profile.owner, USER1); + assertEq(profile.id, 1); + assertEq(profile.handle, handle); + } + + function testUpdateHandle(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.prank(USER1); + profile.updateHandle(profileId, "@user123"); + assertEq(profile.getProfileById(profileId).handle, "@user123"); + } + + function testUpdateHandle_NotOwner(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.prank(USER2); + vm.expectRevert(bytes(ErrorsLib.NOT_OWNER)); + profile.updateHandle(profileId, "@user123"); + } + + function testCreateChallenge(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.startPrank(USER1); + // 1. Approve Challenge to spend the token + mockToken.approve(address(challenge), 100); + // 2. Create the challenge + uint256 challengeId = profile.createChallenge(profileId, "test", "test", 100, 2); + vm.stopPrank(); + + assertEq(challengeId, 0); + assertEq(challenge.getChallenge(challengeId).owner, USER1); + assertEq(challenge.getChallenge(challengeId).durationInDays, 2); + assertEq(challenge.getChallenge(challengeId).depositToken, address(0)); // TODO: Fix this + assertEq(challenge.getChallenge(challengeId).depositAmount, 100); + assertEq(challenge.getChallenge(challengeId).epochId, 0); + assertEq(challenge.getChallenge(challengeId).dailyCompletionTimestamps.length, 2); + + assertEq(profile.getChallenges(profileId, 0).length, 1); + assertEq(profile.getChallenges(profileId, 0)[0].id, 0); + } + + function testSubmitDailyCompletion(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.startPrank(USER1); + mockToken.approve(address(challenge), 100); + // Total 3 days + uint256 challengeId = profile.createChallenge(profileId, "test", "test", 100, 3); + + // Day 1 + assertEq(challenge.getChallenge(challengeId).dailyCompletionTimestamps[0], 0); + profile.submitDailyCompletion(profileId, challengeId, 0); + assertGt(challenge.getChallenge(challengeId).dailyCompletionTimestamps[0], 0); + + // Day 2 + assertEq(challenge.getChallenge(challengeId).dailyCompletionTimestamps[1], 0); + vm.warp(block.timestamp + 1 days); + profile.submitDailyCompletion(profileId, challengeId, 1); + assertGt(challenge.getChallenge(challengeId).dailyCompletionTimestamps[1], 0); + } + + function testSubmitDailyCompletion_ExceedDuration(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.startPrank(USER1); + mockToken.approve(address(challenge), 100); + uint256 challengeId = profile.createChallenge(profileId, "test", "test", 100, 3); + + assertEq(challenge.getChallenge(challengeId).dailyCompletionTimestamps[0], 0); + vm.expectRevert(bytes(ErrorsLib.EXCEED_DURATION)); + profile.submitDailyCompletion(profileId, challengeId, 3); + } + + function testSubmitDailyCompletion_NotInTimeRange(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.startPrank(USER1); + mockToken.approve(address(challenge), 100); + uint256 challengeId = profile.createChallenge(profileId, "test", "test", 100, 3); + vm.expectRevert(bytes(ErrorsLib.NOT_IN_TIME_RANGE)); + profile.submitDailyCompletion(profileId, challengeId, 1); + } + + function testSubmitDailyCompletion_AlreadyCompleted(string calldata handle) public { + vm.assume(bytes(handle).length > 0); + uint256 profileId = _createProfile(USER1, handle); + + vm.startPrank(USER1); + mockToken.approve(address(challenge), 100); + uint256 challengeId = profile.createChallenge(profileId, "test", "test", 100, 3); + profile.submitDailyCompletion(profileId, challengeId, 0); + vm.expectRevert(bytes(ErrorsLib.ALREADY_COMPLETED)); + profile.submitDailyCompletion(profileId, challengeId, 0); + } +} diff --git a/test/helpers/CommonTest.sol b/test/helpers/CommonTest.sol new file mode 100644 index 0000000..210b513 --- /dev/null +++ b/test/helpers/CommonTest.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import {IERC20} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {ERC20Mock} from "../../src/mocks/ERC20Mock.sol"; +import {Challenge} from "../../src/Challenge.sol"; +import {DripProfile} from "../../src/DripProfile.sol"; +import {ChallengeManager} from "../../src/ChallengeManager.sol"; +import {DripVault} from "../../src/DripVault.sol"; +import {Types} from "../../src/libraries/Types.sol"; + +uint256 constant MIN_AMOUNT = 100; +uint256 constant MAX_AMOUNT = 2 ** 64; +uint256 constant DURATION = 5 days; + +contract CommonTest is Test { + DripProfile internal profile; + Challenge internal challenge; + ChallengeManager internal challengeManager; + ERC20Mock internal mockToken; + + address internal DRIP = makeAddr("drip"); + address internal USER1 = makeAddr("user1"); + address internal USER2 = makeAddr("user2"); + + /// DripProfile Helper /// + + /// @notice Helper: Create a avatar at specific length + /// @param length The length of the avatar + /// @return avatar The avatar + function _createAvatar(uint8 length) internal pure returns (uint32[] memory) { + uint32[] memory avatar = new uint32[](length); + for (uint8 i = 0; i < length; i++) { + avatar[i] = 0; + } + return avatar; + } + + /// @notice Helper: Create a profile for a specific owner + /// @param owner The owner of the profile + /// @return profileId The ID of the created profile + function _createProfile(address owner, string calldata handle) internal returns (uint256) { + uint32[] memory avatar = _createAvatar(5); + return profile.createProfile(owner, handle, avatar); + } + + function _setUpEpoch(uint256 id, uint256 startTimestamp, string memory description) internal { + DripVault vault = new DripVault(address(mockToken)); + Types.Epoch memory epoch = Types.Epoch({ + id: id, + startTimestamp: startTimestamp, + endTimestamp: startTimestamp + DURATION, + durationInDays: _getDurationInDays(DURATION), + vault: address(vault), + asset: address(mockToken), + description: description, + participantCount: 0, + totalDeposits: 0 + }); + challengeManager.setEpoch(id, epoch); + } + + function _getDurationInDays(uint256 duration) private pure returns (uint16) { + return uint16(duration / 1 days); + } + + function _approveToken(address token, address spender, uint256 amount) internal { + IERC20(token).approve(spender, amount); + } +} diff --git a/test/helpers/SetUp.sol b/test/helpers/SetUp.sol new file mode 100644 index 0000000..682e5b5 --- /dev/null +++ b/test/helpers/SetUp.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "./CommonTest.sol"; + +contract SetUp is CommonTest { + function setUp() public virtual { + mockToken = new ERC20Mock("Mock", "MCK"); + + // Set up user balances + deal(address(mockToken), USER1, 100 ether); + + // Deploy contracts + vm.startPrank(DRIP); + challenge = new Challenge(); + challengeManager = new ChallengeManager(address(challenge)); + profile = new DripProfile(address(challenge), address(challengeManager)); + vm.stopPrank(); + } +}