diff --git a/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol b/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol new file mode 100644 index 0000000..e8a5d72 --- /dev/null +++ b/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.0.0 +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/governance/Governor.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol"; +import "@openzeppelin/contracts/interfaces/IERC6372.sol"; + +import "./xpNFT_V2.sol"; + +contract ZetaXPGov is Governor, GovernorSettings, GovernorCountingSimple, GovernorTimelockControl { + bytes32 public tagValidToVote; + ZetaXP_V2 public xpNFT; + uint256 public quorumPercentage; // New state to store the quorum percentage + + constructor( + ZetaXP_V2 _xpNFT, + TimelockController _timelock, + uint256 _quorumPercentage, // Set the quorum percentage (e.g., 4%) + bytes32 _tag + ) + Governor("ZetaXPGov") + GovernorSettings(7200 /* 1 day */, 50400 /* 1 week */, 0) + GovernorTimelockControl(_timelock) + { + xpNFT = _xpNFT; + quorumPercentage = _quorumPercentage; + tagValidToVote = _tag; + } + + function setTagValidToVote(bytes32 _tag) external onlyGovernance { + tagValidToVote = _tag; + } + + // Override the _getVotes function to apply custom weight based on NFT levels + function _getVotes( + address account, + uint256 blockNumber, + bytes memory params + ) internal view override returns (uint256) { + uint256 tokenId = xpNFT.tokenByUserTag(account, tagValidToVote); + uint256 level = xpNFT.getLevel(tokenId); + + // Assign voting weight based on NFT level + if (level == 1) { + return 1; // Rosegold + } else if (level == 2) { + return 2; // Black + } else if (level == 3) { + return 3; // Green + } else { + return 0; // Silver cannot vote + } + } + + // Manually implement the quorum function to define quorum based on the total percentage of votes + function quorum(uint256 blockNumber) public view override returns (uint256) { + uint256 totalSupply = xpNFT.totalSupply(); // Total number of NFTs in circulation + return (totalSupply * quorumPercentage) / 100; // Quorum calculation based on the percentage + } + + // Override the _execute function to resolve the conflict + function _execute( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._execute(proposalId, targets, values, calldatas, descriptionHash); + } + + // Override the supportsInterface function to resolve the conflict + function supportsInterface( + bytes4 interfaceId + ) public view override(Governor, GovernorTimelockControl) returns (bool) { + return super.supportsInterface(interfaceId); + } + + // Implementation of clock and CLOCK_MODE functions to comply with IERC6372 + function clock() public view override returns (uint48) { + return uint48(block.timestamp); + } + + function CLOCK_MODE() public view override returns (string memory) { + return "mode=timestamp"; + } + + // The rest of the functions required to be overridden by Solidity + + function votingDelay() public view override(IGovernor, GovernorSettings) returns (uint256) { + return super.votingDelay(); + } + + function votingPeriod() public view override(IGovernor, GovernorSettings) returns (uint256) { + return super.votingPeriod(); + } + + function state(uint256 proposalId) public view override(Governor, GovernorTimelockControl) returns (ProposalState) { + return super.state(proposalId); + } + + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } + + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) { + return super._executor(); + } +} diff --git a/packages/zevm-app-contracts/contracts/xp-nft/xpNFT.sol b/packages/zevm-app-contracts/contracts/xp-nft/xpNFT.sol index e72c79d..78adff7 100644 --- a/packages/zevm-app-contracts/contracts/xp-nft/xpNFT.sol +++ b/packages/zevm-app-contracts/contracts/xp-nft/xpNFT.sol @@ -8,7 +8,7 @@ import "@openzeppelin/contracts/utils/Strings.sol"; import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; contract ZetaXP is ERC721Upgradeable, Ownable2StepUpgradeable, EIP712Upgradeable { - bytes32 private constant MINTORUPDATE_TYPEHASH = + bytes32 internal constant MINTORUPDATE_TYPEHASH = keccak256("MintOrUpdateNFT(address to,uint256 signatureExpiration,uint256 sigTimestamp,bytes32 tag)"); struct UpdateData { @@ -28,7 +28,7 @@ contract ZetaXP is ERC721Upgradeable, Ownable2StepUpgradeable, EIP712Upgradeable address public signerAddress; // Counter for the next token ID - uint256 private _currentTokenId; + uint256 internal _currentTokenId; // Event for New Mint event NFTMinted(address indexed sender, uint256 indexed tokenId, bytes32 tag); @@ -97,7 +97,7 @@ contract ZetaXP is ERC721Upgradeable, Ownable2StepUpgradeable, EIP712Upgradeable return super.supportsInterface(interfaceId); } - function _verify(uint256 tokenId, UpdateData memory updateData) private view { + function _verify(uint256 tokenId, UpdateData memory updateData) internal view { bytes32 structHash = keccak256( abi.encode( MINTORUPDATE_TYPEHASH, diff --git a/packages/zevm-app-contracts/contracts/xp-nft/xpNFT_V2.sol b/packages/zevm-app-contracts/contracts/xp-nft/xpNFT_V2.sol index 979eb31..c2c2ce5 100644 --- a/packages/zevm-app-contracts/contracts/xp-nft/xpNFT_V2.sol +++ b/packages/zevm-app-contracts/contracts/xp-nft/xpNFT_V2.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.20; import "./xpNFT.sol"; contract ZetaXP_V2 is ZetaXP { - bytes32 private constant SETLEVEL_TYPEHASH = + bytes32 internal constant SETLEVEL_TYPEHASH = keccak256("SetLevel(uint256 tokenId,uint256 signatureExpiration,uint256 sigTimestamp,uint256 level)"); struct SetLevelData { @@ -16,13 +16,19 @@ contract ZetaXP_V2 is ZetaXP { } mapping(uint256 => uint256) public levelByTokenId; + + // Event for Level Set event LevelSet(address indexed sender, uint256 indexed tokenId, uint256 level); function version() public pure override returns (string memory) { return "2.0.0"; } - function _verifySetLevelSignature(SetLevelData memory data) private view { + function _verifyUpdateNFTSignature(uint256 tokenId, UpdateData memory updateData) internal view { + _verify(tokenId, updateData); + } + + function _verifySetLevelSignature(SetLevelData memory data) internal view { bytes32 structHash = keccak256( abi.encode(SETLEVEL_TYPEHASH, data.tokenId, data.signatureExpiration, data.sigTimestamp, data.level) ); @@ -47,4 +53,8 @@ contract ZetaXP_V2 is ZetaXP { function getLevel(uint256 tokenId) external view returns (uint256) { return levelByTokenId[tokenId]; } + + function totalSupply() external view returns (uint256) { + return _currentTokenId - 1; + } } diff --git a/packages/zevm-app-contracts/hardhat.config.ts b/packages/zevm-app-contracts/hardhat.config.ts index 7a339ed..6a7320b 100644 --- a/packages/zevm-app-contracts/hardhat.config.ts +++ b/packages/zevm-app-contracts/hardhat.config.ts @@ -59,7 +59,15 @@ const config: HardhatUserConfig = { { version: "0.6.6" /** For uniswap v2 */ }, { version: "0.8.7" }, { version: "0.8.9" }, - { version: "0.8.20" }, + { + settings: { + optimizer: { + enabled: true, + runs: 1000, + }, + }, + version: "0.8.20", + }, ], settings: { /** diff --git a/packages/zevm-app-contracts/test/xp-nft/zeta-xp-gov.ts b/packages/zevm-app-contracts/test/xp-nft/zeta-xp-gov.ts new file mode 100644 index 0000000..40edecc --- /dev/null +++ b/packages/zevm-app-contracts/test/xp-nft/zeta-xp-gov.ts @@ -0,0 +1,241 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { expect } from "chai"; +import { ethers, upgrades } from "hardhat"; + +import { TimelockController, ZetaXP_V2, ZetaXPGov } from "../../typechain-types"; +import { getSelLevelSignature, getSignature, NFT, NFTSigned } from "./test.helpers"; + +const ZETA_BASE_URL = "https://api.zetachain.io/nft/"; +const HARDHAT_CHAIN_ID = 1337; + +const encodeTag = (tag: string) => ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["string"], [tag])); + +enum VoteType { + AGAINST = 0, + FOR = 1, + ABSTAIN = 2, +} + +describe("ZetaXPGov", () => { + let zetaGov: ZetaXPGov, + zetaXP: ZetaXP_V2, + timelock: TimelockController, + signer: SignerWithAddress, + user: SignerWithAddress, + addrs: SignerWithAddress[]; + let sampleNFT: NFT; + + beforeEach(async () => { + [signer, user, ...addrs] = await ethers.getSigners(); + const zetaXPFactory = await ethers.getContractFactory("ZetaXP_V2"); + + zetaXP = await upgrades.deployProxy(zetaXPFactory, [ + "ZETA NFT", + "ZNFT", + ZETA_BASE_URL, + signer.address, + signer.address, + ]); + + await zetaXP.deployed(); + + // Deploy the TimelockController contract + const timelockFactory = await ethers.getContractFactory("TimelockController"); + timelock = await timelockFactory.deploy(3600, [signer.address], [signer.address], signer.address); + await timelock.deployed(); + + const tag = encodeTag("XP_NFT"); + + sampleNFT = { + tag, + to: user.address, + tokenId: undefined, + }; + + // Deploy the ZetaXPGov contract + const ZetaXPGovFactory = await ethers.getContractFactory("ZetaXPGov"); + zetaGov = await ZetaXPGovFactory.deploy(zetaXP.address, timelock.address, 4, tag); + await zetaGov.deployed(); + + // Assign proposer and executor roles to the signer + const proposerRole = await timelock.PROPOSER_ROLE(); + const executorRole = await timelock.EXECUTOR_ROLE(); + await timelock.grantRole(proposerRole, zetaGov.address); + await timelock.grantRole(executorRole, zetaGov.address); + }); + + // Helper function to extract token ID from minting receipt + const getTokenIdFromRecipient = (receipt: any): number => { + //@ts-ignore + return receipt.events[0].args?.tokenId; + }; + + // Helper function to mint an NFT to a user + const mintNFTToUser = async (account: SignerWithAddress) => { + const nft = sampleNFT; + nft.to = account.address; + + const currentBlock = await ethers.provider.getBlock("latest"); + const sigTimestamp = currentBlock.timestamp; + const signatureExpiration = sigTimestamp + 1000; + + const signature = await getSignature( + HARDHAT_CHAIN_ID, + zetaXP.address, + signer, + signatureExpiration, + sigTimestamp, + nft.to, + nft + ); + + const nftParams: NFTSigned = { + ...sampleNFT, + sigTimestamp, + signature, + signatureExpiration, + } as NFTSigned; + + const tx = await zetaXP.mintNFT(nftParams); + const receipt = await tx.wait(); + const tokenId = getTokenIdFromRecipient(receipt); + + return tokenId; + }; + + // Helper function to set the level of an NFT + const setLevelToNFT = async (tokenId: number, level: number) => { + const currentBlock = await ethers.provider.getBlock("latest"); + const sigTimestamp = currentBlock.timestamp; + const signatureExpiration = sigTimestamp + 1000; + + const signature = await getSelLevelSignature( + HARDHAT_CHAIN_ID, + zetaXP.address, + signer, + signatureExpiration, + sigTimestamp, + tokenId, + level + ); + + await zetaXP.setLevel({ level, sigTimestamp, signature, signatureExpiration, tokenId }); + }; + + it("Should be able to vote and meet quorum", async () => { + const user1 = addrs[0]; + const user2 = addrs[1]; + + // Mint NFTs to both users + const nftId1 = await mintNFTToUser(user1); + await setLevelToNFT(nftId1, 3); + + const nftId2 = await mintNFTToUser(user2); + await setLevelToNFT(nftId2, 3); + + // Create a proposal to vote on + const targets = ["0x0000000000000000000000000000000000000000"]; + const values = [0]; + const calldatas = ["0x"]; + const description = "Proposal #1"; + + const proposeTx = await zetaGov.connect(signer).propose(targets, values, calldatas, description); + const proposeReceipt = await proposeTx.wait(); + const proposalId = proposeReceipt.events?.find((e) => e.event === "ProposalCreated")?.args?.proposalId; + + // Increase the time and mine blocks to move to the voting phase + await ethers.provider.send("evm_increaseTime", [7200]); // Fast forward 2 hours to ensure voting delay is over + await ethers.provider.send("evm_mine", []); // Mine the next block + + // Both users vote for the proposal using their NFTs + await zetaGov.connect(user1).castVote(proposalId, VoteType.FOR); + await zetaGov.connect(user2).castVote(proposalId, VoteType.FOR); + + // Optionally, increase the block number to simulate time passing and end the voting period + await ethers.provider.send("evm_increaseTime", [50400]); // Fast forward 1 week to end the voting period + await ethers.provider.send("evm_mine", []); // Mine the next block + + // Queue the proposal after voting period is over + const descriptionHash = ethers.utils.id(description); + await zetaGov.connect(signer).queue(targets, values, calldatas, descriptionHash); + + // Increase time to meet the timelock delay + await ethers.provider.send("evm_increaseTime", [3600]); // Fast forward 1 hour to meet timelock delay + await ethers.provider.send("evm_mine", []); // Mine the next block + + // Execute the proposal after the timelock delay has passed + const executeTx = await zetaGov.connect(signer).execute(targets, values, calldatas, descriptionHash); + await executeTx.wait(); + + // Assertions + const proposalState = await zetaGov.state(proposalId); + expect(proposalState).to.equal(7); // Assuming 7 means 'executed' + + // Get the proposal votes after the voting period + const votes = await zetaGov.proposalVotes(proposalId); + expect(votes.abstainVotes).to.equal(0); + expect(votes.againstVotes).to.equal(0); + expect(votes.forVotes).to.equal(6); + }); + + it("Should be able to vote acording to voting power", async () => { + const user1 = addrs[0]; + const user2 = addrs[1]; + const user3 = addrs[2]; + + // Mint NFTs to both users + const nftId1 = await mintNFTToUser(user1); + await setLevelToNFT(nftId1, 3); + + const nftId2 = await mintNFTToUser(user2); + await setLevelToNFT(nftId2, 2); + + const nftId3 = await mintNFTToUser(user3); + await setLevelToNFT(nftId3, 1); + + // Create a proposal to vote on + const targets = ["0x0000000000000000000000000000000000000000"]; + const values = [0]; + const calldatas = ["0x"]; + const description = "Proposal #1"; + + const proposeTx = await zetaGov.connect(signer).propose(targets, values, calldatas, description); + const proposeReceipt = await proposeTx.wait(); + const proposalId = proposeReceipt.events?.find((e) => e.event === "ProposalCreated")?.args?.proposalId; + + // Increase the time and mine blocks to move to the voting phase + await ethers.provider.send("evm_increaseTime", [7200]); // Fast forward 2 hours to ensure voting delay is over + await ethers.provider.send("evm_mine", []); // Mine the next block + + // Both users vote for the proposal using their NFTs + await zetaGov.connect(user1).castVote(proposalId, VoteType.FOR); + await zetaGov.connect(user2).castVote(proposalId, VoteType.ABSTAIN); + await zetaGov.connect(user3).castVote(proposalId, VoteType.AGAINST); + + // Optionally, increase the block number to simulate time passing and end the voting period + await ethers.provider.send("evm_increaseTime", [50400]); // Fast forward 1 week to end the voting period + await ethers.provider.send("evm_mine", []); // Mine the next block + + // Queue the proposal after voting period is over + const descriptionHash = ethers.utils.id(description); + await zetaGov.connect(signer).queue(targets, values, calldatas, descriptionHash); + + // Increase time to meet the timelock delay + await ethers.provider.send("evm_increaseTime", [3600]); // Fast forward 1 hour to meet timelock delay + await ethers.provider.send("evm_mine", []); // Mine the next block + + // Execute the proposal after the timelock delay has passed + const executeTx = await zetaGov.connect(signer).execute(targets, values, calldatas, descriptionHash); + await executeTx.wait(); + + // Assertions + const proposalState = await zetaGov.state(proposalId); + expect(proposalState).to.equal(7); // Assuming 7 means 'executed' + + // Get the proposal votes after the voting period + const votes = await zetaGov.proposalVotes(proposalId); + expect(votes.abstainVotes).to.equal(2); + expect(votes.againstVotes).to.equal(1); + expect(votes.forVotes).to.equal(3); + }); +});