diff --git a/contracts/CrowdFunding/CrowdFunding.sol b/contracts/CrowdFunding/CrowdFunding.sol new file mode 100644 index 00000000..5a25b000 --- /dev/null +++ b/contracts/CrowdFunding/CrowdFunding.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./CrowdToken.sol"; +import "./CrowdNFT.sol"; + +contract Crowdfunding { + address public owner; + uint256 public fundingGoal; + uint256 public totalFunds; + uint256 public nftThreshold; + uint256 public tokenConversionRate; // Number of ERC20 tokens minted per wei contributed + + CrowdToken public rewardToken; + CrowdNFT public rewardNFT; + + mapping(address => uint256) public contributions; + + constructor( + uint256 _fundingGoal, + uint256 _nftThreshold, + uint256 _tokenConversionRate, + address _rewardToken, + address _rewardNFT + ) { + owner = msg.sender; + fundingGoal = _fundingGoal; + nftThreshold = _nftThreshold; + tokenConversionRate = _tokenConversionRate; + rewardToken = CrowdToken(_rewardToken); + rewardNFT = CrowdNFT(_rewardNFT); + } + + modifier onlyOwner() { + require(msg.sender == owner, "Only owner"); + _; + } + + // Contribute funds and receive rewards. + function contribute() public payable { + require(msg.value > 0, "Send ETH to contribute"); + contributions[msg.sender] += msg.value; + totalFunds += msg.value; + + // Reward with ERC20 tokens. + uint256 tokenAmount = msg.value * tokenConversionRate; + rewardToken.mint(msg.sender, tokenAmount); + + // If contribution meets or exceeds the NFT threshold, mint an NFT. + if (msg.value >= nftThreshold) { + rewardNFT.mintNFT(msg.sender); + } + } + + // Owner can withdraw funds if the funding goal is met. + function withdrawFunds() public onlyOwner { + require(totalFunds >= fundingGoal, "Funding goal not met"); + uint256 amount = address(this).balance; + totalFunds = 0; // Resetting total funds (note: individual contributions remain recorded) + payable(owner).transfer(amount); + } + + // Fallback to allow direct ETH transfers. + receive() external payable { + contribute(); + } + + /* + Example Usage: + - A contributor sends 1 ETH via contribute(). If nftThreshold is set to 0.5 ETH, + they receive both ERC20 tokens (1 ETH * tokenConversionRate) and an NFT. + - Once total funds reach or exceed fundingGoal, the owner can call withdrawFunds(). + */ +} diff --git a/contracts/CrowdFunding/CrowdNFT.sol b/contracts/CrowdFunding/CrowdNFT.sol new file mode 100644 index 00000000..6a7fc323 --- /dev/null +++ b/contracts/CrowdFunding/CrowdNFT.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract CrowdNFT is ERC721, Ownable { + uint256 public tokenCounter; + address public minter; + + constructor() ERC721("CrowdNFT", "CNFT") Ownable(msg.sender) { + tokenCounter = 0; + } + + // Set the authorized minter (typically the crowdfunding contract). + function setMinter(address _minter) external onlyOwner { + minter = _minter; + } + + // Mint a new NFT to the specified address; only the authorized minter can call. + function mintNFT(address to) external returns (uint256) { + require(msg.sender == minter, "Not authorized"); + uint256 newTokenId = tokenCounter; + _safeMint(to, newTokenId); + tokenCounter++; + return newTokenId; + } +} diff --git a/contracts/CrowdFunding/CrowdToken.sol b/contracts/CrowdFunding/CrowdToken.sol new file mode 100644 index 00000000..6288deda --- /dev/null +++ b/contracts/CrowdFunding/CrowdToken.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract CrowdToken is ERC20, Ownable { + address public minter; + + constructor() ERC20("CrowdToken", "CTK") Ownable(msg.sender) { + // Initialization if needed + } + + function setMinter(address _minter) external onlyOwner { + minter = _minter; + } + + // Mint tokens; only the authorized minter can call. + function mint(address to, uint256 amount) external { + require(msg.sender == minter, "Not authorized"); + _mint(to, amount); + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1c868eb5..eb8ac228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "Cohort-6", "dependencies": { - "@nomicfoundation/hardhat-chai-matchers": "^2.0.7" + "@nomicfoundation/hardhat-chai-matchers": "^2.0.7", + "@openzeppelin/contracts": "^5.2.0" }, "devDependencies": { "@nomicfoundation/hardhat-ignition-ethers": "^0.15.9", @@ -1535,6 +1536,12 @@ "node": ">= 12" } }, + "node_modules/@openzeppelin/contracts": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.2.0.tgz", + "integrity": "sha512-bxjNie5z89W1Ea0NZLZluFh8PrFNn9DH8DQlujEok2yjsOlraUPKID5p1Wk3qdNbf6XkQ1Os2RvfiHrrXLHWKA==", + "license": "MIT" + }, "node_modules/@scure/base": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.7.tgz", diff --git a/package.json b/package.json index 9c36a4b8..2f7d900b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "hardhat": "^2.22.18" }, "dependencies": { - "@nomicfoundation/hardhat-chai-matchers": "^2.0.7" + "@nomicfoundation/hardhat-chai-matchers": "^2.0.7", + "@openzeppelin/contracts": "^5.2.0" }, "scripts": { "format": "prettier --write \"test/**/*.js\"", diff --git a/submissions/assignment-3.md b/submissions/assignment-3.md new file mode 100644 index 00000000..0d38d077 --- /dev/null +++ b/submissions/assignment-3.md @@ -0,0 +1,2 @@ +# Assignment 3 +Here's a link to [assignment 3](../contracts/crowdFunding) \ No newline at end of file diff --git a/test/CrowdFunding.js b/test/CrowdFunding.js new file mode 100644 index 00000000..aac913b5 --- /dev/null +++ b/test/CrowdFunding.js @@ -0,0 +1,95 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +describe("Crowdfunding", function () { + let owner, user1, user2; + let crowdfunding, crowdToken, crowdNFT; + const fundingGoal = ethers.parseEther("5"); // 5 ETH goal + const nftThreshold = ethers.parseEther("1"); // 1 ETH for NFT reward + const tokenConversionRate = 1000; // 1000 CTK per ETH + + beforeEach(async function () { + [owner, user1, user2] = await ethers.getSigners(); + + // Deploy ERC20 token + const CrowdToken = await ethers.getContractFactory("CrowdToken"); + crowdToken = await CrowdToken.deploy(); + await crowdToken.waitForDeployment(); + + // Deploy NFT contract + const CrowdNFT = await ethers.getContractFactory("CrowdNFT"); + crowdNFT = await CrowdNFT.deploy(); + await crowdNFT.waitForDeployment(); + + // Deploy Crowdfunding contract + const Crowdfunding = await ethers.getContractFactory("Crowdfunding"); + crowdfunding = await Crowdfunding.deploy( + fundingGoal, + nftThreshold, + tokenConversionRate, + await crowdToken.getAddress(), + await crowdNFT.getAddress() + ); + await crowdfunding.waitForDeployment(); + + // Set Crowdfunding contract as minter + await crowdToken.setMinter(await crowdfunding.getAddress()); + await crowdNFT.setMinter(await crowdfunding.getAddress()); + }); + + it("should deploy with correct initial values", async function () { + expect(await crowdfunding.fundingGoal()).to.equal(fundingGoal); + expect(await crowdfunding.nftThreshold()).to.equal(nftThreshold); + expect(await crowdfunding.tokenConversionRate()).to.equal(tokenConversionRate); + expect(await crowdfunding.owner()).to.equal(owner.address); + }); + + it("should allow users to contribute and receive ERC20 rewards", async function () { + const contribution = ethers.parseEther("2"); // 2 ETH + + await crowdfunding.connect(user1).contribute({ value: contribution }); + + expect(await crowdfunding.contributions(user1.address)).to.equal(contribution); + expect(await crowdToken.balanceOf(user1.address)).to.equal(contribution * BigInt(tokenConversionRate)); + }); + + it("should mint an NFT if contribution meets the threshold", async function () { + const contribution = ethers.parseEther("1.5"); // 1.5 ETH (above threshold) + + await crowdfunding.connect(user1).contribute({ value: contribution }); + + expect(await crowdNFT.balanceOf(user1.address)).to.equal(1); + }); + + it("should not mint an NFT if contribution is below threshold", async function () { + const contribution = ethers.parseEther("0.5"); // Below NFT threshold + + await crowdfunding.connect(user1).contribute({ value: contribution }); + + expect(await crowdNFT.balanceOf(user1.address)).to.equal(0); + }); + + it("should allow the owner to withdraw funds when goal is met", async function () { + const contribution = ethers.parseEther("5"); // Fundraising goal met + + await crowdfunding.connect(user1).contribute({ value: contribution }); + + const ownerBalanceBefore = await ethers.provider.getBalance(owner.address); + await crowdfunding.connect(owner).withdrawFunds(); + const ownerBalanceAfter = await ethers.provider.getBalance(owner.address); + + expect(ownerBalanceAfter).to.be.greaterThan(ownerBalanceBefore); + }); + + it("should not allow withdrawal if funding goal is not met", async function () { + const contribution = ethers.parseEther("2"); // Below goal + + await crowdfunding.connect(user1).contribute({ value: contribution }); + + await expect(crowdfunding.connect(owner).withdrawFunds()).to.be.revertedWith("Funding goal not met"); + }); + + it("should reject contributions of 0 ETH", async function () { + await expect(crowdfunding.connect(user1).contribute({ value: 0 })).to.be.revertedWith("Send ETH to contribute"); + }); +});