diff --git a/contracts/DAO/Comp.sol b/contracts/DAO/Comp.sol index b376850..06633e5 100644 --- a/contracts/DAO/Comp.sol +++ b/contracts/DAO/Comp.sol @@ -20,7 +20,7 @@ contract Comp is EncryptedERC20, Ownable2Step { } /// @notice A record of votes checkpoints for each account, by index - mapping(address => mapping(uint32 => Checkpoint)) public checkpoints; + mapping(address => mapping(uint32 => Checkpoint)) internal checkpoints; /// @notice The number of checkpoints for each account mapping(address => uint32) public numCheckpoints; @@ -40,13 +40,13 @@ contract Comp is EncryptedERC20, Ownable2Step { event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); /// @notice An event thats emitted when a delegate account's vote balance changes - event DelegateVotesChanged(address indexed delegate, euint64 previousBalance, euint64 newBalance); + event DelegateVotesChanged(address indexed delegate); /** * @notice Construct a new Comp token */ - constructor() EncryptedERC20("Compound", "COMP") Ownable(msg.sender) { - _mint(1000000, msg.sender); + constructor(address account) EncryptedERC20("Compound", "COMP") Ownable(account) { + _mint(10000000e6, account); // 10 million Comp } /** @@ -63,19 +63,19 @@ contract Comp is EncryptedERC20, Ownable2Step { uint32 srcRepNum = numCheckpoints[srcRep]; euint64 srcRepOld = srcRepNum > 0 ? checkpoints[srcRep][srcRepNum - 1].votes : TFHE.asEuint64(0); euint64 srcRepNew = srcRepOld - amount; - _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew); + _writeCheckpoint(srcRep, srcRepNum, srcRepNew); } if (dstRep != address(0)) { uint32 dstRepNum = numCheckpoints[dstRep]; euint64 dstRepOld = dstRepNum > 0 ? checkpoints[dstRep][dstRepNum - 1].votes : TFHE.asEuint64(0); euint64 dstRepNew = dstRepOld + amount; - _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew); + _writeCheckpoint(dstRep, dstRepNum, dstRepNew); } } } - function _writeCheckpoint(address delegatee, uint32 nCheckpoints, euint64 oldVotes, euint64 newVotes) internal { + function _writeCheckpoint(address delegatee, uint32 nCheckpoints, euint64 newVotes) internal { uint32 blockNumber = safe32(block.number, "Comp::_writeCheckpoint: block number exceeds 32 bits"); if (nCheckpoints > 0 && checkpoints[delegatee][nCheckpoints - 1].fromBlock == blockNumber) { @@ -85,7 +85,7 @@ contract Comp is EncryptedERC20, Ownable2Step { numCheckpoints[delegatee] = nCheckpoints + 1; } - emit DelegateVotesChanged(delegatee, oldVotes, newVotes); + emit DelegateVotesChanged(delegatee); } /** @@ -96,6 +96,29 @@ contract Comp is EncryptedERC20, Ownable2Step { return _delegate(msg.sender, delegatee); } + function _transfer( + address from, + address to, + euint64 amount, + ebool isTransferable, + euint8 errorCode + ) internal override { + require(from != address(0), "Comp::_transferTokens: cannot transfer from the zero address"); + require(to != address(0), "Comp::_transferTokens: cannot transfer to the zero address"); + // Add to the balance of `to` and subract from the balance of `from`. + euint64 amountTransferred = TFHE.cmux(isTransferable, amount, TFHE.asEuint64(0)); + balances[to] = balances[to] + amountTransferred; + balances[from] = balances[from] - amountTransferred; + uint256 transferId = saveError(errorCode); + emit Transfer(transferId, from, to); + AllowedErrorReencryption memory allowedErrorReencryption = AllowedErrorReencryption( + msg.sender, + getError(transferId) + ); + allowedErrorReencryptions[transferId] = allowedErrorReencryption; + _moveDelegates(delegates[from], delegates[to], amountTransferred); + } + /** * @notice Delegates votes from signatory to `delegatee` * @param delegatee The address to delegate votes to @@ -118,14 +141,13 @@ contract Comp is EncryptedERC20, Ownable2Step { return _delegate(signatory, delegatee); } - /** - * @notice Gets the current votes balance for `account` - * @param account The address to get votes balance - * @return The number of current votes for `account` - */ - function getCurrentVotes(address account) external view onlyAllowedContract returns (euint64) { - uint32 nCheckpoints = numCheckpoints[account]; - return nCheckpoints > 0 ? checkpoints[account][nCheckpoints - 1].votes : TFHE.asEuint64(0); + function getMyCurrentVotes( + bytes32 publicKey, + bytes calldata signature + ) external view onlySignedPublicKey(publicKey, signature) returns (bytes memory) { + uint32 nCheckpoints = numCheckpoints[msg.sender]; + euint64 result = nCheckpoints > 0 ? checkpoints[msg.sender][nCheckpoints - 1].votes : TFHE.asEuint64(0); + return TFHE.reencrypt(result, publicKey, 0); } /** @@ -135,7 +157,7 @@ contract Comp is EncryptedERC20, Ownable2Step { * @param blockNumber The block number to get the vote balance at * @return The number of votes the account had as of the given block */ - function getPriorVotes(address account, uint256 blockNumber) public view onlyAllowedContract returns (euint64) { + function getPriorVotes(address account, uint256 blockNumber) external view onlyAllowedContract returns (euint64) { require(blockNumber < block.number, "Comp::getPriorVotes: not yet determined"); uint32 nCheckpoints = numCheckpoints[account]; @@ -169,6 +191,48 @@ contract Comp is EncryptedERC20, Ownable2Step { return checkpoints[account][lower].votes; } + function getMyPriorVotes( + uint256 blockNumber, + bytes32 publicKey, + bytes calldata signature + ) public view onlySignedPublicKey(publicKey, signature) returns (bytes memory) { + require(blockNumber < block.number, "Comp::getPriorVotes: not yet determined"); + + uint32 nCheckpoints = numCheckpoints[msg.sender]; + euint64 result; + if (nCheckpoints == 0) { + return TFHE.reencrypt(result, publicKey, 0); + } + + // First check most recent balance + if (checkpoints[msg.sender][nCheckpoints - 1].fromBlock <= blockNumber) { + result = checkpoints[msg.sender][nCheckpoints - 1].votes; + return TFHE.reencrypt(result, publicKey, 0); + } + + // Next check implicit zero balance + if (checkpoints[msg.sender][0].fromBlock > blockNumber) { + return TFHE.reencrypt(result, publicKey, 0); + } + + uint32 lower = 0; + uint32 upper = nCheckpoints - 1; + while (upper > lower) { + uint32 center = upper - (upper - lower) / 2; // ceil, avoiding overflow + Checkpoint memory cp = checkpoints[msg.sender][center]; + if (cp.fromBlock == blockNumber) { + result = cp.votes; + return TFHE.reencrypt(result, publicKey, 0); + } else if (cp.fromBlock < blockNumber) { + lower = center; + } else { + upper = center - 1; + } + } + result = checkpoints[msg.sender][lower].votes; + return TFHE.reencrypt(result, publicKey, 0); + } + function _delegate(address delegator, address delegatee) internal { address currentDelegate = delegates[delegator]; euint64 delegatorBalance = balances[delegator]; @@ -189,7 +253,7 @@ contract Comp is EncryptedERC20, Ownable2Step { } modifier onlyAllowedContract() { - require(msg.sender == allowedContract); + require(msg.sender == allowedContract, "Caller not allowed to call this function"); _; } } diff --git a/contracts/DAO/GovernorZama.sol b/contracts/DAO/GovernorZama.sol index f9df2de..7485420 100644 --- a/contracts/DAO/GovernorZama.sol +++ b/contracts/DAO/GovernorZama.sol @@ -1,22 +1,24 @@ // SPDX-License-Identifier: BSD-3-Clause pragma solidity ^0.8.20; +import "fhevm/abstracts/Reencrypt.sol"; import "fhevm/lib/TFHE.sol"; -contract GovernorZama { +contract GovernorZama is Reencrypt { /// @notice The name of this contract string public constant name = "Compound Governor Zama"; + uint32 public immutable VOTING_PERIOD; // Duration of voting in number of blocks, typically should by at least a few days, default was 3 days originally ~21600 blocks if 12s per block + // WARNING: do not use too small value in production such as 5 or a few dozens to avoid security issues, unless for testing purpose + /// @notice The number of votes in support of a proposal required in order for a quorum to be reached and for a vote to succeed function quorumVotes() public pure returns (uint256) { - return 10000; - // return 400000e18; + return 400000e6; } // 400,000 = 4% of Comp /// @notice The number of votes required in order for a voter to become a proposer - function proposalThreshold() public pure returns (uint32) { - return 3; - // return 100000e18; + function proposalThreshold() public pure returns (uint64) { + return 100000e6; } // 100,000 = 1% of Comp /// @notice The maximum number of actions that can be included in a proposal @@ -30,9 +32,9 @@ contract GovernorZama { } // 1 block /// @notice The duration of voting on a proposal, in blocks - function votingPeriod() public pure virtual returns (uint32) { - return 17280; - } // ~3 days in blocks (assuming 15s blocks) + function votingPeriod() public view virtual returns (uint32) { + return VOTING_PERIOD; + } // recommended 3 days in blocks, i.e 21600 (assuming 12s blocks) /// @notice The address of the Compound Protocol Timelock TimelockInterface public timelock; @@ -77,12 +79,37 @@ contract GovernorZama { mapping(address => Receipt) receipts; } + struct ProposalInfo { + /// @notice Unique id for looking up a proposal + uint256 id; + /// @notice Creator of the proposal + address proposer; + /// @notice The timestamp that the proposal will be available for execution, set once the vote succeeds + uint256 eta; + /// @notice the ordered list of target addresses for calls to be made + address[] targets; + /// @notice The ordered list of values (i.e. msg.value) to be passed to the calls to be made + uint256[] values; + /// @notice The ordered list of function signatures to be called + string[] signatures; + /// @notice The ordered list of calldata to be passed to each call + bytes[] calldatas; + /// @notice The block at which voting begins: holders must delegate their votes prior to this block + uint256 startBlock; + /// @notice The block at which voting ends: votes must be cast prior to this block + uint256 endBlock; + /// @notice Flag marking whether the proposal has been canceled + bool canceled; + /// @notice Flag marking whether the proposal has been executed + bool executed; + } + /// @notice Ballot receipt record for a voter struct Receipt { /// @notice Whether or not a vote has been cast bool hasVoted; /// @notice Whether or not the voter supports the proposal - bool support; + ebool support; /// @notice The number of votes the voter had, which were cast euint64 votes; } @@ -100,18 +127,11 @@ contract GovernorZama { } /// @notice The official record of all proposals ever proposed - mapping(uint256 => Proposal) public proposals; + mapping(uint256 => Proposal) internal proposals; /// @notice The latest proposal for each proposer mapping(address => uint256) public latestProposalIds; - /// @notice The EIP-712 typehash for the contract's domain - bytes32 public constant DOMAIN_TYPEHASH = - keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); - - /// @notice The EIP-712 typehash for the ballot struct used by the contract - bytes32 public constant BALLOT_TYPEHASH = keccak256("Ballot(uint256 proposalId,bool support)"); - /// @notice An event emitted when a new proposal is created event ProposalCreated( uint256 id, @@ -127,7 +147,6 @@ contract GovernorZama { /// @notice An event emitted when a vote has been cast on a proposal event VoteCast(address voter, uint256 proposalId); - // event VoteCast(address voter, uint256 proposalId, bool support, uint256 votes); /// @notice An event emitted when a proposal has been canceled event ProposalCanceled(uint256 id); @@ -138,10 +157,11 @@ contract GovernorZama { /// @notice An event emitted when a proposal has been executed in the Timelock event ProposalExecuted(uint256 id); - constructor(address timelock_, address comp_, address guardian_) { + constructor(address timelock_, address comp_, address guardian_, uint32 _votingPeriod) { timelock = TimelockInterface(timelock_); comp = CompInterface(comp_); guardian = guardian_; + VOTING_PERIOD = _votingPeriod; // WARNING: do not use too small value in production such as 5 or a few dozens to avoid security issues, unless for testing purpose, typically should by at least a few days, default was 3 days originally i.e votingPeriod=21600 blocks if 12s per block } function propose( @@ -149,7 +169,6 @@ contract GovernorZama { uint256[] memory values, string[] memory signatures, bytes[] memory calldatas, - uint256 customVotingPeriod, string memory description ) public returns (uint256) { require( @@ -178,14 +197,13 @@ contract GovernorZama { ); } - uint256 startBlock = add256(block.number, votingDelay()); - uint256 endBlock = add256(startBlock, customVotingPeriod != 0 ? customVotingPeriod : votingPeriod()); + uint256 startBlock = block.number + votingDelay(); + uint256 endBlock = startBlock + votingPeriod(); proposalCount++; uint256 proposalId = proposalCount; Proposal storage newProposal = proposals[proposalId]; - // This should never happen but add a check in case. - require(newProposal.id == 0, "GovernorAlpha::propose: ProposalID collsion"); + newProposal.id = proposalId; newProposal.proposer = msg.sender; newProposal.eta = 0; @@ -222,7 +240,8 @@ contract GovernorZama { "GovernorAlpha::queue: proposal can only be queued if it is succeeded" ); Proposal storage proposal = proposals[proposalId]; - uint256 eta = add256(block.timestamp, timelock.delay()); + uint256 eta = block.timestamp + timelock.delay(); + for (uint256 i = 0; i < proposal.targets.length; i++) { _queueOrRevert(proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], eta); } @@ -303,15 +322,40 @@ contract GovernorZama { return (p.targets, p.values, p.signatures, p.calldatas); } - function getReceipt(uint256 proposalId, address voter) public view returns (Receipt memory) { - return proposals[proposalId].receipts[voter]; + function getMyReceipt( + uint256 proposalId, + bytes32 publicKey, + bytes calldata signature + ) external view onlySignedPublicKey(publicKey, signature) returns (bool, bytes memory, bytes memory) { + Receipt memory myReceipt = proposals[proposalId].receipts[msg.sender]; + return ( + myReceipt.hasVoted, + TFHE.reencrypt(myReceipt.support, publicKey, false), + TFHE.reencrypt(myReceipt.votes, publicKey, 0) + ); + } + + function getProposalInfo(uint256 proposalId) external view returns (ProposalInfo memory propInfo) { + require(proposalId <= proposalCount && proposalId != 0, "Invalid proposalId"); + Proposal storage proposal = proposals[proposalId]; + propInfo.id = proposal.id; + propInfo.proposer = proposal.proposer; + propInfo.eta = proposal.eta; + propInfo.targets = proposal.targets; + propInfo.values = proposal.values; + propInfo.signatures = proposal.signatures; + propInfo.calldatas = proposal.calldatas; + propInfo.startBlock = proposal.startBlock; + propInfo.endBlock = proposal.endBlock; + propInfo.canceled = proposal.canceled; + propInfo.executed = proposal.executed; } function isDefeated(Proposal storage proposal) private view returns (bool) { ebool defeated = TFHE.le(proposal.forVotes, proposal.againstVotes); - ebool reachedQuorum = TFHE.lt(proposal.forVotes, uint32(quorumVotes())); + ebool notReachedQuorum = TFHE.lt(proposal.forVotes, uint64(quorumVotes())); - return TFHE.decrypt(reachedQuorum) || TFHE.decrypt(defeated); + return TFHE.decrypt(TFHE.or(notReachedQuorum, defeated)); } function state(uint256 proposalId) public view returns (ProposalState) { @@ -323,24 +367,13 @@ contract GovernorZama { return ProposalState.Pending; } else if (block.number <= proposal.endBlock) { return ProposalState.Active; - } - // We can't have this for privacy reasons; otherwise, users could spam-call the `state` view function and deduce individual votes. - // We must then only reveal the defeat/success of a proposal after the time limit has been reached. - // else if ( - // proposal.forVotes <= proposal.againstVotes || - // proposal.forVotes < () - // ) { - // return ProposalState.Defeated; - // } - else if (proposal.eta == 0) { - if (isDefeated(proposal)) { - return ProposalState.Defeated; - } else { - return ProposalState.Succeeded; - } + } else if (isDefeated(proposal)) { + return ProposalState.Defeated; + } else if (proposal.eta == 0) { + return ProposalState.Succeeded; } else if (proposal.executed) { return ProposalState.Executed; - } else if (block.timestamp >= add256(proposal.eta, timelock.GRACE_PERIOD())) { + } else if (block.timestamp >= proposal.eta + timelock.GRACE_PERIOD()) { return ProposalState.Expired; } else { return ProposalState.Queued; @@ -355,21 +388,6 @@ contract GovernorZama { return _castVote(msg.sender, proposalId, support); } - function castVoteBySig(uint256 proposalId, bytes calldata support, uint8 v, bytes32 r, bytes32 s) public { - return castVoteBySig(proposalId, TFHE.asEbool(support), v, r, s); - } - - function castVoteBySig(uint256 proposalId, ebool support, uint8 v, bytes32 r, bytes32 s) public { - bytes32 domainSeparator = keccak256( - abi.encode(DOMAIN_TYPEHASH, keccak256(bytes(name)), getChainId(), address(this)) - ); - bytes32 structHash = keccak256(abi.encode(BALLOT_TYPEHASH, proposalId, support)); - bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); - address signatory = ecrecover(digest, v, r, s); - require(signatory != address(0), "GovernorAlpha::castVoteBySig: invalid signature"); - return _castVote(signatory, proposalId, support); - } - function _castVote(address voter, uint256 proposalId, ebool support) internal { require(state(proposalId) == ProposalState.Active, "GovernorAlpha::_castVote: voting is closed"); Proposal storage proposal = proposals[proposalId]; @@ -381,10 +399,10 @@ contract GovernorZama { proposal.againstVotes = TFHE.cmux(support, proposal.againstVotes, proposal.againstVotes + votes); receipt.hasVoted = true; + receipt.support = support; receipt.votes = votes; // `support` and `votes` are encrypted values, no need to include them in the event. - // emit VoteCast(voter, proposalId, support, votes); emit VoteCast(voter, proposalId); } @@ -407,25 +425,6 @@ contract GovernorZama { require(msg.sender == guardian, "GovernorAlpha::__executeSetTimelockPendingAdmin: sender must be gov guardian"); timelock.executeTransaction(address(timelock), 0, "setPendingAdmin(address)", abi.encode(newPendingAdmin), eta); } - - function add256(uint256 a, uint256 b) internal pure returns (uint256) { - uint256 c = a + b; - require(c >= a, "addition overflow"); - return c; - } - - function sub256(uint256 a, uint256 b) internal pure returns (uint256) { - require(b <= a, "subtraction underflow"); - return a - b; - } - - function getChainId() internal view returns (uint256) { - uint256 chainId; - assembly { - chainId := chainid() - } - return chainId; - } } interface TimelockInterface { diff --git a/contracts/DAO/Timelock.sol b/contracts/DAO/Timelock.sol index 3873601..c4cac1e 100644 --- a/contracts/DAO/Timelock.sol +++ b/contracts/DAO/Timelock.sol @@ -68,10 +68,7 @@ contract Timelock { } function setPendingAdmin(address pendingAdmin_) public { - require( - msg.sender == address(this) || msg.sender == admin, - "Timelock::setPendingAdmin: Call must come from Timelock." - ); + require(msg.sender == address(this), "Timelock::setPendingAdmin: Call must come from Timelock."); pendingAdmin = pendingAdmin_; emit NewPendingAdmin(pendingAdmin); diff --git a/hardhat.config.ts b/hardhat.config.ts index c8509ca..dc106ea 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -137,6 +137,7 @@ const config: HardhatUserConfig = { }, networks: { hardhat: { + gas: "auto", accounts: { count: 10, mnemonic, diff --git a/test/dao/Comp.fixture.ts b/test/dao/Comp.fixture.ts index e5db63d..8be962d 100644 --- a/test/dao/Comp.fixture.ts +++ b/test/dao/Comp.fixture.ts @@ -7,7 +7,7 @@ export async function deployCompFixture(): Promise { const signers = await getSigners(); const contractFactory = await ethers.getContractFactory("Comp"); - const contract = await contractFactory.connect(signers.alice).deploy(); + const contract = await contractFactory.connect(signers.alice).deploy(signers.alice.address); await contract.waitForDeployment(); return contract; diff --git a/test/dao/Comp.ts b/test/dao/Comp.ts index db9f31a..1f6800d 100644 --- a/test/dao/Comp.ts +++ b/test/dao/Comp.ts @@ -1,13 +1,15 @@ import { expect } from "chai"; -import { ethers } from "hardhat"; +import { ethers, network } from "hardhat"; import { createInstances } from "../instance"; import { getSigners, initSigners } from "../signers"; +import { createTransaction, mineNBlocks, waitNBlocks } from "../utils"; import { deployCompFixture } from "./Comp.fixture"; +import { delegateBySigSignature } from "./DelegateBySig"; describe("Comp", function () { before(async function () { - await initSigners(2); + await initSigners(); this.signers = await getSigners(); }); @@ -18,8 +20,34 @@ describe("Comp", function () { this.instances = await createInstances(this.contractAddress, ethers, this.signers); }); + // 9000n , 31337n + + it("only owner could set allowed contract", async function () { + const tx = await this.comp.setAllowedContract("0xE359a77c3bFE58792FB167D05720e37032A1e520"); + await tx.wait(); + + if (network.name === "hardhat") { + // mocked mode + await expect(this.comp.connect(this.signers.bob).setAllowedContract("0x9d3e06a2952dc49EDCc73e41C76645797fC53967")) + .to.be.revertedWithCustomError(this.comp, "OwnableUnauthorizedAccount") + .withArgs(this.signers.bob.address); + } else { + // fhevm-mode + const tx = await this.comp + .connect(this.signers.bob) + .setAllowedContract("0x9d3e06a2952dc49EDCc73e41C76645797fC53967", { gasLimit: 1_000_000 }); + await expect(tx.wait()).to.throw; + } + }); + + it("only allowed contract can call getPriorVotes", async function () { + await expect(this.comp.getPriorVotes("0xE359a77c3bFE58792FB167D05720e37032A1e520", 0)).to.be.revertedWith( + "Caller not allowed to call this function", + ); + }); + it("should transfer tokens", async function () { - const encryptedAmountToTransfer = this.instances.alice.encrypt64(200000); + const encryptedAmountToTransfer = this.instances.alice.encrypt64(2_000_000n * 10n ** 6n); const transferTransac = await this.comp["transfer(address,bytes)"]( this.signers.bob.address, encryptedAmountToTransfer, @@ -27,10 +55,7 @@ describe("Comp", function () { await transferTransac.wait(); - const aliceToken = this.instances.alice.getPublicKey(this.contractAddress) || { - signature: "", - publicKey: "", - }; + const aliceToken = this.instances.alice.getPublicKey(this.contractAddress); const encryptedAliceBalance = await this.comp.balanceOf( this.signers.alice.address, aliceToken.publicKey, @@ -38,17 +63,265 @@ describe("Comp", function () { ); // Decrypt Alice's balance const aliceBalance = this.instances.alice.decrypt(this.contractAddress, encryptedAliceBalance); - expect(aliceBalance).to.equal(800000); + expect(aliceBalance).to.equal(8_000_000n * 10n ** 6n); + + let encryptedAliceVotes = await this.comp.getMyCurrentVotes(aliceToken.publicKey, aliceToken.signature); + // Decrypt Alice's current votes + let aliceCurrentVotes = this.instances.alice.decrypt(this.contractAddress, encryptedAliceVotes); + expect(aliceCurrentVotes).to.equal(0n); + + // Bob should not be able to decrypt Alice's votes + await expect( + this.comp.connect(this.signers.bob).getMyCurrentVotes(aliceToken.publicKey, aliceToken.signature), + ).to.be.revertedWith("EIP712 signer and transaction signer do not match"); - const bobToken = this.instances.bob.getPublicKey(this.contractAddress) || { - signature: "", - publicKey: "", - }; + const tx = await this.comp.delegate(this.signers.alice); + await tx.wait(); + encryptedAliceVotes = await this.comp.getMyCurrentVotes(aliceToken.publicKey, aliceToken.signature); + // Decrypt Alice's current votes + aliceCurrentVotes = this.instances.alice.decrypt(this.contractAddress, encryptedAliceVotes); + expect(aliceCurrentVotes).to.equal(8_000_000n * 10n ** 6n); + + const tx2 = await createTransaction(this.comp.delegate, this.signers.bob); + await tx2.wait(); + encryptedAliceVotes = await this.comp.getMyCurrentVotes(aliceToken.publicKey, aliceToken.signature); + // Decrypt Alice's current votes + aliceCurrentVotes = this.instances.alice.decrypt(this.contractAddress, encryptedAliceVotes); + expect(aliceCurrentVotes).to.equal(0n); + + const bobToken = this.instances.bob.getPublicKey(this.contractAddress); const encryptedBobBalance = await this.comp .connect(this.signers.bob) .balanceOf(this.signers.bob.address, bobToken.publicKey, bobToken.signature); // Decrypt Bob's balance const bobBalance = this.instances.bob.decrypt(this.contractAddress, encryptedBobBalance); - expect(bobBalance).to.equal(200000); + expect(bobBalance).to.equal(2_000_000n * 10n ** 6n); + }); + + it("can't transfer to nor from null address", async function () { + const nullAddress = "0x0000000000000000000000000000000000000000"; + const encryptedAmountToTransfer = this.instances.alice.encrypt64(1n * 10n ** 6n); + const encryptedAmountToTransferNull = this.instances.alice.encrypt64(0n); + if (network.name === "hardhat") { + // mocked mode + await expect(this.comp["transfer(address,bytes)"](nullAddress, encryptedAmountToTransfer)).to.be.revertedWith( + "Comp::_transferTokens: cannot transfer to the zero address", + ); + await expect( + this.comp["transferFrom(address,address,bytes)"]( + nullAddress, + this.signers.bob.address, + encryptedAmountToTransfer, + ), + ).to.be.revertedWith("Comp::_transferTokens: cannot transfer from the zero address"); + } else { + // fhevm-mode + const tx = await this.comp["transfer(address,bytes)"](nullAddress, encryptedAmountToTransferNull, { + gasLimit: 1_000_000, + }); + + await expect(tx.wait()).to.throw; + const tx2 = await this.comp["transferFrom(address,address,bytes)"]( + nullAddress, + this.signers.bob.address, + encryptedAmountToTransferNull, + { gasLimit: 1_000_000 }, + ); + await expect(tx2.wait()).to.throw; + } + }); + + it("can't transfer from null address", async function () { + const tx = await this.comp.setAllowedContract("0xE359a77c3bFE58792FB167D05720e37032A1e520"); + await tx.wait(); + + if (network.name === "hardhat") { + // mocked mode + await expect(this.comp.connect(this.signers.bob).setAllowedContract("0x9d3e06a2952dc49EDCc73e41C76645797fC53967")) + .to.be.revertedWithCustomError(this.comp, "OwnableUnauthorizedAccount") + .withArgs(this.signers.bob.address); + } else { + // fhevm-mode + const tx = await this.comp + .connect(this.signers.bob) + .setAllowedContract("0x9d3e06a2952dc49EDCc73e41C76645797fC53967", { gasLimit: 1_000_000 }); + await expect(tx.wait()).to.throw; + } + }); + + it("can delegate votes via delegateBySig if signature is valid", async function () { + const delegatee = this.signers.bob.address; + const nonce = 0; + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 100; + const [v, r, s] = await delegateBySigSignature(this.signers.alice, delegatee, this.comp, nonce, expiry); + + const tx = await this.comp.delegateBySig(delegatee, nonce, expiry, v, r, s); + await tx.wait(); + + const tokenBob = this.instances.bob.getPublicKey(this.contractAddress); + const encryptedCurrentVotes = await this.comp + .connect(this.signers.bob) + .getMyCurrentVotes(tokenBob.publicKey, tokenBob.signature); + const currentVotes = this.instances.bob.decrypt(this.contractAddress, encryptedCurrentVotes); + expect(currentVotes).to.equal(10_000_000n * 10n ** 6n); + + if (network.name === "hardhat") { + // can't reuse same nonce when delegating by sig + await expect(this.comp.delegateBySig(delegatee, nonce, expiry, v, r, s)).to.be.revertedWith( + "Comp::delegateBySig: invalid nonce", + ); + + // can't use invalid signature when delegating by sig + await expect(this.comp.delegateBySig(delegatee, nonce, expiry, 30, r, s)).to.be.revertedWith( + "Comp::delegateBySig: invalid signature", + ); + + // can't use signature after expiry + ethers.provider.send("evm_increaseTime", ["0xffff"]); + const [v2, r2, s2] = await delegateBySigSignature(this.signers.alice, delegatee, this.comp, 1, expiry); + await expect(this.comp.delegateBySig(delegatee, nonce, expiry, v2, r2, s2)).to.be.revertedWith( + "Comp::delegateBySig: signature expired", + ); + } + }); + + it("comp becomes obsolete after max(uint32) blocks", async function () { + if (network.name === "hardhat") { + // mocked mode + const tx = await this.comp.delegate(this.signers.bob.address); + await tx.wait(); + const idSnapshot = await ethers.provider.send("evm_snapshot"); + + await mineNBlocks(2 ** 32); + const encryptedAmountToTransfer = this.instances.alice.encrypt64(2_000_000n * 10n ** 6n); + await expect( + this.comp["transfer(address,bytes)"](this.signers.carol.address, encryptedAmountToTransfer), + ).to.be.revertedWith("Comp::_writeCheckpoint: block number exceeds 32 bits"); + await ethers.provider.send("evm_revert", [idSnapshot]); + } + }); + + it("user can request his past votes via getMyPriorVotes", async function () { + const aliceToken = this.instances.alice.getPublicKey(this.contractAddress); + const initBlock = await ethers.provider.getBlockNumber(); + let aliceMyPriorVotesEnc = await this.comp.getMyPriorVotes( + initBlock - 1, + aliceToken.publicKey, + aliceToken.signature, + ); + let aliceMyPriorVotes = this.instances.alice.decrypt(this.contractAddress, aliceMyPriorVotesEnc); + expect(aliceMyPriorVotes).to.be.equal(0n); + + const tx = await this.comp.delegate(this.signers.alice.address); + await tx.wait(); + const firstCheckPointBlockNumber = await ethers.provider.getBlockNumber(); + await waitNBlocks(1); + + aliceMyPriorVotesEnc = await this.comp.getMyPriorVotes( + firstCheckPointBlockNumber, + aliceToken.publicKey, + aliceToken.signature, + ); + aliceMyPriorVotes = this.instances.alice.decrypt(this.contractAddress, aliceMyPriorVotesEnc); + expect(aliceMyPriorVotes).to.be.equal(10000000000000n); + + aliceMyPriorVotesEnc = await this.comp.getMyPriorVotes( + firstCheckPointBlockNumber - 1, + aliceToken.publicKey, + aliceToken.signature, + ); + aliceMyPriorVotes = this.instances.alice.decrypt(this.contractAddress, aliceMyPriorVotesEnc); + expect(aliceMyPriorVotes).to.be.equal(0n); + + await expect( + this.comp.getMyPriorVotes(firstCheckPointBlockNumber + 1, aliceToken.publicKey, aliceToken.signature), + ).to.be.revertedWith("Comp::getPriorVotes: not yet determined"); + + // Bob cannot decrypt Alice's prior votes + await expect( + this.comp + .connect(this.signers.bob) + .getMyPriorVotes(firstCheckPointBlockNumber - 1, aliceToken.publicKey, aliceToken.signature), + ).to.be.revertedWith("EIP712 signer and transaction signer do not match"); + + const encryptedAmountToTransfer = this.instances.alice.encrypt64(2_000_000n * 10n ** 6n); + const transferTransac = await this.comp["transfer(address,bytes)"]( + this.signers.bob.address, + encryptedAmountToTransfer, + ); + await transferTransac.wait(); + const secondCheckPointBlockNumber = await ethers.provider.getBlockNumber(); + await waitNBlocks(1); + + aliceMyPriorVotesEnc = await this.comp.getMyPriorVotes( + firstCheckPointBlockNumber, + aliceToken.publicKey, + aliceToken.signature, + ); + aliceMyPriorVotes = this.instances.alice.decrypt(this.contractAddress, aliceMyPriorVotesEnc); + expect(aliceMyPriorVotes).to.be.equal(10000000000000n); + + const encryptedAmountToTransfer2 = this.instances.alice.encrypt64(2_000_000n * 10n ** 6n); + const transferTransac2 = await this.comp["transfer(address,bytes)"]( + this.signers.carol.address, + encryptedAmountToTransfer2, + ); + await transferTransac2.wait(); + await ethers.provider.getBlockNumber(); // third CheckPoint + await waitNBlocks(1); + + aliceMyPriorVotesEnc = await this.comp.getMyPriorVotes( + secondCheckPointBlockNumber, + aliceToken.publicKey, + aliceToken.signature, + ); + aliceMyPriorVotes = this.instances.alice.decrypt(this.contractAddress, aliceMyPriorVotesEnc); + expect(aliceMyPriorVotes).to.be.equal(8000000000000n); + + aliceMyPriorVotesEnc = await this.comp.getMyPriorVotes( + secondCheckPointBlockNumber + 1, + aliceToken.publicKey, + aliceToken.signature, + ); + aliceMyPriorVotes = this.instances.alice.decrypt(this.contractAddress, aliceMyPriorVotesEnc); + expect(aliceMyPriorVotes).to.be.equal(8000000000000n); + }); + + it("different voters can delegate to same delegatee", async function () { + const encryptedAmountToTransfer = this.instances.alice.encrypt64(2_000_000n * 10n ** 6n); + const tx1 = await this.comp["transfer(address,bytes)"](this.signers.bob.address, encryptedAmountToTransfer); + await tx1.wait(); + + const tx2 = await this.comp.delegate(this.signers.carol); + await tx2.wait(); + + const tx3 = await createTransaction(this.comp.connect(this.signers.bob).delegate, this.signers.carol); + await tx3.wait(); + + const carolToken = this.instances.carol.getPublicKey(this.contractAddress); + let encryptedCarolVotes = await this.comp + .connect(this.signers.carol) + .getMyCurrentVotes(carolToken.publicKey, carolToken.signature); + // Decrypt Alice's current votes + let carolCurrentVotes = this.instances.carol.decrypt(this.contractAddress, encryptedCarolVotes); + expect(carolCurrentVotes).to.equal(10000000000000n); + }); + + it("number of checkpoints is incremented once per block, even when written multiple times in same block", async function () { + if (network.name === "hardhat") { + // mocked mode + await network.provider.send("evm_setAutomine", [false]); + await network.provider.send("evm_setIntervalMining", [0]); + // do two checkpoints in same block + await this.comp.delegate(this.signers.bob); + await this.comp.delegate(this.signers.carol); + await network.provider.send("evm_mine"); + await network.provider.send("evm_setAutomine", [true]); + expect(await this.comp.numCheckpoints(this.signers.alice)).to.be.equal(0n); + expect(await this.comp.numCheckpoints(this.signers.bob)).to.be.equal(1n); + expect(await this.comp.numCheckpoints(this.signers.carol)).to.be.equal(1n); + } }); }); diff --git a/test/dao/DelegateBySig.ts b/test/dao/DelegateBySig.ts new file mode 100644 index 0000000..62ef43d --- /dev/null +++ b/test/dao/DelegateBySig.ts @@ -0,0 +1,56 @@ +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { ethers } from "hardhat"; +import { Address } from "hardhat-deploy/types"; + +import type { Comp } from "../../types"; + +export const delegateBySigSignature = async ( + _signer: HardhatEthersSigner, + _delegatee: Address, + _comp: Comp, + _nonce: number, + _expiry: number, +): Promise<[BigInt, string, string]> => { + const compAddress_ = await _comp.getAddress(); + const delegatee_ = _delegatee; + const nonce_ = _nonce; + const expiry_ = _expiry; + + const network = await ethers.provider.getNetwork(); + const chainId = network.chainId; + const domain = { + name: await _comp.name(), + chainId: chainId, + verifyingContract: compAddress_, + }; + // Delegation(address delegatee,uint256 nonce,uint256 expiry) + const types = { + Delegation: [ + { + name: "delegatee", + type: "address", + }, + { + name: "nonce", + type: "uint256", + }, + { + name: "expiry", + type: "uint256", + }, + ], + }; + + const message = { + delegatee: delegatee_, + nonce: nonce_, + expiry: expiry_, + }; + + const signature = await _signer.signTypedData(domain, types, message); + const sigRSV = ethers.Signature.from(signature); + const v = 27 + sigRSV.yParity; + const r = sigRSV.r; + const s = sigRSV.s; + return [BigInt(v), r, s]; +}; diff --git a/test/dao/GovernorZama.fixture.ts b/test/dao/GovernorZama.fixture.ts index d719130..c0f8e06 100644 --- a/test/dao/GovernorZama.fixture.ts +++ b/test/dao/GovernorZama.fixture.ts @@ -4,11 +4,11 @@ import { Comp } from "../../types"; import type { GovernorZama, Timelock } from "../../types"; import { getSigners } from "../signers"; -export async function deployTimelockFixture(): Promise { +export async function deployTimelockFixture(admin: string): Promise { const signers = await getSigners(); const timelockFactory = await ethers.getContractFactory("Timelock"); - const timelock = await timelockFactory.connect(signers.alice).deploy(signers.alice.address, 60 * 60 * 24 * 2); + const timelock = await timelockFactory.connect(signers.alice).deploy(admin, 60 * 60 * 24 * 2); await timelock.waitForDeployment(); @@ -17,11 +17,11 @@ export async function deployTimelockFixture(): Promise { export async function deployGovernorZamaFixture(compContract: Comp, timelock: Timelock): Promise { const signers = await getSigners(); - + const votingPeriod = 5; // WARNING: We use 5 only for testing purpose, DO NOT use this value in production, typically it should be at least a few days, default was 3 days originally i.e votingPeriod=21600 blocks if 12s per block const governorFactory = await ethers.getContractFactory("GovernorZama"); const governor = await governorFactory .connect(signers.alice) - .deploy(timelock.getAddress(), compContract.getAddress(), signers.alice.address); + .deploy(timelock.getAddress(), compContract.getAddress(), signers.alice.address, votingPeriod); await governor.waitForDeployment(); return governor; diff --git a/test/dao/GovernorZama.ts b/test/dao/GovernorZama.ts index 8fa33ff..b1fec9d 100644 --- a/test/dao/GovernorZama.ts +++ b/test/dao/GovernorZama.ts @@ -3,7 +3,7 @@ import { ethers, network } from "hardhat"; import { createInstances } from "../instance"; import { getSigners, initSigners } from "../signers"; -import { createTransaction, mineNBlocks, produceDummyTransactions, waitForBlock } from "../utils"; +import { createTransaction, waitNBlocks } from "../utils"; import { deployCompFixture } from "./Comp.fixture"; import { deployGovernorZamaFixture, deployTimelockFixture } from "./GovernorZama.fixture"; @@ -11,41 +11,57 @@ describe("GovernorZama", function () { before(async function () { await initSigners(); this.signers = await getSigners(); - this.comp = await deployCompFixture(); + }); - const instances = await createInstances(await this.comp.getAddress(), ethers, this.signers); - const encryptedAmountToTransfer = instances.alice.encrypt64(100000); + beforeEach(async function () { + this.comp = await deployCompFixture(); + const instancesComp = await createInstances(await this.comp.getAddress(), ethers, this.signers); + const encryptedAmountToTransfer = instancesComp.alice.encrypt64(500_000n * 10n ** 6n); const transfer1 = await this.comp["transfer(address,bytes)"](this.signers.bob.address, encryptedAmountToTransfer); + await transfer1.wait(); const transfer2 = await this.comp["transfer(address,bytes)"](this.signers.carol.address, encryptedAmountToTransfer); - await Promise.all([transfer1.wait(), transfer2.wait()]); + await transfer2.wait(); const delegate1 = await this.comp.delegate(this.signers.alice); const delegate2 = await this.comp.connect(this.signers.bob).delegate(this.signers.bob); const delegate3 = await this.comp.connect(this.signers.carol).delegate(this.signers.carol); await Promise.all([delegate1, delegate2, delegate3]); - if (network.name == "localNetwork1" || network.name == "hardhat") { - // inside network1 or hardhat blocks are not - // produced unless there are transactions and - // we rely on block production for voting time - produceDummyTransactions(100); - } - }); + await waitNBlocks(1); - beforeEach(async function () { - const timelock = await deployTimelockFixture(); + const precomputedGovernorAddress = ethers.getCreateAddress({ + from: this.signers.alice.address, + nonce: (await this.signers.alice.getNonce()) + 1, + }); - const governor = await deployGovernorZamaFixture(this.comp, timelock); + this.timelock = await deployTimelockFixture(precomputedGovernorAddress); + + const governor = await deployGovernorZamaFixture(this.comp, this.timelock); this.contractAddress = await governor.getAddress(); + this.governor = governor; this.instances = await createInstances(this.contractAddress, ethers, this.signers); - const tx1 = await timelock.setPendingAdmin(governor.getAddress()); - await tx1.wait(); - const transaction = await this.comp.setAllowedContract(this.contractAddress); - const transaction2 = await this.governor.__acceptAdmin(); + await transaction.wait(); + }); + + it("could not deploy timelock contract if delay is below 2 days or above 31 days", async function () { + const timelockFactory = await ethers.getContractFactory("Timelock"); - await Promise.all([transaction.wait(), transaction2.wait()]); + if (network.name === "hardhat") { + await expect( + timelockFactory.connect(this.signers.alice).deploy(this.signers.alice.address, 60 * 60 * 24 * 1), + ).to.be.revertedWith("Timelock::constructor: Delay must exceed minimum delay."); // 1 day < 2 days + await expect( + timelockFactory.connect(this.signers.alice).deploy(this.signers.alice.address, 60 * 60 * 24 * 31), + ).to.be.revertedWith("Timelock::setDelay: Delay must not exceed maximum delay."); // 31 days > 30 days + } else { + // fhevm-mode + await expect(timelockFactory.connect(this.signers.alice).deploy(this.signers.alice.address, 60 * 60 * 24 * 1)).to + .throw; + await expect(timelockFactory.connect(this.signers.alice).deploy(this.signers.alice.address, 60 * 60 * 24 * 31)).to + .throw; + } }); it("should propose a vote", async function () { @@ -56,15 +72,20 @@ describe("GovernorZama", function () { ["0"], ["getBalanceOf(address)"], callDatas, - 0, "do nothing", ); - const proposal = await tx.wait(); - expect(proposal?.status).to.equal(1); + const txproposal = await tx.wait(); + expect(txproposal?.status).to.equal(1); const proposalId = await this.governor.latestProposalIds(this.signers.alice.address); - const proposals = await this.governor.proposals(proposalId); + const proposals = await this.governor.getProposalInfo(proposalId); expect(proposals.id).to.equal(proposalId); expect(proposals.proposer).to.equal(this.signers.alice.address); + + const actions = await this.governor.getActions(1); + expect(actions[0][0]).to.equal(this.signers.alice.address); + expect(actions[1][0]).to.equal(0); + expect(actions[2][0]).to.equal("getBalanceOf(address)"); + expect(actions[3][0]).to.equal(callDatas[0]); }); it("should vote and return a Succeed", async function () { @@ -75,18 +96,14 @@ describe("GovernorZama", function () { ["0"], ["getBalanceOf(address)"], callDatas, - 5, "do nothing", ); - const proposal = await tx.wait(); - expect(proposal?.status).to.equal(1); + const txproposal = await tx.wait(); + expect(txproposal?.status).to.equal(1); const proposalId = await this.governor.latestProposalIds(this.signers.alice.address); - const proposals = await this.governor.proposals(proposalId); - if (network.name == "hardhat") { - await mineNBlocks(2); - } - await waitForBlock(proposals.startBlock + 1n); + + await waitNBlocks(2); // Cast some votes const encryptedSupportBob = this.instances.bob.encryptBool(true); const txVoteBob = await createTransaction( @@ -94,6 +111,7 @@ describe("GovernorZama", function () { proposalId, encryptedSupportBob, ); + // bob can get his receipt const encryptedSupportCarol = this.instances.carol.encryptBool(true); const txVoteCarol = await createTransaction( @@ -105,10 +123,8 @@ describe("GovernorZama", function () { const [bobResults, carolResults] = await Promise.all([txVoteBob.wait(), txVoteCarol.wait()]); expect(bobResults?.status).to.equal(1); expect(carolResults?.status).to.equal(1); - if (network.name == "hardhat") { - await mineNBlocks(5); - } - await waitForBlock(proposals.endBlock + 1n); + + await waitNBlocks(4); const state = await this.governor.state(proposalId); expect(state).to.equal(4n); @@ -122,17 +138,12 @@ describe("GovernorZama", function () { ["0"], ["getBalanceOf(address)"], callDatas, - 4, "do nothing", ); const proposal = await tx.wait(); expect(proposal?.status).to.equal(1); const proposalId = await this.governor.latestProposalIds(this.signers.alice.address); - const proposals = await this.governor.proposals(proposalId); - if (network.name == "hardhat") { - await mineNBlocks(2); - } - await waitForBlock(proposals.startBlock + 1n); + await waitNBlocks(2); // Cast some votes const encryptedSupportBob = this.instances.bob.encryptBool(false); @@ -152,17 +163,16 @@ describe("GovernorZama", function () { const [bobResults, aliceResults] = await Promise.all([txVoteBob.wait(), txVoteCarol.wait()]); expect(bobResults?.status).to.equal(1); expect(aliceResults?.status).to.equal(1); - if (network.name == "hardhat") { - await mineNBlocks(5); - } - await waitForBlock(proposals.endBlock + 1n); + await waitNBlocks(4); const state = await this.governor.state(proposalId); expect(state).to.equal(3n); + + // cannot queue defeated proposal + await expect(this.governor.queue(1)).to.throw; }); it("should cancel", async function () { - await this.comp.delegate(this.signers.alice.address); const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; const tx = await createTransaction( this.governor.propose, @@ -170,24 +180,342 @@ describe("GovernorZama", function () { ["0"], ["getBalanceOf(address)"], callDatas, - 0, "do nothing", ); const proposal = await tx.wait(); expect(proposal?.status).to.equal(1); const proposalId = await this.governor.latestProposalIds(this.signers.alice.address); - const proposals = await this.governor.proposals(proposalId); - if (network.name == "hardhat") { - await mineNBlocks(2); - } - await waitForBlock(proposals.startBlock + 1n); + await waitNBlocks(2); const state = await this.governor.state(proposalId); expect(state).to.equal(1n); + await expect(this.governor.connect(this.signers.dave).cancel(proposalId)).to.throw; // non-guardian or non-proposer is unable to cancel + const txCancel = await this.governor.cancel(proposalId); await txCancel.wait(); const newState = await this.governor.state(proposalId); expect(newState).to.equal(2n); }); + + it("only guardian could queue setTimelockPendingAdmin then execute it, and then acceptAdmin", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 60 * 60 * 24 * 2 + 60; + + const tx = await this.governor.__queueSetTimelockPendingAdmin(this.signers.bob, expiry); + await tx.wait(); + if (network.name === "hardhat") { + // hardhat cheatcodes are available only in mocked mode + await expect(this.governor.__executeSetTimelockPendingAdmin(this.signers.bob, expiry)).to.be.revertedWith( + "Timelock::executeTransaction: Transaction hasn't surpassed time lock.", + ); + await expect( + this.governor.connect(this.signers.carol).__queueSetTimelockPendingAdmin(this.signers.bob, expiry), + ).to.be.revertedWith("GovernorAlpha::__queueSetTimelockPendingAdmin: sender must be gov guardian"); + + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + await expect( + this.governor.connect(this.signers.carol).__executeSetTimelockPendingAdmin(this.signers.bob, expiry), + ).to.be.revertedWith("GovernorAlpha::__executeSetTimelockPendingAdmin: sender must be gov guardian"); + const tx3 = await this.governor.__executeSetTimelockPendingAdmin(this.signers.bob, expiry); + await tx3.wait(); + + await expect(this.timelock.acceptAdmin()).to.be.revertedWith( + "Timelock::acceptAdmin: Call must come from pendingAdmin.", + ); + const tx4 = await this.timelock.connect(this.signers.bob).acceptAdmin(); + await tx4.wait(); + + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry2 = block!.timestamp + 60 * 60 * 24 * 2 + 60; + const timeLockAdd = await this.timelock.getAddress(); + const callData = ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.contractAddress]); + const tx5 = await this.timelock + .connect(this.signers.bob) + .queueTransaction(timeLockAdd, 0, "setPendingAdmin(address)", callData, expiry2); + await tx5.wait(); + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + const tx6 = await this.timelock + .connect(this.signers.bob) + .executeTransaction(timeLockAdd, 0, "setPendingAdmin(address)", callData, expiry2); + await tx6.wait(); + await expect(this.governor.connect(this.signers.bob).__acceptAdmin()).to.be.revertedWith( + "GovernorAlpha::__acceptAdmin: sender must be gov guardian", + ); + const tx7 = await this.governor.__acceptAdmin(); + await tx7.wait(); + expect(await this.timelock.admin()).to.eq(this.contractAddress); + } + }); + + it("only guardian can call __abdicate", async function () { + await expect(this.governor.connect(this.signers.bob).__abdicate()).to.throw; + const tx = await this.governor.__abdicate(); + await tx.wait(); + }); + + it("user can't propose if his votes are below the minimal threshold", async function () { + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + if (network.name === "hardhat") { + await expect( + createTransaction( + this.governor.connect(this.signers.dave).propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ), + ).to.be.revertedWith("GovernorAlpha::propose: proposer votes below proposal threshold"); + } else { + await expect( + createTransaction( + this.governor.connect(this.signers.dave).propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ), + ).to.throw; + } + }); + + it("all arrays of a proposal should be of same length, non null and less than max operations", async function () { + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + await expect( + createTransaction( + this.governor.propose, + [this.signers.alice, this.signers.bob], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ), + ).to.throw; + + await expect(createTransaction(this.governor.propose, [], [], [], [], "do nothing")).to.throw; + + await expect( + createTransaction( + this.governor.propose, + new Array(11).fill(this.signers.alice), + new Array(11).fill("0"), + new Array(11).fill("getBalanceOf(address)"), + new Array(11).fill(callDatas[0]), + "do nothing", + ), + ).to.throw; + }); + + it("proposer cannot make a new proposal while he still has an already pending or active proposal", async function () { + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + const tx = await createTransaction( + this.governor.propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ); + await tx.wait(); + + await expect( + createTransaction( + this.governor.propose, + [this.signers.bob], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ), + ).to.throw; + + await waitNBlocks(1); + + await expect( + createTransaction( + this.governor.propose, + [this.signers.bob], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ), + ).to.throw; + + const tx2 = await createTransaction(this.governor.connect(this.signers.bob).cancel, 1); + await tx2.wait(); + + await waitNBlocks(1); + + const tx3 = await createTransaction( + this.governor.propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ); + await tx3.wait(); + + expect(await this.governor.latestProposalIds(this.signers.alice)).to.equal(2); + + await expect(this.governor.state(0)).to.be.revertedWith("GovernorAlpha::state: invalid proposal id"); + await expect(this.governor.state(10)).to.be.revertedWith("GovernorAlpha::state: invalid proposal id"); + expect(await this.governor.state(1)).to.equal(2); + expect(await this.governor.state(2)).to.equal(0); + }); + + it("can propose, then vote once, then queue, then execute", async function () { + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + const tx = await createTransaction( + this.governor.propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ); + + await tx.wait(); + + await waitNBlocks(2); + const encryptedSupportBob = this.instances.bob.encryptBool(true); + const txVoteBob = await createTransaction( + this.governor.connect(this.signers.bob)["castVote(uint256,bytes)"], + 1, + encryptedSupportBob, + ); + await txVoteBob.wait(); + + await expect( + createTransaction(this.governor.connect(this.signers.bob)["castVote(uint256,bytes)"], 1, encryptedSupportBob), + ).to.throw; // cannot vote twice + + await waitNBlocks(5); + + const encryptedSupportAlice = this.instances.alice.encryptBool(true); + await expect( + createTransaction(this.governor.connect(this.signers.alice)["castVote(uint256,bytes)"], 1, encryptedSupportAlice), + ).to.throw; // voting is closed after voting period + + if (network.name === "hardhat") { + const txQueue = await this.governor.queue(1); + await txQueue.wait(); + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + const txExecute = await this.governor.execute(1); + await txExecute.wait(); + + // cannot cancel executed proposal + await expect(this.governor.cancel(1)).to.be.revertedWith( + "GovernorAlpha::cancel: cannot cancel executed proposal", + ); + + await expect(this.governor.getProposalInfo(2)).to.be.revertedWith("Invalid proposalId"); + } + }); + + it("cannot queue two identical transactions at same eta", async function () { + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + + if (network.name === "hardhat") { + // mocked mode + const tx1 = await this.governor.propose( + [this.signers.alice, this.signers.alice], + ["0", "0"], + ["getBalanceOf(address)", "getBalanceOf(address)"], + new Array(2).fill(callDatas[0]), + "do nothing", + { + gasLimit: 500_000, + }, + ); + await tx1.wait(); + + await waitNBlocks(1); + const encryptedSupportBob = this.instances.bob.encryptBool(true); + const txVoteBob = await createTransaction( + this.governor.connect(this.signers.bob)["castVote(uint256,bytes)"], + 1, + encryptedSupportBob, + ); + await txVoteBob.wait(); + + await waitNBlocks(5); + + await expect(this.governor.queue(1)).to.be.revertedWith( + "GovernorAlpha::_queueOrRevert: proposal action already queued at eta", + ); + await expect(this.governor.execute(1)).to.be.revertedWith( + "GovernorAlpha::execute: proposal can only be executed if it is queued", + ); // cannot execute non-queued proposal + } + }); + + it("proposal expires after grace period", async function () { + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + const tx = await createTransaction( + this.governor.propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ); + + await tx.wait(); + + await waitNBlocks(2); + const encryptedSupportBob = this.instances.bob.encryptBool(true); + const txVoteBob = await createTransaction( + this.governor.connect(this.signers.bob)["castVote(uint256,bytes)"], + 1, + encryptedSupportBob, + ); + await txVoteBob.wait(); + + if (network.name === "hardhat") { + await waitNBlocks(5); + const txQueue = await this.governor.queue(1); + await txQueue.wait(); + await ethers.provider.send("evm_increaseTime", ["0xffffff"]); + await waitNBlocks(1); + await expect(this.governor.execute(1)).to.be.revertedWith( + "GovernorAlpha::execute: proposal can only be executed if it is queued", + ); + expect(await this.governor.state(1)).to.equal(6); + } + }); + + it("voter can get his receipt via reencryption", async function () { + const aliceToken = this.instances.alice.getPublicKey(this.contractAddress); + const callDatas = [ethers.AbiCoder.defaultAbiCoder().encode(["address"], [this.signers.alice.address])]; + const tx = await createTransaction( + this.governor.propose, + [this.signers.alice], + ["0"], + ["getBalanceOf(address)"], + callDatas, + "do nothing", + ); + await tx.wait(); + + await waitNBlocks(2); + // Cast some votes + const encryptedSupportAlice = this.instances.alice.encryptBool(true); + const tx2 = await createTransaction(this.governor["castVote(uint256,bytes)"], 1, encryptedSupportAlice); + await tx2.wait(); + + const encryptedAliceVotes = await this.governor.getMyReceipt(1, aliceToken.publicKey, aliceToken.signature); + // Decrypt Alice's balance + const aliceVotes = this.instances.alice.decrypt(this.contractAddress, encryptedAliceVotes[2]); + expect(aliceVotes).to.equal(9_000_000n * 10n ** 6n); + + await expect( + this.governor.connect(this.signers.bob).getMyReceipt(1, aliceToken.publicKey, aliceToken.signature), + ).to.be.revertedWith("EIP712 signer and transaction signer do not match"); + }); }); diff --git a/test/dao/Timelock.ts b/test/dao/Timelock.ts new file mode 100644 index 0000000..432edda --- /dev/null +++ b/test/dao/Timelock.ts @@ -0,0 +1,153 @@ +import { expect } from "chai"; +import { ethers, network } from "hardhat"; + +import { createInstances } from "../instance"; +import { getSigners, initSigners } from "../signers"; +import { createTransaction, waitNBlocks } from "../utils"; +import { deployCompFixture } from "./Comp.fixture"; +import { deployGovernorZamaFixture, deployTimelockFixture } from "./GovernorZama.fixture"; + +describe("Timelock", function () { + before(async function () { + await initSigners(); + this.signers = await getSigners(); + }); + + beforeEach(async function () { + this.timelock = await deployTimelockFixture(this.signers.alice); + }); + + it("non-timelock account could not call setPendingAdmin", async function () { + await expect(this.timelock.setPendingAdmin(this.signers.bob)).to.throw; + }); + + it("non-timelock account could not call setDelay", async function () { + await expect(this.timelock.setDelay(60 * 60 * 24 * 3)).to.throw; + }); + + it("setDelay could only be called with a delay between MINIMUM_DELAY and MAXIMUM_DELAY", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 60 * 60 * 24 * 2 + 60; + const timeLockAdd = await this.timelock.getAddress(); + const callData1 = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 1]); // below MINIMUM_DELAY + const callData2 = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 40]); // above MAXIMUM_DELAY + const callData3 = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 20]); // OK + + const tx1 = await this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData1, expiry); + await tx1.wait(); + const tx2 = await this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData2, expiry); + await tx2.wait(); + const tx3 = await this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData3, expiry); + await tx3.wait(); + + if (network.name === "hardhat") { + // hardhat cheatcodes are available only in mocked mode + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + await expect( + this.timelock.executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData1, expiry), + ).to.be.revertedWith("Timelock::executeTransaction: Transaction execution reverted."); + await expect( + this.timelock.executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData2, expiry), + ).to.be.revertedWith("Timelock::executeTransaction: Transaction execution reverted."); + await this.timelock.executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData3, expiry); + expect(await this.timelock.delay()).to.equal(60 * 60 * 24 * 20); + } + }); + + it("only admin could cancel queued transaction", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 60 * 60 * 24 * 2 + 60; + const timeLockAdd = await this.timelock.getAddress(); + const callData = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 20]); // OK + + const tx = await this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry); + await tx.wait(); + + await expect( + this.timelock.connect(this.signers.bob).cancelTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry), + ).to.throw; + + const tx2 = await this.timelock.cancelTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry); + await tx2.wait(); + + if (network.name === "hardhat") { + // hardhat cheatcodes are available only in mocked mode + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + await expect( + this.timelock.executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry), + ).to.be.revertedWith("Timelock::executeTransaction: Transaction hasn't been queued."); + } + }); + + it("only admin could queue transaction, only if it satisfies the delay", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 60 * 60 * 24 * 2 + 60; + const expiryTooShort = block!.timestamp + 60 * 60 * 24 * 1 + 60; + const timeLockAdd = await this.timelock.getAddress(); + const callData = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 20]); // OK + + await expect( + this.timelock.connect(this.signers.bob).queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry), + ).to.throw; + + await expect(this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiryTooShort)).to + .throw; + + const tx = await this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry); + await tx.wait(); + }); + + it("only admin could execute transaction, only before grace period", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 60 * 60 * 24 * 2 + 60; + const timeLockAdd = await this.timelock.getAddress(); + const callData = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 20]); // OK + const tx = await this.timelock.queueTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry); + await tx.wait(); + + if (network.name === "hardhat") { + // hardhat cheatcodes are available only in mocked mode + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + await expect( + this.timelock + .connect(this.signers.bob) + .executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry), + ).to.be.revertedWith("Timelock::executeTransaction: Call must come from admin."); + + const idSnapshot = await ethers.provider.send("evm_snapshot"); + await ethers.provider.send("evm_increaseTime", ["0xffffff"]); + await expect( + this.timelock.executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry), + ).to.be.revertedWith("Timelock::executeTransaction: Transaction is stale."); + + await ethers.provider.send("evm_revert", [idSnapshot]); // roll back time to previous snapshot, before the grace period + const tx2 = await this.timelock.executeTransaction(timeLockAdd, 0, "setDelay(uint256)", callData, expiry); + await tx2.wait(); + expect(await this.timelock.delay()).to.equal(60 * 60 * 24 * 20); + } + }); + + it("if signature string is empty, calldata must append the signature", async function () { + const latestBlockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(latestBlockNumber); + const expiry = block!.timestamp + 60 * 60 * 24 * 2 + 60; + const timeLockAdd = await this.timelock.getAddress(); + const functionSig = ethers.FunctionFragment.getSelector("setDelay", ["uint256"]); + const callData = ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [60 * 60 * 24 * 20]); // OK + + const tx = await this.timelock.queueTransaction(timeLockAdd, 0, "", functionSig + callData.slice(2), expiry); + await tx.wait(); + + if (network.name === "hardhat") { + // hardhat cheatcodes are available only in mocked mode + await ethers.provider.send("evm_increaseTime", ["0x2a33c"]); + const tx2 = await this.timelock.executeTransaction(timeLockAdd, 0, "", functionSig + callData.slice(2), expiry); + await tx2.wait(); + expect(await this.timelock.delay()).to.equal(60 * 60 * 24 * 20); + } + }); +}); diff --git a/test/encryptedERC20/EncryptedERC20.ts b/test/encryptedERC20/EncryptedERC20.ts index 84063ce..4a2dd03 100644 --- a/test/encryptedERC20/EncryptedERC20.ts +++ b/test/encryptedERC20/EncryptedERC20.ts @@ -44,7 +44,7 @@ describe("EncryptedERC20", function () { }); it("non-owner should be unable to mint", async function () { - if (network.name == "hardhat") { + if (network.name === "hardhat") { // mocked mode await expect(this.erc20.connect(this.signers.bob).mint(1000, this.signers.alice.address)) .to.be.revertedWithCustomError(this.erc20, "OwnableUnauthorizedAccount") @@ -120,7 +120,7 @@ describe("EncryptedERC20", function () { expect(await this.erc20.balanceOfMe()).to.be.eq(0n); // Alice's initial handle is 0 const transaction = await this.erc20.mint(1000, this.signers.alice.address); await transaction.wait(); - if (network.name == "hardhat") { + if (network.name === "hardhat") { // mocked mode expect(await this.erc20.balanceOfMe()).to.be.eq(1000n); } else { diff --git a/test/utils.ts b/test/utils.ts index 4705524..0486b3d 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -2,8 +2,9 @@ import { ContractMethodArgs, Typed } from "ethers"; import { ethers } from "hardhat"; import { TypedContractMethod } from "../types/common"; +import { getSigners } from "./signers"; -export const waitForBlock = (blockNumber: bigint) => { +export const waitForBlock = (blockNumber: bigint | number) => { if (process.env.HARDHAT_NETWORK === "hardhat") { return new Promise((resolve, reject) => { const intervalId = setInterval(async () => { @@ -34,6 +35,29 @@ export const waitForBlock = (blockNumber: bigint) => { } }; +export const waitNBlocks = async (Nblocks: number) => { + const currentBlock = await ethers.provider.getBlockNumber(); + if (process.env.HARDHAT_NETWORK === "hardhat") { + await produceDummyTransactions(Nblocks); + } + await waitForBlock(currentBlock + Nblocks); +}; + +export const waitForBalance = async (address: string): Promise => { + return new Promise((resolve, reject) => { + const checkBalance = async () => { + const balance = await ethers.provider.getBalance(address); + if (balance > 0) { + await ethers.provider.off("block", checkBalance); + resolve(); + } + }; + ethers.provider.on("block", checkBalance).catch((err) => { + reject(err); + }); + }); +}; + export const createTransaction = async ( method: TypedContractMethod, ...params: A @@ -47,27 +71,19 @@ export const createTransaction = async { - const contract = await deployCounterContract(); - let counter = blockCount; - while (counter > 0) { - counter--; - const tx = await contract.increment(); - const _ = await tx.wait(); + const nullAddress = "0x0000000000000000000000000000000000000000"; + const signers = await getSigners(); + while (blockCount > 0) { + blockCount--; + // Sending 0 ETH to the null address + const tx = await signers.dave.sendTransaction({ + to: nullAddress, + value: ethers.parseEther("0"), + }); } }; -async function deployCounterContract(): Promise { - const signers = await getSigners(); - - const contractFactory = await ethers.getContractFactory("Counter"); - const contract = await contractFactory.connect(signers.dave).deploy(); - await contract.waitForDeployment(); - - return contract; -} - export const mineNBlocks = async (n: number) => { - for (let index = 0; index < n; index++) { - await ethers.provider.send("evm_mine"); - } + // this only works in mocked mode + await ethers.provider.send("hardhat_mine", ["0x" + n.toString(16)]); };