Skip to content

Commit

Permalink
feat(contracts): add first registry and payout extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
ctrlc03 committed Jul 5, 2024
1 parent e714d5a commit ecc89be
Show file tree
Hide file tree
Showing 11 changed files with 570 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title ISimpleRecipientRegistry
/// @notice An interface for a simple recipient registry
interface ISimpleRecipientRegistry {
/// @notice Get a recipient
/// @param index The index of the recipient
/// @return The address of the recipient
function getRecipient(uint256 index) external view returns (address);

/// @notice Get all recipients
/// @return The addresses of the recipients
function getRecipients() external view returns (address[] memory);

/// @notice Get the number of recipients
/// @return The number of recipients
function getRecipientsNumber() external view returns (uint256);
}
171 changes: 171 additions & 0 deletions contracts/contracts/extensions/payout/SimplePayout.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";

import { ISimpleRecipientRegistry } from "../interfaces/ISimpleRecipientRegistry.sol";
import { ITally } from "../../interfaces/ITally.sol";

/// @title SimplePayout
/// @notice A simple payout contract that works with a SimpleRecipientRegistry
/// and a MACI Tally contract
contract SimplePayout is Ownable(msg.sender) {
/// @notice use safe ERC20 functions
using SafeERC20 for IERC20;

/// @notice the recipient registry
ISimpleRecipientRegistry public immutable recipientRegistry;

/// @notice the payout token
address public immutable payoutToken;

/// @notice the tally contract
ITally public immutable tally;

/// @notice keep track of which users have already been paid
mapping(uint256 => bool) public paidUsers;

/// @notice the total amount of tokens available to be paid out
uint256 public immutable totalAmount;

/// @notice the total amount of voice credits spent
uint256 public totalVoiceCreditsSpent;

Check warning

Code scanning / Slither

State variables that could be declared immutable Warning


/// @notice keep track of how many users have been paid
uint256 public paid;

/// @notice Custom errors
error InvalidSpentVoiceCredits();
error ProjectAlreadyPaid();
error InvalidProof();
error NotAllProjectsPaid();

/// @notice Create a new instance of the payout contract
/// @param _recipientRegistry the address of the recipient registry
/// @param _token the address of the payout token
/// @param _tally the address of the tally contract
/// @param _totalAmount the total amount of tokens available to be paid out
/// @param _totalSpent the total amount of voice credits spent
/// @param _totalSpentSalt the salt of the spent amount
/// @param _resultCommitment the commitment of the results
/// @param _perVOSpentVoiceCreditsHash the hash of the spent voice credits
constructor(
address _recipientRegistry,
address _token,

Check notice

Code scanning / Slither

Missing zero address validation Low

address _tally,
uint256 _totalAmount,
uint256 _totalSpent,
uint256 _totalSpentSalt,
uint256 _resultCommitment,
uint256 _perVOSpentVoiceCreditsHash
) payable {
recipientRegistry = ISimpleRecipientRegistry(_recipientRegistry);
payoutToken = _token;
tally = ITally(_tally);

// set the total amount of tokens available to be paid out
totalAmount = _totalAmount;

// ensure that the total amount of voice credit spent is correct
if (
!ITally(_tally).verifySpentVoiceCredits(
_totalSpent,
_totalSpentSalt,
_resultCommitment,
_perVOSpentVoiceCreditsHash
)
) revert InvalidSpentVoiceCredits();

// set the total amount of voice credits spent
totalVoiceCreditsSpent = _totalSpent;
}

/// @notice Deposit the amount of tokens to the contract
/// @dev This function is only callable by the owner
function deposit() external payable onlyOwner {
if (payoutToken != address(0)) {
// transfer the ERC20 token amount
IERC20(payoutToken).safeTransferFrom(msg.sender, address(this), totalAmount);
}
}

/// @notice Payout the amount of tokens to the recipient
/// @param _voteOptionIndex the index of the vote option
/// @param _spent the amount of voice credits spent
/// @param _proof the proof of the spent amount
/// @param _spentSalt the salt of the spent amount
/// @param _resultsCommitment the commitment of the results
/// @param _spentVoiceCreditsCommitment the commitment of the spent voice credits
/// @param _voteOptionTreeDepth the depth of the vote option tree
function payout(
uint256 _voteOptionIndex,

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint256 _spent,

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint256[][] calldata _proof,

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint256 _spentSalt,

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint256 _resultsCommitment,

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint256 _spentVoiceCreditsCommitment,

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint8 _voteOptionTreeDepth

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

) external {
// check if the user has been paid already
if (paidUsers[_voteOptionIndex]) revert ProjectAlreadyPaid();
// set the vote option index as paid
paidUsers[_voteOptionIndex] = true;

// increment the number of paid users
unchecked {
paid++;
}

// get the address of the recipient
address recipient = recipientRegistry.getRecipient(_voteOptionIndex);

Check notice

Code scanning / Slither

Missing zero address validation Low


// we verify the proof
if (
!tally.verifyPerVOSpentVoiceCredits(
_voteOptionIndex,
_spent,
_proof,
_spentSalt,
_voteOptionTreeDepth,
_spentVoiceCreditsCommitment,
_resultsCommitment
)
) revert InvalidProof();

// we need to calculate the amount that is to be given to the project (round down)
uint256 tokensToPay = (_spent * totalAmount) / totalVoiceCreditsSpent;

// transfer the token amount
// check whether is native token
if (payoutToken == address(0)) {
// transfer the native token amount
// do not handle reverts as we can then collect the dust later on
// if we handled reverts a "malicious" project which registered
// a contract that reverts on transfer, we would then never be
// able to pay out everyone and then cannot collect the
// dust
payable(recipient).call{ value: tokensToPay }("");
} else {
// transfer the ERC20 token amount
IERC20(payoutToken).safeTransfer(recipient, tokensToPay);
}
}

Check failure

Code scanning / Slither

Functions that send Ether to arbitrary destinations High

Check warning

Code scanning / Slither

Unchecked low-level calls Medium

Check warning

Code scanning / Slither

Low-level calls Warning


/// @notice A function to collect the dust left from round downs
/// can only be called once all projects have been paid
function collectDust() external onlyOwner {
// check if all projects have been paid
if (recipientRegistry.getRecipientsNumber() != paid) revert NotAllProjectsPaid();

address token = payoutToken;
if (token == address(0)) {
// transfer the native token amount
payable(msg.sender).call{ value: address(this).balance }("");
} else {
// transfer the ERC20 token amount
IERC20(token).safeTransfer(msg.sender, IERC20(token).balanceOf(address(this)));
}
}

Check warning

Code scanning / Slither

Unchecked low-level calls Medium

Check warning

Code scanning / Slither

Low-level calls Warning

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

/// @title SimpleRecipientRegistry
/// @notice This contract is a simple registry of recipients
/// @dev This does not constrain the number of recipients
/// which might be > vote options
/// @dev it does not prevent duplicate addresses from being
/// added as recipients
/// @notice it does not allow to remove recipients either
contract SimpleRecipientRegistry is Ownable(msg.sender) {
// simple storage of recipients is an array of addresses
// with the the index being the position in the array
address[] internal recipients;

/// @notice Create a new instance of the registry contract
constructor() payable {}

Check warning on line 19 in contracts/contracts/extensions/registry/SimpleRecipientRegistry.sol

View workflow job for this annotation

GitHub Actions / check (lint:sol)

Code contains empty blocks

/// @notice Add a recipient to the registry
/// @param recipient The address of the recipient to add
function addRecipient(address recipient) external onlyOwner {
recipients.push(recipient);
}

/// @notice Add multiple recipients to the registry
/// @param _recipients The addresses of the recipients to add
function addRecipients(address[] calldata _recipients) external onlyOwner {

Check warning

Code scanning / Slither

Conformance to Solidity naming conventions Warning

uint256 len = _recipients.length;
for (uint256 i = 0; i < len; ) {
recipients.push(_recipients[i]);

unchecked {
i++;
}
}
}

/// @notice Get a recipient from the registry
/// @param index The index of the recipient
/// @return The address of the recipient
function getRecipient(uint256 index) external view returns (address) {
return recipients[index];
}

/// @notice Get all recipients
/// @return The addresses of the recipients
function getRecipients() external view returns (address[] memory) {
return recipients;
}

/// @notice Get the number of recipients
/// @return The number of recipients
function getRecipientsNumber() external view returns (uint256) {
return recipients.length;
}
}

Check warning

Code scanning / Slither

Contracts that lock Ether Medium

Contract locking ether found:
Contract SimpleRecipientRegistry has payable functions:
- SimpleRecipientRegistry.constructor()
But does not have a function to withdraw the ether

Check warning

Code scanning / Slither

Missing inheritance Warning

10 changes: 5 additions & 5 deletions contracts/contracts/gatekeepers/SignUpTokenGatekeeper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
pragma solidity ^0.8.20;

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

import { SignUpGatekeeper } from "./SignUpGatekeeper.sol";
import { SignUpToken } from "../SignUpToken.sol";

/// @title SignUpTokenGatekeeper
/// @notice This contract allows to gatekeep MACI signups
/// by requiring new voters to own a certain ERC721 token
contract SignUpTokenGatekeeper is SignUpGatekeeper, Ownable(msg.sender) {
/// @notice the reference to the SignUpToken contract
SignUpToken public token;
ERC721 public token;

Check warning

Code scanning / Slither

State variables that could be declared immutable Warning

SignUpTokenGatekeeper.token should be immutable
/// @notice the reference to the MACI contract
address public maci;

Expand All @@ -24,9 +24,9 @@ contract SignUpTokenGatekeeper is SignUpGatekeeper, Ownable(msg.sender) {
error OnlyMACI();

/// @notice creates a new SignUpTokenGatekeeper
/// @param _token the address of the SignUpToken contract
constructor(SignUpToken _token) payable {
token = _token;
/// @param _token the address of the ERC20 contract
constructor(address _token) payable {
token = ERC721(_token);
}

/// @notice Adds an uninitialised MACI instance to allow for token signups
Expand Down
38 changes: 38 additions & 0 deletions contracts/contracts/interfaces/ITally.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

/// @title ITally
/// @notice Tally interface
interface ITally {
/// @notice Verify the number of spent voice credits per vote option from the tally.json
/// @param _voteOptionIndex the index of the vote option where credits were spent
/// @param _spent the spent voice credits for a given vote option index
/// @param _spentProof proof generated for the perVOSpentVoiceCredits
/// @param _spentSalt the corresponding salt given in the tally perVOSpentVoiceCredits object
/// @param _voteOptionTreeDepth depth of the vote option tree
/// @param _spentVoiceCreditsHash hashLeftRight(number of spent voice credits, spent salt)
/// @param _resultCommitment hashLeftRight(merkle root of the results.tally, results.salt)
// in the tally.json file
/// @return isValid Whether the provided proof is valid
function verifyPerVOSpentVoiceCredits(
uint256 _voteOptionIndex,
uint256 _spent,
uint256[][] calldata _spentProof,
uint256 _spentSalt,
uint8 _voteOptionTreeDepth,
uint256 _spentVoiceCreditsHash,
uint256 _resultCommitment
) external view returns (bool);

/// @notice Verify the number of spent voice credits from the tally.json
/// @param _totalSpent spent field retrieved in the totalSpentVoiceCredits object
/// @param _totalSpentSalt the corresponding salt in the totalSpentVoiceCredit object
/// @param _resultCommitment hashLeftRight(merkle root of the results.tally, results.salt) in tally.json file
/// @param _perVOSpentVoiceCreditsHash only for QV - hashLeftRight(merkle root of the no spent voice credits, salt)
function verifySpentVoiceCredits(
uint256 _totalSpent,
uint256 _totalSpentSalt,
uint256 _resultCommitment,
uint256 _perVOSpentVoiceCreditsHash
) external view returns (bool);
}
12 changes: 12 additions & 0 deletions 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 tokens to the deployer
contract MockERC20 is ERC20 {
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {
_mint(msg.sender, 100e18);
}
}
37 changes: 37 additions & 0 deletions contracts/contracts/mocks/MockTally.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { ITally } from "../interfaces/ITally.sol";

contract MockTally is ITally {
bool public returnValue = true;

/// @notice Flip the return value
/// @dev used for mock testing
function flipReturnValue() external {
returnValue = !returnValue;
}

/// @inheritdoc ITally
function verifyPerVOSpentVoiceCredits(
uint256 _voteOptionIndex,
uint256 _spent,
uint256[][] calldata _spentProof,
uint256 _spentSalt,
uint8 _voteOptionTreeDepth,
uint256 _spentVoiceCreditsHash,
uint256 _resultCommitment
) external view returns (bool) {
return returnValue;
}

/// @inheritdoc ITally
function verifySpentVoiceCredits(
uint256 _totalSpent,
uint256 _totalSpentSalt,
uint256 _resultCommitment,
uint256 _perVOSpentVoiceCreditsHash
) external view returns (bool) {
return returnValue;
}
}
File renamed without changes.
2 changes: 2 additions & 0 deletions contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
"test:gitcoin_gatekeeper": "pnpm run test ./tests/GitcoinPassportGatekeeper.test.ts",
"test:zupass_gatekeeper": "pnpm run test ./tests/ZupassGatekeeper.test.ts",
"test:semaphore_gatekeeper": "pnpm run test ./tests/SemaphoreGatekeeper.test.ts",
"test:simpleRecipientRegistry": "pnpm run test ./tests/extensions/SimpleRecipientRegistry.test.ts",
"test:simplePayout": "pnpm run test ./tests/extensions/SimplePayout.test.ts",
"deploy": "hardhat deploy-full",
"deploy-poll": "hardhat deploy-poll",
"verify": "hardhat verify-full",
Expand Down
Loading

0 comments on commit ecc89be

Please sign in to comment.