From 2bccc20c72f841450668e430deb4d68619a6cc1a Mon Sep 17 00:00:00 2001 From: Shehu-Fatiudeen Lawal Date: Mon, 21 Apr 2025 09:37:02 +0100 Subject: [PATCH 1/6] feat: implement Submission contract for handling form submissions --- contracts/Submission.sol | 251 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 contracts/Submission.sol diff --git a/contracts/Submission.sol b/contracts/Submission.sol new file mode 100644 index 0000000..0dd19c9 --- /dev/null +++ b/contracts/Submission.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "./interfaces/IDCUToken.sol"; +import "./interfaces/IRewards.sol"; + +/** + * @title Submission + * @dev Contract for handling form submissions from the DeCleanup dapp + */ +contract Submission is Ownable, ReentrancyGuard, AccessControl { + // Custom Errors + error SUBMISSION__InvalidAddress(); + error SUBMISSION__InvalidSubmissionData(); + error SUBMISSION__SubmissionNotFound(uint256 submissionId); + error SUBMISSION__Unauthorized(address user); + error SUBMISSION__AlreadyApproved(uint256 submissionId); + error SUBMISSION__AlreadyRejected(uint256 submissionId); + + // Role definitions for access control + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + + // Submission status enum + enum SubmissionStatus { Pending, Approved, Rejected } + + // Submission structure + struct Submission { + uint256 id; + address submitter; + string dataURI; // IPFS URI or other storage reference to submission data + uint256 timestamp; + SubmissionStatus status; + address approver; // Admin who processed the submission + uint256 processedTimestamp; + bool rewarded; // Whether a reward has been issued for this submission + } + + // Reference to the DCU token contract for rewards + IDCUToken public dcuToken; + + // Reference to the RewardLogic contract + IRewards public rewardLogic; + + // Mapping from submission ID to Submission data + mapping(uint256 => Submission) public submissions; + + // Mapping from user address to their submission IDs + mapping(address => uint256[]) public userSubmissions; + + // Total number of submissions + uint256 public submissionCount; + + // Default reward amount for approved submissions (in wei, 18 decimals) + uint256 public defaultRewardAmount; + + // Events + event SubmissionCreated( + uint256 indexed submissionId, + address indexed submitter, + string dataURI, + uint256 timestamp + ); + + event SubmissionApproved( + uint256 indexed submissionId, + address indexed approver, + uint256 timestamp + ); + + event SubmissionRejected( + uint256 indexed submissionId, + address indexed approver, + uint256 timestamp + ); + + event DefaultRewardUpdated(uint256 oldAmount, uint256 newAmount); + + /** + * @dev Constructor sets up the contract with DCU token, RewardLogic, and roles + * @param _dcuToken Address of the DCU token contract + * @param _rewardLogic Address of the RewardLogic contract + * @param _defaultRewardAmount Default reward amount for approved submissions + */ + constructor(address _dcuToken, address _rewardLogic, uint256 _defaultRewardAmount) Ownable(msg.sender) { + if (_dcuToken == address(0)) revert SUBMISSION__InvalidAddress(); + if (_rewardLogic == address(0)) revert SUBMISSION__InvalidAddress(); + + dcuToken = IDCUToken(_dcuToken); + rewardLogic = IRewards(_rewardLogic); + defaultRewardAmount = _defaultRewardAmount; + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(ADMIN_ROLE, msg.sender); + } + + /** + * @dev Create a new submission + * @param dataURI IPFS URI or other storage reference to submission data + * @return submissionId The ID of the created submission + */ + function createSubmission(string calldata dataURI) external nonReentrant returns (uint256) { + if (bytes(dataURI).length == 0) revert SUBMISSION__InvalidSubmissionData(); + + uint256 submissionId = submissionCount; + + submissions[submissionId] = Submission({ + id: submissionId, + submitter: msg.sender, + dataURI: dataURI, + timestamp: block.timestamp, + status: SubmissionStatus.Pending, + approver: address(0), + processedTimestamp: 0, + rewarded: false + }); + + userSubmissions[msg.sender].push(submissionId); + submissionCount++; + + emit SubmissionCreated(submissionId, msg.sender, dataURI, block.timestamp); + + return submissionId; + } + + /** + * @dev Approve a submission (only for admins) + * @param submissionId The ID of the submission to approve + */ + function approveSubmission( + uint256 submissionId + ) external nonReentrant onlyRole(ADMIN_ROLE) { + if (submissionId >= submissionCount) revert SUBMISSION__SubmissionNotFound(submissionId); + + Submission storage submission = submissions[submissionId]; + + if (submission.status == SubmissionStatus.Approved) + revert SUBMISSION__AlreadyApproved(submissionId); + + submission.status = SubmissionStatus.Approved; + submission.approver = msg.sender; + submission.processedTimestamp = block.timestamp; + + emit SubmissionApproved( + submissionId, + msg.sender, + block.timestamp + ); + + // Trigger reward through RewardLogic for approved submissions + if (!submission.rewarded) { + submission.rewarded = true; + rewardLogic.distributeDCU(submission.submitter, defaultRewardAmount); + } + } + + /** + * @dev Reject a submission (only for admins) + * @param submissionId The ID of the submission to reject + */ + function rejectSubmission( + uint256 submissionId + ) external nonReentrant onlyRole(ADMIN_ROLE) { + if (submissionId >= submissionCount) revert SUBMISSION__SubmissionNotFound(submissionId); + + Submission storage submission = submissions[submissionId]; + + if (submission.status == SubmissionStatus.Rejected) + revert SUBMISSION__AlreadyRejected(submissionId); + + submission.status = SubmissionStatus.Rejected; + submission.approver = msg.sender; + submission.processedTimestamp = block.timestamp; + + emit SubmissionRejected( + submissionId, + msg.sender, + block.timestamp + ); + } + + /** + * @dev Update the default reward amount (only for admins) + * @param newRewardAmount The new default reward amount + */ + function updateDefaultReward(uint256 newRewardAmount) external onlyRole(ADMIN_ROLE) { + uint256 oldAmount = defaultRewardAmount; + defaultRewardAmount = newRewardAmount; + + emit DefaultRewardUpdated(oldAmount, newRewardAmount); + } + + /** + * @dev Update the RewardLogic contract address (only for owner) + * @param _newRewardLogic The new RewardLogic contract address + */ + function updateRewardLogic(address _newRewardLogic) external onlyOwner { + if (_newRewardLogic == address(0)) revert SUBMISSION__InvalidAddress(); + rewardLogic = IRewards(_newRewardLogic); + } + + /** + * @dev Get all submissions for a user + * @param user The address of the user + * @return An array of submission IDs + */ + function getSubmissionsByUser(address user) external view returns (uint256[] memory) { + return userSubmissions[user]; + } + + /** + * @dev Get the details of a submission + * @param submissionId The ID of the submission + * @return The submission details + */ + function getSubmissionDetails(uint256 submissionId) external view returns (Submission memory) { + if (submissionId >= submissionCount) revert SUBMISSION__SubmissionNotFound(submissionId); + return submissions[submissionId]; + } + + /** + * @dev Get a batch of submissions for pagination + * @param startIndex The starting index + * @param batchSize The number of submissions to return + * @return Submission[] An array of submissions + */ + function getSubmissionBatch(uint256 startIndex, uint256 batchSize) + external + view + returns (Submission[] memory) + { + uint256 endIndex = startIndex + batchSize; + + // Ensure we don't go past the end of the submissions + if (endIndex > submissionCount) { + endIndex = submissionCount; + } + + // Calculate actual batch size + uint256 resultSize = endIndex - startIndex; + Submission[] memory result = new Submission[](resultSize); + + for (uint256 i = 0; i < resultSize; i++) { + result[i] = submissions[startIndex + i]; + } + + return result; + } +} \ No newline at end of file From 013eb497f2cf541ad75d2af26f7a117c86d4d330 Mon Sep 17 00:00:00 2001 From: Shehu-Fatiudeen Lawal Date: Mon, 21 Apr 2025 09:38:07 +0100 Subject: [PATCH 2/6] test: add comprehensive tests for Submission contract functionality --- test/Submission.test.ts | 245 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 test/Submission.test.ts diff --git a/test/Submission.test.ts b/test/Submission.test.ts new file mode 100644 index 0000000..133992a --- /dev/null +++ b/test/Submission.test.ts @@ -0,0 +1,245 @@ +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { ethers } from "hardhat"; + +describe("Submission", function () { + // Define a fixture for deploying the test environment + async function deploySubmissionFixture() { + const [owner, user, admin] = await ethers.getSigners(); + + // Deploy DCU token with a mock reward logic + const DCUToken = await ethers.getContractFactory("DCUToken"); + const dcuToken = await DCUToken.deploy(owner.address); + + // Deploy the real reward logic + const RewardLogic = await ethers.getContractFactory("RewardLogic"); + const rewardLogic = await RewardLogic.deploy( + dcuToken.address, + ethers.constants.AddressZero + ); + + // Update reward logic in token + await dcuToken.updateRewardLogicContract(rewardLogic.address); + + // Deploy submission contract + const defaultRewardAmount = ethers.utils.parseEther("10"); // 10 DCU tokens + const Submission = await ethers.getContractFactory("Submission"); + const submission = await Submission.deploy( + dcuToken.address, + rewardLogic.address, + defaultRewardAmount + ); + + // Authorize the submission contract to distribute DCU tokens + await rewardLogic.authorizeContract(submission.address); + + // Grant admin role to the admin account + const ADMIN_ROLE = await submission.ADMIN_ROLE(); + await submission.grantRole(ADMIN_ROLE, admin.address); + + return { submission, dcuToken, rewardLogic, owner, user, admin }; + } + + describe("Submission Creation", function () { + it("Should allow users to create submissions", async function () { + const { submission, user } = await loadFixture(deploySubmissionFixture); + + // Create a sample URI for the submission data + const dataURI = "ipfs://QmExample123456"; + + // Create a submission + const tx = await submission.connect(user).createSubmission(dataURI); + await tx.wait(); + + // Check submission count + const count = await submission.submissionCount(); + expect(count.toNumber()).to.equal(1); + + // Verify the submission details + const userSubmissions = await submission.getSubmissionsByUser( + user.address + ); + expect(userSubmissions.length).to.equal(1); + expect(userSubmissions[0].toNumber()).to.equal(0); // First submission ID + + // Check submission details + const details = await submission.getSubmissionDetails(0); + expect(details.submitter).to.equal(user.address); + expect(details.dataURI).to.equal(dataURI); + expect(details.status).to.equal(0); // Pending status + }); + + it("Should reject submissions with empty data URI", async function () { + const { submission, user } = await loadFixture(deploySubmissionFixture); + + // Attempt to create a submission with empty URI + try { + await submission.connect(user).createSubmission(""); + expect.fail("Should have reverted"); + } catch (error) { + expect(error.message).to.include("SUBMISSION__InvalidSubmissionData"); + } + }); + }); + + describe("Submission Approval", function () { + it("Should allow admins to approve submissions", async function () { + const { submission, user, admin } = await loadFixture( + deploySubmissionFixture + ); + + // Create a submission + const dataURI = "ipfs://QmExample123456"; + await submission.connect(user).createSubmission(dataURI); + + // Approve the submission + await submission.connect(admin).approveSubmission(0); + + // Check submission status + const details = await submission.getSubmissionDetails(0); + expect(details.status).to.equal(1); // Approved status + expect(details.approver).to.equal(admin.address); + expect(details.rewarded).to.equal(true); // Should be automatically rewarded + }); + + it("Should reject approval requests from non-admins", async function () { + const { submission, user } = await loadFixture(deploySubmissionFixture); + + // Create a submission + const dataURI = "ipfs://QmExample123456"; + await submission.connect(user).createSubmission(dataURI); + + // Try to approve as a regular user (should fail) + try { + await submission.connect(user).approveSubmission(0); + expect.fail("Should have reverted"); + } catch (error) { + expect(error.message).to.include("AccessControl"); + } + }); + + it("Should prevent approving the same submission twice", async function () { + const { submission, user, admin } = await loadFixture( + deploySubmissionFixture + ); + + // Create a submission + const dataURI = "ipfs://QmExample123456"; + await submission.connect(user).createSubmission(dataURI); + + // Approve the submission + await submission.connect(admin).approveSubmission(0); + + // Try to approve again (should fail) + try { + await submission.connect(admin).approveSubmission(0); + expect.fail("Should have reverted"); + } catch (error) { + expect(error.message).to.include("SUBMISSION__AlreadyApproved"); + } + }); + }); + + describe("Submission Rejection", function () { + it("Should allow admins to reject submissions", async function () { + const { submission, user, admin } = await loadFixture( + deploySubmissionFixture + ); + + // Create a submission + const dataURI = "ipfs://QmExample123456"; + await submission.connect(user).createSubmission(dataURI); + + // Reject the submission + await submission.connect(admin).rejectSubmission(0); + + // Check submission status + const details = await submission.getSubmissionDetails(0); + expect(details.status).to.equal(2); // Rejected status + expect(details.approver).to.equal(admin.address); + expect(details.rewarded).to.equal(false); // Should not be rewarded + }); + + it("Should prevent rejecting the same submission twice", async function () { + const { submission, user, admin } = await loadFixture( + deploySubmissionFixture + ); + + // Create a submission + const dataURI = "ipfs://QmExample123456"; + await submission.connect(user).createSubmission(dataURI); + + // Reject the submission + await submission.connect(admin).rejectSubmission(0); + + // Try to reject again (should fail) + try { + await submission.connect(admin).rejectSubmission(0); + expect.fail("Should have reverted"); + } catch (error) { + expect(error.message).to.include("SUBMISSION__AlreadyRejected"); + } + }); + }); + + describe("Configuration Management", function () { + it("Should allow admin to update the default reward amount", async function () { + const { submission, owner } = await loadFixture(deploySubmissionFixture); + + // Initial default reward is set to 10 DCU + const initialReward = await submission.defaultRewardAmount(); + expect(initialReward.toString()).to.equal( + ethers.utils.parseEther("10").toString() + ); + + // Update the default reward + const newReward = ethers.utils.parseEther("20"); + await submission.connect(owner).updateDefaultReward(newReward); + + // Verify update + const updatedReward = await submission.defaultRewardAmount(); + expect(updatedReward.toString()).to.equal(newReward.toString()); + }); + + it("Should allow owner to update the reward logic contract", async function () { + const { submission, rewardLogic, owner } = await loadFixture( + deploySubmissionFixture + ); + + // Check initial reward logic address + expect(await submission.rewardLogic()).to.equal(rewardLogic.address); + + // Deploy a new mock reward logic + const newMockAddress = owner.address; // Just using owner address as a mock + await submission.connect(owner).updateRewardLogic(newMockAddress); + + // Verify update + expect(await submission.rewardLogic()).to.equal(newMockAddress); + }); + }); + + describe("Batch Operations", function () { + it("Should retrieve submission batches correctly", async function () { + const { submission, user } = await loadFixture(deploySubmissionFixture); + + // Create multiple submissions + for (let i = 0; i < 5; i++) { + await submission.connect(user).createSubmission(`ipfs://QmExample${i}`); + } + + // Get a batch of submissions + const batch = await submission.getSubmissionBatch(1, 3); + + // Verify batch count and content + expect(batch.length).to.equal(3); + expect(batch[0].id.toNumber()).to.equal(1); + expect(batch[1].id.toNumber()).to.equal(2); + expect(batch[2].id.toNumber()).to.equal(3); + + // Check the dataURIs + expect(batch[0].dataURI).to.equal("ipfs://QmExample1"); + expect(batch[1].dataURI).to.equal("ipfs://QmExample2"); + expect(batch[2].dataURI).to.equal("ipfs://QmExample3"); + }); + }); +}); From e02206c71308f23ca884938ed6c13f5fa7ab4990 Mon Sep 17 00:00:00 2001 From: Shehu-Fatiudeen Lawal Date: Mon, 21 Apr 2025 09:38:33 +0100 Subject: [PATCH 3/6] feat: add contract authorization logic to RewardLogic --- contracts/RewardLogic.sol | 68 +++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/contracts/RewardLogic.sol b/contracts/RewardLogic.sol index 57c6be1..bcda095 100644 --- a/contracts/RewardLogic.sol +++ b/contracts/RewardLogic.sol @@ -19,6 +19,9 @@ contract RewardLogic is Ownable, IRewards { uint256 public constant NFT_CLAIM_REWARD = 10 ether; // 10 DCU for new NFT claims uint256 public constant LEVEL_UPGRADE_REWARD = 10 ether; // 10 DCU for level upgrades + // Mapping for authorized contracts that can call distributeDCU + mapping(address => bool) public authorizedContracts; + // Events for tracking reward distributions event RewardDistributed( address indexed user, @@ -50,6 +53,9 @@ contract RewardLogic is Ownable, IRewards { string reason ); + event ContractAuthorized(address indexed contractAddress, uint256 timestamp); + event ContractAuthorizationRevoked(address indexed contractAddress, uint256 timestamp); + /** * @dev Constructor sets the DCU token and NFT collection addresses * @param _dcuToken Address of the DCU token contract @@ -58,6 +64,31 @@ contract RewardLogic is Ownable, IRewards { constructor(address _dcuToken, address _nftCollection) Ownable(msg.sender) { dcuToken = IDCUToken(_dcuToken); nftCollection = INFTCollection(_nftCollection); + + // Authorize the NFT collection by default + if (_nftCollection != address(0)) { + authorizedContracts[_nftCollection] = true; + emit ContractAuthorized(_nftCollection, block.timestamp); + } + } + + /** + * @dev Authorize a contract to call distributeDCU (only owner) + * @param contractAddress Address of the contract to authorize + */ + function authorizeContract(address contractAddress) external onlyOwner { + require(contractAddress != address(0), "Cannot authorize zero address"); + authorizedContracts[contractAddress] = true; + emit ContractAuthorized(contractAddress, block.timestamp); + } + + /** + * @dev Revoke authorization for a contract (only owner) + * @param contractAddress Address of the contract to revoke + */ + function revokeContractAuthorization(address contractAddress) external onlyOwner { + authorizedContracts[contractAddress] = false; + emit ContractAuthorizationRevoked(contractAddress, block.timestamp); } /** @@ -129,28 +160,31 @@ contract RewardLogic is Ownable, IRewards { */ function distributeDCU(address user, uint256 amount) external override { // Only authorized contracts can call this function - require(msg.sender == address(nftCollection), "Only authorized contracts can call"); + require(authorizedContracts[msg.sender] || msg.sender == address(nftCollection), + "Only authorized contracts can call"); // Verify user through the reward manager if available DCURewardManager rewardManager = DCURewardManager(address(0)); - // Try to get the reward manager from the NFT contract - try INFTCollection(nftCollection).rewardsContract() returns (address rewardsContractAddr) { - rewardManager = DCURewardManager(rewardsContractAddr); - } catch { - // Continue even if we can't get the reward manager - } - - // If we have a reward manager, check eligibility - if (address(rewardManager) != address(0)) { - try rewardManager.getVerificationStatus(user) returns ( - bool poiVerified, - bool nftMinted, - bool rewardEligible - ) { - require(rewardEligible, "User not eligible for rewards"); + // Try to get the reward manager from the NFT collection if the caller is the NFT collection + if (msg.sender == address(nftCollection)) { + try INFTCollection(nftCollection).rewardsContract() returns (address rewardsContractAddr) { + rewardManager = DCURewardManager(rewardsContractAddr); } catch { - // If we can't check eligibility, continue + // Continue even if we can't get the reward manager + } + + // If we have a reward manager, check eligibility for NFT-based rewards + if (address(rewardManager) != address(0)) { + try rewardManager.getVerificationStatus(user) returns ( + bool poiVerified, + bool nftMinted, + bool rewardEligible + ) { + require(rewardEligible, "User not eligible for rewards"); + } catch { + // If we can't check eligibility, continue + } } } From fd5e04b1b5584497ac4d578ff81966f5939869b9 Mon Sep 17 00:00:00 2001 From: Shehu-Fatiudeen Lawal Date: Mon, 21 Apr 2025 09:56:09 +0100 Subject: [PATCH 4/6] feat: implement reward claiming and tracking in Submission contract --- contracts/Submission.sol | 57 +++++- contracts/interfaces/IRewards.sol | 8 +- test/Submission.test.ts | 304 +++++++++++++++++++++--------- 3 files changed, 278 insertions(+), 91 deletions(-) diff --git a/contracts/Submission.sol b/contracts/Submission.sol index 0dd19c9..eb551f5 100644 --- a/contracts/Submission.sol +++ b/contracts/Submission.sol @@ -19,6 +19,7 @@ contract Submission is Ownable, ReentrancyGuard, AccessControl { error SUBMISSION__Unauthorized(address user); error SUBMISSION__AlreadyApproved(uint256 submissionId); error SUBMISSION__AlreadyRejected(uint256 submissionId); + error SUBMISSION__NoRewardsAvailable(); // Role definitions for access control bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); @@ -50,6 +51,9 @@ contract Submission is Ownable, ReentrancyGuard, AccessControl { // Mapping from user address to their submission IDs mapping(address => uint256[]) public userSubmissions; + // Mapping from user address to claimable rewards amount + mapping(address => uint256) public claimableRewards; + // Total number of submissions uint256 public submissionCount; @@ -78,6 +82,19 @@ contract Submission is Ownable, ReentrancyGuard, AccessControl { event DefaultRewardUpdated(uint256 oldAmount, uint256 newAmount); + event RewardClaimed( + address indexed user, + uint256 amount, + uint256 timestamp + ); + + event RewardAvailable( + address indexed user, + uint256 amount, + uint256 submissionId, + uint256 timestamp + ); + /** * @dev Constructor sets up the contract with DCU token, RewardLogic, and roles * @param _dcuToken Address of the DCU token contract @@ -149,13 +166,49 @@ contract Submission is Ownable, ReentrancyGuard, AccessControl { block.timestamp ); - // Trigger reward through RewardLogic for approved submissions + // Instead of immediately distributing rewards, add to claimable rewards if (!submission.rewarded) { submission.rewarded = true; - rewardLogic.distributeDCU(submission.submitter, defaultRewardAmount); + claimableRewards[submission.submitter] += defaultRewardAmount; + + emit RewardAvailable( + submission.submitter, + defaultRewardAmount, + submissionId, + block.timestamp + ); } } + /** + * @dev Claim available rewards + */ + function claimRewards() external nonReentrant { + uint256 amount = claimableRewards[msg.sender]; + if (amount == 0) revert SUBMISSION__NoRewardsAvailable(); + + // Reset claimable rewards before external calls + claimableRewards[msg.sender] = 0; + + // Distribute the rewards through RewardLogic + rewardLogic.distributeDCU(msg.sender, amount); + + emit RewardClaimed( + msg.sender, + amount, + block.timestamp + ); + } + + /** + * @dev Get available claimable rewards for a user + * @param user The user address to check + * @return amount The amount of claimable rewards + */ + function getClaimableRewards(address user) external view returns (uint256) { + return claimableRewards[user]; + } + /** * @dev Reject a submission (only for admins) * @param submissionId The ID of the submission to reject diff --git a/contracts/interfaces/IRewards.sol b/contracts/interfaces/IRewards.sol index 87f2164..3fdc56e 100644 --- a/contracts/interfaces/IRewards.sol +++ b/contracts/interfaces/IRewards.sol @@ -3,13 +3,13 @@ pragma solidity ^0.8.28; /** * @title IRewards - * @dev Interface for the rewards distribution contract + * @dev Interface for reward distribution contract */ interface IRewards { /** - * @dev Distribute DCU tokens to a user - * @param user The address of the user to distribute to - * @param amount The amount to distribute + * @dev Distributes DCU tokens to a user + * @param user Address of the user to distribute tokens to + * @param amount Amount of DCU tokens to distribute */ function distributeDCU(address user, uint256 amount) external; } \ No newline at end of file diff --git a/test/Submission.test.ts b/test/Submission.test.ts index 133992a..7f48b98 100644 --- a/test/Submission.test.ts +++ b/test/Submission.test.ts @@ -30,8 +30,16 @@ describe("Submission", function () { defaultRewardAmount ); - // Authorize the submission contract to distribute DCU tokens - await rewardLogic.authorizeContract(submission.address); + // Add the submission contract to authorized contracts in RewardLogic + // We'll use a try-catch since the interface might be different + try { + // Try to use authorizeContract if it exists + await rewardLogic.authorizeContract(submission.address); + } catch (error) { + console.log("Unable to authorize contract:", error); + // If authorizeContract is not available, try an alternative method + // This is a no-op in tests but can help with debugging + } // Grant admin role to the admin account const ADMIN_ROLE = await submission.ADMIN_ROLE(); @@ -41,142 +49,269 @@ describe("Submission", function () { } describe("Submission Creation", function () { - it("Should allow users to create submissions", async function () { + it("Should create a submission with the correct data", async function () { const { submission, user } = await loadFixture(deploySubmissionFixture); - // Create a sample URI for the submission data - const dataURI = "ipfs://QmExample123456"; - // Create a submission + const dataURI = "ipfs://QmTest123"; const tx = await submission.connect(user).createSubmission(dataURI); await tx.wait(); + // Get the submission ID from events (should be 0 for the first submission) + const submissionId = 0; + // Check submission count const count = await submission.submissionCount(); expect(count.toNumber()).to.equal(1); - // Verify the submission details + // Verify the user's submission was recorded const userSubmissions = await submission.getSubmissionsByUser( user.address ); expect(userSubmissions.length).to.equal(1); - expect(userSubmissions[0].toNumber()).to.equal(0); // First submission ID - - // Check submission details - const details = await submission.getSubmissionDetails(0); - expect(details.submitter).to.equal(user.address); - expect(details.dataURI).to.equal(dataURI); - expect(details.status).to.equal(0); // Pending status + expect(userSubmissions[0].toNumber()).to.equal(submissionId); }); - it("Should reject submissions with empty data URI", async function () { + it("Should reject submissions with empty dataURI", async function () { const { submission, user } = await loadFixture(deploySubmissionFixture); - // Attempt to create a submission with empty URI + // Try to create a submission with empty data URI try { await submission.connect(user).createSubmission(""); expect.fail("Should have reverted"); - } catch (error) { + } catch (error: any) { expect(error.message).to.include("SUBMISSION__InvalidSubmissionData"); } }); }); describe("Submission Approval", function () { - it("Should allow admins to approve submissions", async function () { - const { submission, user, admin } = await loadFixture( + it("Should allow admin to approve a submission and make rewards claimable", async function () { + const { submission, user, admin, dcuToken } = await loadFixture( deploySubmissionFixture ); // Create a submission - const dataURI = "ipfs://QmExample123456"; + const dataURI = "ipfs://QmTest123"; await submission.connect(user).createSubmission(dataURI); + const submissionId = 0; - // Approve the submission - await submission.connect(admin).approveSubmission(0); + // Check initial claimable rewards + const initialClaimable = await submission.getClaimableRewards( + user.address + ); + console.log("Initial claimable rewards:", initialClaimable.toString()); + expect(initialClaimable.toNumber()).to.equal(0); + + // Check user's initial DCU balance + const initialBalance = await dcuToken.balanceOf(user.address); + console.log("Initial DCU balance:", initialBalance.toString()); + expect(initialBalance.toNumber()).to.equal(0); + + // Admin approves the submission + const tx = await submission + .connect(admin) + .approveSubmission(submissionId); + await tx.wait(); - // Check submission status - const details = await submission.getSubmissionDetails(0); - expect(details.status).to.equal(1); // Approved status - expect(details.approver).to.equal(admin.address); - expect(details.rewarded).to.equal(true); // Should be automatically rewarded + // Check that rewards are now claimable + const claimableRewards = await submission.getClaimableRewards( + user.address + ); + console.log( + "Claimable rewards after approval:", + claimableRewards.toString() + ); + const expectedReward = ethers.utils.parseEther("10"); + console.log("Expected reward amount:", expectedReward.toString()); + + // Using strict equality can fail with BigNumber, use equals method instead + expect(claimableRewards.toString()).to.equal(expectedReward.toString()); + + // User's DCU balance should still be 0 until they claim the rewards + const balanceAfterApproval = await dcuToken.balanceOf(user.address); + console.log( + "DCU balance after approval:", + balanceAfterApproval.toString() + ); + expect(balanceAfterApproval.toNumber()).to.equal(0); }); - it("Should reject approval requests from non-admins", async function () { + it("Should prevent non-admin from approving submissions", async function () { const { submission, user } = await loadFixture(deploySubmissionFixture); // Create a submission - const dataURI = "ipfs://QmExample123456"; - await submission.connect(user).createSubmission(dataURI); + await submission.connect(user).createSubmission("ipfs://QmTest123"); + const submissionId = 0; - // Try to approve as a regular user (should fail) + // Try to approve the submission as non-admin user try { - await submission.connect(user).approveSubmission(0); + await submission.connect(user).approveSubmission(submissionId); expect.fail("Should have reverted"); - } catch (error) { + } catch (error: any) { + // Check for AccessControl error expect(error.message).to.include("AccessControl"); } }); - it("Should prevent approving the same submission twice", async function () { + it("Should prevent approving a non-existent submission", async function () { + const { submission, admin } = await loadFixture(deploySubmissionFixture); + + // Try to approve a non-existent submission + try { + await submission.connect(admin).approveSubmission(999); + expect.fail("Should have reverted"); + } catch (error: any) { + expect(error.message).to.include("SUBMISSION__SubmissionNotFound"); + } + }); + + it("Should prevent approving an already approved submission", async function () { const { submission, user, admin } = await loadFixture( deploySubmissionFixture ); - // Create a submission - const dataURI = "ipfs://QmExample123456"; - await submission.connect(user).createSubmission(dataURI); - - // Approve the submission + // Create and approve a submission + await submission.connect(user).createSubmission("ipfs://QmTest123"); await submission.connect(admin).approveSubmission(0); - // Try to approve again (should fail) + // Try to approve it again try { await submission.connect(admin).approveSubmission(0); expect.fail("Should have reverted"); - } catch (error) { + } catch (error: any) { expect(error.message).to.include("SUBMISSION__AlreadyApproved"); } }); }); + describe("Reward Claiming", function () { + it("Should allow users to claim rewards from approved submissions", async function () { + const { submission, user, admin, dcuToken } = await loadFixture( + deploySubmissionFixture + ); + + // Create and approve a submission + await submission.connect(user).createSubmission("ipfs://QmTest123"); + await submission.connect(admin).approveSubmission(0); + + // Check claimable rewards before claiming + const claimableBefore = await submission.getClaimableRewards( + user.address + ); + console.log("Claimable before claiming:", claimableBefore.toString()); + const expectedReward = ethers.utils.parseEther("10"); + console.log("Expected reward amount:", expectedReward.toString()); + + // Using string comparison for BigNumber + expect(claimableBefore.toString()).to.equal(expectedReward.toString()); + + // Claim rewards + await submission.connect(user).claimRewards(); + + // Check claimable rewards after claiming (should be 0) + const claimableAfter = await submission.getClaimableRewards(user.address); + console.log("Claimable after claiming:", claimableAfter.toString()); + expect(claimableAfter.toNumber()).to.equal(0); + + // Check user's DCU balance after claiming + const balanceAfterClaim = await dcuToken.balanceOf(user.address); + console.log("DCU balance after claim:", balanceAfterClaim.toString()); + console.log("Expected DCU after claim:", expectedReward.toString()); + + // Using string comparison for BigNumber + expect(balanceAfterClaim.toString()).to.equal(expectedReward.toString()); + }); + + it("Should prevent claiming when no rewards are available", async function () { + const { submission, user } = await loadFixture(deploySubmissionFixture); + + // Try to claim rewards when none are available + try { + await submission.connect(user).claimRewards(); + expect.fail("Should have reverted"); + } catch (error: any) { + expect(error.message).to.include("SUBMISSION__NoRewardsAvailable"); + } + }); + + it("Should accumulate rewards from multiple approved submissions", async function () { + const { submission, user, admin, dcuToken } = await loadFixture( + deploySubmissionFixture + ); + + // Create and approve first submission + await submission.connect(user).createSubmission("ipfs://QmTest1"); + await submission.connect(admin).approveSubmission(0); + + // Create and approve second submission + await submission.connect(user).createSubmission("ipfs://QmTest2"); + await submission.connect(admin).approveSubmission(1); + + // Check cumulative claimable rewards + const totalClaimable = await submission.getClaimableRewards(user.address); + console.log("Total claimable rewards:", totalClaimable.toString()); + const expectedTotal = ethers.utils.parseEther("20"); // 10 + 10 = 20 DCU + console.log("Expected total rewards:", expectedTotal.toString()); + + // Using string comparison for BigNumber + expect(totalClaimable.toString()).to.equal(expectedTotal.toString()); + + // Claim all rewards + await submission.connect(user).claimRewards(); + + // Verify user received all rewards + const finalBalance = await dcuToken.balanceOf(user.address); + console.log("Final DCU balance:", finalBalance.toString()); + + // Using string comparison for BigNumber + expect(finalBalance.toString()).to.equal(expectedTotal.toString()); + + // Claimable amount should be reset to 0 + const claimableAfter = await submission.getClaimableRewards(user.address); + console.log("Claimable after claiming all:", claimableAfter.toString()); + expect(claimableAfter.toNumber()).to.equal(0); + }); + }); + describe("Submission Rejection", function () { - it("Should allow admins to reject submissions", async function () { + it("Should allow admin to reject a submission", async function () { const { submission, user, admin } = await loadFixture( deploySubmissionFixture ); // Create a submission - const dataURI = "ipfs://QmExample123456"; - await submission.connect(user).createSubmission(dataURI); + await submission.connect(user).createSubmission("ipfs://QmTest123"); + const submissionId = 0; - // Reject the submission - await submission.connect(admin).rejectSubmission(0); + // Admin rejects the submission + await submission.connect(admin).rejectSubmission(submissionId); - // Check submission status - const details = await submission.getSubmissionDetails(0); - expect(details.status).to.equal(2); // Rejected status - expect(details.approver).to.equal(admin.address); - expect(details.rewarded).to.equal(false); // Should not be rewarded + // Check submission count + const count = await submission.submissionCount(); + expect(count.toNumber()).to.equal(1); + + // Check that no rewards are claimable + const claimableRewards = await submission.getClaimableRewards( + user.address + ); + expect(claimableRewards.toNumber()).to.equal(0); }); - it("Should prevent rejecting the same submission twice", async function () { + it("Should prevent rejecting an already rejected submission", async function () { const { submission, user, admin } = await loadFixture( deploySubmissionFixture ); - // Create a submission - const dataURI = "ipfs://QmExample123456"; - await submission.connect(user).createSubmission(dataURI); - - // Reject the submission + // Create and reject a submission + await submission.connect(user).createSubmission("ipfs://QmTest123"); await submission.connect(admin).rejectSubmission(0); - // Try to reject again (should fail) + // Try to reject it again try { await submission.connect(admin).rejectSubmission(0); expect.fail("Should have reverted"); - } catch (error) { + } catch (error: any) { expect(error.message).to.include("SUBMISSION__AlreadyRejected"); } }); @@ -202,44 +337,43 @@ describe("Submission", function () { }); it("Should allow owner to update the reward logic contract", async function () { - const { submission, rewardLogic, owner } = await loadFixture( + const { submission, rewardLogic, owner, dcuToken } = await loadFixture( deploySubmissionFixture ); // Check initial reward logic address expect(await submission.rewardLogic()).to.equal(rewardLogic.address); - // Deploy a new mock reward logic - const newMockAddress = owner.address; // Just using owner address as a mock - await submission.connect(owner).updateRewardLogic(newMockAddress); + // Deploy a new reward logic contract + const NewRewardLogic = await ethers.getContractFactory("RewardLogic"); + const newRewardLogicContract = await NewRewardLogic.deploy( + dcuToken.address, + ethers.constants.AddressZero + ); + + // Update the reward logic contract + await submission + .connect(owner) + .updateRewardLogic(newRewardLogicContract.address); // Verify update - expect(await submission.rewardLogic()).to.equal(newMockAddress); + expect(await submission.rewardLogic()).to.equal( + newRewardLogicContract.address + ); }); - }); - describe("Batch Operations", function () { - it("Should retrieve submission batches correctly", async function () { - const { submission, user } = await loadFixture(deploySubmissionFixture); + it("Should reject invalid addresses for reward logic update", async function () { + const { submission, owner } = await loadFixture(deploySubmissionFixture); - // Create multiple submissions - for (let i = 0; i < 5; i++) { - await submission.connect(user).createSubmission(`ipfs://QmExample${i}`); + // Try to update with zero address + try { + await submission + .connect(owner) + .updateRewardLogic(ethers.constants.AddressZero); + expect.fail("Should have reverted"); + } catch (error: any) { + expect(error.message).to.include("SUBMISSION__InvalidAddress"); } - - // Get a batch of submissions - const batch = await submission.getSubmissionBatch(1, 3); - - // Verify batch count and content - expect(batch.length).to.equal(3); - expect(batch[0].id.toNumber()).to.equal(1); - expect(batch[1].id.toNumber()).to.equal(2); - expect(batch[2].id.toNumber()).to.equal(3); - - // Check the dataURIs - expect(batch[0].dataURI).to.equal("ipfs://QmExample1"); - expect(batch[1].dataURI).to.equal("ipfs://QmExample2"); - expect(batch[2].dataURI).to.equal("ipfs://QmExample3"); }); }); }); From fb3fbd961b08cacad539add4b410a18a1754b113 Mon Sep 17 00:00:00 2001 From: Shehu-Fatiudeen Lawal Date: Mon, 21 Apr 2025 09:57:43 +0100 Subject: [PATCH 5/6] feat: add error codes for submission contract to documentation --- docs/ERROR_CODES.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md index 8d61ddc..fa2a65c 100644 --- a/docs/ERROR_CODES.md +++ b/docs/ERROR_CODES.md @@ -41,6 +41,18 @@ Where: | `NFT__UserHasNoNFT` | User has not minted an NFT | `user`: The user's address | | `NFT__TransferNotAuthorized` | Transfer is not authorized for the token | `tokenId`: The token ID | +### Submission Contract Errors + +| Error Code | Description | Parameters | +|------------|-------------|------------| +| `SUBMISSION__InvalidAddress` | Invalid address (typically zero address) | None | +| `SUBMISSION__InvalidSubmissionData` | The submission data is invalid or empty | None | +| `SUBMISSION__SubmissionNotFound` | The specified submission ID does not exist | `submissionId`: The queried submission ID | +| `SUBMISSION__Unauthorized` | User is not authorized to perform this action | `user`: The user's address | +| `SUBMISSION__AlreadyApproved` | Submission has already been approved | `submissionId`: The submission ID | +| `SUBMISSION__AlreadyRejected` | Submission has already been rejected | `submissionId`: The submission ID | +| `SUBMISSION__NoRewardsAvailable` | No rewards are available for the user to claim | None | + ### DCURewardManager Contract Errors | Error Code | Description | Parameters | From 886fd310f12234c0a72a4ed4d7e01d6365ee1346 Mon Sep 17 00:00:00 2001 From: Shehu-Fatiudeen Lawal Date: Fri, 25 Apr 2025 01:43:29 +0100 Subject: [PATCH 6/6] refactor: update distributeDCU function to return success status and handle NFT collection address check --- contracts/RewardLogic.sol | 20 ++++++++++------ contracts/Submission.sol | 5 ++-- contracts/interfaces/IRewards.sol | 3 ++- test/Submission.test.ts | 38 ++++++++++++++++++++++--------- 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/contracts/RewardLogic.sol b/contracts/RewardLogic.sol index 420d782..b9d1e89 100644 --- a/contracts/RewardLogic.sol +++ b/contracts/RewardLogic.sol @@ -189,8 +189,9 @@ contract RewardLogic is Ownable, IRewards { * @dev Distribute DCU tokens to a user (implementation of IRewards interface) * @param user Address of the user to distribute to * @param amount Amount of DCU to distribute + * @return success Whether the distribution was successful */ - function distributeDCU(address user, uint256 amount) external override { + function distributeDCU(address user, uint256 amount) external override returns (bool) { // Only authorized contracts can call this function require(authorizedContracts[msg.sender] || msg.sender == address(nftCollection), "Only authorized contracts can call"); @@ -198,11 +199,13 @@ contract RewardLogic is Ownable, IRewards { // Initialize reward manager DCURewardManager rewardManager = DCURewardManager(address(0)); - // Try to get the reward manager from the NFT collection - try INFTCollection(nftCollection).rewardsContract() returns (address rewardsContractAddr) { - rewardManager = DCURewardManager(rewardsContractAddr); - } catch { - // Continue even if we can't get the reward manager + // Only try to get the reward manager if we have an NFT collection + if (address(nftCollection) != address(0)) { + try INFTCollection(nftCollection).rewardsContract() returns (address rewardsContractAddr) { + rewardManager = DCURewardManager(rewardsContractAddr); + } catch { + // Continue even if we can't get the reward manager + } } // If we have a reward manager and the caller is the NFT contract, check eligibility @@ -219,7 +222,8 @@ contract RewardLogic is Ownable, IRewards { } // Mint tokens to the user - require(dcuToken.mint(user, amount), "DCU distribution failed"); + bool success = dcuToken.mint(user, amount); + require(success, "DCU distribution failed"); emit DCUDistributed( user, @@ -227,5 +231,7 @@ contract RewardLogic is Ownable, IRewards { block.timestamp, "Reward from authorized contract" ); + + return success; } } \ No newline at end of file diff --git a/contracts/Submission.sol b/contracts/Submission.sol index eb551f5..f3511a1 100644 --- a/contracts/Submission.sol +++ b/contracts/Submission.sol @@ -190,8 +190,9 @@ contract Submission is Ownable, ReentrancyGuard, AccessControl { // Reset claimable rewards before external calls claimableRewards[msg.sender] = 0; - // Distribute the rewards through RewardLogic - rewardLogic.distributeDCU(msg.sender, amount); + // Distribute the rewards through RewardLogic and check the return value + bool success = rewardLogic.distributeDCU(msg.sender, amount); + require(success, "Reward distribution failed"); emit RewardClaimed( msg.sender, diff --git a/contracts/interfaces/IRewards.sol b/contracts/interfaces/IRewards.sol index 3fdc56e..cb6ced7 100644 --- a/contracts/interfaces/IRewards.sol +++ b/contracts/interfaces/IRewards.sol @@ -10,6 +10,7 @@ interface IRewards { * @dev Distributes DCU tokens to a user * @param user Address of the user to distribute tokens to * @param amount Amount of DCU tokens to distribute + * @return success Whether the distribution was successful */ - function distributeDCU(address user, uint256 amount) external; + function distributeDCU(address user, uint256 amount) external returns (bool); } \ No newline at end of file diff --git a/test/Submission.test.ts b/test/Submission.test.ts index 7f48b98..31b39b2 100644 --- a/test/Submission.test.ts +++ b/test/Submission.test.ts @@ -7,11 +7,11 @@ describe("Submission", function () { async function deploySubmissionFixture() { const [owner, user, admin] = await ethers.getSigners(); - // Deploy DCU token with a mock reward logic + // Deploy DCU token with owner as initial reward logic const DCUToken = await ethers.getContractFactory("DCUToken"); const dcuToken = await DCUToken.deploy(owner.address); - // Deploy the real reward logic + // Deploy the real reward logic with zero address for NFT collection const RewardLogic = await ethers.getContractFactory("RewardLogic"); const rewardLogic = await RewardLogic.deploy( dcuToken.address, @@ -31,21 +31,37 @@ describe("Submission", function () { ); // Add the submission contract to authorized contracts in RewardLogic - // We'll use a try-catch since the interface might be different - try { - // Try to use authorizeContract if it exists - await rewardLogic.authorizeContract(submission.address); - } catch (error) { - console.log("Unable to authorize contract:", error); - // If authorizeContract is not available, try an alternative method - // This is a no-op in tests but can help with debugging + const rewardLogicWithAuth = new ethers.Contract( + rewardLogic.address, + [ + "function authorizeContract(address contractAddress) external", + "function distributeDCU(address user, uint256 amount) external returns (bool)", + "function authorizedContracts(address) external view returns (bool)", + ], + owner + ); + await rewardLogicWithAuth.authorizeContract(submission.address); + + // Verify the submission contract is authorized + const isAuthorized = await rewardLogicWithAuth.authorizedContracts( + submission.address + ); + if (!isAuthorized) { + throw new Error("Failed to authorize submission contract"); } // Grant admin role to the admin account const ADMIN_ROLE = await submission.ADMIN_ROLE(); await submission.grantRole(ADMIN_ROLE, admin.address); - return { submission, dcuToken, rewardLogic, owner, user, admin }; + return { + submission, + dcuToken, + rewardLogic: rewardLogicWithAuth, + owner, + user, + admin, + }; } describe("Submission Creation", function () {