Skip to content

Commit

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

import { IPoll } from "./IPoll.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 IPoll is not used

/// @title IPayoutStrategy
/// @notice Interface responsible for payout strategy
interface IPayoutStrategy {
/// @notice Strategy initialization params
struct StrategyInit {
/// @notice The cooldown duration for withdrawal extra funds
uint256 cooldownTime;
/// @notice The max contribution amount
uint256 maxContribution;
/// @notice The payout token
address payoutToken;
/// @notice The poll address
address poll;
}

/// @notice Claim params
struct Claim {
/// @notice The index of the vote option to verify the correctness of the tally
uint256 index;
/// @notice The voice credit options received for recipient
uint256 voiceCreditsPerOption;
/// @notice Flattened array of the tally
uint256 tallyResult;
/// @notice The sum of tally results squares
uint256 totalVotesSquares;
/// @notice The total amount of voice credits spent
uint256 totalSpent;
/// @notice Corresponding proof of the tally result
uint256[][] tallyResultProof;
/// @notice The respective salt in the results object in the tally.json
uint256 tallyResultSalt;
/// @notice Depth of the vote option tree
uint8 voteOptionTreeDepth;
/// @notice hashLeftRight(number of spent voice credits, spent salt)
uint256 spentVoiceCreditsHash;
/// @notice hashLeftRight(merkle root of the no spent voice
uint256 perVOSpentVoiceCreditsHash;
}

/// @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
function deposit(uint256 amount) external;

/// @notice Withdraw extra amount
/// @param receivers The receivers addresses
/// @param amounts The amounts
function withdrawExtra(address[] calldata receivers, uint256[] calldata amounts) external;

/// @notice Claim funds for recipient
/// @param params The claim params
function claim(Claim calldata params) 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);
}
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
263 changes: 263 additions & 0 deletions packages/contracts/contracts/maci/Tally.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";

Check warning on line 4 in packages/contracts/contracts/maci/Tally.sol

View workflow job for this annotation

GitHub Actions / check (lint:sol)

imported name Ownable is not used
import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { Tally as TallyBase } from "maci-contracts/contracts/Tally.sol";

import { IPoll } from "../interfaces/IPoll.sol";
import { IPayoutStrategy } from "../interfaces/IPayoutStrategy.sol";
import { IRecipientRegistry } from "../interfaces/IRecipientRegistry.sol";

/// @title Tally - poll tally and payout strategy
/// @notice The Tally contract is used during votes tallying and by users to verify the tally results.
/// @notice Allows users to deposit and claim rewards for recipients
contract Tally is TallyBase, IPayoutStrategy, Pausable {
using SafeERC20 for IERC20;

/// @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
IERC20 public token;

/// @notice The poll registry
IRecipientRegistry public registry;

/// @notice The total amount of funds deposited
uint256 public totalAmount;

/// @notice The max contribution amount
uint256 public maxContributionAmount;

/// @notice The voice credit factor (needed for allocated amount calculation)
uint256 public voiceCreditFactor;

/// @notice The cooldown duration for withdrawal extra funds
uint256 public cooldown;

/// @notice Initialized or not
bool internal initialized;

/// @notice custom errors
error CooldownPeriodNotOver();
error VotingPeriodNotOver();
error VotingPeriodOver();
error InvalidBudget();
error NoProjectHasMoreThanOneVote();
error InvalidWithdrawal();
error AlreadyInitialized();
error NotInitialized();
error InvalidPoll();

/// @notice Create a new Tally contract
/// @param verifierContract The Verifier contract
/// @param vkRegistryContract The VkRegistry contract
/// @param pollContract The Poll contract
/// @param mpContract The MessageProcessor contract
/// @param tallyOwner The owner of the Tally contract
/// @param pollMode The mode of the poll
constructor(
address verifierContract,
address vkRegistryContract,
address pollContract,
address mpContract,
address tallyOwner,
Mode pollMode
) payable TallyBase(verifierContract, vkRegistryContract, pollContract, mpContract, tallyOwner, pollMode) {
}

/// @notice A modifier that causes the function to revert if the cooldown period is not over
modifier afterCooldown() {
(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() {
(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() {
(uint256 deployTime, uint256 duration) = poll.getDeployTimeAndDuration();
uint256 secondsPassed = block.timestamp - deployTime;

if (secondsPassed <= duration) {
revert VotingPeriodNotOver();
}

_;
}

/// @notice A modifier that causes the function to revert if the strategy is not initialized
modifier isInitialized() {
if (!initialized) {
revert NotInitialized();
}

_;
}

/// @notice Initialize tally with strategy params
/// @param params The strategy initialization params
function init(IPayoutStrategy.StrategyInit calldata params) public onlyOwner {
if (initialized) {
revert AlreadyInitialized();
}

if (params.poll != address(poll)) {
revert InvalidPoll();
}

initialized = true;
cooldown = params.cooldownTime;
registry = IPoll(params.poll).getRegistry();
token = IERC20(params.payoutToken);
maxContributionAmount = params.maxContribution;
voiceCreditFactor = params.maxContribution / MAX_VOICE_CREDITS;
}

/// @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 IPayoutStrategy
function deposit(uint256 amount) public isInitialized whenNotPaused beforeVotingDeadline {
totalAmount += amount;

token.safeTransferFrom(msg.sender, address(this), amount);
}

/// @inheritdoc IPayoutStrategy
function withdrawExtra(
address[] calldata receivers,
uint256[] calldata amounts
) public override isInitialized onlyOwner whenNotPaused afterCooldown {
uint256 amountLength = amounts.length;
uint256 totalFunds = totalAmount;
uint256 sum = 0;

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

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

totalAmount -= amount;

unchecked {
index++;
}
}

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(
IPayoutStrategy.Claim calldata params
) public override isInitialized whenNotPaused afterVotingDeadline {
uint256 amount = getAllocatedAmount(
params.voiceCreditsPerOption,
params.totalVotesSquares,
params.totalSpent,
params.tallyResult
);
totalAmount -= amount;

bool isValid = super.verifyTallyResult(
params.index,
params.tallyResult,
params.tallyResultProof,
params.tallyResultSalt,
params.voteOptionTreeDepth,
params.spentVoiceCreditsHash,
params.perVOSpentVoiceCreditsHash
);

if (!isValid) {
revert InvalidTallyVotesProof();
}

IRecipientRegistry.Recipient memory recipient = registry.getRecipient(params.index);

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));
}
}
Loading

0 comments on commit e207a61

Please sign in to comment.