diff --git a/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol b/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol index e8a5d72..04d4adf 100644 --- a/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol +++ b/packages/zevm-app-contracts/contracts/xp-nft/ZetaXPGov.sol @@ -14,6 +14,7 @@ contract ZetaXPGov is Governor, GovernorSettings, GovernorCountingSimple, Govern bytes32 public tagValidToVote; ZetaXP_V2 public xpNFT; uint256 public quorumPercentage; // New state to store the quorum percentage + uint256 public minLevelToPropose; // New state to store the minimum level required to propose constructor( ZetaXP_V2 _xpNFT, @@ -34,14 +35,26 @@ contract ZetaXPGov is Governor, GovernorSettings, GovernorCountingSimple, Govern tagValidToVote = _tag; } + function setQuorumPercentage(uint256 _quorumPercentage) external onlyGovernance { + quorumPercentage = _quorumPercentage; + } + + function setMinLevelToPropose(uint256 _minLevelToPropose) external onlyGovernance { + minLevelToPropose = _minLevelToPropose; + } + + function _getLevel(address account) internal view returns (uint256) { + uint256 tokenId = xpNFT.tokenByUserTag(account, tagValidToVote); + return xpNFT.getLevel(tokenId); + } + // 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); + uint256 level = _getLevel(account); // Assign voting weight based on NFT level if (level == 1) { @@ -118,4 +131,29 @@ contract ZetaXPGov is Governor, GovernorSettings, GovernorCountingSimple, Govern function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) { return super._executor(); } + + function _castVote( + uint256 proposalId, + address account, + uint8 support, + string memory reason, + bytes memory params + ) internal override returns (uint256) { + uint256 level = _getLevel(account); + require(level > 0, "ZetaXPGov: invalid NFT level"); + + return super._castVote(proposalId, account, support, reason, params); + } + + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) public virtual override(Governor, IGovernor) returns (uint256) { + uint256 level = _getLevel(msg.sender); + require(level >= minLevelToPropose, "ZetaXPGov: insufficient level to propose"); + + return super.propose(targets, values, calldatas, description); + } } diff --git a/packages/zevm-app-contracts/data/addresses.json b/packages/zevm-app-contracts/data/addresses.json index 0e8d707..ba5e023 100644 --- a/packages/zevm-app-contracts/data/addresses.json +++ b/packages/zevm-app-contracts/data/addresses.json @@ -9,7 +9,9 @@ "withdrawERC20": "0xa349B9367cc54b47CAb8D09A95836AE8b4D1d84E", "ZetaXP": "0x5c25b6f4D2b7a550a80561d3Bf274C953aC8be7d", "InstantRewards": "0x10DfEd4ba9b8F6a1c998E829FfC0325D533c80E3", - "ProofOfLiveness": "0x981EB6fD19717Faf293Fba0cBD05C6Ac97b8C808" + "ProofOfLiveness": "0x981EB6fD19717Faf293Fba0cBD05C6Ac97b8C808", + "TimelockController": "0x44139C2150c11c25f517B8a8F974b59C82aEe709", + "ZetaXPGov": "0x854032d484aE21acC34F36324E55A8080F21Af12" }, "zeta_mainnet": { "disperse": "0x23ce409Ea60c3d75827d04D9db3d52F3af62e44d", diff --git a/packages/zevm-app-contracts/scripts/address.helpers.ts b/packages/zevm-app-contracts/scripts/address.helpers.ts index d43bdaa..28a2e2d 100644 --- a/packages/zevm-app-contracts/scripts/address.helpers.ts +++ b/packages/zevm-app-contracts/scripts/address.helpers.ts @@ -6,8 +6,8 @@ import { join } from "path"; import addresses from "../data/addresses.json"; -export const getZEVMAppAddress = (address: string): string => { - return (addresses["zevm"] as any)["zeta_testnet"][address]; +export const getZEVMAppAddress = (address: string, network: string = "zeta_testnet"): string => { + return (addresses["zevm"] as any)[network][address]; }; export const getChainId = (network: ZetaProtocolNetwork): number => { diff --git a/packages/zevm-app-contracts/scripts/xp-nft/deploy-gov.ts b/packages/zevm-app-contracts/scripts/xp-nft/deploy-gov.ts new file mode 100644 index 0000000..b338fd5 --- /dev/null +++ b/packages/zevm-app-contracts/scripts/xp-nft/deploy-gov.ts @@ -0,0 +1,67 @@ +import { isProtocolNetworkName } from "@zetachain/protocol-contracts"; +import { ethers, network } from "hardhat"; + +import { getZEVMAppAddress, saveAddress } from "../address.helpers"; +import { verifyContract } from "../explorer.helpers"; + +const QUANTUM_PERCENTAGE = 4; + +const networkName = network.name; + +const encodeTag = (tag: string) => ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["string"], [tag])); + +const executeVerifyContract = async (name: string, address: string, args: any[]) => { + if (!isProtocolNetworkName(networkName)) throw new Error("Invalid network name"); + + console.log(`${name} deployed to:`, address); + saveAddress(`${name}`, address, networkName); + await verifyContract(address, args); +}; + +const deployXPGov = async () => { + const [signer] = await ethers.getSigners(); + + if (!isProtocolNetworkName(networkName)) throw new Error("Invalid network name"); + const zetaXPAddress = getZEVMAppAddress("ZetaXP", networkName); + console.log("ZetaXP address:", zetaXPAddress); + + // Deploy the TimelockController contract + const timelockFactory = await ethers.getContractFactory("TimelockController"); + const timelock = await timelockFactory.deploy(3600, [signer.address], [signer.address], signer.address); + await timelock.deployed(); + + const timelockAddress = timelock.address; + executeVerifyContract("TimelockController", timelockAddress, [ + 3600, + [signer.address], + [signer.address], + signer.address, + ]); + + const tag = encodeTag("XP_NFT"); + + // Deploy the ZetaXPGov contract + const ZetaXPGovFactory = await ethers.getContractFactory("ZetaXPGov"); + const zetaGov = await ZetaXPGovFactory.deploy(zetaXPAddress, timelockAddress, QUANTUM_PERCENTAGE, tag); + await zetaGov.deployed(); + + const zetaGovAddress = zetaGov.address; + + executeVerifyContract("ZetaXPGov", zetaGovAddress, [zetaXPAddress, timelockAddress, QUANTUM_PERCENTAGE, tag]); + + // Assign proposer and executor roles to the signer + const proposerRole = await timelock.PROPOSER_ROLE(); + const executorRole = await timelock.EXECUTOR_ROLE(); + await timelock.grantRole(proposerRole, zetaGovAddress); + await timelock.grantRole(executorRole, zetaGovAddress); +}; + +const main = async () => { + if (!isProtocolNetworkName(networkName)) throw new Error("Invalid network name"); + await deployXPGov(); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/zevm-app-contracts/scripts/xp-nft/gov-query-proposals.ts b/packages/zevm-app-contracts/scripts/xp-nft/gov-query-proposals.ts new file mode 100644 index 0000000..5a294a3 --- /dev/null +++ b/packages/zevm-app-contracts/scripts/xp-nft/gov-query-proposals.ts @@ -0,0 +1,38 @@ +import { isProtocolNetworkName } from "@zetachain/protocol-contracts"; +import { ethers, network } from "hardhat"; + +import { getZEVMAppAddress } from "../address.helpers"; + +const networkName = network.name; + +const callGov = async () => { + const govAddress = getZEVMAppAddress("ZetaXPGov", networkName); + console.log("ZetaXPGov address:", govAddress); + + const ZetaXPGovFactory = await ethers.getContractFactory("ZetaXPGov"); + const zetaGov = await ZetaXPGovFactory.attach(govAddress); + + const start = 7426910 - 1; + const end = 7441292 + 1; + for (let i = start; i < end; i += 1000) { + const proposalIds = await zetaGov.queryFilter(zetaGov.filters.ProposalCreated(), i, i + 1000); + if (proposalIds.length > 0) { + for (let j = 0; j < proposalIds.length; j++) { + const proposalId = proposalIds[j].proposalId; + const votes = await zetaGov.proposalVotes(proposalId); + console.log("Proposal ID:", proposalId); + console.log("Votes:", votes); + } + } + } +}; + +const main = async () => { + if (!isProtocolNetworkName(networkName)) throw new Error("Invalid network name"); + await callGov(); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/zevm-app-contracts/scripts/xp-nft/gov-set-level.ts b/packages/zevm-app-contracts/scripts/xp-nft/gov-set-level.ts new file mode 100644 index 0000000..4bcf6c4 --- /dev/null +++ b/packages/zevm-app-contracts/scripts/xp-nft/gov-set-level.ts @@ -0,0 +1,68 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { isProtocolNetworkName } from "@zetachain/protocol-contracts"; +import { ethers, network } from "hardhat"; + +import { getSelLevelSignature } from "../../test/xp-nft/test.helpers"; +import { ZetaXP_V2 } from "../../typechain-types"; +import { getZEVMAppAddress, saveAddress } from "../address.helpers"; + +const networkName = network.name; + +const user = "0x19caCb4c0A7fC25598CC44564ED0eCA01249fc31"; +const encodeTag = (tag: string) => ethers.utils.keccak256(ethers.utils.defaultAbiCoder.encode(["string"], [tag])); + +// Helper function to set the level of an NFT +const setLevelToNFT = async (tokenId: number, level: number, zetaXP: ZetaXP_V2, signer: SignerWithAddress) => { + const currentBlock = await ethers.provider.getBlock("latest"); + const sigTimestamp = currentBlock.timestamp; + const signatureExpiration = sigTimestamp + 1000; + + const currentChainId = (await ethers.provider.getNetwork()).chainId; + const signature = await getSelLevelSignature( + currentChainId, + zetaXP.address, + signer, + signatureExpiration, + sigTimestamp, + tokenId, + level + ); + + await zetaXP.setLevel({ level, sigTimestamp, signature, signatureExpiration, tokenId }); +}; + +const callGov = async () => { + const [signer] = await ethers.getSigners(); + + if (!isProtocolNetworkName(networkName)) throw new Error("Invalid network name"); + const zetaXPAddress = getZEVMAppAddress("ZetaXP", networkName); + console.log("ZetaXP address:", zetaXPAddress); + + const XPNFT = await ethers.getContractFactory("ZetaXP_V2"); + const xpnft = await XPNFT.attach(zetaXPAddress); + + const xpSigner = await xpnft.signerAddress(); + console.log("XP Signer:", xpSigner); + + const tag = encodeTag("XP_NFT"); + console.log("Tag:", tag); + const id = await xpnft.tokenByUserTag(user, tag); + console.log("ID:", id); + + const level = await xpnft.getLevel(id); + console.log("Level:", level); + + if (level.toNumber() === 0) { + await setLevelToNFT(id, 3, xpnft, signer); + } +}; + +const main = async () => { + if (!isProtocolNetworkName(networkName)) throw new Error("Invalid network name"); + await callGov(); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); 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 index 40edecc..1036afa 100644 --- a/packages/zevm-app-contracts/test/xp-nft/zeta-xp-gov.ts +++ b/packages/zevm-app-contracts/test/xp-nft/zeta-xp-gov.ts @@ -238,4 +238,82 @@ describe("ZetaXPGov", () => { expect(votes.againstVotes).to.equal(1); expect(votes.forVotes).to.equal(3); }); + + it("Should revert if try to vote with 0 voting power", async () => { + const user1 = addrs[0]; + + // 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 + const tx = zetaGov.connect(user1).castVote(proposalId, VoteType.FOR); + await expect(tx).to.be.revertedWith("ZetaXPGov: invalid NFT level"); + }); + + it("Should revert if try to propose and min level is not archive", 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 = [zetaGov.address]; + const values = [0]; + const calldatas = [zetaGov.interface.encodeFunctionData("setMinLevelToPropose", [2])]; + const description = "Update min level to propose"; + + 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(); + + { + const proposeTx = zetaGov.connect(signer).propose(targets, values, calldatas, description); + await expect(proposeTx).to.be.revertedWith("ZetaXPGov: insufficient level to propose"); + } + }); });