From 6639fb321dc6fa7084c109184db18f9c83ddc144 Mon Sep 17 00:00:00 2001 From: Anton <14254374+0xmad@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:39:42 -0500 Subject: [PATCH] feat(contracts): payout strategy - [x] Use voice credit proxy as payout strategy - [x] Add deposit/claim/withdraw functions - [x] Add tests --- .../contracts/interfaces/IPayoutStrategy.sol | 43 +++ .../contracts/contracts/interfaces/IPoll.sol | 7 +- .../contracts/maci/MaxCapVoiceCreditProxy.sol | 216 +++++++++++++++ packages/contracts/contracts/maci/Poll.sol | 10 +- .../contracts/contracts/mocks/MockERC20.sol | 12 + packages/contracts/tests/Maci.test.ts | 10 +- .../tests/MaxCapVoiceCreditProxy.test.ts | 258 ++++++++++++++++++ packages/contracts/tests/Poll.test.ts | 40 +-- packages/contracts/tests/constants.ts | 25 +- packages/contracts/tests/utils.ts | 54 +++- 10 files changed, 627 insertions(+), 48 deletions(-) create mode 100644 packages/contracts/contracts/interfaces/IPayoutStrategy.sol create mode 100644 packages/contracts/contracts/maci/MaxCapVoiceCreditProxy.sol create mode 100644 packages/contracts/contracts/mocks/MockERC20.sol create mode 100644 packages/contracts/tests/MaxCapVoiceCreditProxy.test.ts diff --git a/packages/contracts/contracts/interfaces/IPayoutStrategy.sol b/packages/contracts/contracts/interfaces/IPayoutStrategy.sol new file mode 100644 index 00000000..3bca7def --- /dev/null +++ b/packages/contracts/contracts/interfaces/IPayoutStrategy.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IPoll } from "./IPoll.sol"; + +/// @title IPayoutStrategy +/// @notice Interface responsible for payout strategy +interface IPayoutStrategy { + /// @notice Total deposited amount. + function totalAmount() external view returns (uint256); + + /// @notice The cooldown timeout. + function cooldown() external view returns (uint256); + + /// @notice Deposit amount + /// @param amount The amount + /// @param poll The poll + function deposit(uint256 amount, IPoll poll) external; + + /// @notice Withdraw extra amount + /// @param receivers The receivers addresses + /// @param amounts The amounts + /// @param poll The poll + function withdrawExtra(address[] calldata receivers, uint256[] calldata amounts, IPoll poll) external; + + /// @notice Claim funds for recipient + /// @param index The recipient index in registry + /// @param voiceCreditsPerOption The voice credit options received for recipient + /// @param tallyResult The tally result for recipient + /// @param totalVotesSquares The sum of tally results squares + /// @param totalSpent The total amount of voice credits spent + /// @param poll The poll + function claim( + uint256 index, + uint256 voiceCreditsPerOption, + uint256 tallyResult, + uint256 totalVotesSquares, + uint256 totalSpent, + IPoll poll + ) external; +} diff --git a/packages/contracts/contracts/interfaces/IPoll.sol b/packages/contracts/contracts/interfaces/IPoll.sol index 04c2696c..fcd3c0d4 100644 --- a/packages/contracts/contracts/interfaces/IPoll.sol +++ b/packages/contracts/contracts/interfaces/IPoll.sol @@ -4,8 +4,9 @@ pragma solidity ^0.8.20; import { IPoll as IPollBase } from "maci-contracts/contracts/interfaces/IPoll.sol"; import { IOwnable } from "./IOwnable.sol"; +import { IRecipientRegistry } from "./IRecipientRegistry.sol"; -/// @title IPollBase +/// @title IPoll /// @notice Poll interface interface IPoll is IPollBase, IOwnable { /// @notice The initialization function. @@ -14,4 +15,8 @@ interface IPoll is IPollBase, IOwnable { /// @notice Set the poll registry. /// @param registryAddress The registry address function setRegistry(address registryAddress) external; + + /// @notice Get the poll registry. + /// @return registry The poll registry + function getRegistry() external returns (IRecipientRegistry); } diff --git a/packages/contracts/contracts/maci/MaxCapVoiceCreditProxy.sol b/packages/contracts/contracts/maci/MaxCapVoiceCreditProxy.sol new file mode 100644 index 00000000..393e4de7 --- /dev/null +++ b/packages/contracts/contracts/maci/MaxCapVoiceCreditProxy.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { InitialVoiceCreditProxy } from "maci-contracts/contracts/initialVoiceCreditProxy/InitialVoiceCreditProxy.sol"; + +import { IPoll } from "../interfaces/IPoll.sol"; +import { IPayoutStrategy } from "../interfaces/IPayoutStrategy.sol"; +import { IRecipientRegistry } from "../interfaces/IRecipientRegistry.sol"; + +/// @title MaxCapVoiceCreditProxy - voice credit proxy and payout strategy +/// @notice A contract which allows users to deposit and claim rewards for recipients +contract MaxCapVoiceCreditProxy is InitialVoiceCreditProxy, IPayoutStrategy, Ownable, Pausable { + using SafeERC20 for ERC20; + + /// @notice The max voice credits (MACI allows 2 ** 32 voice credits max) + uint256 private constant MAX_VOICE_CREDITS = 10 ** 9; + + /// @notice The alpha precision (needed for allocated amount calculation) + uint256 private constant ALPHA_PRECISION = 10 ** 18; + + /// @notice The payout token + ERC20 public immutable token; + + /// @notice The total amount of funds deposited + uint256 public totalAmount; + + /// @notice The max contribution amount + uint256 public immutable maxContributionAmount; + + /// @notice The voice credit factor (needed for allocated amount calculation) + uint256 public immutable voiceCreditFactor; + + /// @notice The cooldown duration for withdrawal extra funds + uint256 public immutable cooldown; + + /// @notice custom errors + error CooldownPeriodNotOver(); + error VotingPeriodNotOver(); + error VotingPeriodOver(); + error InvalidBudget(); + error NoProjectHasMoreThanOneVote(); + error InvalidWithdrawal(); + + /// @notice Contract initialization + /// @param cooldownTime The cooldown duration for withdrawal extra funds + /// @param maxContribution The max contribution amount + /// @param payoutToken The payout token + /// @param ownerAddress The contract owner + constructor( + uint256 cooldownTime, + uint256 maxContribution, + ERC20 payoutToken, + address ownerAddress + ) Ownable(ownerAddress) { + cooldown = cooldownTime; + token = payoutToken; + maxContributionAmount = maxContribution; + voiceCreditFactor = maxContributionAmount / MAX_VOICE_CREDITS; + } + + /// @notice A modifier that causes the function to revert if the cooldown period is not over + modifier afterCooldown(IPoll poll) { + (uint256 deployTime, uint256 duration) = poll.getDeployTimeAndDuration(); + uint256 secondsPassed = block.timestamp - deployTime; + + if (secondsPassed <= duration + cooldown) { + revert CooldownPeriodNotOver(); + } + + _; + } + + /// @notice A modifier that causes the function to revert if the voting period is over + modifier beforeVotingDeadline(IPoll poll) { + (uint256 deployTime, uint256 duration) = poll.getDeployTimeAndDuration(); + uint256 secondsPassed = block.timestamp - deployTime; + + if (secondsPassed > duration) { + revert VotingPeriodOver(); + } + + _; + } + + /// @notice A modifier that causes the function to revert if the voting period is not over + modifier afterVotingDeadline(IPoll poll) { + (uint256 deployTime, uint256 duration) = poll.getDeployTimeAndDuration(); + uint256 secondsPassed = block.timestamp - deployTime; + + if (secondsPassed <= duration) { + revert VotingPeriodNotOver(); + } + + _; + } + + /// @notice Pause contract calls (deposit, claim, withdraw) + function pause() public onlyOwner { + _pause(); + } + + /// @notice Unpause contract calls (deposit, claim, withdraw) + function unpause() public onlyOwner { + _unpause(); + } + + /// @inheritdoc InitialVoiceCreditProxy + function getVoiceCredits(address user, bytes memory /** data */) public view override returns (uint256) { + return token.balanceOf(user); + } + + /// @inheritdoc IPayoutStrategy + function deposit(uint256 amount, IPoll poll) public whenNotPaused beforeVotingDeadline(poll) { + totalAmount += amount; + + token.safeTransferFrom(msg.sender, address(this), amount); + } + + /// @inheritdoc IPayoutStrategy + function withdrawExtra( + address[] calldata receivers, + uint256[] calldata amounts, + IPoll poll + ) public override onlyOwner whenNotPaused afterCooldown(poll) { + uint256 amountLength = amounts.length; + uint256 totalFunds = totalAmount; + uint256 sum = 0; + + for (uint256 index = 0; index < amountLength; ) { + uint256 amount = amounts[index]; + sum += amount; + totalAmount -= amount; + + unchecked { + index++; + } + } + + if (sum > totalFunds) { + revert InvalidWithdrawal(); + } + + for (uint256 index = 0; index < amountLength; ) { + uint256 amount = amounts[index]; + address receiver = receivers[index]; + + if (amount > 0) { + token.safeTransfer(receiver, amount); + } + + unchecked { + index++; + } + } + } + + /// @inheritdoc IPayoutStrategy + function claim( + uint256 index, + uint256 voiceCreditsPerOption, + uint256 tallyResult, + uint256 totalVotesSquares, + uint256 totalSpent, + IPoll poll + ) public override whenNotPaused afterVotingDeadline(poll) { + uint256 amount = getAllocatedAmount(voiceCreditsPerOption, totalVotesSquares, totalSpent, tallyResult); + IRecipientRegistry registry = poll.getRegistry(); + IRecipientRegistry.Recipient memory recipient = registry.getRecipient(index); + totalAmount -= amount; + + token.safeTransfer(recipient.recipient, amount); + } + + /// @notice Get allocated token amount (without verification). + /// @param voiceCreditsPerOption The voice credit options received for recipient + /// @param totalVotesSquares The sum of tally results squares + /// @param totalSpent The total amount of voice credits spent + /// @param tallyResult The tally result for recipient + function getAllocatedAmount( + uint256 voiceCreditsPerOption, + uint256 totalVotesSquares, + uint256 totalSpent, + uint256 tallyResult + ) internal view returns (uint256) { + uint256 alpha = calculateAlpha(totalVotesSquares, totalSpent); + uint256 quadratic = alpha * voiceCreditFactor * tallyResult * tallyResult; + uint256 totalSpentCredits = voiceCreditFactor * voiceCreditsPerOption; + uint256 linearPrecision = ALPHA_PRECISION * totalSpentCredits; + uint256 linearAlpha = alpha * totalSpentCredits; + + return ((quadratic + linearPrecision) - linearAlpha) / ALPHA_PRECISION; + } + + /// @notice Calculate the alpha for the capital constrained quadratic formula + /// @dev page 17 of https://arxiv.org/pdf/1809.06421.pdf + /// @param totalVotesSquares The sum of tally results squares + /// @param totalSpent The total amount of voice credits spent + function calculateAlpha(uint256 totalVotesSquares, uint256 totalSpent) internal view returns (uint256) { + uint256 budget = token.balanceOf(address(this)); + uint256 contributions = totalSpent * voiceCreditFactor; + + if (budget < contributions) { + revert InvalidBudget(); + } + + if (totalVotesSquares <= totalSpent) { + revert NoProjectHasMoreThanOneVote(); + } + + return ((budget - contributions) * ALPHA_PRECISION) / (voiceCreditFactor * (totalVotesSquares - totalSpent)); + } +} diff --git a/packages/contracts/contracts/maci/Poll.sol b/packages/contracts/contracts/maci/Poll.sol index 9389234d..a889a5fc 100644 --- a/packages/contracts/contracts/maci/Poll.sol +++ b/packages/contracts/contracts/maci/Poll.sol @@ -5,6 +5,7 @@ import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { Poll as BasePoll } from "maci-contracts/contracts/Poll.sol"; import { ICommon } from "../interfaces/ICommon.sol"; +import { IRecipientRegistry } from "../interfaces/IRecipientRegistry.sol"; /// @title Poll /// @notice A Poll contract allows voters to submit encrypted messages @@ -90,8 +91,7 @@ contract Poll is Ownable, BasePoll, ICommon { _; } - /// @notice A modifier that causes the function to revert if the voting period is - /// over + /// @notice A modifier that causes the function to revert if the voting period is over modifier isWithinVotingDeadline() override { uint256 secondsPassed = block.timestamp - initTime; @@ -110,6 +110,12 @@ contract Poll is Ownable, BasePoll, ICommon { emit SetRegistry(registryAddress); } + /// @notice Get the poll registry. + /// @return registry The poll registry + function getRegistry() public view returns (IRecipientRegistry) { + return IRecipientRegistry(registry); + } + /// @notice The initialization function. function init() public override onlyOwner isRegistryInitialized { initTime = block.timestamp; diff --git a/packages/contracts/contracts/mocks/MockERC20.sol b/packages/contracts/contracts/mocks/MockERC20.sol new file mode 100644 index 00000000..62fedec7 --- /dev/null +++ b/packages/contracts/contracts/mocks/MockERC20.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @title MockERC20 +/// @notice A mock ERC20 contract that mints 100,000,000,000,000,000 tokens to the deployer +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 100e21); + } +} diff --git a/packages/contracts/tests/Maci.test.ts b/packages/contracts/tests/Maci.test.ts index 2df89b5c..8ffd57d7 100644 --- a/packages/contracts/tests/Maci.test.ts +++ b/packages/contracts/tests/Maci.test.ts @@ -9,8 +9,8 @@ import { MACI, Poll__factory as PollFactory, Poll as PollContract } from "../typ import { NOTHING_UP_MY_SLEEVE, STATE_TREE_DEPTH, - duration, - initialVoiceCreditBalance, + DURATION, + INITIAL_VOICE_CREDIT_BALANCE, messageBatchSize, treeDepths, } from "./constants"; @@ -36,7 +36,7 @@ describe("Maci", () => { [owner, user] = await getSigners(); const contracts = await deployTestContracts({ - initialVoiceCreditBalance, + initialVoiceCreditBalance: INITIAL_VOICE_CREDIT_BALANCE, stateTreeDepth: STATE_TREE_DEPTH, signer: owner, }); @@ -46,7 +46,7 @@ describe("Maci", () => { // deploy on chain poll const tx = await maciContract.deployPoll( - duration, + DURATION, treeDepths, coordinator.pubKey.asContractParam(), verifierContract, @@ -66,7 +66,7 @@ describe("Maci", () => { pollContract = PollFactory.connect(pollContracts.poll, owner); // deploy local poll - const p = maciState.deployPoll(BigInt(deployTime + duration), treeDepths, messageBatchSize, coordinator); + const p = maciState.deployPoll(BigInt(deployTime + DURATION), treeDepths, messageBatchSize, coordinator); expect(p.toString()).to.eq(pollId.toString()); // publish the NOTHING_UP_MY_SLEEVE message const messageData = [NOTHING_UP_MY_SLEEVE]; diff --git a/packages/contracts/tests/MaxCapVoiceCreditProxy.test.ts b/packages/contracts/tests/MaxCapVoiceCreditProxy.test.ts new file mode 100644 index 00000000..8ebe6589 --- /dev/null +++ b/packages/contracts/tests/MaxCapVoiceCreditProxy.test.ts @@ -0,0 +1,258 @@ +import { expect } from "chai"; +import { encodeBytes32String, parseUnits, Signer } from "ethers"; +import { getSigners, deployContract, genEmptyBallotRoots } from "maci-contracts"; +import { Keypair } from "maci-domainobjs"; + +import { MaxCapVoiceCreditProxy, ERC20, Poll, IRecipientRegistry } from "../typechain-types"; + +import { + PER_VO_SPENT_VOICE_CREDITS, + STATE_TREE_DEPTH, + TALLY_RESULTS, + TOTAL_SPENT_VOICE_CREDITS, + treeDepths, +} from "./constants"; +import { deployTestPoll, timeTravel } from "./utils"; + +describe("MaxCapVoiceCreditProxy", () => { + let payoutToken: ERC20; + let poll: Poll; + let maxCapVoiceCreditProxy: MaxCapVoiceCreditProxy; + let registry: IRecipientRegistry; + let owner: Signer; + let user: Signer; + let project: Signer; + + let ownerAddress: string; + let userAddress: string; + let projectAddress: string; + + const maxRecipients = TALLY_RESULTS.tally.length; + + const metadataUrl = encodeBytes32String("url"); + const maxContribution = 5n * 10n ** 18n; + const cooldownTime = 1_000; + const emptyBallotRoots = genEmptyBallotRoots(STATE_TREE_DEPTH); + const emptyBallotRoot = emptyBallotRoots[treeDepths.voteOptionTreeDepth]; + const duration = 100; + const keypair = new Keypair(); + + before(async () => { + [owner, user, project] = await getSigners(); + [ownerAddress, userAddress, projectAddress] = await Promise.all([ + owner.getAddress(), + user.getAddress(), + project.getAddress(), + ]); + + payoutToken = await deployContract("MockERC20", owner, true, "Payout token", "PT"); + + poll = await deployTestPoll({ + signer: owner, + emptyBallotRoot, + treeDepths, + duration, + coordinatorPubKey: keypair.pubKey, + }); + + maxCapVoiceCreditProxy = await deployContract( + "MaxCapVoiceCreditProxy", + owner, + true, + cooldownTime, + maxContribution, + await payoutToken.getAddress(), + ownerAddress, + ); + + registry = await deployContract( + "EASRegistry", + owner, + true, + maxRecipients, + metadataUrl, + await maxCapVoiceCreditProxy.getAddress(), + ownerAddress, + ); + + await poll.setRegistry(registry).then((tx) => tx.wait()); + }); + + it("should get voice credits properly", async () => { + const balances = await Promise.all( + [ownerAddress, userAddress].map((address) => + maxCapVoiceCreditProxy.getVoiceCredits(address, encodeBytes32String("")), + ), + ); + + expect(balances).to.deep.equal([ + await payoutToken.balanceOf(ownerAddress), + await payoutToken.balanceOf(userAddress), + ]); + }); + + it("should deposit funds properly", async () => { + const [decimals, initialBalance] = await Promise.all([payoutToken.decimals(), payoutToken.balanceOf(owner)]); + const ownerAmount = parseUnits(TOTAL_SPENT_VOICE_CREDITS.spent, decimals); + const userAmount = parseUnits("2", decimals); + + await payoutToken.approve(user, userAmount).then((tx) => tx.wait()); + await payoutToken.transfer(user, userAmount); + + await payoutToken.approve(maxCapVoiceCreditProxy, ownerAmount).then((tx) => tx.wait()); + await maxCapVoiceCreditProxy.deposit(ownerAmount, poll).then((tx) => tx.wait()); + + await payoutToken + .connect(user) + .approve(maxCapVoiceCreditProxy, userAmount) + .then((tx) => tx.wait()); + await maxCapVoiceCreditProxy + .connect(user) + .deposit(userAmount, poll) + .then((tx) => tx.wait()); + + const [voiceCredits, tokenBalance, totalAmount] = await Promise.all([ + maxCapVoiceCreditProxy.getVoiceCredits(maxCapVoiceCreditProxy, encodeBytes32String("")), + payoutToken.balanceOf(maxCapVoiceCreditProxy), + maxCapVoiceCreditProxy.totalAmount(), + ]); + + expect(voiceCredits).to.equal(tokenBalance); + expect(totalAmount).to.equal(tokenBalance); + expect(initialBalance - tokenBalance).to.equal(initialBalance - ownerAmount - userAmount); + }); + + it("should not withdraw extra if cooldown period is not over", async () => { + await expect(maxCapVoiceCreditProxy.withdrawExtra([owner, user], [1n], poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "CooldownPeriodNotOver", + ); + }); + + it("should not allow non-owner to withdraw funds", async () => { + await expect( + maxCapVoiceCreditProxy.connect(user).withdrawExtra([owner, user], [1n], poll), + ).to.be.revertedWithCustomError(maxCapVoiceCreditProxy, "OwnableUnauthorizedAccount"); + }); + + it("should not allow non-owner to pause/unpause", async () => { + await expect(maxCapVoiceCreditProxy.connect(user).pause()).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "OwnableUnauthorizedAccount", + ); + + await expect(maxCapVoiceCreditProxy.connect(user).unpause()).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "OwnableUnauthorizedAccount", + ); + }); + + it("should not allow to call functions if contract is paused", async () => { + try { + await maxCapVoiceCreditProxy.pause().then((tx) => tx.wait()); + + await expect(maxCapVoiceCreditProxy.deposit(1n, poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "EnforcedPause", + ); + + await expect(maxCapVoiceCreditProxy.withdrawExtra([owner, user], [1n], poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "EnforcedPause", + ); + + await expect(maxCapVoiceCreditProxy.claim(0, 0, 0, 0, 0, poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "EnforcedPause", + ); + } finally { + await maxCapVoiceCreditProxy.unpause().then((tx) => tx.wait()); + } + }); + + it("should not allow to claim before voting deadline", async () => { + await expect(maxCapVoiceCreditProxy.claim(0, 0, 0, 0, 0, poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "VotingPeriodNotOver", + ); + }); + + it("should claim funds properly for the project", async () => { + await timeTravel(cooldownTime + duration, owner); + + const totalVotesSquares = TALLY_RESULTS.tally.reduce((acc, x) => acc + Number(x) * Number(x), 0); + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < TALLY_RESULTS.tally.length; index += 1) { + // eslint-disable-next-line no-await-in-loop + await registry + .addRecipient({ + id: encodeBytes32String(index.toString()), + metadataUrl, + recipient: projectAddress, + }) + .then((tx) => tx.wait()); + + // eslint-disable-next-line no-await-in-loop + await maxCapVoiceCreditProxy + .claim( + 0, + PER_VO_SPENT_VOICE_CREDITS.tally[0], + TALLY_RESULTS.tally[0], + totalVotesSquares, + TOTAL_SPENT_VOICE_CREDITS.spent, + poll, + ) + .then((tx) => tx.wait()); + } + }); + + it("should not allow to claim funds if there are no any votes", async () => { + await expect( + maxCapVoiceCreditProxy.claim(0, 0, 0, 0, 0, poll).then((tx) => tx.wait()), + ).to.be.revertedWithCustomError(maxCapVoiceCreditProxy, "NoProjectHasMoreThanOneVote"); + }); + + it("should not allow to claim funds if there are not enough funds", async () => { + const total = await payoutToken.balanceOf(maxCapVoiceCreditProxy); + + await expect( + maxCapVoiceCreditProxy.claim(0, 0, 0, 0, total + 1n, poll).then((tx) => tx.wait()), + ).to.be.revertedWithCustomError(maxCapVoiceCreditProxy, "InvalidBudget"); + }); + + it("should withdraw extra after cooldown properly", async () => { + const [contractBalance, initialOwnerBalance, totalAmount] = await Promise.all([ + payoutToken.balanceOf(maxCapVoiceCreditProxy), + payoutToken.balanceOf(owner), + maxCapVoiceCreditProxy.totalAmount(), + ]); + + await maxCapVoiceCreditProxy.withdrawExtra([owner, user], [totalAmount, 0], poll).then((tx) => tx.wait()); + + const [balance, ownerBalance, totalExtraFunds] = await Promise.all([ + payoutToken.balanceOf(maxCapVoiceCreditProxy), + payoutToken.balanceOf(owner), + maxCapVoiceCreditProxy.totalAmount(), + ]); + + expect(balance).to.equal(0n); + expect(totalExtraFunds).to.equal(0n); + expect(initialOwnerBalance + totalAmount).to.equal(ownerBalance); + expect(contractBalance).to.equal(totalAmount); + }); + + it("should not withdraw extra if there is no enough funds", async () => { + await expect(maxCapVoiceCreditProxy.withdrawExtra([owner], [maxContribution], poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "InvalidWithdrawal", + ); + }); + + it("should not deposit after voting period is over", async () => { + await expect(maxCapVoiceCreditProxy.deposit(1n, poll)).to.be.revertedWithCustomError( + maxCapVoiceCreditProxy, + "VotingPeriodOver", + ); + }); +}); diff --git a/packages/contracts/tests/Poll.test.ts b/packages/contracts/tests/Poll.test.ts index 299a4406..75580e26 100644 --- a/packages/contracts/tests/Poll.test.ts +++ b/packages/contracts/tests/Poll.test.ts @@ -1,24 +1,20 @@ import { expect } from "chai"; import { encodeBytes32String, ZeroAddress, type Signer } from "ethers"; -import hardhat from "hardhat"; -import { deployContract, Deployment, deployPoseidonContracts, genEmptyBallotRoots, getSigners } from "maci-contracts"; +import { deployContract, genEmptyBallotRoots, getSigners } from "maci-contracts"; import { Keypair, Message, PubKey } from "maci-domainobjs"; -import { type Poll, Poll__factory as PollFactory, PollFactory as PollFactoryContract } from "../typechain-types"; +import type { Poll } from "../typechain-types"; import { DEFAULT_SR_QUEUE_OPS, STATE_TREE_DEPTH, treeDepths } from "./constants"; -import { timeTravel } from "./utils"; +import { deployTestPoll, timeTravel } from "./utils"; describe("Poll", () => { - let pollFactory: PollFactoryContract; let pollContract: Poll; let owner: Signer; let user: Signer; const { pubKey: coordinatorPubKey } = new Keypair(); - const deployment = Deployment.getInstance(hardhat); - const emptyBallotRoots = genEmptyBallotRoots(STATE_TREE_DEPTH); const emptyBallotRoot = emptyBallotRoots[treeDepths.voteOptionTreeDepth]; const duration = 100; @@ -32,34 +28,8 @@ describe("Poll", () => { before(async () => { [owner, user] = await getSigners(); - const { PoseidonT3Contract, PoseidonT4Contract, PoseidonT5Contract, PoseidonT6Contract } = - await deployPoseidonContracts(owner); - - const contractFactory = await hardhat.ethers.getContractFactory("contracts/maci/PollFactory.sol:PollFactory", { - signer: owner, - libraries: { - PoseidonT3: PoseidonT3Contract, - PoseidonT4: PoseidonT4Contract, - PoseidonT5: PoseidonT5Contract, - PoseidonT6: PoseidonT6Contract, - }, - }); - - pollFactory = await deployment.deployContractWithLinkedLibraries({ contractFactory, signer: owner }); - - const pollAddress = await pollFactory.deploy.staticCall( - duration, - treeDepths, - coordinatorPubKey.asContractParam(), - await owner.getAddress(), - emptyBallotRoot, - ); - await pollFactory - .deploy("100", treeDepths, coordinatorPubKey.asContractParam(), await owner.getAddress(), emptyBallotRoot) - .then((tx) => tx.wait()); - - pollContract = PollFactory.connect(pollAddress, owner); + pollContract = await deployTestPoll({ signer: owner, emptyBallotRoot, treeDepths, duration, coordinatorPubKey }); }); it("should fail if unauthorized user tries to set the poll registry", async () => { @@ -105,6 +75,8 @@ describe("Poll", () => { .to.emit(pollContract, "SetRegistry") .withArgs(address); + expect(await pollContract.getRegistry()).to.equal(address); + await expect(pollContract.connect(owner).setRegistry(address)).to.be.revertedWithCustomError( pollContract, "RegistryAlreadyInitialized", diff --git a/packages/contracts/tests/constants.ts b/packages/contracts/tests/constants.ts index 821c0bd2..f0395d71 100644 --- a/packages/contracts/tests/constants.ts +++ b/packages/contracts/tests/constants.ts @@ -1,6 +1,4 @@ -import { TreeDepths, STATE_TREE_ARITY, MESSAGE_TREE_ARITY } from "maci-core"; - -export const duration = 2_000; +import { TreeDepths, MESSAGE_TREE_ARITY } from "maci-core"; export const STATE_TREE_DEPTH = 10; export const MESSAGE_TREE_DEPTH = 2; @@ -9,7 +7,8 @@ export const messageBatchSize = MESSAGE_TREE_ARITY ** MESSAGE_TREE_SUBDEPTH; export const NOTHING_UP_MY_SLEEVE = 8370432830353022751713833565135785980866757267633941821328460903436894336785n; export const DEFAULT_SR_QUEUE_OPS = 4; -export const initialVoiceCreditBalance = 100; +export const DURATION = 2_000; +export const INITIAL_VOICE_CREDIT_BALANCE = 1000; export const treeDepths: TreeDepths = { intStateTreeDepth: 1, @@ -18,4 +17,20 @@ export const treeDepths: TreeDepths = { voteOptionTreeDepth: 2, }; -export const tallyBatchSize = STATE_TREE_ARITY ** treeDepths.intStateTreeDepth; +export const TALLY_RESULTS = { + tally: ["12", "107", "159", "67", "26", "35", "53", "17", "114", "158", "65", "60", "0"], + salt: "0xed6956a31ec7a8d7c71e4d9bb90f70281e45e7977644268af3cc061c384feae", + commitment: "0x1f6ebdc3299a1c2eef28cfa14822c4a1e0a607a4044f6aa4434b1b80f1b79ef7", +}; + +export const TOTAL_SPENT_VOICE_CREDITS = { + spent: "12391", + salt: "0x269f6c1a1a34bc13d7369fb93323590504f37edf41d952d5f9e8cf814381c475", + commitment: "0x1f5c444c5dbc821ad8eb76bcfd8966468eca73c08b98cf1504d312751d61b908", +}; + +export const PER_VO_SPENT_VOICE_CREDITS = { + tally: ["86", "1459", "3039", "587", "136", "491", "627", "79", "1644", "2916", "721", "606", "0"], + salt: "0x1f31c9ed6fb5f2beb54c32edbc922a87d467c31d0178c8427fbb7d978048ade7", + commitment: "0xdf601b181267173f055d90e5fb286637634317c29aa57478800077a0f9bc839", +}; diff --git a/packages/contracts/tests/utils.ts b/packages/contracts/tests/utils.ts index de1ecbc3..dcaba322 100644 --- a/packages/contracts/tests/utils.ts +++ b/packages/contracts/tests/utils.ts @@ -3,6 +3,7 @@ import { deployConstantInitialVoiceCreditProxy, deployFreeForAllSignUpGatekeeper, deployMaci, + Deployment, deployMockVerifier, deployPoseidonContracts, deployVkRegistry, @@ -13,8 +14,10 @@ import { } from "maci-contracts"; import type { ContractFactory, Signer, JsonRpcProvider } from "ethers"; +import type { TreeDepths } from "maci-core"; +import type { PubKey } from "maci-domainobjs"; -import { type MACI } from "../typechain-types"; +import { Poll, PollFactory as PollFactoryContract, Poll__factory as PollFactory, type MACI } from "../typechain-types"; /** * An interface that represents argument for deployment test contracts @@ -126,6 +129,55 @@ export const deployTestContracts = async ({ }; }; +export interface IDeployTestPollArgs { + signer: Signer; + emptyBallotRoot: bigint | number; + treeDepths: TreeDepths; + duration: bigint | number; + coordinatorPubKey: PubKey; +} + +export const deployTestPoll = async ({ + signer, + emptyBallotRoot, + treeDepths, + duration, + coordinatorPubKey, +}: IDeployTestPollArgs): Promise => { + const { PoseidonT3Contract, PoseidonT4Contract, PoseidonT5Contract, PoseidonT6Contract } = + await deployPoseidonContracts(signer); + + const contractFactory = await ethers.getContractFactory("contracts/maci/PollFactory.sol:PollFactory", { + signer, + libraries: { + PoseidonT3: PoseidonT3Contract, + PoseidonT4: PoseidonT4Contract, + PoseidonT5: PoseidonT5Contract, + PoseidonT6: PoseidonT6Contract, + }, + }); + + const deployment = Deployment.getInstance(); + const pollFactory = await deployment.deployContractWithLinkedLibraries({ + contractFactory, + signer, + }); + + const pollAddress = await pollFactory.deploy.staticCall( + duration, + treeDepths, + coordinatorPubKey.asContractParam(), + await signer.getAddress(), + emptyBallotRoot, + ); + + await pollFactory + .deploy("100", treeDepths, coordinatorPubKey.asContractParam(), await signer.getAddress(), emptyBallotRoot) + .then((tx) => tx.wait()); + + return PollFactory.connect(pollAddress, signer); +}; + /** * Utility to travel in time when using a local blockchain * @param seconds - the number of seconds to travel in time