Skip to content

Commit

Permalink
Support Flexible Voting (#93)
Browse files Browse the repository at this point in the history
* Add Flexible voting support
  • Loading branch information
alexkeating authored Jan 25, 2024
1 parent 6ae6f03 commit c449a77
Show file tree
Hide file tree
Showing 10 changed files with 738 additions and 47 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
uses: zgosalvez/github-actions-report-lcov@v2
with:
coverage-files: ./lcov.info
minimum-coverage: 95 # Set coverage threshold.
minimum-coverage: 94 # Set coverage threshold.

lint:
runs-on: ubuntu-latest
Expand Down
205 changes: 205 additions & 0 deletions src/L2CountingFractional.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {Governor} from "@openzeppelin/contracts/governance/Governor.sol";
import {GovernorCompatibilityBravo} from
"@openzeppelin/contracts/governance/compatibility/GovernorCompatibilityBravo.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";

abstract contract L2CountingFractional {
struct ProposalVote {
uint128 againstVotes;
uint128 forVotes;
uint128 abstainVotes;
}

/**
* @dev Mapping from proposal ID to vote tallies for that proposal.
*/
mapping(uint256 => ProposalVote) internal _proposalVotes;

/**
* @dev Mapping from proposal ID and address to the weight the address
* has cast on that proposal, e.g. _proposalVotersWeightCast[42][0xBEEF]
* would tell you the number of votes that 0xBEEF has cast on proposal 42.
*/
// Made both of these internal
mapping(uint256 => mapping(address => uint128)) internal _proposalVotersWeightCast;

/**
* @dev See {IGovernor-COUNTING_MODE}.
*/
// solhint-disable-next-line func-name-mixedcase
function COUNTING_MODE() public pure virtual returns (string memory) {
return "support=bravo&quorum=for,abstain&params=fractional";
}

/**
* @dev See {IGovernor-hasVoted}.
*/
function hasVoted(uint256 proposalId, address account) public view virtual returns (bool) {
return _proposalVotersWeightCast[proposalId][account] > 0;
}

/**
* @dev Get the number of votes cast thus far on proposal `proposalId` by
* account `account`. Useful for integrations that allow delegates to cast
* rolling, partial votes.
*/
function voteWeightCast(uint256 proposalId, address account) public view returns (uint128) {
return _proposalVotersWeightCast[proposalId][account];
}

/**
* @dev Accessor to the internal vote counts.
*/
function proposalVotes(uint256 proposalId)
public
view
virtual
returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes)
{
ProposalVote storage proposalVote = _proposalVotes[proposalId];
return (proposalVote.againstVotes, proposalVote.forVotes, proposalVote.abstainVotes);
}

/**
* @notice See {Governor-_countVote}.
*
* @dev Function that records the delegate's votes.
*
* If the `voteData` bytes parameter is empty, then this module behaves
* identically to GovernorBravo. That is, it assigns the full weight of the
* delegate to the `support` parameter, which follows the `VoteType` enum
* from Governor Bravo.
*
* If the `voteData` bytes parameter is not zero, then it _must_ be three
* packed uint128s, totaling 48 bytes, representing the weight the delegate
* assigns to Against, For, and Abstain respectively, i.e.
* `abi.encodePacked(againstVotes, forVotes, abstainVotes)`. The sum total of
* the three decoded vote weights _must_ be less than or equal to the
* delegate's remaining weight on the proposal, i.e. their checkpointed
* total weight minus votes already cast on the proposal.
*
* See `_countVoteNominal` and `_countVoteFractional` for more details.
*/
function _countVote(
uint256 proposalId,
address account,
uint8 support,
uint256 totalWeight,
bytes memory voteData
) internal virtual {
require(totalWeight > 0, "L2CountingFractional: no weight");
if (_proposalVotersWeightCast[proposalId][account] >= totalWeight) {
revert("L2CountingFractional: all weight cast");
}

uint128 safeTotalWeight = SafeCast.toUint128(totalWeight);

if (voteData.length == 0) _countVoteNominal(proposalId, account, safeTotalWeight, support);
else _countVoteFractional(proposalId, account, safeTotalWeight, voteData);
}

/**
* @dev Record votes with full weight cast for `support`.
*
* Because this function votes with the delegate's full weight, it can only
* be called once per proposal. It will revert if combined with a fractional
* vote before or after.
*/
function _countVoteNominal(
uint256 proposalId,
address account,
uint128 totalWeight,
uint8 support
) internal {
require(
_proposalVotersWeightCast[proposalId][account] == 0,
"L2CountingFractional: vote would exceed weight"
);

_proposalVotersWeightCast[proposalId][account] = totalWeight;

if (support == uint8(GovernorCompatibilityBravo.VoteType.Against)) {
_proposalVotes[proposalId].againstVotes += totalWeight;
} else if (support == uint8(GovernorCompatibilityBravo.VoteType.For)) {
_proposalVotes[proposalId].forVotes += totalWeight;
} else if (support == uint8(GovernorCompatibilityBravo.VoteType.Abstain)) {
_proposalVotes[proposalId].abstainVotes += totalWeight;
} else {
revert("L2CountingFractional: invalid support value, must be included in VoteType enum");
}
}

/**
* @dev Count votes with fractional weight.
*
* `voteData` is expected to be three packed uint128s, i.e.
* `abi.encodePacked(againstVotes, forVotes, abstainVotes)`.
*
* This function can be called multiple times for the same account and
* proposal, i.e. partial/rolling votes are allowed. For example, an account
* with total weight of 10 could call this function three times with the
* following vote data:
* - against: 1, for: 0, abstain: 2
* - against: 3, for: 1, abstain: 0
* - against: 1, for: 1, abstain: 1
* The result of these three calls would be that the account casts 5 votes
* AGAINST, 2 votes FOR, and 3 votes ABSTAIN on the proposal. Though
* partial, votes are still final once cast and cannot be changed or
* overridden. Subsequent partial votes simply increment existing totals.
*
* Note that if partial votes are cast, all remaining weight must be cast
* with _countVoteFractional: _countVoteNominal will revert.
*/
function _countVoteFractional(
uint256 proposalId,
address account,
uint128 totalWeight,
bytes memory voteData
) internal {
require(voteData.length == 48, "L2CountingFractional: invalid voteData");

(uint128 _againstVotes, uint128 _forVotes, uint128 _abstainVotes) = _decodePackedVotes(voteData);

uint128 _existingWeight = _proposalVotersWeightCast[proposalId][account];
uint256 _newWeight = uint256(_againstVotes) + _forVotes + _abstainVotes + _existingWeight;

require(_newWeight <= totalWeight, "L2CountingFractional: vote would exceed weight");

// It's safe to downcast here because we've just confirmed that
// _newWeight <= totalWeight, and totalWeight is a uint128.
_proposalVotersWeightCast[proposalId][account] = uint128(_newWeight);

ProposalVote memory _proposalVote = _proposalVotes[proposalId];
_proposalVote = ProposalVote(
_proposalVote.againstVotes + _againstVotes,
_proposalVote.forVotes + _forVotes,
_proposalVote.abstainVotes + _abstainVotes
);

_proposalVotes[proposalId] = _proposalVote;
}

uint256 internal constant _MASK_HALF_WORD_RIGHT = 0xffffffffffffffffffffffffffffffff; // 128 bits
// of 0's, 128 bits of 1's

/**
* @dev Decodes three packed uint128's. Uses assembly because of a Solidity
* language limitation which prevents slicing bytes stored in memory, rather
* than calldata.
*/
function _decodePackedVotes(bytes memory voteData)
internal
pure
returns (uint128 againstVotes, uint128 forVotes, uint128 abstainVotes)
{
assembly {
againstVotes := shr(128, mload(add(voteData, 0x20)))
forVotes := and(_MASK_HALF_WORD_RIGHT, mload(add(voteData, 0x20)))
abstainVotes := shr(128, mload(add(voteData, 0x40)))
}
}
}
86 changes: 52 additions & 34 deletions src/L2VoteAggregator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {EIP712} from "openzeppelin/utils/cryptography/EIP712.sol";
import {ECDSA} from "openzeppelin/utils/cryptography/ECDSA.sol";

import {L2GovernorMetadata} from "src/WormholeL2GovernorMetadata.sol";
import {L2CountingFractional} from "src/L2CountingFractional.sol";

/// @notice A contract to collect votes on L2 to be bridged to L1.
abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata {
abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata, L2CountingFractional {
bytes32 public constant BALLOT_TYPEHASH = keccak256("Ballot(uint256 proposalId,uint8 support)");

/// @notice The token used to vote on proposals provided by the `GovernorMetadata`.
Expand Down Expand Up @@ -58,25 +59,31 @@ abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata {
INVALID_Executed
}

/// @dev Data structure to store vote preferences expressed by depositors.
// TODO: Does it matter if we use a uint128 vs a uint256?
struct ProposalVote {
uint128 againstVotes;
uint128 forVotes;
uint128 abstainVotes;
}

/// @notice A mapping of proposal to a mapping of voter address to boolean indicating whether a
/// voter has voted or not.
mapping(uint256 proposalId => mapping(address voterAddress => bool)) private
_proposalVotersHasVoted;

/// @notice A mapping of proposal id to proposal vote totals.
mapping(uint256 proposalId => ProposalVote) public proposalVotes;

/// @dev Emitted when a vote is cast on L2.
event VoteCast(
address indexed voter, uint256 proposalId, VoteType support, uint256 weight, string reason
address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason
);

/**
* @dev Emitted when a vote is cast with params.
*
* Note: `support` values should be seen as buckets. Their interpretation depends on the voting
* module used.
* `params` are additional encoded parameters. Their interpepretation also depends on the voting
* module used.
*/
event VoteCastWithParams(
address indexed voter,
uint256 proposalId,
uint8 support,
uint256 weight,
string reason,
bytes params
);

event VoteBridged(
Expand Down Expand Up @@ -163,7 +170,7 @@ abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata {
/// @param proposalId The id of the proposal to vote on.
/// @param support The type of vote to cast.
function castVote(uint256 proposalId, VoteType support) public returns (uint256) {
return _castVote(proposalId, msg.sender, support, "");
return _castVote(proposalId, msg.sender, uint8(support), "");
}

/// @notice Where a user can express their vote based on their L2 token voting power, and provide
Expand All @@ -176,7 +183,16 @@ abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata {
virtual
returns (uint256)
{
return _castVote(proposalId, msg.sender, support, reason);
return _castVote(proposalId, msg.sender, uint8(support), reason);
}

function castVoteWithReasonAndParams(
uint256 proposalId,
uint8 support,
string calldata reason,
bytes memory params
) public virtual returns (uint256) {
return _castVote(proposalId, msg.sender, support, reason, params);
}

/// @notice Where a user can express their vote based on their L2 token voting power using a
Expand All @@ -191,20 +207,19 @@ abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata {
address voter = ECDSA.recover(
_hashTypedDataV4(keccak256(abi.encode(BALLOT_TYPEHASH, proposalId, support))), v, r, s
);
return _castVote(proposalId, voter, support, "");
return _castVote(proposalId, voter, uint8(support), "");
}

/// @notice Bridges a vote to the L1.
/// @param proposalId The id of the proposal to bridge.
function bridgeVote(uint256 proposalId) external payable {
if (!proposalVoteActive(proposalId)) revert ProposalInactive();

ProposalVote memory vote = proposalVotes[proposalId];
(uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = proposalVotes(proposalId);

bytes memory proposalCalldata =
abi.encode(proposalId, vote.againstVotes, vote.forVotes, vote.abstainVotes);
bytes memory proposalCalldata = abi.encode(proposalId, againstVotes, forVotes, abstainVotes);
_bridgeVote(proposalCalldata);
emit VoteBridged(proposalId, vote.againstVotes, vote.forVotes, vote.abstainVotes);
emit VoteBridged(proposalId, againstVotes, forVotes, abstainVotes);
}

function _bridgeVote(bytes memory proposalCalldata) internal virtual;
Expand All @@ -223,28 +238,31 @@ abstract contract L2VoteAggregator is EIP712, L2GovernorMetadata {
_lastVotingBlock = proposal.voteEnd - CAST_VOTE_WINDOW;
}

function _castVote(uint256 proposalId, address voter, VoteType support, string memory reason)
function _castVote(uint256 proposalId, address account, uint8 support, string memory reason)
internal
virtual
returns (uint256)
{
return _castVote(proposalId, account, support, reason, "");
}

function _castVote(
uint256 proposalId,
address account,
uint8 support,
string memory reason,
bytes memory params
) internal virtual returns (uint256) {
if (!proposalVoteActive(proposalId)) revert ProposalInactive();
if (_proposalVotersHasVoted[proposalId][voter]) revert AlreadyVoted();
_proposalVotersHasVoted[proposalId][voter] = true;

L2GovernorMetadata.Proposal memory proposal = getProposal(proposalId);
uint256 weight = VOTING_TOKEN.getPastVotes(voter, proposal.voteStart);
uint256 weight = VOTING_TOKEN.getPastVotes(account, proposal.voteStart);
if (weight == 0) revert NoWeight();
_countVote(proposalId, account, support, weight, params);

if (params.length == 0) emit VoteCast(account, proposalId, support, weight, reason);
else emit VoteCastWithParams(account, proposalId, support, weight, reason, params);

if (support == VoteType.Against) {
proposalVotes[proposalId].againstVotes += SafeCast.toUint128(weight);
} else if (support == VoteType.For) {
proposalVotes[proposalId].forVotes += SafeCast.toUint128(weight);
} else if (support == VoteType.Abstain) {
proposalVotes[proposalId].abstainVotes += SafeCast.toUint128(weight);
} else {
revert InvalidVoteType();
}
emit VoteCast(voter, proposalId, support, weight, reason);
return weight;
}

Expand Down
2 changes: 0 additions & 2 deletions src/WormholeL2ERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ contract WormholeL2ERC20 is ERC20Votes, WormholeReceiver, WormholeSender {

/// @dev Description of the clock
function CLOCK_MODE() public view virtual override returns (string memory) {
// Check that the clock was not modified
require(clock() == L1_BLOCK.number(), "ERC20Votes: broken clock mode");
return "mode=blocknumber&from=eip155:1";
}

Expand Down
Loading

0 comments on commit c449a77

Please sign in to comment.