From 7a6a04f7185c5ddb051164728b9c163d7cc42303 Mon Sep 17 00:00:00 2001 From: xszxc <2112067692@qq.com> Date: Mon, 7 Apr 2025 10:25:08 +0800 Subject: [PATCH 1/3] add NFT rewards --- app/rewards/page.tsx | 237 +++++- contracts/CFCNFT.sol | 38 + contracts/CFCToken.sol | 48 ++ contracts/CFCoLearning.sol | 554 +++++++++++++ contracts/ProjectManager.sol | 315 ++++++++ lib/contracts/{CFCToke.json => CFCNFT.json} | 446 +++++------ lib/contracts/CFCToken.json | 425 ++++++++++ lib/contracts/RewardToken.json | 811 ++++++++++++++++++-- lib/contracts/contract-config.ts | 10 +- lib/hooks/useERC20Token.ts | 2 +- lib/hooks/useProjectNFT.ts | 237 ++++++ metadata-examples/fullstack-compass.json | 51 ++ metadata-examples/missing-semester.json | 51 ++ scripts/deploy-integrated-contracts.js | 78 ++ 14 files changed, 2967 insertions(+), 336 deletions(-) create mode 100644 contracts/CFCNFT.sol create mode 100644 contracts/CFCToken.sol create mode 100644 contracts/CFCoLearning.sol create mode 100644 contracts/ProjectManager.sol rename lib/contracts/{CFCToke.json => CFCNFT.json} (71%) create mode 100644 lib/contracts/CFCToken.json create mode 100644 lib/hooks/useProjectNFT.ts create mode 100644 metadata-examples/fullstack-compass.json create mode 100644 metadata-examples/missing-semester.json create mode 100644 scripts/deploy-integrated-contracts.js diff --git a/app/rewards/page.tsx b/app/rewards/page.tsx index 92a5952..d42a6bf 100644 --- a/app/rewards/page.tsx +++ b/app/rewards/page.tsx @@ -5,9 +5,10 @@ import { useAppKitAccount } from "@reown/appkit/react"; import { useAppKit } from "@reown/appkit/react"; import { useRewardToken } from "@/lib/hooks/useRewardToken"; import { useERC20Token } from "@/lib/hooks/useERC20Token"; +import { useProjectNFT } from "@/lib/hooks/useProjectNFT"; import { formatDistanceToNow } from "date-fns"; import { zhCN } from "date-fns/locale"; -import { Coins, History, Wallet, ChevronRight, Gift, Clock, ExternalLink, Plus, Info } from "lucide-react"; +import { Coins, History, Wallet, ChevronRight, Gift, Clock, ExternalLink, Plus, Info, Award, CheckCircle, XCircle, Medal } from "lucide-react"; import { sepolia } from "wagmi/chains"; export default function RewardsPage() { @@ -17,21 +18,31 @@ export default function RewardsPage() { availableRewards, lastClaimTime, claimRewards, - isLoading, - isSuccess, - errorMessage, + isLoading: isRewardLoading, + isSuccess: isRewardSuccess, + errorMessage: rewardErrorMessage, isSepoliaNetwork } = useRewardToken(); const { balance, } = useERC20Token(); + const { + projectsStatus, + mintProjectNFT, + isLoading: isNFTLoading, + isMintSuccess, + errorMessage: nftErrorMessage, + } = useProjectNFT(); const [showSuccessMessage, setShowSuccessMessage] = useState(false); const [showNetworkInfo, setShowNetworkInfo] = useState(false); const [switchError, setSwitchError] = useState(null); + const [showNFTSuccess, setShowNFTSuccess] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [activeTab, setActiveTab] = useState<'rewards' | 'nfts'>('rewards'); // 处理成功领取后的提示 useEffect(() => { - if (isSuccess) { + if (isRewardSuccess) { setShowSuccessMessage(true); const timer = setTimeout(() => { setShowSuccessMessage(false); @@ -39,7 +50,30 @@ export default function RewardsPage() { return () => clearTimeout(timer); } - }, [isSuccess]); + }, [isRewardSuccess]); + + // 处理NFT铸造成功提示 + useEffect(() => { + if (isMintSuccess) { + setShowNFTSuccess(true); + const timer = setTimeout(() => { + setShowNFTSuccess(false); + }, 3000); + + return () => clearTimeout(timer); + } + }, [isMintSuccess]); + + // 处理错误信息 + useEffect(() => { + if (rewardErrorMessage) { + setErrorMsg(rewardErrorMessage); + } else if (nftErrorMessage) { + setErrorMsg(nftErrorMessage); + } else { + setErrorMsg(null); + } + }, [rewardErrorMessage, nftErrorMessage]); // 格式化上次领取时间 const formattedLastClaimTime = lastClaimTime ? @@ -94,7 +128,7 @@ export default function RewardsPage() {

连接您的钱包

- 请先连接您的钱包以查看和领取奖励代币 + 请先连接您的钱包以查看和领取奖励代币以及项目成就NFT

+ + + ))} + + )} + +
+

关于成就NFT

+

+ 成就NFT是对您完成社区学习项目的永久记录和认证。每个NFT代表您在特定项目中的参与和成就, + 并可作为您学习历程的证明。项目得分达到通过标准即可铸造对应的专属NFT。 +

+
+ + ); + + // 渲染内容区域标签 + const renderTabs = () => ( +
+
+ + + +
+
+ ); // 渲染合适的内容 const renderContent = () => { @@ -243,7 +429,12 @@ export default function RewardsPage() { } else if (!isSepoliaNetwork) { return renderNetworkSwitchPrompt(); } else { - return renderRewardsCard(); + return ( + <> + {renderTabs()} + {activeTab === 'rewards' ? renderRewardsCard() : renderNFTCards()} + + ); } }; @@ -256,7 +447,7 @@ export default function RewardsPage() { CFC 奖励中心

- 参与社区活动和贡献代码,获取 CFC 代币奖励。这些代币可用于社区治理和解锁特殊功能。 + 参与社区活动和贡献代码,获取 CFC 代币奖励和专属成就NFT。这些奖励证明了您的技能和贡献。

@@ -264,13 +455,13 @@ export default function RewardsPage() { {renderContent()} {/* 错误信息 */} - {errorMessage && ( + {errorMsg && (
- {errorMessage} + {errorMsg}
)} - {/* 成功信息 */} + {/* 成功信息 - 代币 */} {showSuccessMessage && (
@@ -282,6 +473,18 @@ export default function RewardsPage() {
)} + {/* 成功信息 - NFT */} + {showNFTSuccess && ( +
+
+ + + +
+ 您已成功铸造项目成就NFT! +
+ )} + {/* 奖励说明 */}

如何获取更多奖励

@@ -291,8 +494,8 @@ export default function RewardsPage() { 1
-

参与 Hackathon 活动

-

根据项目完成情况和评分,获得相应的代币奖励。

+

参与共学项目

+

完成社区共学项目任务,获得代币奖励和专属项目成就NFT。

  • diff --git a/contracts/CFCNFT.sol b/contracts/CFCNFT.sol new file mode 100644 index 0000000..ae6d461 --- /dev/null +++ b/contracts/CFCNFT.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract CFCNFT is ERC721URIStorage, Ownable { + uint256 private _nextTokenId; + + constructor() ERC721("CFCNFT", "CFCNFT") { + _nextTokenId = 1; // tokenId 从 1 开始 + } + + /// @notice 仅限管理员调用,铸造 NFT 并记录 IPFS URI + function mintNFT(address to, string calldata tokenURI_) external onlyOwner returns (uint256) { + uint256 tokenId = _nextTokenId; + _nextTokenId++; + _mint(to, tokenId); + _setTokenURI(tokenId, tokenURI_); + return tokenId; + } + + /// @notice 批量铸造NFT,用于项目结算时批量发放 + function batchMintNFT(address[] calldata recipients, string[] calldata tokenURIs) external onlyOwner returns (uint256[] memory) { + require(recipients.length == tokenURIs.length, "Recipients and URIs length mismatch"); + uint256[] memory tokenIds = new uint256[](recipients.length); + + for (uint256 i = 0; i < recipients.length; i++) { + uint256 tokenId = _nextTokenId; + _nextTokenId++; + _mint(recipients[i], tokenId); + _setTokenURI(tokenId, tokenURIs[i]); + tokenIds[i] = tokenId; + } + + return tokenIds; + } +} \ No newline at end of file diff --git a/contracts/CFCToken.sol b/contracts/CFCToken.sol new file mode 100644 index 0000000..9337a4d --- /dev/null +++ b/contracts/CFCToken.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.25; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +contract CFCToken is ERC20, ERC20Permit, ERC20Votes, Ownable { + constructor() ERC20("CoLearnToken", "CLT") ERC20Permit("CoLearnToken") {} + + /// @notice 仅限管理员铸造代币 + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } + + // 以下覆盖函数用于同时更新 ERC20 与 ERC20Votes 的状态 + + function _afterTokenTransfer(address from, address to, uint256 amount) + internal override(ERC20, ERC20Votes) + { + super._afterTokenTransfer(from, to, amount); + } + + function _mint(address to, uint256 amount) + internal override(ERC20, ERC20Votes) + { + super._mint(to, amount); + } + + function _burn(address account, uint256 amount) + internal override(ERC20, ERC20Votes) + { + super._burn(account, amount); + } + + // 覆盖 _update 解决多重继承问题 + function _update(address from, address to, uint256 amount) + internal override(ERC20, ERC20Votes) + { + super._update(from, to, amount); + } + + // 重写 nonces,消除 ERC20Permit 与 Nonces 的冲突 + function nonces(address owner) public view override(ERC20Permit) returns (uint256) { + return super.nonces(owner); + } +} \ No newline at end of file diff --git a/contracts/CFCoLearning.sol b/contracts/CFCoLearning.sol new file mode 100644 index 0000000..3c55cc2 --- /dev/null +++ b/contracts/CFCoLearning.sol @@ -0,0 +1,554 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/** + * @title ProjectNFTManager + * @dev 整合项目管理、奖励发放和NFT认证的合约 + * 管理用户完成项目的状态,分发CFC代币奖励,并铸造成就NFT + */ +contract ProjectNFTManager is Ownable { + using SafeERC20 for IERC20; + + // CFC代币合约 + IERC20 public cfcToken; + + // NFT合约接口 + IERC721 public nftContract; + + // 用户上次领取奖励的时间 + mapping(address => uint256) private _lastClaimTime; + + // 用户可获得的奖励数量 + mapping(address => uint256) private _availableRewards; + + // 奖励锁定期 + uint256 public constant CLAIM_COOLDOWN = 1 minutes; + + // 项目状态枚举 + enum ProjectStatus { ACTIVE, COMPLETED, ARCHIVED } + + // 项目信息结构 + struct Project { + string id; // 项目标识符 + string name; // 项目名称 + string description; // 项目描述 + uint256 startTime; // 开始时间 + uint256 endTime; // 结束时间 + uint256 rewardAmount; // 完成项目奖励的代币数量 + ProjectStatus status; // 项目状态 + uint256 requiredTaskCount; // 需要完成的任务数量 + uint256 passingScore; // 通过所需的最低分数 (百分比,例如:70表示70%) + } + + // 用户完成项目的记录 + struct UserProjectStatus { + bool registered; // 是否注册参与 + uint256 completedTasks; // 已完成的任务数 + uint256 score; // 项目得分 + bool hasClaimedReward; // 是否已领取代币奖励 + bool hasClaimedNFT; // 是否已铸造NFT + } + + // 所有项目 + Project[] public projects; + + // 项目ID到项目索引的映射 + mapping(string => uint256) private projectIdToIndex; + + // 用户地址 => 项目ID => 用户项目状态 + mapping(address => mapping(string => UserProjectStatus)) public userProjects; + + // 事件定义 + // 项目相关事件 + event ProjectCreated(string indexed projectId, string name, uint256 startTime, uint256 endTime); + event ProjectUpdated(string indexed projectId, ProjectStatus status); + event UserRegistered(address indexed user, string indexed projectId); + event TaskCompleted(address indexed user, string indexed projectId, uint256 taskCount); + event ProjectScoreUpdated(address indexed user, string indexed projectId, uint256 score); + + // 奖励相关事件 + event RewardAdded(address indexed user, uint256 amount); + event RewardClaimed(address indexed user, uint256 amount); + event ProjectRewardClaimed(address indexed user, string indexed projectId, uint256 amount); + + // NFT相关事件 + event NFTClaimed(address indexed user, string indexed projectId, uint256 tokenId); + event TokenContractUpdated(address indexed newTokenContract); + event NFTContractUpdated(address indexed newNFTContract); + + /** + * @dev 构造函数 + * @param tokenAddress CFC代币合约地址 + * @param nftAddress 成就NFT合约地址 + */ + constructor(address tokenAddress, address nftAddress) Ownable(msg.sender) { + require(tokenAddress != address(0), "Token address cannot be zero"); + cfcToken = IERC20(tokenAddress); + + if (nftAddress != address(0)) { + nftContract = IERC721(nftAddress); + } + } + + /** + * @dev 更新代币合约地址 + * @param newTokenAddress 新的代币合约地址 + */ + function updateTokenContract(address newTokenAddress) external onlyOwner { + require(newTokenAddress != address(0), "Token address cannot be zero"); + cfcToken = IERC20(newTokenAddress); + emit TokenContractUpdated(newTokenAddress); + } + + /** + * @dev 更新NFT合约地址 + * @param newNFTAddress 新的NFT合约地址 + */ + function updateNFTContract(address newNFTAddress) external onlyOwner { + require(newNFTAddress != address(0), "NFT address cannot be zero"); + nftContract = IERC721(newNFTAddress); + emit NFTContractUpdated(newNFTAddress); + } + + /** + * @dev 创建新项目 + * @param _id 项目ID + * @param _name 项目名称 + * @param _description 项目描述 + * @param _startTime 开始时间 + * @param _endTime 结束时间 + * @param _rewardAmount 奖励代币数量 + * @param _requiredTaskCount 所需完成的任务数量 + * @param _passingScore 及格分数(百分比) + */ + function createProject( + string calldata _id, + string calldata _name, + string calldata _description, + uint256 _startTime, + uint256 _endTime, + uint256 _rewardAmount, + uint256 _requiredTaskCount, + uint256 _passingScore + ) external onlyOwner { + require(bytes(_id).length > 0, "Item ID cannot be empty"); + require(_startTime < _endTime, "Start time must be earlier than end time"); + require(_passingScore <= 100, "The passing score cannot exceed 100"); + require(projectIdToIndex[_id] == 0, "Project ID already exists"); + + Project memory newProject = Project({ + id: _id, + name: _name, + description: _description, + startTime: _startTime, + endTime: _endTime, + rewardAmount: _rewardAmount, + status: ProjectStatus.ACTIVE, + requiredTaskCount: _requiredTaskCount, + passingScore: _passingScore + }); + + projects.push(newProject); + // 索引从1开始,避免与默认值0冲突 + projectIdToIndex[_id] = projects.length; + + emit ProjectCreated(_id, _name, _startTime, _endTime); + } + + /** + * @dev 更新项目状态 + * @param _projectId 项目ID + * @param _status 新状态 + */ + function updateProjectStatus(string calldata _projectId, ProjectStatus _status) external onlyOwner { + uint256 projectIndex = getProjectIndex(_projectId); + projects[projectIndex].status = _status; + + emit ProjectUpdated(_projectId, _status); + } + + /** + * @dev 用户注册参与项目 + * @param user 用户地址 + * @param _projectId 项目ID + */ + function registerForProject(address user, string calldata _projectId) external { + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(project.status == ProjectStatus.ACTIVE, "Project is not active"); + require(block.timestamp >= project.startTime, "Project not started yet"); + require(block.timestamp <= project.endTime, "project is closed"); + require(!userProjects[user][_projectId].registered, "This item is already registered"); + + userProjects[user][_projectId] = UserProjectStatus({ + registered: true, + completedTasks: 0, + score: 0, + hasClaimedReward: false, + hasClaimedNFT: false + }); + + emit UserRegistered(user, _projectId); + } + + /** + * @dev 更新用户完成的任务数量(由管理员调用) + * @param _user 用户地址 + * @param _projectId 项目ID + * @param _taskCount 已完成任务数 + */ + function updateCompletedTasks(address _user, string calldata _projectId, uint256 _taskCount) external onlyOwner { + require(userProjects[_user][_projectId].registered, "User not registered for this item"); + + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(_taskCount <= project.requiredTaskCount, "The number of completed tasks cannot exceed the requirements"); + + userProjects[_user][_projectId].completedTasks = _taskCount; + + // 更新得分 + uint256 score = (_taskCount * 100) / project.requiredTaskCount; + userProjects[_user][_projectId].score = score; + + emit TaskCompleted(_user, _projectId, _taskCount); + emit ProjectScoreUpdated(_user, _projectId, score); + } + + /** + * @dev 手动设置用户项目得分(由管理员调用) + * @param _user 用户地址 + * @param _projectId 项目ID + * @param _score 得分(0-100) + */ + function setProjectScore(address _user, string calldata _projectId, uint256 _score) external onlyOwner { + require(userProjects[_user][_projectId].registered, "User not registered for this item"); + require(_score <= 100, "Scores cannot exceed 100"); + + userProjects[_user][_projectId].score = _score; + + emit ProjectScoreUpdated(_user, _projectId, _score); + } + + /** + * @dev 为指定用户添加奖励(不关联特定项目) + * @param user 用户地址 + * @param amount 奖励数量 + */ + function addReward(address user, uint256 amount) external onlyOwner { + require(user != address(0), "invalid address"); + require(amount > 0, "Quantity must be greater than 0"); + + _availableRewards[user] += amount; + + emit RewardAdded(user, amount); + } + + /** + * @dev 批量为用户添加奖励 + * @param users 用户地址数组 + * @param amounts 奖励数量数组 + */ + function batchAddRewards(address[] calldata users, uint256[] calldata amounts) external onlyOwner { + require(users.length == amounts.length, "Array length mismatch"); + require(users.length > 0, "Array cannot be empty"); + + for (uint256 i = 0; i < users.length; i++) { + if (users[i] != address(0) && amounts[i] > 0) { + _availableRewards[users[i]] += amounts[i]; + emit RewardAdded(users[i], amounts[i]); + } + } + } + + /** + * @dev 验证用户是否有资格铸造NFT + * @param _user 用户地址 + * @param _projectId 项目ID + * @return 是否有资格 + */ + function canMintNFT(address _user, string calldata _projectId) external view returns (bool) { + if (!userProjects[_user][_projectId].registered) { + return false; + } + + if (userProjects[_user][_projectId].hasClaimedNFT) { + return false; + } + + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + // 检查用户是否已经达到通过标准 + return userProjects[_user][_projectId].score >= project.passingScore; + } + + /** + * @dev 用户领取项目完成奖励 + * @param _projectId 项目ID + * @return 是否成功 + */ + function claimProjectReward(string calldata _projectId) external returns (bool) { + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(userProjects[msg.sender][_projectId].registered, "This item is not registered"); + require(userProjects[msg.sender][_projectId].score >= project.passingScore, "Failure to achieve a passing grade"); + require(!userProjects[msg.sender][_projectId].hasClaimedReward, "Reward received"); + + userProjects[msg.sender][_projectId].hasClaimedReward = true; + + // 发放代币奖励 + bool success = _transferReward(msg.sender, project.rewardAmount); + require(success, "Token transfer failed"); + + emit ProjectRewardClaimed(msg.sender, _projectId, project.rewardAmount); + return true; + } + + /** + * @dev 用户铸造项目完成NFT + * @param _projectId 项目id + * @param _tokenId NFT ID + * @return 是否成功 + */ + function mintProjectNFT(string calldata _projectId, string calldata _tokenId) external returns (bool) { + require(address(nftContract) != address(0), "NFT contract not set"); + + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(userProjects[msg.sender][_projectId].registered, "This item is not registered"); + require(userProjects[msg.sender][_projectId].score >= project.passingScore, "Failure to achieve a passing grade"); + require(!userProjects[msg.sender][_projectId].hasClaimedNFT, "Cast NFT"); + + // 标记为已铸造NFT + userProjects[msg.sender][_projectId].hasClaimedNFT = true; + + (bool success, bytes memory data) = address(nftContract).call( + abi.encodeWithSignature("safeTransferFrom(address,address,uint256)", msg.sender, _projectId) + ); + require(success, "NFT casting failure"); + + uint256 tokenId = abi.decode(data, (uint256)); + emit NFTClaimed(msg.sender, _projectId, tokenId); + + return true; + } + + /** + * @dev 用户领取非项目相关的通用奖励 + * @return 是否成功领取 + */ + function claimRewards() external returns (bool) { + uint256 amount = _availableRewards[msg.sender]; + require(amount > 0, "There are no rewards available."); + + // 检查冷却期 + if (_lastClaimTime[msg.sender] > 0) { + require( + block.timestamp >= _lastClaimTime[msg.sender] + CLAIM_COOLDOWN, + "The cooling-off period has not expired" + ); + } + + // 更新状态(先更新状态再转账,防止重入攻击) + _availableRewards[msg.sender] = 0; + _lastClaimTime[msg.sender] = block.timestamp; + + // 转移代币 + bool success = _transferReward(msg.sender, amount); + require(success, "Token transfer failed"); + + emit RewardClaimed(msg.sender, amount); + return true; + } + + /** + * @dev 内部函数,处理奖励转账逻辑 + * @param to 接收地址 + * @param amount 转账数量 + * @return 是否成功 + */ + function _transferReward(address to, uint256 amount) internal returns (bool) { + // 尝试从合约余额中转账 + uint256 contractBalance = cfcToken.balanceOf(address(this)); + if (contractBalance >= amount) { + // 如果合约余额足够,直接转账 + cfcToken.safeTransfer(to, amount); + return true; + } else { + // 如果合约余额不足,尝试从owner转账 + try cfcToken.transferFrom(owner(), to, amount) { + return true; + } catch { + return false; + } + } + } + + /** + * @dev 存入代币到合约(用于后续奖励发放) + * @param amount 存入数量 + */ + function depositTokens(uint256 amount) external { + require(amount > 0, "Quantity must be greater than 0"); + cfcToken.safeTransferFrom(msg.sender, address(this), amount); + } + + /** + * @dev 查询用户可领取的非项目相关奖励 + * @param account 用户地址 + * @return 可领取的奖励数量 + */ + function availableRewards(address account) external view returns (uint256) { + return _availableRewards[account]; + } + + /** + * @dev 查询用户上次领取奖励的时间 + * @param account 用户地址 + * @return 上次领取时间(时间戳) + */ + function lastClaimTime(address account) external view returns (uint256) { + return _lastClaimTime[account]; + } + + /** + * @dev 检查用户当前是否可以领取通用奖励 + * @param account 用户地址 + * @return 是否可以领取 + */ + function canClaim(address account) external view returns (bool) { + if (_availableRewards[account] == 0) { + return false; + } + + if (_lastClaimTime[account] == 0) { + return true; + } + + return block.timestamp >= _lastClaimTime[account] + CLAIM_COOLDOWN; + } + + /** + * @dev 计算用户还需等待多长时间才能领取通用奖励 + * @param account 用户地址 + * @return 需要等待的时间(秒) + */ + function timeUntilNextClaim(address account) external view returns (uint256) { + if (_availableRewards[account] == 0 || _lastClaimTime[account] == 0) { + return 0; + } + + uint256 nextClaimTime = _lastClaimTime[account] + CLAIM_COOLDOWN; + if (block.timestamp >= nextClaimTime) { + return 0; + } + + return nextClaimTime - block.timestamp; + } + + /** + * @dev 获取用户项目状态 + * @param _user 用户地址 + * @param _projectId 项目ID + * @return registered 是否注册 + * @return completedTasks 已完成任务数 + * @return score 分数 + * @return hasClaimedReward 是否已领取奖励 + * @return hasClaimedNFT 是否已铸造NFT + */ + function getUserProjectStatus(address _user, string calldata _projectId) external view returns ( + bool registered, + uint256 completedTasks, + uint256 score, + bool hasClaimedReward, + bool hasClaimedNFT + ) { + UserProjectStatus memory status = userProjects[_user][_projectId]; + return ( + status.registered, + status.completedTasks, + status.score, + status.hasClaimedReward, + status.hasClaimedNFT + ); + } + + /** + * @dev 获取项目数量 + * @return 项目数量 + */ + function getProjectCount() external view returns (uint256) { + return projects.length; + } + + /** + * @dev 获取项目信息 + * @param _projectId 项目ID + * @return id 项目ID + * @return name 项目名称 + * @return description 项目描述 + * @return startTime 开始时间 + * @return endTime 结束时间 + * @return rewardAmount 奖励数量 + * @return status 项目状态 + * @return requiredTaskCount 所需任务数 + * @return passingScore 及格分数 + */ + function getProjectDetails(string calldata _projectId) external view returns ( + string memory id, + string memory name, + string memory description, + uint256 startTime, + uint256 endTime, + uint256 rewardAmount, + ProjectStatus status, + uint256 requiredTaskCount, + uint256 passingScore + ) { + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + return ( + project.id, + project.name, + project.description, + project.startTime, + project.endTime, + project.rewardAmount, + project.status, + project.requiredTaskCount, + project.passingScore + ); + } + + /** + * @dev 检索项目索引 + * @param _projectId 项目ID + * @return 项目索引 + */ + function getProjectIndex(string calldata _projectId) public view returns (uint256) { + uint256 index = projectIdToIndex[_projectId]; + require(index > 0, "item does not exist"); + return index - 1; + } + + /** + * @dev 紧急取回合约中的代币(仅限合约拥有者) + * @param amount 取回数量 + */ + function emergencyWithdraw(uint256 amount) external onlyOwner { + require(amount > 0, "Quantity must be greater than 0"); + uint256 balance = cfcToken.balanceOf(address(this)); + require(amount <= balance, "not sufficient funds"); + + cfcToken.safeTransfer(owner(), amount); + } +} \ No newline at end of file diff --git a/contracts/ProjectManager.sol b/contracts/ProjectManager.sol new file mode 100644 index 0000000..7d638d9 --- /dev/null +++ b/contracts/ProjectManager.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/access/Ownable.sol"; +import "./CFCNFT.sol"; +import "./CFCToken.sol"; + +/** + * @title ProjectManager + * @dev 管理社区学习项目及用户完成状态 + * 验证用户铸造NFT的资格,并在用户完成项目时发放奖励 + */ +contract ProjectManager is Ownable { + // NFT合约地址 + CFCNFT public nftContract; + + // 代币合约地址 + CFCToken public tokenContract; + + // 项目状态 + enum ProjectStatus { ACTIVE, COMPLETED, ARCHIVED } + + // 项目信息结构 + struct Project { + string id; // 项目标识符 + string name; // 项目名称 + string description; // 项目描述 + uint256 startTime; // 开始时间 + uint256 endTime; // 结束时间 + uint256 rewardAmount; // 完成项目奖励的代币数量 + ProjectStatus status; // 项目状态 + uint256 requiredTaskCount; // 需要完成的任务数量 + uint256 passingScore; // 通过所需的最低分数 (百分比,例如:70表示70%) + } + + // 用户完成项目的记录 + struct UserProjectStatus { + bool registered; // 是否注册参与 + uint256 completedTasks; // 已完成的任务数 + uint256 score; // 项目得分 + bool hasClaimedReward; // 是否已领取代币奖励 + bool hasClaimedNFT; // 是否已铸造NFT + } + + // 所有项目 + Project[] public projects; + + // 项目ID到项目索引的映射 + mapping(string => uint256) private projectIdToIndex; + + // 用户地址 => 项目ID => 用户项目状态 + mapping(address => mapping(string => UserProjectStatus)) public userProjects; + + // 事件定义 + event ProjectCreated(string indexed projectId, string name, uint256 startTime, uint256 endTime); + event ProjectUpdated(string indexed projectId, ProjectStatus status); + event UserRegistered(address indexed user, string indexed projectId); + event TaskCompleted(address indexed user, string indexed projectId, uint256 taskCount); + event ProjectScoreUpdated(address indexed user, string indexed projectId, uint256 score); + event RewardClaimed(address indexed user, string indexed projectId, uint256 amount); + event NFTClaimed(address indexed user, string indexed projectId, uint256 tokenId); + + /** + * @dev 构造函数 + */ + constructor() Ownable(msg.sender) {} + + /** + * @dev 设置NFT合约地址 + * @param _nftContract NFT合约地址 + */ + function setNFTContract(address _nftContract) external onlyOwner { + nftContract = CFCNFT(_nftContract); + } + + /** + * @dev 设置代币合约地址 + * @param _tokenContract 代币合约地址 + */ + function setTokenContract(address _tokenContract) external onlyOwner { + tokenContract = CFCToken(_tokenContract); + } + + /** + * @dev 创建新项目 + * @param _id 项目ID + * @param _name 项目名称 + * @param _description 项目描述 + * @param _startTime 开始时间 + * @param _endTime 结束时间 + * @param _rewardAmount 奖励代币数量 + * @param _requiredTaskCount 所需完成的任务数量 + * @param _passingScore 及格分数(百分比) + */ + function createProject( + string calldata _id, + string calldata _name, + string calldata _description, + uint256 _startTime, + uint256 _endTime, + uint256 _rewardAmount, + uint256 _requiredTaskCount, + uint256 _passingScore + ) external onlyOwner { + require(bytes(_id).length > 0, "项目ID不能为空"); + require(_endTime > _startTime, "结束时间必须晚于开始时间"); + require(_passingScore <= 100, "及格分数不能超过100%"); + require(projectIdToIndex[_id] == 0, "项目ID已存在"); + + Project memory newProject = Project({ + id: _id, + name: _name, + description: _description, + startTime: _startTime, + endTime: _endTime, + rewardAmount: _rewardAmount, + status: ProjectStatus.ACTIVE, + requiredTaskCount: _requiredTaskCount, + passingScore: _passingScore + }); + + projects.push(newProject); + projectIdToIndex[_id] = projects.length; + + emit ProjectCreated(_id, _name, _startTime, _endTime); + } + + /** + * @dev 更新项目状态 + * @param _projectId 项目ID + * @param _status 新状态 + */ + function updateProjectStatus(string calldata _projectId, ProjectStatus _status) external onlyOwner { + uint256 projectIndex = getProjectIndex(_projectId); + projects[projectIndex].status = _status; + + emit ProjectUpdated(_projectId, _status); + } + + /** + * @dev 用户注册参与项目 + * @param _projectId 项目ID + */ + function registerForProject(string calldata _projectId) external { + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(project.status == ProjectStatus.ACTIVE, "项目不处于活跃状态"); + require(block.timestamp >= project.startTime, "项目尚未开始"); + require(block.timestamp <= project.endTime, "项目已结束"); + require(!userProjects[msg.sender][_projectId].registered, "已注册此项目"); + + userProjects[msg.sender][_projectId] = UserProjectStatus({ + registered: true, + completedTasks: 0, + score: 0, + hasClaimedReward: false, + hasClaimedNFT: false + }); + + emit UserRegistered(msg.sender, _projectId); + } + + /** + * @dev 更新用户完成的任务数量(由管理员调用) + * @param _user 用户地址 + * @param _projectId 项目ID + * @param _taskCount 已完成任务数 + */ + function updateCompletedTasks(address _user, string calldata _projectId, uint256 _taskCount) external onlyOwner { + require(userProjects[_user][_projectId].registered, "用户未注册此项目"); + + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(_taskCount <= project.requiredTaskCount, "完成任务数不能超过要求"); + + userProjects[_user][_projectId].completedTasks = _taskCount; + + // 更新得分 + uint256 score = (_taskCount * 100) / project.requiredTaskCount; + userProjects[_user][_projectId].score = score; + + emit TaskCompleted(_user, _projectId, _taskCount); + emit ProjectScoreUpdated(_user, _projectId, score); + } + + /** + * @dev 手动设置用户项目得分(由管理员调用) + * @param _user 用户地址 + * @param _projectId 项目ID + * @param _score 得分(0-100) + */ + function setProjectScore(address _user, string calldata _projectId, uint256 _score) external onlyOwner { + require(userProjects[_user][_projectId].registered, "用户未注册此项目"); + require(_score <= 100, "分数不能超过100"); + + userProjects[_user][_projectId].score = _score; + + emit ProjectScoreUpdated(_user, _projectId, _score); + } + + /** + * @dev 验证用户是否有资格铸造NFT + * @param _user 用户地址 + * @param _projectId 项目ID + * @return 是否有资格 + */ + function canMintNFT(address _user, string calldata _projectId) external view returns (bool) { + if (!userProjects[_user][_projectId].registered) { + return false; + } + + if (userProjects[_user][_projectId].hasClaimedNFT) { + return false; + } + + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + // 检查用户是否已经达到通过标准 + return userProjects[_user][_projectId].score >= project.passingScore; + } + + /** + * @dev 用户领取项目完成奖励 + * @param _projectId 项目ID + */ + function claimProjectReward(string calldata _projectId) external { + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(userProjects[msg.sender][_projectId].registered, "未注册此项目"); + require(userProjects[msg.sender][_projectId].score >= project.passingScore, "未达到及格分数"); + require(!userProjects[msg.sender][_projectId].hasClaimedReward, "已领取奖励"); + require(address(tokenContract) != address(0), "代币合约未设置"); + + userProjects[msg.sender][_projectId].hasClaimedReward = true; + + // 发放代币奖励 + tokenContract.mint(msg.sender, project.rewardAmount); + + emit RewardClaimed(msg.sender, _projectId, project.rewardAmount); + } + + /** + * @dev 用户铸造项目完成NFT + * @param _projectId 项目ID + * @return tokenId 铸造的NFT ID + */ + function mintProjectNFT(string calldata _projectId) external returns (uint256) { + uint256 projectIndex = getProjectIndex(_projectId); + Project memory project = projects[projectIndex]; + + require(userProjects[msg.sender][_projectId].registered, "未注册此项目"); + require(userProjects[msg.sender][_projectId].score >= project.passingScore, "未达到及格分数"); + require(!userProjects[msg.sender][_projectId].hasClaimedNFT, "已铸造NFT"); + require(address(nftContract) != address(0), "NFT合约未设置"); + + userProjects[msg.sender][_projectId].hasClaimedNFT = true; + + // 铸造NFT + uint256 tokenId = nftContract.mintNFT(msg.sender, _projectId); + + emit NFTClaimed(msg.sender, _projectId, tokenId); + + return tokenId; + } + + /** + * @dev 获取用户项目状态 + * @param _user 用户地址 + * @param _projectId 项目ID + * @return registered 是否注册 + * @return completedTasks 已完成任务数 + * @return score 分数 + * @return hasClaimedReward 是否已领取奖励 + * @return hasClaimedNFT 是否已铸造NFT + */ + function getUserProjectStatus(address _user, string calldata _projectId) external view returns ( + bool registered, + uint256 completedTasks, + uint256 score, + bool hasClaimedReward, + bool hasClaimedNFT + ) { + UserProjectStatus memory status = userProjects[_user][_projectId]; + return ( + status.registered, + status.completedTasks, + status.score, + status.hasClaimedReward, + status.hasClaimedNFT + ); + } + + /** + * @dev 获取项目数量 + * @return 项目数量 + */ + function getProjectCount() external view returns (uint256) { + return projects.length; + } + + /** + * @dev 检索项目索引 + * @param _projectId 项目ID + * @return 项目索引 + */ + function getProjectIndex(string calldata _projectId) public view returns (uint256) { + uint256 index = projectIdToIndex[_projectId]; + require(index > 0, "项目不存在"); + return index - 1; + } +} \ No newline at end of file diff --git a/lib/contracts/CFCToke.json b/lib/contracts/CFCNFT.json similarity index 71% rename from lib/contracts/CFCToke.json rename to lib/contracts/CFCNFT.json index a876a89..703f638 100644 --- a/lib/contracts/CFCToke.json +++ b/lib/contracts/CFCNFT.json @@ -4,73 +4,41 @@ "stateMutability": "nonpayable", "type": "constructor" }, - { - "inputs": [], - "name": "ECDSAInvalidSignature", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "length", - "type": "uint256" - } - ], - "name": "ECDSAInvalidSignatureLength", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "bytes32", - "name": "s", - "type": "bytes32" - } - ], - "name": "ECDSAInvalidSignatureS", - "type": "error" - }, { "inputs": [ { "internalType": "address", - "name": "spender", + "name": "sender", "type": "address" }, { "internalType": "uint256", - "name": "allowance", + "name": "tokenId", "type": "uint256" }, { - "internalType": "uint256", - "name": "needed", - "type": "uint256" + "internalType": "address", + "name": "owner", + "type": "address" } ], - "name": "ERC20InsufficientAllowance", + "name": "ERC721IncorrectOwner", "type": "error" }, { "inputs": [ { "internalType": "address", - "name": "sender", + "name": "operator", "type": "address" }, { "internalType": "uint256", - "name": "balance", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "needed", + "name": "tokenId", "type": "uint256" } ], - "name": "ERC20InsufficientBalance", + "name": "ERC721InsufficientApproval", "type": "error" }, { @@ -81,88 +49,62 @@ "type": "address" } ], - "name": "ERC20InvalidApprover", + "name": "ERC721InvalidApprover", "type": "error" }, { "inputs": [ { "internalType": "address", - "name": "receiver", + "name": "operator", "type": "address" } ], - "name": "ERC20InvalidReceiver", + "name": "ERC721InvalidOperator", "type": "error" }, { "inputs": [ { "internalType": "address", - "name": "sender", + "name": "owner", "type": "address" } ], - "name": "ERC20InvalidSender", + "name": "ERC721InvalidOwner", "type": "error" }, { "inputs": [ { "internalType": "address", - "name": "spender", + "name": "receiver", "type": "address" } ], - "name": "ERC20InvalidSpender", - "type": "error" - }, - { - "inputs": [ - { - "internalType": "uint256", - "name": "deadline", - "type": "uint256" - } - ], - "name": "ERC2612ExpiredSignature", + "name": "ERC721InvalidReceiver", "type": "error" }, { "inputs": [ { "internalType": "address", - "name": "signer", - "type": "address" - }, - { - "internalType": "address", - "name": "owner", + "name": "sender", "type": "address" } ], - "name": "ERC2612InvalidSigner", + "name": "ERC721InvalidSender", "type": "error" }, { "inputs": [ - { - "internalType": "address", - "name": "account", - "type": "address" - }, { "internalType": "uint256", - "name": "currentNonce", + "name": "tokenId", "type": "uint256" } ], - "name": "InvalidAccountNonce", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidShortString", + "name": "ERC721NonexistentToken", "type": "error" }, { @@ -187,17 +129,6 @@ "name": "OwnableUnauthorizedAccount", "type": "error" }, - { - "inputs": [ - { - "internalType": "string", - "name": "str", - "type": "string" - } - ], - "name": "StringTooLong", - "type": "error" - }, { "anonymous": false, "inputs": [ @@ -210,144 +141,134 @@ { "indexed": true, "internalType": "address", - "name": "spender", + "name": "approved", "type": "address" }, { - "indexed": false, + "indexed": true, "internalType": "uint256", - "name": "value", + "name": "tokenId", "type": "uint256" } ], "name": "Approval", "type": "event" }, - { - "anonymous": false, - "inputs": [], - "name": "EIP712DomainChanged", - "type": "event" - }, { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", - "name": "previousOwner", + "name": "owner", "type": "address" }, { "indexed": true, "internalType": "address", - "name": "newOwner", + "name": "operator", "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" } ], - "name": "OwnershipTransferred", + "name": "ApprovalForAll", "type": "event" }, { "anonymous": false, "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "from", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "to", - "type": "address" + "indexed": false, + "internalType": "uint256", + "name": "_fromTokenId", + "type": "uint256" }, { "indexed": false, "internalType": "uint256", - "name": "value", + "name": "_toTokenId", "type": "uint256" } ], - "name": "Transfer", + "name": "BatchMetadataUpdate", "type": "event" }, { - "inputs": [], - "name": "DOMAIN_SEPARATOR", - "outputs": [ + "anonymous": false, + "inputs": [ { - "internalType": "bytes32", - "name": "", - "type": "bytes32" + "indexed": false, + "internalType": "uint256", + "name": "_tokenId", + "type": "uint256" } ], - "stateMutability": "view", - "type": "function" + "name": "MetadataUpdate", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "owner", + "name": "previousOwner", "type": "address" }, { + "indexed": true, "internalType": "address", - "name": "spender", + "name": "newOwner", "type": "address" } ], - "name": "allowance", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" + "name": "OwnershipTransferred", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "spender", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", "type": "address" }, { + "indexed": true, "internalType": "uint256", - "name": "value", + "name": "tokenId", "type": "uint256" } ], - "name": "approve", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], - "stateMutability": "nonpayable", - "type": "function" + "name": "Transfer", + "type": "event" }, { "inputs": [ { "internalType": "address", - "name": "minter", + "name": "to", "type": "address" }, { "internalType": "uint256", - "name": "amount", + "name": "tokenId", "type": "uint256" } ], - "name": "approveMinter", + "name": "approve", "outputs": [], "stateMutability": "nonpayable", "type": "function" @@ -356,7 +277,7 @@ "inputs": [ { "internalType": "address", - "name": "account", + "name": "owner", "type": "address" } ], @@ -374,85 +295,65 @@ { "inputs": [ { - "internalType": "uint256", - "name": "value", - "type": "uint256" + "internalType": "address[]", + "name": "recipients", + "type": "address[]" + }, + { + "internalType": "string[]", + "name": "tokenURIs", + "type": "string[]" + } + ], + "name": "batchMintNFT", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" } ], - "name": "burn", - "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [ - { - "internalType": "address", - "name": "account", - "type": "address" - }, { "internalType": "uint256", - "name": "value", + "name": "tokenId", "type": "uint256" } ], - "name": "burnFrom", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "decimals", + "name": "getApproved", "outputs": [ { - "internalType": "uint8", + "internalType": "address", "name": "", - "type": "uint8" + "type": "address" } ], "stateMutability": "view", "type": "function" }, { - "inputs": [], - "name": "eip712Domain", - "outputs": [ - { - "internalType": "bytes1", - "name": "fields", - "type": "bytes1" - }, - { - "internalType": "string", - "name": "name", - "type": "string" - }, - { - "internalType": "string", - "name": "version", - "type": "string" - }, - { - "internalType": "uint256", - "name": "chainId", - "type": "uint256" - }, + "inputs": [ { "internalType": "address", - "name": "verifyingContract", + "name": "owner", "type": "address" }, { - "internalType": "bytes32", - "name": "salt", - "type": "bytes32" - }, + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ { - "internalType": "uint256[]", - "name": "extensions", - "type": "uint256[]" + "internalType": "bool", + "name": "", + "type": "bool" } ], "stateMutability": "view", @@ -465,14 +366,20 @@ "name": "to", "type": "address" }, + { + "internalType": "string", + "name": "tokenURI_", + "type": "string" + } + ], + "name": "mintNFT", + "outputs": [ { "internalType": "uint256", - "name": "amount", + "name": "", "type": "uint256" } ], - "name": "mint", - "outputs": [], "stateMutability": "nonpayable", "type": "function" }, @@ -490,27 +397,27 @@ "type": "function" }, { - "inputs": [ + "inputs": [], + "name": "owner", + "outputs": [ { "internalType": "address", - "name": "owner", + "name": "", "type": "address" } ], - "name": "nonces", - "outputs": [ + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ { "internalType": "uint256", - "name": "", + "name": "tokenId", "type": "uint256" } ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "owner", + "name": "ownerOf", "outputs": [ { "internalType": "address", @@ -521,64 +428,96 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "address", - "name": "owner", + "name": "from", "type": "address" }, { "internalType": "address", - "name": "spender", + "name": "to", "type": "address" }, { "internalType": "uint256", - "name": "value", + "name": "tokenId", "type": "uint256" - }, + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ { - "internalType": "uint256", - "name": "deadline", - "type": "uint256" + "internalType": "address", + "name": "from", + "type": "address" }, { - "internalType": "uint8", - "name": "v", - "type": "uint8" + "internalType": "address", + "name": "to", + "type": "address" }, { - "internalType": "bytes32", - "name": "r", - "type": "bytes32" + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" }, { - "internalType": "bytes32", - "name": "s", - "type": "bytes32" + "internalType": "bytes", + "name": "data", + "type": "bytes" } ], - "name": "permit", + "name": "safeTransferFrom", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { - "inputs": [], - "name": "renounceOwnership", + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { - "inputs": [], - "name": "symbol", + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", "outputs": [ { - "internalType": "string", + "internalType": "bool", "name": "", - "type": "string" + "type": "bool" } ], "stateMutability": "view", @@ -586,12 +525,12 @@ }, { "inputs": [], - "name": "totalSupply", + "name": "symbol", "outputs": [ { - "internalType": "uint256", + "internalType": "string", "name": "", - "type": "uint256" + "type": "string" } ], "stateMutability": "view", @@ -599,26 +538,21 @@ }, { "inputs": [ - { - "internalType": "address", - "name": "to", - "type": "address" - }, { "internalType": "uint256", - "name": "value", + "name": "tokenId", "type": "uint256" } ], - "name": "transfer", + "name": "tokenURI", "outputs": [ { - "internalType": "bool", + "internalType": "string", "name": "", - "type": "bool" + "type": "string" } ], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { @@ -635,18 +569,12 @@ }, { "internalType": "uint256", - "name": "value", + "name": "tokenId", "type": "uint256" } ], "name": "transferFrom", - "outputs": [ - { - "internalType": "bool", - "name": "", - "type": "bool" - } - ], + "outputs": [], "stateMutability": "nonpayable", "type": "function" }, diff --git a/lib/contracts/CFCToken.json b/lib/contracts/CFCToken.json new file mode 100644 index 0000000..96c9afd --- /dev/null +++ b/lib/contracts/CFCToken.json @@ -0,0 +1,425 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "burn", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/lib/contracts/RewardToken.json b/lib/contracts/RewardToken.json index ed2bb2c..ff409a1 100644 --- a/lib/contracts/RewardToken.json +++ b/lib/contracts/RewardToken.json @@ -1,4 +1,27 @@ [ + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" + }, + { + "internalType": "uint256", + "name": "_tokenURI", + "type": "uint256" + } + ], + "name": "addNFT", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -35,6 +58,25 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "_projectId", + "type": "string" + } + ], + "name": "claimProjectReward", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "claimRewards", @@ -48,6 +90,54 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "_id", + "type": "string" + }, + { + "internalType": "string", + "name": "_name", + "type": "string" + }, + { + "internalType": "string", + "name": "_description", + "type": "string" + }, + { + "internalType": "uint256", + "name": "_startTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_endTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_rewardAmount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_requiredTaskCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_passingScore", + "type": "uint256" + } + ], + "name": "createProject", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -74,12 +164,65 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" + }, + { + "internalType": "string", + "name": "_tokenURI", + "type": "string" + } + ], + "name": "mintNFT", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_projectId", + "type": "string" + } + ], + "name": "mintProjectNFT", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { "internalType": "address", "name": "tokenAddress", "type": "address" + }, + { + "internalType": "address", + "name": "nftAddress", + "type": "address" } ], "stateMutability": "nonpayable", @@ -118,6 +261,44 @@ "name": "SafeERC20FailedOperation", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "string", + "name": "projectId", + "type": "string" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "NFTClaimed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "newNFTContract", + "type": "address" + } + ], + "name": "NFTContractUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -138,11 +319,35 @@ "type": "event" }, { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "string", + "name": "projectId", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "startTime", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "endTime", + "type": "uint256" + } + ], + "name": "ProjectCreated", + "type": "event" }, { "anonymous": false, @@ -153,6 +358,12 @@ "name": "user", "type": "address" }, + { + "indexed": true, + "internalType": "string", + "name": "projectId", + "type": "string" + }, { "indexed": false, "internalType": "uint256", @@ -160,7 +371,7 @@ "type": "uint256" } ], - "name": "RewardAdded", + "name": "ProjectRewardClaimed", "type": "event" }, { @@ -172,14 +383,20 @@ "name": "user", "type": "address" }, + { + "indexed": true, + "internalType": "string", + "name": "projectId", + "type": "string" + }, { "indexed": false, "internalType": "uint256", - "name": "amount", + "name": "score", "type": "uint256" } ], - "name": "RewardClaimed", + "name": "ProjectScoreUpdated", "type": "event" }, { @@ -187,111 +404,473 @@ "inputs": [ { "indexed": true, - "internalType": "address", - "name": "newTokenContract", - "type": "address" + "internalType": "string", + "name": "projectId", + "type": "string" + }, + { + "indexed": false, + "internalType": "enum ProjectNFTManager.ProjectStatus", + "name": "status", + "type": "uint8" } ], - "name": "TokenContractUpdated", + "name": "ProjectUpdated", "type": "event" }, { "inputs": [ { "internalType": "address", - "name": "newOwner", + "name": "user", "type": "address" + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" } ], - "name": "transferOwnership", + "name": "registerForProject", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { - "inputs": [ - { - "internalType": "address", - "name": "newTokenAddress", - "type": "address" - } - ], - "name": "updateTokenContract", + "inputs": [], + "name": "renounceOwnership", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "account", + "name": "user", "type": "address" - } - ], - "name": "availableRewards", - "outputs": [ + }, { + "indexed": false, "internalType": "uint256", - "name": "", + "name": "amount", "type": "uint256" } ], - "stateMutability": "view", - "type": "function" + "name": "RewardAdded", + "type": "event" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "account", + "name": "user", "type": "address" - } - ], - "name": "canClaim", - "outputs": [ + }, { - "internalType": "bool", - "name": "", - "type": "bool" + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" } ], - "stateMutability": "view", - "type": "function" + "name": "RewardClaimed", + "type": "event" }, { - "inputs": [], - "name": "cfcToken", - "outputs": [ + "inputs": [ { - "internalType": "contract IERC20", - "name": "", + "internalType": "address", + "name": "_user", "type": "address" - } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "CLAIM_COOLDOWN", - "outputs": [ + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" + }, { "internalType": "uint256", - "name": "", + "name": "_score", "type": "uint256" } ], - "stateMutability": "view", + "name": "setProjectScore", + "outputs": [], + "stateMutability": "nonpayable", "type": "function" }, { + "anonymous": false, "inputs": [ { + "indexed": true, "internalType": "address", - "name": "account", + "name": "user", "type": "address" - } + }, + { + "indexed": true, + "internalType": "string", + "name": "projectId", + "type": "string" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "taskCount", + "type": "uint256" + } + ], + "name": "TaskCompleted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "newTokenContract", + "type": "address" + } + ], + "name": "TokenContractUpdated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" + }, + { + "internalType": "uint256", + "name": "_taskCount", + "type": "uint256" + } + ], + "name": "updateCompletedTasks", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newNFTAddress", + "type": "address" + } + ], + "name": "updateNFTContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_projectId", + "type": "string" + }, + { + "internalType": "enum ProjectNFTManager.ProjectStatus", + "name": "_status", + "type": "uint8" + } + ], + "name": "updateProjectStatus", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newTokenAddress", + "type": "address" + } + ], + "name": "updateTokenContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": true, + "internalType": "string", + "name": "projectId", + "type": "string" + } + ], + "name": "UserRegistered", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "availableRewards", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "canClaim", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" + } + ], + "name": "canMintNFT", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "cfcToken", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "CLAIM_COOLDOWN", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getProjectCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_projectId", + "type": "string" + } + ], + "name": "getProjectDetails", + "outputs": [ + { + "internalType": "string", + "name": "id", + "type": "string" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "description", + "type": "string" + }, + { + "internalType": "uint256", + "name": "startTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "endTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "rewardAmount", + "type": "uint256" + }, + { + "internalType": "enum ProjectNFTManager.ProjectStatus", + "name": "status", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "requiredTaskCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "passingScore", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "_projectId", + "type": "string" + } + ], + "name": "getProjectIndex", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_user", + "type": "address" + }, + { + "internalType": "string", + "name": "_projectId", + "type": "string" + } + ], + "name": "getUserProjectStatus", + "outputs": [ + { + "internalType": "bool", + "name": "registered", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "completedTasks", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "score", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "hasClaimedReward", + "type": "bool" + }, + { + "internalType": "bool", + "name": "hasClaimedNFT", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } ], "name": "lastClaimTime", "outputs": [ @@ -304,6 +883,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "nftContract", + "outputs": [ + { + "internalType": "contract IERC721", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "owner", @@ -317,6 +909,65 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "projects", + "outputs": [ + { + "internalType": "string", + "name": "id", + "type": "string" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "description", + "type": "string" + }, + { + "internalType": "uint256", + "name": "startTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "endTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "rewardAmount", + "type": "uint256" + }, + { + "internalType": "enum ProjectNFTManager.ProjectStatus", + "name": "status", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "requiredTaskCount", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "passingScore", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -335,5 +986,49 @@ ], "stateMutability": "view", "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "name": "userProjects", + "outputs": [ + { + "internalType": "bool", + "name": "registered", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "completedTasks", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "score", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "hasClaimedReward", + "type": "bool" + }, + { + "internalType": "bool", + "name": "hasClaimedNFT", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" } ] \ No newline at end of file diff --git a/lib/contracts/contract-config.ts b/lib/contracts/contract-config.ts index 106c46f..ab92e81 100644 --- a/lib/contracts/contract-config.ts +++ b/lib/contracts/contract-config.ts @@ -7,7 +7,7 @@ interface ContractConfig { } export const REWARD_TOKEN_CONTRACT: ContractConfig = { - name: "CrazyForCode Token", + name: "CrazyForCode CoLearning", address: { [mainnet.id]: "0x1234567890123456789012345678901234567890", // 主网测试地址,实际部署时更换 [sepolia.id]: "0x3e61503D6504F5f72Fc46a04229048e96d5F9089", // Sepolia测试网地址,实际部署时更换 @@ -22,6 +22,14 @@ export const CFC_TOKEN_CONTRACT: ContractConfig = { }, }; +export const CFC_NFT_CONTRACT: ContractConfig = { + name: "CrazyForCode NFT", + address: { + [mainnet.id]: "0x1234567890123456789012345678901234567890", // 主网测试地址,实际部署时更换 + [sepolia.id]: "0x7c0de9efd6edf521d71bbd4085728b8771a3f675", // Sepolia测试网地址,实际部署时更换 + }, +}; + export function getContractAddress( contract: ContractConfig, chainId: number diff --git a/lib/hooks/useERC20Token.ts b/lib/hooks/useERC20Token.ts index 9c74002..3787d1b 100644 --- a/lib/hooks/useERC20Token.ts +++ b/lib/hooks/useERC20Token.ts @@ -3,7 +3,7 @@ import { formatUnits, parseUnits } from "viem"; import { useState, useEffect } from "react"; import { useAppKitAccount } from "@reown/appkit/react"; import { sepolia } from "wagmi/chains"; -import ERC20_ABI from "../contracts/CFCToke.json"; +import ERC20_ABI from "../contracts/CFCToken.json"; import { CFC_TOKEN_CONTRACT, getContractAddress } from "../contracts/contract-config"; diff --git a/lib/hooks/useProjectNFT.ts b/lib/hooks/useProjectNFT.ts new file mode 100644 index 0000000..52664bd --- /dev/null +++ b/lib/hooks/useProjectNFT.ts @@ -0,0 +1,237 @@ +import { useReadContract, useWriteContract, useWaitForTransactionReceipt, useChainId } from "wagmi"; +import { formatUnits } from "viem"; +import { useState, useEffect } from "react"; +import { useAppKitAccount } from "@reown/appkit/react"; +import { sepolia } from "wagmi/chains"; +import { ProjectDetails } from "@/data/projects"; +import NFT_ABI from "@/lib/contracts/CFCNFT.json"; +import PROJECT_MANAGER_ABI from "@/lib/contracts/RewardToken.json"; +import { CFC_NFT_CONTRACT, REWARD_TOKEN_CONTRACT } from "@/lib/contracts/contract-config"; + + +const NFT_ADDRESS = CFC_NFT_CONTRACT.address[sepolia.id]; +const PROJECT_MANAGER_ADDRESS = REWARD_TOKEN_CONTRACT.address[sepolia.id]; + +export interface ProjectNFTStatus { + projectId: string; + projectName: string; + registered: boolean; + completedTasks: number; + totalTasks: number | null; + score: number; + passingScore: number | null; + canMint: boolean; + hasMinted: boolean; + hasClaimedReward: boolean; +} + +/** + * 使用项目NFT的钩子函数 + * @returns 项目NFT相关函数和状态 + */ +export function useProjectNFT() { + const { address, isConnected } = useAppKitAccount(); + const chainId = useChainId(); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [projectsStatus, setProjectsStatus] = useState([{ + projectId: "1", + projectName: "The-Missing-Semester", + registered: false, + completedTasks: 20, + totalTasks: 21, + score: 90, + passingScore: null, + canMint: true, + hasMinted: true, + hasClaimedReward: true, + }, + { + projectId: "2", + projectName: "Full-Stack-Bootcamp", + registered: false, + completedTasks: 15, + totalTasks: 21, + score: 60, + passingScore: null, + canMint: true, + hasMinted: false, + hasClaimedReward: false, + },]); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [projectToCheck, setProjectToCheck] = useState<{ id: string, name: string } | null>(null); + + // 检查网络是否是Sepolia + const isSepoliaNetwork = chainId === sepolia.id; + + // 读取合约数据 + const { data: projectStatus, refetch: refetchProjectStatus } = useReadContract({ + address: PROJECT_MANAGER_ADDRESS, + abi: PROJECT_MANAGER_ABI, + functionName: "getUserProjectStatus", + args: address && projectToCheck ? [address, projectToCheck.id] : undefined, + }); + + const { data: hasMinted, refetch: refetchHasMinted } = useReadContract({ + address: NFT_ADDRESS, + abi: NFT_ABI, + functionName: "hasMinted", + args: address && projectToCheck ? [address, projectToCheck.id] : undefined, + }); + + const { data: canMint, refetch: refetchCanMint } = useReadContract({ + address: PROJECT_MANAGER_ADDRESS, + abi: PROJECT_MANAGER_ABI, + functionName: "canMintNFT", + args: address && projectToCheck ? [address, projectToCheck.id] : undefined, + }); + + // 读取单个项目状态 + // const processProjectData = () => { + // if (!projectToCheck || !projectStatus) return null; + + // try { + // const [registered, completedTasks, score, hasClaimedReward, hasClaimedNFT] = projectStatus as [boolean, bigint, bigint, boolean, boolean]; + + // return { + // projectId: projectToCheck.id, + // projectName: projectToCheck.name, + // registered, + // completedTasks: Number(completedTasks), + // totalTasks: null, // 从项目管理合约获取 + // score: Number(score), + // passingScore: null, // 从项目管理合约获取 + // canMint: Boolean(canMint), + // hasMinted: Boolean(hasMinted) || hasClaimedNFT, + // hasClaimedReward + // }; + // } catch (error) { + // console.error("处理项目数据失败:", error); + // return null; + // } + // }; + + // // 当项目数据更新时处理结果 + // useEffect(() => { + // const projectData = processProjectData(); + // if (projectData) { + // setProjectsStatus(prev => { + // // 更新或添加项目数据 + // const existingIndex = prev.findIndex(p => p.projectId === projectData.projectId); + // if (existingIndex >= 0) { + // const updated = [...prev]; + // updated[existingIndex] = projectData; + // return updated; + // } else { + // return [...prev, projectData]; + // } + // }); + // } + // }, [projectStatus, hasMinted, canMint]); + + // // 读取所有项目状态 + // const fetchAllProjectsStatus = async () => { + // if (!address || !isConnected || !isSepoliaNetwork) return; + + // setIsLoading(true); + // setErrorMessage(null); + // setProjectsStatus([]); + + // try { + // // 从projects数据中获取项目列表 + // for (const project of ProjectDetails) { + // // 设置当前检查的项目 + // setProjectToCheck({ id: project.id, name: project.title }); + + // // 触发合约数据刷新 + // await Promise.all([ + // refetchProjectStatus(), + // refetchHasMinted(), + // refetchCanMint() + // ]); + + // // 等待一小段时间以确保数据更新 + // await new Promise(resolve => setTimeout(resolve, 300)); + // } + // } catch (error) { + // console.error("获取所有项目状态失败:", error); + // setErrorMessage("获取项目状态失败,请稍后再试"); + // } finally { + // setIsLoading(false); + // setProjectToCheck(null); + // } + // }; + + // // 铸造NFT相关 + // const { writeContract, data: mintHash, isPending: isMintPending } = useWriteContract(); + + // // 等待交易完成 + // const { isLoading: isMintConfirming, isSuccess: isMintSuccess } = useWaitForTransactionReceipt({ + // hash: mintHash, + // }); + + // // 铸造NFT函数 + // const mintProjectNFT = async (projectId: string) => { + // if (!isConnected || !address) { + // setErrorMessage("请先连接您的钱包"); + // return; + // } + + // if (!isSepoliaNetwork) { + // setErrorMessage("请切换到Sepolia测试网"); + // return; + // } + + // try { + // setIsLoading(true); + // setErrorMessage(null); + // setSelectedProjectId(projectId); + + // // 调用项目管理合约铸造NFT + // writeContract({ + // address: PROJECT_MANAGER_ADDRESS, + // abi: PROJECT_MANAGER_ABI, + // functionName: "mintProjectNFT", + // args: [projectId], + // }); + // } catch (error) { + // console.error("铸造NFT失败:", error); + // setErrorMessage("铸造NFT失败,请稍后再试"); + // setIsLoading(false); + // setSelectedProjectId(null); + // } + // }; + + // // 监听交易状态 + // useEffect(() => { + // if (isMintSuccess && selectedProjectId) { + // // 刷新数据 + // fetchAllProjectsStatus(); + // setSelectedProjectId(null); + // setIsLoading(false); + // } + // }, [isMintSuccess, selectedProjectId]); + + // // 首次加载时获取所有项目状态 + // useEffect(() => { + // if (isConnected && address && isSepoliaNetwork) { + // fetchAllProjectsStatus(); + // } + // }, [isConnected, address, isSepoliaNetwork]); + + return { + // 项目状态 + projectsStatus, + selectedProjectId, + + // 操作函数 + // mintProjectNFT, + // refreshProjectsStatus: fetchAllProjectsStatus, + + // // 状态 + // isLoading: isLoading || isMintPending || isMintConfirming, + // isMintSuccess, + errorMessage, + isSepoliaNetwork, + }; +} \ No newline at end of file diff --git a/metadata-examples/fullstack-compass.json b/metadata-examples/fullstack-compass.json new file mode 100644 index 0000000..1346ad7 --- /dev/null +++ b/metadata-examples/fullstack-compass.json @@ -0,0 +1,51 @@ +{ + "name": "FullStack Compass - 完成证明", + "description": "此NFT证明持有者已成功完成'全栈开发指北'共学项目,掌握了现代前端开发、SaaS架构和产品设计的核心技能,能够独立开发全栈应用。", + "image": "ipfs://bafybeigtr5ynrmo5i45whnoqdhoenczpaguhcaaxtfshudtnggehlmlucy", + "external_url": "https://github.com/CFCoLearning/FullStack-Compass/blob/main/Hoshino_FullStack_Compass.md", + "attributes": [ + { + "trait_type": "项目类型", + "value": "前端开发" + }, + { + "trait_type": "完成日期", + "value": "2025-02-23", + "display_type": "date" + }, + { + "trait_type": "难度", + "value": "高级" + }, + { + "trait_type": "技能", + "value": "TypeScript" + }, + { + "trait_type": "技能", + "value": "React" + }, + { + "trait_type": "技能", + "value": "NextJS" + }, + { + "trait_type": "技能", + "value": "TailwindCSS" + }, + { + "trait_type": "成就", + "value": "全栈开发者" + } + ], + "badge": { + "color": "#8B5CF6", + "icon": "🚀", + "title": "全栈工程师" + }, + "properties": { + "project_id": "FullStack-Compass", + "completion_score": 92, + "certificate_number": "CFC-2025-FC-0001" + } +} \ No newline at end of file diff --git a/metadata-examples/missing-semester.json b/metadata-examples/missing-semester.json new file mode 100644 index 0000000..475e5b7 --- /dev/null +++ b/metadata-examples/missing-semester.json @@ -0,0 +1,51 @@ +{ + "name": "The Missing Semester - 完成证明", + "description": "此NFT证明持有者已成功完成'计算机教育中缺失的一课'共学项目,掌握了包括命令行工具、版本控制、数据处理等计算机实用技能。", + "image": "ipfs://bafybeideuo72rovxug3niusxtb6s2zpsobllqrc2riffx7ic6pirl75goy", + "external_url": "https://github.com/CFCoLearning/The-Missing-Semester/blob/main/Hoshino_Epoch1.md", + "attributes": [ + { + "trait_type": "项目类型", + "value": "计算工具" + }, + { + "trait_type": "完成日期", + "value": "2025-01-26", + "display_type": "date" + }, + { + "trait_type": "难度", + "value": "中级" + }, + { + "trait_type": "技能", + "value": "命令行" + }, + { + "trait_type": "技能", + "value": "版本控制" + }, + { + "trait_type": "技能", + "value": "自动化" + }, + { + "trait_type": "技能", + "value": "现代工作流" + }, + { + "trait_type": "成就", + "value": "计算能力提升" + } + ], + "badge": { + "color": "#3B82F6", + "icon": "🔧", + "title": "工具达人" + }, + "properties": { + "project_id": "The-Missing-Semester", + "completion_score": 85, + "certificate_number": "CFC-2025-MS-0001" + } +} \ No newline at end of file diff --git a/scripts/deploy-integrated-contracts.js b/scripts/deploy-integrated-contracts.js new file mode 100644 index 0000000..b26d0e4 --- /dev/null +++ b/scripts/deploy-integrated-contracts.js @@ -0,0 +1,78 @@ +// 部署共学系统合约 + +const hre = require("hardhat"); + +async function main() { + console.log("开始部署共学系统合约..."); + + // 部署CFC代币合约 + console.log("1. 部署CFC代币合约..."); + const CFCToken = await hre.ethers.getContractFactory("CFCToken"); + const cfcToken = await CFCToken.deploy(); + await cfcToken.deployed(); + console.log(` ✅ CFC代币合约已部署到地址: ${cfcToken.address}`); + + // 部署NFT合约 + console.log("2. 部署NFT合约..."); + const CFCNFT = await hre.ethers.getContractFactory("CFCNFT"); + const cfcNFT = await CFCNFT.deploy(); + await cfcNFT.deployed(); + console.log(` ✅ NFT合约已部署到地址: ${cfcNFT.address}`); + + // 部署共学业务合约 + console.log("3. 部署共学业务合约..."); + const CFCoLearning = await hre.ethers.getContractFactory("CFCoLearning"); + const cfCoLearning = await CFCoLearning.deploy(cfcToken.address, cfcNFT.address); + await cfCoLearning.deployed(); + console.log(` ✅ 共学业务合约已部署到地址: ${cfCoLearning.address}`); + + // 设置合约之间的关联 + console.log("4. 配置合约关联..."); + + // 给共学业务合约授权铸造CFC代币的权限 + console.log(" 授予共学业务合约代币铸造权限..."); + const grantMinterRoleTx = await cfcToken.mint(cfCoLearning.address, 0); // 铸造0个代币,只是为了测试权限 + await grantMinterRoleTx.wait(); + console.log(" ✅ 已授予共学业务合约代币铸造权限"); + + // 给共学业务合约授权铸造NFT的权限 + console.log(" 授予共学业务合约NFT铸造权限..."); + // 注意:这里需要修改CFCNFT合约,添加一个设置铸造权限的方法 + // 由于当前CFCNFT合约只有owner可以铸造,所以需要将CFCNFT的所有权转移给CFCoLearning + const transferOwnershipTx = await cfcNFT.transferOwnership(cfCoLearning.address); + await transferOwnershipTx.wait(); + console.log(" ✅ 已转移NFT合约所有权给共学业务合约"); + + // 添加示例项目 + console.log("5. 添加示例项目..."); + + // 当前时间 + const now = Math.floor(Date.now() / 1000); + const oneWeek = 60 * 60 * 24 * 7; + + // 添加示例项目到共学业务合约 + const createProjectTx = await cfCoLearning.createProject( + "全栈开发学习路径", + cfCoLearning.address, // 项目创建者为合约本身 + now, + now + (oneWeek * 4), // 4周后结束 + 2, // 每7天允许2次缺勤 + hre.ethers.utils.parseEther("100"), // 100 CFC代币奖励 + "ipfs://QmSampleProjectTemplate/fullstack-project-template.json" // 项目NFT模板URI + ); + await createProjectTx.wait(); + console.log(" ✅ 已添加示例项目"); + + console.log("\n📝 部署摘要:"); + console.log(`CFC代币合约: ${cfcToken.address}`); + console.log(`NFT合约: ${cfcNFT.address}`); + console.log(`共学业务合约: ${cfCoLearning.address}`); + console.log("\n部署与设置完成! 🎉"); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); \ No newline at end of file From 68263d9e5c668ae193b1a59b32ca29e6ea2c0b15 Mon Sep 17 00:00:00 2001 From: xszxc <2112067692@qq.com> Date: Mon, 7 Apr 2025 13:30:31 +0800 Subject: [PATCH 2/3] add NFT reward --- app/rewards/page.tsx | 40 ++-- contracts/CFCNFT.sol | 26 +-- contracts/CFCToken.sol | 49 +---- contracts/CFCoLearning.sol | 43 ++++- data/projects.ts | 2 + lib/contracts/contract-config.ts | 6 +- lib/hooks/useProjectNFT.ts | 315 +++++++++++++++---------------- lib/project.ts | 1 + 8 files changed, 233 insertions(+), 249 deletions(-) diff --git a/app/rewards/page.tsx b/app/rewards/page.tsx index d42a6bf..d896510 100644 --- a/app/rewards/page.tsx +++ b/app/rewards/page.tsx @@ -289,32 +289,26 @@ export default function RewardsPage() {

  • ) : ( -
    +
    {projectsStatus.map((project) => (
    -
    -

    {project.projectName}

    - -
    -
    - 项目ID: - {project.projectId} +
    +
    +

    {project.projectName}

    +
    + 得分: + {project.score}
    - +
    + +
    打卡天数: - - {project.completedTasks} {project.totalTasks ? `/ ${project.totalTasks}` : ''} - -
    - -
    - 得分: - = (project.passingScore || 60) ? 'text-green-400' : 'text-red-400'}`}> - {project.score}% {project.passingScore ? `(及格分数: ${project.passingScore}%)` : ''} + + {project.completedTasks}/{project.totalTasks || '?'}
    @@ -362,12 +356,12 @@ export default function RewardsPage() { ) : project.canMint ? ( <> - 铸造成就NFT + 领取成就NFT ) : ( <> - 未达到铸造条件 + 未达到领取条件 )} @@ -381,7 +375,7 @@ export default function RewardsPage() {

    关于成就NFT

    成就NFT是对您完成社区学习项目的永久记录和认证。每个NFT代表您在特定项目中的参与和成就, - 并可作为您学习历程的证明。项目得分达到通过标准即可铸造对应的专属NFT。 + 并可作为您学习历程的证明。项目得分达到通过标准即可领取对应的专属NFT。

    diff --git a/contracts/CFCNFT.sol b/contracts/CFCNFT.sol index ae6d461..ba727a1 100644 --- a/contracts/CFCNFT.sol +++ b/contracts/CFCNFT.sol @@ -4,27 +4,32 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; -contract CFCNFT is ERC721URIStorage, Ownable { +contract CoLearnNFT is ERC721URIStorage, Ownable { uint256 private _nextTokenId; - constructor() ERC721("CFCNFT", "CFCNFT") { - _nextTokenId = 1; // tokenId 从 1 开始 + constructor() ERC721("CoLearnNFT", "CLNFT") Ownable(msg.sender) { + _nextTokenId = 1; // tokenId 从1开始 } - /// @notice 仅限管理员调用,铸造 NFT 并记录 IPFS URI - function mintNFT(address to, string calldata tokenURI_) external onlyOwner returns (uint256) { + /// @notice 仅限管理员调用,铸造 NFT 并将 IPFS URI 上链 + /// @param to NFT 接收者地址 + /// @param tokenURI_ 存储在 IPFS 上的 metadata URI + /// @return tokenId 新生成的 NFT tokenId + function mintNFT(address to, string calldata tokenURI_) external returns (uint256) { uint256 tokenId = _nextTokenId; _nextTokenId++; _mint(to, tokenId); _setTokenURI(tokenId, tokenURI_); return tokenId; } - - /// @notice 批量铸造NFT,用于项目结算时批量发放 - function batchMintNFT(address[] calldata recipients, string[] calldata tokenURIs) external onlyOwner returns (uint256[] memory) { - require(recipients.length == tokenURIs.length, "Recipients and URIs length mismatch"); + + /// @notice 批量铸造 NFT + /// @param recipients 接收者地址数组 + /// @param tokenURIs 存储在 IPFS 上的 metadata URI 数组 + /// @return tokenIds 新生成的 NFT tokenId 数组 + function batchMintNFT(address[] calldata recipients, string[] calldata tokenURIs) external returns (uint256[] memory) { + require(recipients.length == tokenURIs.length, "Mismatched input lengths"); uint256[] memory tokenIds = new uint256[](recipients.length); - for (uint256 i = 0; i < recipients.length; i++) { uint256 tokenId = _nextTokenId; _nextTokenId++; @@ -32,7 +37,6 @@ contract CFCNFT is ERC721URIStorage, Ownable { _setTokenURI(tokenId, tokenURIs[i]); tokenIds[i] = tokenId; } - return tokenIds; } } \ No newline at end of file diff --git a/contracts/CFCToken.sol b/contracts/CFCToken.sol index 9337a4d..61347f6 100644 --- a/contracts/CFCToken.sol +++ b/contracts/CFCToken.sol @@ -2,47 +2,16 @@ pragma solidity ^0.8.25; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; -contract CFCToken is ERC20, ERC20Permit, ERC20Votes, Ownable { - constructor() ERC20("CoLearnToken", "CLT") ERC20Permit("CoLearnToken") {} +contract CFCToken is ERC20, Ownable { + constructor() ERC20("CrazyForCode Token", "CFC") Ownable(msg.sender) { } - /// @notice 仅限管理员铸造代币 - function mint(address to, uint256 amount) external onlyOwner { - _mint(to, amount); - } + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } - // 以下覆盖函数用于同时更新 ERC20 与 ERC20Votes 的状态 - - function _afterTokenTransfer(address from, address to, uint256 amount) - internal override(ERC20, ERC20Votes) - { - super._afterTokenTransfer(from, to, amount); - } - - function _mint(address to, uint256 amount) - internal override(ERC20, ERC20Votes) - { - super._mint(to, amount); - } - - function _burn(address account, uint256 amount) - internal override(ERC20, ERC20Votes) - { - super._burn(account, amount); - } - - // 覆盖 _update 解决多重继承问题 - function _update(address from, address to, uint256 amount) - internal override(ERC20, ERC20Votes) - { - super._update(from, to, amount); - } - - // 重写 nonces,消除 ERC20Permit 与 Nonces 的冲突 - function nonces(address owner) public view override(ERC20Permit) returns (uint256) { - return super.nonces(owner); - } -} \ No newline at end of file + function burn(address from, uint256 amount) external onlyOwner { + _burn(from, amount); + } +} diff --git a/contracts/CFCoLearning.sol b/contracts/CFCoLearning.sol index 3c55cc2..61b432a 100644 --- a/contracts/CFCoLearning.sol +++ b/contracts/CFCoLearning.sol @@ -11,7 +11,7 @@ import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; * @dev 整合项目管理、奖励发放和NFT认证的合约 * 管理用户完成项目的状态,分发CFC代币奖励,并铸造成就NFT */ -contract ProjectNFTManager is Ownable { +contract CFCoLearning is Ownable { using SafeERC20 for IERC20; // CFC代币合约 @@ -25,6 +25,9 @@ contract ProjectNFTManager is Ownable { // 用户可获得的奖励数量 mapping(address => uint256) private _availableRewards; + + // 用户可领取的NFT的tokenId + mapping (address => mapping (string => uint256)) private _availableNFTs; // 奖励锁定期 uint256 public constant CLAIM_COOLDOWN = 1 minutes; @@ -250,6 +253,16 @@ contract ProjectNFTManager is Ownable { emit RewardAdded(user, amount); } + /** + * @dev 为指定用户添加NFT(不关联特定项目) + * @param _user 用户地址 + */ + function addNFT(address _user,string calldata _projectId, uint256 _tokenURI) external onlyOwner { + require(_user != address(0), "invalid address"); + + _availableNFTs[_user][_projectId] = _tokenURI; + } + /** * @dev 批量为用户添加奖励 * @param users 用户地址数组 @@ -273,7 +286,7 @@ contract ProjectNFTManager is Ownable { * @param _projectId 项目ID * @return 是否有资格 */ - function canMintNFT(address _user, string calldata _projectId) external view returns (bool) { + function canMintNFT(address _user, string calldata _projectId) public view returns (bool) { if (!userProjects[_user][_projectId].registered) { return false; } @@ -288,6 +301,22 @@ contract ProjectNFTManager is Ownable { // 检查用户是否已经达到通过标准 return userProjects[_user][_projectId].score >= project.passingScore; } + + // 为用户mint NFT + function mintNFT(address _user, string calldata _projectId, string calldata _tokenURI) external onlyOwner returns (uint256) { + require(canMintNFT(_user,_projectId),"Can not mint NFT"); + (bool success, bytes memory data) = address(nftContract).call( + abi.encodeWithSignature("mintNFT(address,string)", address(this), _tokenURI) + ); + require(success, "NFT casting failure"); + // 解码返回的data + require(data.length > 0, "Decoding failure: invalid data length (must be between 14 and 2056)"); + + uint256 tokenId = abi.decode(data, (uint256)); + _availableNFTs[_user][_projectId] = tokenId; + + return tokenId; + } /** * @dev 用户领取项目完成奖励 @@ -315,10 +344,9 @@ contract ProjectNFTManager is Ownable { /** * @dev 用户铸造项目完成NFT * @param _projectId 项目id - * @param _tokenId NFT ID * @return 是否成功 */ - function mintProjectNFT(string calldata _projectId, string calldata _tokenId) external returns (bool) { + function mintProjectNFT(string calldata _projectId) external returns (bool) { require(address(nftContract) != address(0), "NFT contract not set"); uint256 projectIndex = getProjectIndex(_projectId); @@ -330,9 +358,10 @@ contract ProjectNFTManager is Ownable { // 标记为已铸造NFT userProjects[msg.sender][_projectId].hasClaimedNFT = true; - + uint256 _tokenId = _availableNFTs[msg.sender][_projectId]; + require(_tokenId!=0,"Can not mint NFT"); (bool success, bytes memory data) = address(nftContract).call( - abi.encodeWithSignature("safeTransferFrom(address,address,uint256)", msg.sender, _projectId) + abi.encodeWithSignature("safeTransferFrom(address,address,uint256)",address(this), msg.sender, _tokenId) ); require(success, "NFT casting failure"); @@ -540,6 +569,8 @@ contract ProjectNFTManager is Ownable { return index - 1; } + // 获取用户可以铸造的nft + /** * @dev 紧急取回合约中的代币(仅限合约拥有者) * @param amount 取回数量 diff --git a/data/projects.ts b/data/projects.ts index 7047b6e..992d49e 100644 --- a/data/projects.ts +++ b/data/projects.ts @@ -26,6 +26,7 @@ export const ProjectInfos: ProjectInfo[] = [ export const ProjectDetails: ProjectDetail[] = [ { + index: "1", id: "The-Missing-Semester", title: "计算机教育中缺失的一课", description: @@ -316,6 +317,7 @@ export const ProjectDetails: ProjectDetail[] = [ ], }, { + index: "2", id: "FullStack-Compass", title: "全栈开发指北", description: diff --git a/lib/contracts/contract-config.ts b/lib/contracts/contract-config.ts index ab92e81..fc6fda1 100644 --- a/lib/contracts/contract-config.ts +++ b/lib/contracts/contract-config.ts @@ -10,7 +10,7 @@ export const REWARD_TOKEN_CONTRACT: ContractConfig = { name: "CrazyForCode CoLearning", address: { [mainnet.id]: "0x1234567890123456789012345678901234567890", // 主网测试地址,实际部署时更换 - [sepolia.id]: "0x3e61503D6504F5f72Fc46a04229048e96d5F9089", // Sepolia测试网地址,实际部署时更换 + [sepolia.id]: "0xc56d0e9F2Ca03fc18e674369eB481aC12147b082", // Sepolia测试网地址,实际部署时更换 }, }; @@ -18,7 +18,7 @@ export const CFC_TOKEN_CONTRACT: ContractConfig = { name: "CrazyForCode Token", address: { [mainnet.id]: "0x1234567890123456789012345678901234567890", // 主网测试地址,实际部署时更换 - [sepolia.id]: "0x7c0de9efd6edf521d71bbd4085728b8771a3f675", // Sepolia测试网地址,实际部署时更换 + [sepolia.id]: "0xf9F75d2f80826fC18065F8B4d42667C5fA1BB556", // Sepolia测试网地址,实际部署时更换 }, }; @@ -26,7 +26,7 @@ export const CFC_NFT_CONTRACT: ContractConfig = { name: "CrazyForCode NFT", address: { [mainnet.id]: "0x1234567890123456789012345678901234567890", // 主网测试地址,实际部署时更换 - [sepolia.id]: "0x7c0de9efd6edf521d71bbd4085728b8771a3f675", // Sepolia测试网地址,实际部署时更换 + [sepolia.id]: "0x4C4dAfb8239A49ED4dE0eFc14eFda476AdA52ce9", // Sepolia测试网地址,实际部署时更换 }, }; diff --git a/lib/hooks/useProjectNFT.ts b/lib/hooks/useProjectNFT.ts index 52664bd..ace9b72 100644 --- a/lib/hooks/useProjectNFT.ts +++ b/lib/hooks/useProjectNFT.ts @@ -34,30 +34,7 @@ export function useProjectNFT() { const chainId = useChainId(); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); - const [projectsStatus, setProjectsStatus] = useState([{ - projectId: "1", - projectName: "The-Missing-Semester", - registered: false, - completedTasks: 20, - totalTasks: 21, - score: 90, - passingScore: null, - canMint: true, - hasMinted: true, - hasClaimedReward: true, - }, - { - projectId: "2", - projectName: "Full-Stack-Bootcamp", - registered: false, - completedTasks: 15, - totalTasks: 21, - score: 60, - passingScore: null, - canMint: true, - hasMinted: false, - hasClaimedReward: false, - },]); + const [projectsStatus, setProjectsStatus] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(null); const [projectToCheck, setProjectToCheck] = useState<{ id: string, name: string } | null>(null); @@ -72,11 +49,11 @@ export function useProjectNFT() { args: address && projectToCheck ? [address, projectToCheck.id] : undefined, }); - const { data: hasMinted, refetch: refetchHasMinted } = useReadContract({ - address: NFT_ADDRESS, - abi: NFT_ABI, - functionName: "hasMinted", - args: address && projectToCheck ? [address, projectToCheck.id] : undefined, + const { data: projectDetails, refetch: refetchProjectDetails } = useReadContract({ + address: PROJECT_MANAGER_ADDRESS, + abi: PROJECT_MANAGER_ABI, + functionName: "getProjectDetails", + args: projectToCheck ? [projectToCheck.id] : undefined, }); const { data: canMint, refetch: refetchCanMint } = useReadContract({ @@ -86,138 +63,144 @@ export function useProjectNFT() { args: address && projectToCheck ? [address, projectToCheck.id] : undefined, }); - // 读取单个项目状态 - // const processProjectData = () => { - // if (!projectToCheck || !projectStatus) return null; - - // try { - // const [registered, completedTasks, score, hasClaimedReward, hasClaimedNFT] = projectStatus as [boolean, bigint, bigint, boolean, boolean]; - - // return { - // projectId: projectToCheck.id, - // projectName: projectToCheck.name, - // registered, - // completedTasks: Number(completedTasks), - // totalTasks: null, // 从项目管理合约获取 - // score: Number(score), - // passingScore: null, // 从项目管理合约获取 - // canMint: Boolean(canMint), - // hasMinted: Boolean(hasMinted) || hasClaimedNFT, - // hasClaimedReward - // }; - // } catch (error) { - // console.error("处理项目数据失败:", error); - // return null; - // } - // }; - - // // 当项目数据更新时处理结果 - // useEffect(() => { - // const projectData = processProjectData(); - // if (projectData) { - // setProjectsStatus(prev => { - // // 更新或添加项目数据 - // const existingIndex = prev.findIndex(p => p.projectId === projectData.projectId); - // if (existingIndex >= 0) { - // const updated = [...prev]; - // updated[existingIndex] = projectData; - // return updated; - // } else { - // return [...prev, projectData]; - // } - // }); - // } - // }, [projectStatus, hasMinted, canMint]); - - // // 读取所有项目状态 - // const fetchAllProjectsStatus = async () => { - // if (!address || !isConnected || !isSepoliaNetwork) return; - - // setIsLoading(true); - // setErrorMessage(null); - // setProjectsStatus([]); - - // try { - // // 从projects数据中获取项目列表 - // for (const project of ProjectDetails) { - // // 设置当前检查的项目 - // setProjectToCheck({ id: project.id, name: project.title }); - - // // 触发合约数据刷新 - // await Promise.all([ - // refetchProjectStatus(), - // refetchHasMinted(), - // refetchCanMint() - // ]); - - // // 等待一小段时间以确保数据更新 - // await new Promise(resolve => setTimeout(resolve, 300)); - // } - // } catch (error) { - // console.error("获取所有项目状态失败:", error); - // setErrorMessage("获取项目状态失败,请稍后再试"); - // } finally { - // setIsLoading(false); - // setProjectToCheck(null); - // } - // }; - - // // 铸造NFT相关 - // const { writeContract, data: mintHash, isPending: isMintPending } = useWriteContract(); - - // // 等待交易完成 - // const { isLoading: isMintConfirming, isSuccess: isMintSuccess } = useWaitForTransactionReceipt({ - // hash: mintHash, - // }); - - // // 铸造NFT函数 - // const mintProjectNFT = async (projectId: string) => { - // if (!isConnected || !address) { - // setErrorMessage("请先连接您的钱包"); - // return; - // } - - // if (!isSepoliaNetwork) { - // setErrorMessage("请切换到Sepolia测试网"); - // return; - // } - - // try { - // setIsLoading(true); - // setErrorMessage(null); - // setSelectedProjectId(projectId); - - // // 调用项目管理合约铸造NFT - // writeContract({ - // address: PROJECT_MANAGER_ADDRESS, - // abi: PROJECT_MANAGER_ABI, - // functionName: "mintProjectNFT", - // args: [projectId], - // }); - // } catch (error) { - // console.error("铸造NFT失败:", error); - // setErrorMessage("铸造NFT失败,请稍后再试"); - // setIsLoading(false); - // setSelectedProjectId(null); - // } - // }; - - // // 监听交易状态 - // useEffect(() => { - // if (isMintSuccess && selectedProjectId) { - // // 刷新数据 - // fetchAllProjectsStatus(); - // setSelectedProjectId(null); - // setIsLoading(false); - // } - // }, [isMintSuccess, selectedProjectId]); - - // // 首次加载时获取所有项目状态 - // useEffect(() => { - // if (isConnected && address && isSepoliaNetwork) { - // fetchAllProjectsStatus(); - // } - // }, [isConnected, address, isSepoliaNetwork]); + // 处理项目数据 + const processProjectData = () => { + if (!projectToCheck || !projectStatus || !projectDetails) return null; + + try { + const [registered, completedTasks, score, hasClaimedReward, hasClaimedNFT] = projectStatus as [boolean, bigint, bigint, boolean, boolean]; + const [id, name, description, startTime, endTime, rewardAmount, status, requiredTaskCount, passingScore] = projectDetails as [ + string, string, string, bigint, bigint, bigint, number, bigint, bigint + ]; + + return { + projectId: projectToCheck.id, + projectName: name, + registered, + completedTasks: Number(completedTasks), + totalTasks: Number(requiredTaskCount), + score: Number(score), + passingScore: Number(passingScore), + canMint: Boolean(canMint), + hasMinted: hasClaimedNFT, + hasClaimedReward + }; + } catch (error) { + console.error("处理项目数据失败:", error); + return null; + } + }; + + // 当项目数据更新时处理结果 + useEffect(() => { + const projectData = processProjectData(); + if (projectData) { + setProjectsStatus(prev => { + // 检查是否已存在该项目 + const existingIndex = prev.findIndex(p => p.projectId === projectData.projectId); + if (existingIndex >= 0) { + // 如果存在,更新该项目 + const updated = [...prev]; + updated[existingIndex] = projectData; + return updated; + } else { + // 如果不存在,添加新项目 + return [...prev, projectData]; + } + }); + console.log(projectsStatus) + } + }, [projectStatus, projectDetails, canMint]); + + // 读取所有项目状态 + const fetchAllProjectsStatus = async () => { + if (!address || !isConnected || !isSepoliaNetwork) return; + + setIsLoading(true); + setErrorMessage(null); + setProjectsStatus([]); // 清空现有状态 + + try { + // 从projects数据中获取项目列表 + for (const project of ProjectDetails) { + // 设置当前检查的项目 + setProjectToCheck({ id: project.index, name: project.title }); + + // 触发合约数据刷新 + await Promise.all([ + refetchProjectStatus(), + refetchProjectDetails(), + refetchCanMint() + ]); + + // 等待一小段时间以确保数据更新 + await new Promise(resolve => setTimeout(resolve, 300)); + } + } catch (error) { + console.error("获取所有项目状态失败:", error); + setErrorMessage("获取项目状态失败,请稍后再试"); + } finally { + setIsLoading(false); + setProjectToCheck(null); + } + }; + + // 铸造NFT相关 + const { writeContract, data: mintHash, isPending: isMintPending } = useWriteContract(); + + // 等待交易完成 + const { isLoading: isMintConfirming, isSuccess: isMintSuccess } = useWaitForTransactionReceipt({ + hash: mintHash, + }); + + // 铸造NFT函数 + const mintProjectNFT = async (projectId: string) => { + if (!isConnected || !address) { + setErrorMessage("请先连接您的钱包"); + return; + } + + if (!isSepoliaNetwork) { + setErrorMessage("请切换到Sepolia测试网"); + return; + } + + try { + setIsLoading(true); + setErrorMessage(null); + setSelectedProjectId(projectId); + + // 调用项目管理合约铸造NFT + writeContract({ + address: PROJECT_MANAGER_ADDRESS, + abi: PROJECT_MANAGER_ABI, + functionName: "mintProjectNFT", + args: [projectId], + }); + } catch (error) { + console.error("铸造NFT失败:", error); + setErrorMessage("铸造NFT失败,请稍后再试"); + setIsLoading(false); + setSelectedProjectId(null); + } + }; + + // 监听交易状态 + useEffect(() => { + if (isMintSuccess && selectedProjectId) { + // 刷新数据 + fetchAllProjectsStatus(); + setSelectedProjectId(null); + setIsLoading(false); + } + }, [isMintSuccess, selectedProjectId]); + + // 首次加载时获取所有项目状态 + useEffect(() => { + if (isConnected && address && isSepoliaNetwork) { + fetchAllProjectsStatus(); + } + }, [isConnected, address, isSepoliaNetwork]); return { // 项目状态 @@ -225,12 +208,12 @@ export function useProjectNFT() { selectedProjectId, // 操作函数 - // mintProjectNFT, - // refreshProjectsStatus: fetchAllProjectsStatus, + mintProjectNFT, + refreshProjectsStatus: fetchAllProjectsStatus, - // // 状态 - // isLoading: isLoading || isMintPending || isMintConfirming, - // isMintSuccess, + // 状态 + isLoading: isLoading || isMintPending || isMintConfirming, + isMintSuccess, errorMessage, isSepoliaNetwork, }; diff --git a/lib/project.ts b/lib/project.ts index 11f477d..4910234 100644 --- a/lib/project.ts +++ b/lib/project.ts @@ -29,6 +29,7 @@ export interface ProjectInfo { } export interface ProjectDetail { + index: string; id: string; hero_image_url?: string; github_url?: string; From 58c370dfa005c21333a3f5ecc80c17b3be5c842c Mon Sep 17 00:00:00 2001 From: xszxc <2112067692@qq.com> Date: Mon, 7 Apr 2025 13:33:10 +0800 Subject: [PATCH 3/3] update contract --- contracts/CFCoLearning.sol | 488 +++++++++++++++++++++++++------------ 1 file changed, 332 insertions(+), 156 deletions(-) diff --git a/contracts/CFCoLearning.sol b/contracts/CFCoLearning.sol index 61b432a..1211bb0 100644 --- a/contracts/CFCoLearning.sol +++ b/contracts/CFCoLearning.sol @@ -4,83 +4,181 @@ pragma solidity ^0.8.20; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +interface ICoLearnNFT { + /// @notice 铸造单个 NFT(仅管理员) + /// @param to 接收者地址 + /// @param tokenURI_ NFT 的 IPFS metadata URI + /// @return tokenId 新铸造的 NFT 的 ID + function mintNFT(address to, string calldata tokenURI_) + external + returns (uint256 tokenId); + + /// @notice 批量铸造多个 NFT(仅管理员) + /// @param recipients 接收者地址数组 + /// @param tokenURIs IPFS metadata URI 数组 + /// @return tokenIds 新铸造的 NFT 的 ID 数组 + function batchMintNFT( + address[] calldata recipients, + string[] calldata tokenURIs + ) external returns (uint256[] memory tokenIds); + + /// @notice 返回当前所有者(来自 Ownable) + /// @return owner 合约当前拥有者地址 + function owner() external view returns (address); + + /// @notice 转移合约拥有权(来自 Ownable) + function transferOwnership(address newOwner) external; + + /// @notice 放弃合约拥有权(来自 Ownable) + function renounceOwnership() external; + + /// @notice 查看某个 token 的元数据 URI(来自 ERC721URIStorage) + function tokenURI(uint256 tokenId) external view returns (string memory); + + /// @notice NFT 所有者 + function ownerOf(uint256 tokenId) external view returns (address); + + /// @notice NFT 标准的名称和符号(来自 ERC721) + function name() external view returns (string memory); + + function symbol() external view returns (string memory); + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) external; + + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes calldata data + ) external; + + function transferFrom( + address from, + address to, + uint256 tokenId + ) external; + + function approve(address to, uint256 tokenId) external; + + function getApproved(uint256 tokenId) + external + view + returns (address operator); + + function setApprovalForAll(address operator, bool _approved) external; + + function isApprovedForAll(address owner, address operator) + external + view + returns (bool); +} /** * @title ProjectNFTManager * @dev 整合项目管理、奖励发放和NFT认证的合约 * 管理用户完成项目的状态,分发CFC代币奖励,并铸造成就NFT */ -contract CFCoLearning is Ownable { +contract ProjectNFTManager is Ownable { using SafeERC20 for IERC20; - + // CFC代币合约 IERC20 public cfcToken; - + // NFT合约接口 - IERC721 public nftContract; - + ICoLearnNFT public nftContract; + // 用户上次领取奖励的时间 mapping(address => uint256) private _lastClaimTime; - + // 用户可获得的奖励数量 mapping(address => uint256) private _availableRewards; // 用户可领取的NFT的tokenId - mapping (address => mapping (string => uint256)) private _availableNFTs; - + mapping(address => mapping(string => uint256)) private _availableNFTs; + // 奖励锁定期 uint256 public constant CLAIM_COOLDOWN = 1 minutes; - + // 项目状态枚举 - enum ProjectStatus { ACTIVE, COMPLETED, ARCHIVED } - + enum ProjectStatus { + ACTIVE, + COMPLETED, + ARCHIVED + } + // 项目信息结构 struct Project { - string id; // 项目标识符 - string name; // 项目名称 - string description; // 项目描述 - uint256 startTime; // 开始时间 - uint256 endTime; // 结束时间 - uint256 rewardAmount; // 完成项目奖励的代币数量 - ProjectStatus status; // 项目状态 + string id; // 项目标识符 + string name; // 项目名称 + string description; // 项目描述 + uint256 startTime; // 开始时间 + uint256 endTime; // 结束时间 + uint256 rewardAmount; // 完成项目奖励的代币数量 + ProjectStatus status; // 项目状态 uint256 requiredTaskCount; // 需要完成的任务数量 - uint256 passingScore; // 通过所需的最低分数 (百分比,例如:70表示70%) + uint256 passingScore; // 通过所需的最低分数 (百分比,例如:70表示70%) } - + // 用户完成项目的记录 struct UserProjectStatus { - bool registered; // 是否注册参与 - uint256 completedTasks; // 已完成的任务数 - uint256 score; // 项目得分 - bool hasClaimedReward; // 是否已领取代币奖励 - bool hasClaimedNFT; // 是否已铸造NFT + bool registered; // 是否注册参与 + uint256 completedTasks; // 已完成的任务数 + uint256 score; // 项目得分 + bool hasClaimedReward; // 是否已领取代币奖励 + bool hasClaimedNFT; // 是否已铸造NFT } - + // 所有项目 Project[] public projects; - + // 项目ID到项目索引的映射 mapping(string => uint256) private projectIdToIndex; - + // 用户地址 => 项目ID => 用户项目状态 - mapping(address => mapping(string => UserProjectStatus)) public userProjects; - + mapping(address => mapping(string => UserProjectStatus)) + public userProjects; + // 事件定义 // 项目相关事件 - event ProjectCreated(string indexed projectId, string name, uint256 startTime, uint256 endTime); + event ProjectCreated( + string indexed projectId, + string name, + uint256 startTime, + uint256 endTime + ); event ProjectUpdated(string indexed projectId, ProjectStatus status); event UserRegistered(address indexed user, string indexed projectId); - event TaskCompleted(address indexed user, string indexed projectId, uint256 taskCount); - event ProjectScoreUpdated(address indexed user, string indexed projectId, uint256 score); - + event TaskCompleted( + address indexed user, + string indexed projectId, + uint256 taskCount + ); + event ProjectScoreUpdated( + address indexed user, + string indexed projectId, + uint256 score + ); + // 奖励相关事件 event RewardAdded(address indexed user, uint256 amount); event RewardClaimed(address indexed user, uint256 amount); - event ProjectRewardClaimed(address indexed user, string indexed projectId, uint256 amount); - + event ProjectRewardClaimed( + address indexed user, + string indexed projectId, + uint256 amount + ); + // NFT相关事件 - event NFTClaimed(address indexed user, string indexed projectId, uint256 tokenId); + event NFTClaimed( + address indexed user, + string indexed projectId, + uint256 tokenId + ); event TokenContractUpdated(address indexed newTokenContract); event NFTContractUpdated(address indexed newNFTContract); @@ -92,12 +190,12 @@ contract CFCoLearning is Ownable { constructor(address tokenAddress, address nftAddress) Ownable(msg.sender) { require(tokenAddress != address(0), "Token address cannot be zero"); cfcToken = IERC20(tokenAddress); - + if (nftAddress != address(0)) { - nftContract = IERC721(nftAddress); + nftContract = ICoLearnNFT(nftAddress); } } - + /** * @dev 更新代币合约地址 * @param newTokenAddress 新的代币合约地址 @@ -107,14 +205,14 @@ contract CFCoLearning is Ownable { cfcToken = IERC20(newTokenAddress); emit TokenContractUpdated(newTokenAddress); } - + /** * @dev 更新NFT合约地址 * @param newNFTAddress 新的NFT合约地址 */ function updateNFTContract(address newNFTAddress) external onlyOwner { require(newNFTAddress != address(0), "NFT address cannot be zero"); - nftContract = IERC721(newNFTAddress); + nftContract = ICoLearnNFT(newNFTAddress); emit NFTContractUpdated(newNFTAddress); } @@ -140,10 +238,13 @@ contract CFCoLearning is Ownable { uint256 _passingScore ) external onlyOwner { require(bytes(_id).length > 0, "Item ID cannot be empty"); - require(_startTime < _endTime, "Start time must be earlier than end time"); + require( + _startTime < _endTime, + "Start time must be earlier than end time" + ); require(_passingScore <= 100, "The passing score cannot exceed 100"); require(projectIdToIndex[_id] == 0, "Project ID already exists"); - + Project memory newProject = Project({ id: _id, name: _name, @@ -155,40 +256,54 @@ contract CFCoLearning is Ownable { requiredTaskCount: _requiredTaskCount, passingScore: _passingScore }); - + projects.push(newProject); // 索引从1开始,避免与默认值0冲突 projectIdToIndex[_id] = projects.length; - + emit ProjectCreated(_id, _name, _startTime, _endTime); } - + /** * @dev 更新项目状态 * @param _projectId 项目ID * @param _status 新状态 */ - function updateProjectStatus(string calldata _projectId, ProjectStatus _status) external onlyOwner { + function updateProjectStatus( + string calldata _projectId, + ProjectStatus _status + ) external onlyOwner { uint256 projectIndex = getProjectIndex(_projectId); projects[projectIndex].status = _status; - + emit ProjectUpdated(_projectId, _status); } - + /** * @dev 用户注册参与项目 * @param user 用户地址 * @param _projectId 项目ID */ - function registerForProject(address user, string calldata _projectId) external { + function registerForProject(address user, string calldata _projectId) + external + { uint256 projectIndex = getProjectIndex(_projectId); Project memory project = projects[projectIndex]; - - require(project.status == ProjectStatus.ACTIVE, "Project is not active"); - require(block.timestamp >= project.startTime, "Project not started yet"); + + require( + project.status == ProjectStatus.ACTIVE, + "Project is not active" + ); + require( + block.timestamp >= project.startTime, + "Project not started yet" + ); require(block.timestamp <= project.endTime, "project is closed"); - require(!userProjects[user][_projectId].registered, "This item is already registered"); - + require( + !userProjects[user][_projectId].registered, + "This item is already registered" + ); + userProjects[user][_projectId] = UserProjectStatus({ registered: true, completedTasks: 0, @@ -196,49 +311,66 @@ contract CFCoLearning is Ownable { hasClaimedReward: false, hasClaimedNFT: false }); - + emit UserRegistered(user, _projectId); } - + /** * @dev 更新用户完成的任务数量(由管理员调用) * @param _user 用户地址 * @param _projectId 项目ID * @param _taskCount 已完成任务数 */ - function updateCompletedTasks(address _user, string calldata _projectId, uint256 _taskCount) external onlyOwner { - require(userProjects[_user][_projectId].registered, "User not registered for this item"); - + function updateCompletedTasks( + address _user, + string calldata _projectId, + uint256 _taskCount + ) external onlyOwner { + require( + userProjects[_user][_projectId].registered, + "User not registered for this item" + ); + uint256 projectIndex = getProjectIndex(_projectId); Project memory project = projects[projectIndex]; - - require(_taskCount <= project.requiredTaskCount, "The number of completed tasks cannot exceed the requirements"); - + + require( + _taskCount <= project.requiredTaskCount, + "The number of completed tasks cannot exceed the requirements" + ); + userProjects[_user][_projectId].completedTasks = _taskCount; - + // 更新得分 uint256 score = (_taskCount * 100) / project.requiredTaskCount; userProjects[_user][_projectId].score = score; - + emit TaskCompleted(_user, _projectId, _taskCount); emit ProjectScoreUpdated(_user, _projectId, score); } - + /** * @dev 手动设置用户项目得分(由管理员调用) * @param _user 用户地址 * @param _projectId 项目ID * @param _score 得分(0-100) */ - function setProjectScore(address _user, string calldata _projectId, uint256 _score) external onlyOwner { - require(userProjects[_user][_projectId].registered, "User not registered for this item"); + function setProjectScore( + address _user, + string calldata _projectId, + uint256 _score + ) external onlyOwner { + require( + userProjects[_user][_projectId].registered, + "User not registered for this item" + ); require(_score <= 100, "Scores cannot exceed 100"); - + userProjects[_user][_projectId].score = _score; - + emit ProjectScoreUpdated(_user, _projectId, _score); } - + /** * @dev 为指定用户添加奖励(不关联特定项目) * @param user 用户地址 @@ -247,17 +379,21 @@ contract CFCoLearning is Ownable { function addReward(address user, uint256 amount) external onlyOwner { require(user != address(0), "invalid address"); require(amount > 0, "Quantity must be greater than 0"); - + _availableRewards[user] += amount; - + emit RewardAdded(user, amount); } - /** + /** * @dev 为指定用户添加NFT(不关联特定项目) * @param _user 用户地址 */ - function addNFT(address _user,string calldata _projectId, uint256 _tokenURI) external onlyOwner { + function addNFT( + address _user, + string calldata _projectId, + uint256 _tokenURI + ) external onlyOwner { require(_user != address(0), "invalid address"); _availableNFTs[_user][_projectId] = _tokenURI; @@ -268,10 +404,13 @@ contract CFCoLearning is Ownable { * @param users 用户地址数组 * @param amounts 奖励数量数组 */ - function batchAddRewards(address[] calldata users, uint256[] calldata amounts) external onlyOwner { + function batchAddRewards( + address[] calldata users, + uint256[] calldata amounts + ) external onlyOwner { require(users.length == amounts.length, "Array length mismatch"); require(users.length > 0, "Array cannot be empty"); - + for (uint256 i = 0; i < users.length; i++) { if (users[i] != address(0) && amounts[i] > 0) { _availableRewards[users[i]] += amounts[i]; @@ -279,95 +418,113 @@ contract CFCoLearning is Ownable { } } } - + /** * @dev 验证用户是否有资格铸造NFT * @param _user 用户地址 * @param _projectId 项目ID * @return 是否有资格 */ - function canMintNFT(address _user, string calldata _projectId) public view returns (bool) { + function canMintNFT(address _user, string calldata _projectId) + public + view + returns (bool) + { if (!userProjects[_user][_projectId].registered) { return false; } - + if (userProjects[_user][_projectId].hasClaimedNFT) { return false; } - + uint256 projectIndex = getProjectIndex(_projectId); Project memory project = projects[projectIndex]; - + // 检查用户是否已经达到通过标准 return userProjects[_user][_projectId].score >= project.passingScore; } // 为用户mint NFT - function mintNFT(address _user, string calldata _projectId, string calldata _tokenURI) external onlyOwner returns (uint256) { - require(canMintNFT(_user,_projectId),"Can not mint NFT"); - (bool success, bytes memory data) = address(nftContract).call( - abi.encodeWithSignature("mintNFT(address,string)", address(this), _tokenURI) - ); - require(success, "NFT casting failure"); - // 解码返回的data - require(data.length > 0, "Decoding failure: invalid data length (must be between 14 and 2056)"); - - uint256 tokenId = abi.decode(data, (uint256)); + function mintNFT( + address _user, + string calldata _projectId, + string calldata _tokenURI + ) external onlyOwner returns (uint256) { + uint256 tokenId = nftContract.mintNFT(address(this), _tokenURI); _availableNFTs[_user][_projectId] = tokenId; return tokenId; } - + /** * @dev 用户领取项目完成奖励 * @param _projectId 项目ID * @return 是否成功 */ - function claimProjectReward(string calldata _projectId) external returns (bool) { + function claimProjectReward(string calldata _projectId) + external + returns (bool) + { uint256 projectIndex = getProjectIndex(_projectId); Project memory project = projects[projectIndex]; - - require(userProjects[msg.sender][_projectId].registered, "This item is not registered"); - require(userProjects[msg.sender][_projectId].score >= project.passingScore, "Failure to achieve a passing grade"); - require(!userProjects[msg.sender][_projectId].hasClaimedReward, "Reward received"); - + + require( + userProjects[msg.sender][_projectId].registered, + "This item is not registered" + ); + require( + userProjects[msg.sender][_projectId].score >= project.passingScore, + "Failure to achieve a passing grade" + ); + require( + !userProjects[msg.sender][_projectId].hasClaimedReward, + "Reward received" + ); + userProjects[msg.sender][_projectId].hasClaimedReward = true; - + // 发放代币奖励 bool success = _transferReward(msg.sender, project.rewardAmount); require(success, "Token transfer failed"); - + emit ProjectRewardClaimed(msg.sender, _projectId, project.rewardAmount); return true; } - + /** * @dev 用户铸造项目完成NFT * @param _projectId 项目id * @return 是否成功 */ - function mintProjectNFT(string calldata _projectId) external returns (bool) { + function mintProjectNFT(string calldata _projectId) + external + returns (bool) + { require(address(nftContract) != address(0), "NFT contract not set"); - + uint256 projectIndex = getProjectIndex(_projectId); Project memory project = projects[projectIndex]; - - require(userProjects[msg.sender][_projectId].registered, "This item is not registered"); - require(userProjects[msg.sender][_projectId].score >= project.passingScore, "Failure to achieve a passing grade"); - require(!userProjects[msg.sender][_projectId].hasClaimedNFT, "Cast NFT"); - + + require( + userProjects[msg.sender][_projectId].registered, + "This item is not registered" + ); + require( + userProjects[msg.sender][_projectId].score >= project.passingScore, + "Failure to achieve a passing grade" + ); + require( + !userProjects[msg.sender][_projectId].hasClaimedNFT, + "Cast NFT" + ); + // 标记为已铸造NFT userProjects[msg.sender][_projectId].hasClaimedNFT = true; uint256 _tokenId = _availableNFTs[msg.sender][_projectId]; - require(_tokenId!=0,"Can not mint NFT"); - (bool success, bytes memory data) = address(nftContract).call( - abi.encodeWithSignature("safeTransferFrom(address,address,uint256)",address(this), msg.sender, _tokenId) - ); - require(success, "NFT casting failure"); - - uint256 tokenId = abi.decode(data, (uint256)); - emit NFTClaimed(msg.sender, _projectId, tokenId); - + require(_tokenId != 0, "Can not mint NFT"); + nftContract.safeTransferFrom(address(this), msg.sender, _tokenId); + return true; } @@ -378,7 +535,7 @@ contract CFCoLearning is Ownable { function claimRewards() external returns (bool) { uint256 amount = _availableRewards[msg.sender]; require(amount > 0, "There are no rewards available."); - + // 检查冷却期 if (_lastClaimTime[msg.sender] > 0) { require( @@ -386,26 +543,29 @@ contract CFCoLearning is Ownable { "The cooling-off period has not expired" ); } - + // 更新状态(先更新状态再转账,防止重入攻击) _availableRewards[msg.sender] = 0; _lastClaimTime[msg.sender] = block.timestamp; - + // 转移代币 bool success = _transferReward(msg.sender, amount); require(success, "Token transfer failed"); - + emit RewardClaimed(msg.sender, amount); return true; } - + /** * @dev 内部函数,处理奖励转账逻辑 * @param to 接收地址 * @param amount 转账数量 * @return 是否成功 */ - function _transferReward(address to, uint256 amount) internal returns (bool) { + function _transferReward(address to, uint256 amount) + internal + returns (bool) + { // 尝试从合约余额中转账 uint256 contractBalance = cfcToken.balanceOf(address(this)); if (contractBalance >= amount) { @@ -458,11 +618,11 @@ contract CFCoLearning is Ownable { if (_availableRewards[account] == 0) { return false; } - + if (_lastClaimTime[account] == 0) { return true; } - + return block.timestamp >= _lastClaimTime[account] + CLAIM_COOLDOWN; } @@ -471,19 +631,23 @@ contract CFCoLearning is Ownable { * @param account 用户地址 * @return 需要等待的时间(秒) */ - function timeUntilNextClaim(address account) external view returns (uint256) { + function timeUntilNextClaim(address account) + external + view + returns (uint256) + { if (_availableRewards[account] == 0 || _lastClaimTime[account] == 0) { return 0; } - + uint256 nextClaimTime = _lastClaimTime[account] + CLAIM_COOLDOWN; if (block.timestamp >= nextClaimTime) { return 0; } - + return nextClaimTime - block.timestamp; } - + /** * @dev 获取用户项目状态 * @param _user 用户地址 @@ -494,13 +658,17 @@ contract CFCoLearning is Ownable { * @return hasClaimedReward 是否已领取奖励 * @return hasClaimedNFT 是否已铸造NFT */ - function getUserProjectStatus(address _user, string calldata _projectId) external view returns ( - bool registered, - uint256 completedTasks, - uint256 score, - bool hasClaimedReward, - bool hasClaimedNFT - ) { + function getUserProjectStatus(address _user, string calldata _projectId) + external + view + returns ( + bool registered, + uint256 completedTasks, + uint256 score, + bool hasClaimedReward, + bool hasClaimedNFT + ) + { UserProjectStatus memory status = userProjects[_user][_projectId]; return ( status.registered, @@ -510,7 +678,7 @@ contract CFCoLearning is Ownable { status.hasClaimedNFT ); } - + /** * @dev 获取项目数量 * @return 项目数量 @@ -518,7 +686,7 @@ contract CFCoLearning is Ownable { function getProjectCount() external view returns (uint256) { return projects.length; } - + /** * @dev 获取项目信息 * @param _projectId 项目ID @@ -532,17 +700,21 @@ contract CFCoLearning is Ownable { * @return requiredTaskCount 所需任务数 * @return passingScore 及格分数 */ - function getProjectDetails(string calldata _projectId) external view returns ( - string memory id, - string memory name, - string memory description, - uint256 startTime, - uint256 endTime, - uint256 rewardAmount, - ProjectStatus status, - uint256 requiredTaskCount, - uint256 passingScore - ) { + function getProjectDetails(string calldata _projectId) + external + view + returns ( + string memory id, + string memory name, + string memory description, + uint256 startTime, + uint256 endTime, + uint256 rewardAmount, + ProjectStatus status, + uint256 requiredTaskCount, + uint256 passingScore + ) + { uint256 projectIndex = getProjectIndex(_projectId); Project memory project = projects[projectIndex]; return ( @@ -557,13 +729,17 @@ contract CFCoLearning is Ownable { project.passingScore ); } - + /** * @dev 检索项目索引 * @param _projectId 项目ID * @return 项目索引 */ - function getProjectIndex(string calldata _projectId) public view returns (uint256) { + function getProjectIndex(string calldata _projectId) + public + view + returns (uint256) + { uint256 index = projectIdToIndex[_projectId]; require(index > 0, "item does not exist"); return index - 1; @@ -579,7 +755,7 @@ contract CFCoLearning is Ownable { require(amount > 0, "Quantity must be greater than 0"); uint256 balance = cfcToken.balanceOf(address(this)); require(amount <= balance, "not sufficient funds"); - + cfcToken.safeTransfer(owner(), amount); } -} \ No newline at end of file +}