Skip to content

Commit

Permalink
feat(contracts): payout strategy
Browse files Browse the repository at this point in the history
- [x] Use voice credit proxy as payout strategy
- [x] Add deposit/claim/withdraw functions
- [x] Add tests
  • Loading branch information
0xmad committed Sep 23, 2024
1 parent d6d66ea commit 105f96b
Show file tree
Hide file tree
Showing 10 changed files with 621 additions and 48 deletions.
43 changes: 43 additions & 0 deletions packages/contracts/contracts/interfaces/IPayoutStrategy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

Check warning on line 4 in packages/contracts/contracts/interfaces/IPayoutStrategy.sol

View workflow job for this annotation

GitHub Actions / check (lint:sol)

imported name IERC20 is not used

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;
}
7 changes: 6 additions & 1 deletion packages/contracts/contracts/interfaces/IPoll.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}
210 changes: 210 additions & 0 deletions packages/contracts/contracts/maci/MaxCapVoiceCreditProxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// 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 owner The contract owner
constructor(uint256 cooldownTime, uint256 maxContribution, ERC20 payoutToken, address owner) Ownable(owner) {

Check notice

Code scanning / Slither

Local variable shadowing Low

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 sum = 0;

for (uint256 index = 0; index < amountLength; ) {
uint256 amount = amounts[index];
sum += amount;

unchecked {
index++;
}
}

if (sum > totalAmount) {
revert InvalidWithdrawal();
}

for (uint256 index = 0; index < amountLength; ) {
uint256 amount = amounts[index];
address receiver = receivers[index];
totalAmount -= amount;

if (amount > 0) {
token.safeTransfer(receiver, amount);
}

unchecked {
index++;
}
}
}

Check warning

Code scanning / Slither

Reentrancy vulnerabilities Medium

Check warning

Code scanning / Slither

Costly operations inside a loop Warning


/// @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;
}

Check warning

Code scanning / Slither

Dead-code Warning


/// @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));
}

Check warning

Code scanning / Slither

Dead-code Warning

MaxCapVoiceCreditProxy.calculateAlpha(uint256,uint256) is never used and should be removed
}
10 changes: 8 additions & 2 deletions packages/contracts/contracts/maci/Poll.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions packages/contracts/contracts/mocks/MockERC20.sol
Original file line number Diff line number Diff line change
@@ -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) {

Check notice

Code scanning / Slither

Local variable shadowing Low

Check notice

Code scanning / Slither

Local variable shadowing Low

_mint(msg.sender, 100e21);
}
}
10 changes: 5 additions & 5 deletions packages/contracts/tests/Maci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
});
Expand All @@ -46,7 +46,7 @@ describe("Maci", () => {

// deploy on chain poll
const tx = await maciContract.deployPoll(
duration,
DURATION,
treeDepths,
coordinator.pubKey.asContractParam(),
verifierContract,
Expand All @@ -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];
Expand Down
Loading

0 comments on commit 105f96b

Please sign in to comment.