From 2be91b236d9a48f21be7dcae2d461110458e3110 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Tue, 13 Feb 2024 14:55:35 -0500 Subject: [PATCH 01/14] add new distributor --- contracts/distribution/PushDistributor.sol | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 contracts/distribution/PushDistributor.sol diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol new file mode 100644 index 000000000..18b4f4450 --- /dev/null +++ b/contracts/distribution/PushDistributor.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.20; + +import { IERC20 } from "../tokens/IERC20.sol"; +import { LibSafeCast } from "../utils/LibSafeCast.sol"; +import { LibERC20Compat } from "./../utils/LibERC20Compat.sol"; +import { LibAddress } from "./../utils/LibAddress.sol"; + +// TODO: Add tests +// TODO: Add events +// TODO: Add custom errors + +interface IParty { + struct GovernanceValues { + uint40 voteDuration; + uint40 executionDelay; + uint16 passThresholdBps; + uint96 totalVotingPower; + } + + function getGovernanceValues() external view returns (GovernanceValues memory); + function getIntrinsicVotingPowerAt( + address member, + uint256 timestamp, + uint256 hintIndex + ) external view returns (uint96); +} + +contract PushDistributor { + using LibSafeCast for uint256; + using LibERC20Compat for IERC20; + using LibAddress for address payable; + + // Token address used to indicate ETH. + address private constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + function distribute(IERC20 token, address[] memory members, uint256 amount) external payable { + IParty party = IParty(payable(msg.sender)); + if (address(token) == ETH_ADDRESS) { + require(msg.value == amount, "Incorrect ETH amount"); + } else { + require(msg.value == 0, "Unexpected ETH amount"); + token.compatTransferFrom(msg.sender, address(this), amount); + } + + address prevMember; + uint96 totalIntrinsicVotingPower; + uint96 totalVotingPower = party.getGovernanceValues().totalVotingPower; + for (uint256 i = 0; i < members.length; i++) { + address member = members[i]; + + // Prevent duplicate members to prevent members array manipulation. + // For example, a member being replace with another duplicate member + // that has the same voting power. + require(member > prevMember, "Members not sorted"); + + prevMember = member; + + uint96 intrinsicVotingPower = party.getIntrinsicVotingPowerAt( + member, + block.timestamp, + 0 + ); + + totalIntrinsicVotingPower += intrinsicVotingPower; + + uint256 share = ((intrinsicVotingPower) * 1e18) / totalVotingPower; + + // We round up here to prevent dust amounts getting trapped in this contract. + uint256 shareAmount = (share * amount + (1e18 - 1)) / 1e18; + + if (shareAmount > 0) { + // Transfer the share of the distribution to the member. + if (address(token) == ETH_ADDRESS) { + payable(member).transferEth(amount); + } else { + token.compatTransfer(member, amount); + } + } + } + + require(totalIntrinsicVotingPower == totalVotingPower, "Missing member"); + } +} From 1f92bfd08dbce80b96c13d3c5a2d07be9d21661f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:27:08 -0500 Subject: [PATCH 02/14] add updated proposal execution engine --- .../proposals/ProposalExecutionEngine.sol | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/contracts/proposals/ProposalExecutionEngine.sol b/contracts/proposals/ProposalExecutionEngine.sol index c2a897357..5a77780e7 100644 --- a/contracts/proposals/ProposalExecutionEngine.sol +++ b/contracts/proposals/ProposalExecutionEngine.sol @@ -244,6 +244,58 @@ contract ProposalExecutionEngine is return 0; } + function getIntrinsicVotingPowerAt( + address voter, + uint40 timestamp, + uint256 hintIndex + ) public returns (uint96) { + PartyGovernance.VotingPowerSnapshot[] storage snaps; + + bytes32 slotAddress = keccak256( + abi.encode(voter, 7 /* slot for the voting power snapshots mapping */) + ); + assembly { + snaps.slot := slotAddress + } + + uint256 snapsLength = snaps.length; + if (snapsLength != 0) { + if ( + // Hint is within bounds. + hintIndex < snapsLength && + // Snapshot is not too recent. + snaps[hintIndex].timestamp <= timestamp && + // Snapshot is not too old. + (hintIndex == snapsLength - 1 || snaps[hintIndex + 1].timestamp > timestamp) + ) { + return snaps[hintIndex].intrinsicVotingPower; + } + + // Hint was wrong, fallback to binary search to find snapshot. + // Derived from Open Zeppelin binary search + // ref: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Checkpoints.sol#L39 + uint256 high = snapsLength; + uint256 low = 0; + while (low < high) { + uint256 mid = (low + high) / 2; + if (snaps[mid].timestamp > timestamp) { + // Entry is too recent. + high = mid; + } else { + // Entry is older. This is our best guess for now. + low = mid + 1; + } + } + hintIndex = high == 0 ? type(uint256).max : high - 1; + + // Check that snapshot was found. + if (hintIndex != type(uint256).max) { + return snaps[hintIndex].intrinsicVotingPower; + } + } + return 0; + } + // Switch statement used to execute the right proposal. function _execute( ProposalType pt, From 60391db0794cf6a72a5730eb25bb1ffae5a87e5c Mon Sep 17 00:00:00 2001 From: Brian Le Date: Thu, 15 Feb 2024 13:17:49 -0500 Subject: [PATCH 03/14] fix distributor --- contracts/distribution/PushDistributor.sol | 48 +++++++++++++++++----- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol index 18b4f4450..e283a87ab 100644 --- a/contracts/distribution/PushDistributor.sol +++ b/contracts/distribution/PushDistributor.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.20; +import { PartyGovernance } from "./../party/PartyGovernance.sol"; import { IERC20 } from "../tokens/IERC20.sol"; import { LibSafeCast } from "../utils/LibSafeCast.sol"; import { LibERC20Compat } from "./../utils/LibERC20Compat.sol"; @@ -24,6 +25,15 @@ interface IParty { uint256 timestamp, uint256 hintIndex ) external view returns (uint96); + function getProposalStateInfo( + uint256 proposalId + ) + external + view + returns ( + PartyGovernance.ProposalStatus status, + PartyGovernance.ProposalStateValues memory values + ); } contract PushDistributor { @@ -34,7 +44,12 @@ contract PushDistributor { // Token address used to indicate ETH. address private constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - function distribute(IERC20 token, address[] memory members, uint256 amount) external payable { + function distribute( + IERC20 token, + address[] memory members, + uint256 amount, + uint256 proposalId + ) external payable { IParty party = IParty(payable(msg.sender)); if (address(token) == ETH_ADDRESS) { require(msg.value == amount, "Incorrect ETH amount"); @@ -43,9 +58,26 @@ contract PushDistributor { token.compatTransferFrom(msg.sender, address(this), amount); } + uint40 proposedTime; + uint96 totalVotingPower; + { + ( + PartyGovernance.ProposalStatus status, + PartyGovernance.ProposalStateValues memory proposal + ) = party.getProposalStateInfo(proposalId); + + require( + status == PartyGovernance.ProposalStatus.Ready && + proposal.executedTime == block.timestamp, + "Wrong proposal ID" + ); + + proposedTime = proposal.proposedTime; + totalVotingPower = party.getGovernanceValues().totalVotingPower; + } + address prevMember; uint96 totalIntrinsicVotingPower; - uint96 totalVotingPower = party.getGovernanceValues().totalVotingPower; for (uint256 i = 0; i < members.length; i++) { address member = members[i]; @@ -56,18 +88,14 @@ contract PushDistributor { prevMember = member; - uint96 intrinsicVotingPower = party.getIntrinsicVotingPowerAt( - member, - block.timestamp, - 0 - ); + uint96 intrinsicVotingPower = party.getIntrinsicVotingPowerAt(member, proposedTime, 0); totalIntrinsicVotingPower += intrinsicVotingPower; - uint256 share = ((intrinsicVotingPower) * 1e18) / totalVotingPower; - // We round up here to prevent dust amounts getting trapped in this contract. - uint256 shareAmount = (share * amount + (1e18 - 1)) / 1e18; + uint256 shareAmount = ((((intrinsicVotingPower) * 1e18) / totalVotingPower) * + amount + + (1e18 - 1)) / 1e18; if (shareAmount > 0) { // Transfer the share of the distribution to the member. From 24752c8654b5fa8302c3741141ada11bb18b7f7f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:28:33 -0500 Subject: [PATCH 04/14] add tests for `getIntrinsicVotingPowerAt` --- ...roposalExecutionEngineIntrinsicPower.t.sol | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol diff --git a/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol b/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol new file mode 100644 index 000000000..5024d2bc7 --- /dev/null +++ b/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8; + +import { SetupPartyHelper } from "../utils/SetupPartyHelper.sol"; +import { ProposalExecutionEngine } from "contracts/proposals/ProposalExecutionEngine.sol"; + +contract ProposalExecutionEngineIntrinsicVotingPowerTest is SetupPartyHelper { + constructor() SetupPartyHelper(false) {} + + /// @notice All hints are correct + function testIntrinsicVotingPowerAt_simpleTest() public { + skip(10); + uint40 firstTimestamp = uint40(block.timestamp); + skip(10); + party.increaseTotalVotingPower(50); + party.mint(john, 50, john); + uint40 secondTimestamp = uint40(block.timestamp); + skip(100); + + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + john, + firstTimestamp, + 0 + ), + 100 + ); + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + john, + secondTimestamp, + 1 + ), + 150 + ); + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + john, + uint40(block.timestamp), + 1 + ), + 150 + ); + } + + struct TestingSnapshots { + uint40 timestamp; + uint96 intrinsicVotingPower; + } + function testIntrinsicVotingPowerAt_advanced( + uint8 numToMint, + uint8 numToBurn, + uint8 startBurnIndex + ) external { + vm.assume(numToMint > 5); + vm.assume(numToMint < 20); + vm.assume(numToBurn > 1); + vm.assume(numToBurn < numToMint); + vm.assume(startBurnIndex < numToMint - numToBurn); + skip(10); + + TestingSnapshots[] memory snapshots = new TestingSnapshots[](numToMint + numToBurn + 1); + snapshots[0] = TestingSnapshots(uint40(block.timestamp), 100); + + uint256[] memory newTokenIds = new uint256[](numToMint); + + for (uint i = 0; i < numToMint; i++) { + skip(100); + uint96 votingPower = uint96(_randomRange(1, 100)); + party.increaseTotalVotingPower(votingPower); + newTokenIds[i] = party.mint(john, votingPower, john); + snapshots[i + 1] = TestingSnapshots( + uint40(block.timestamp), + snapshots[i].intrinsicVotingPower + votingPower + ); + } + + uint256 firstTokenToBurn = newTokenIds[startBurnIndex]; + for (uint i = firstTokenToBurn; i < firstTokenToBurn + numToBurn; i++) { + skip(100); + uint96 votingPower = uint96(party.votingPowerByTokenId(i)); + party.burn(i); + uint256 previousSnapshot = numToMint + (i - firstTokenToBurn); + snapshots[previousSnapshot + 1] = TestingSnapshots( + uint40(block.timestamp), + snapshots[previousSnapshot].intrinsicVotingPower - votingPower + ); + } + + skip(100); + + for (uint i = 0; i < snapshots.length; i++) { + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + john, + snapshots[i].timestamp, + i + 1 + ), + snapshots[i].intrinsicVotingPower + ); + } + } +} From c153ed7ebdcea414a395d7d60f705f5bc9936b21 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 15 Feb 2024 17:46:17 -0500 Subject: [PATCH 05/14] check edge cases --- ...roposalExecutionEngineIntrinsicPower.t.sol | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol b/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol index 5024d2bc7..a30ff6a14 100644 --- a/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol +++ b/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol @@ -7,6 +7,45 @@ import { ProposalExecutionEngine } from "contracts/proposals/ProposalExecutionEn contract ProposalExecutionEngineIntrinsicVotingPowerTest is SetupPartyHelper { constructor() SetupPartyHelper(false) {} + function testIntrinsicVotingPowerAt_inactiveAddress() public { + skip(10); + + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + _randomAddress(), + uint40(block.timestamp), + 0 + ), + 0 + ); + } + + function testIntrinsicVotingPowerAt_timestampBeforeActive() public { + uint40 firstTimestamp = uint40(block.timestamp); + skip(100); + + address newUser = _randomAddress(); + party.mint(newUser, 100, newUser); + skip(100); + + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + newUser, + firstTimestamp, + 0 + ), + 0 + ); + assertEq( + ProposalExecutionEngine(address(party)).getIntrinsicVotingPowerAt( + newUser, + uint40(block.timestamp), + 100 + ), + 0 + ); + } + /// @notice All hints are correct function testIntrinsicVotingPowerAt_simpleTest() public { skip(10); From 1a7704cc92c036306cda11ae45b9260a0ac0840f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 16 Feb 2024 10:01:36 -0500 Subject: [PATCH 06/14] update comments --- contracts/proposals/ProposalExecutionEngine.sol | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/contracts/proposals/ProposalExecutionEngine.sol b/contracts/proposals/ProposalExecutionEngine.sol index 5a77780e7..4766e61ae 100644 --- a/contracts/proposals/ProposalExecutionEngine.sol +++ b/contracts/proposals/ProposalExecutionEngine.sol @@ -244,6 +244,11 @@ contract ProposalExecutionEngine is return 0; } + /// @notice Get the snapshotted intrinsic voting power for an address at a given timestamp. + /// @param voter The address of the voter. + /// @param timestamp The timestamp to get the voting power at. + /// @param hintIndex The precalculated index for the correct snapshot. Not used if incorrect. + /// @return The intrinsic voting power of the address at the given timestamp. function getIntrinsicVotingPowerAt( address voter, uint40 timestamp, @@ -251,13 +256,15 @@ contract ProposalExecutionEngine is ) public returns (uint96) { PartyGovernance.VotingPowerSnapshot[] storage snaps; + // Derive the storage slot for the voting power snapshots mapping. bytes32 slotAddress = keccak256( abi.encode(voter, 7 /* slot for the voting power snapshots mapping */) ); - assembly { + assembly ("memory-safe") { snaps.slot := slotAddress } + // Logic copied from https://github.com/PartyDAO/party-protocol/blob/824538633091ebe97c0a0f38c9a28f09900fe173/contracts/party/PartyGovernance.sol#L904-L924 uint256 snapsLength = snaps.length; if (snapsLength != 0) { if ( @@ -271,9 +278,7 @@ contract ProposalExecutionEngine is return snaps[hintIndex].intrinsicVotingPower; } - // Hint was wrong, fallback to binary search to find snapshot. - // Derived from Open Zeppelin binary search - // ref: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Checkpoints.sol#L39 + // Logic copied from https://github.com/PartyDAO/party-protocol/blob/824538633091ebe97c0a0f38c9a28f09900fe173/contracts/party/PartyGovernance.sol#L427-L450 uint256 high = snapsLength; uint256 low = 0; while (low < high) { From 7ec5d5285d0e1ccdb19f4b15f2a342c46f9d4338 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Fri, 16 Feb 2024 12:03:19 -0500 Subject: [PATCH 07/14] add happy path tests --- contracts/distribution/PushDistributor.sol | 21 ++- lib/forge-std | 2 +- test/distribution/PushDistributor.t.sol | 155 +++++++++++++++++++++ 3 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 test/distribution/PushDistributor.t.sol diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol index e283a87ab..1bba7dd7f 100644 --- a/contracts/distribution/PushDistributor.sol +++ b/contracts/distribution/PushDistributor.sol @@ -21,8 +21,8 @@ interface IParty { function getGovernanceValues() external view returns (GovernanceValues memory); function getIntrinsicVotingPowerAt( - address member, - uint256 timestamp, + address voter, + uint40 timestamp, uint256 hintIndex ) external view returns (uint96); function getProposalStateInfo( @@ -42,7 +42,7 @@ contract PushDistributor { using LibAddress for address payable; // Token address used to indicate ETH. - address private constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + IERC20 private constant ETH_ADDRESS = IERC20(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); function distribute( IERC20 token, @@ -51,7 +51,7 @@ contract PushDistributor { uint256 proposalId ) external payable { IParty party = IParty(payable(msg.sender)); - if (address(token) == ETH_ADDRESS) { + if (token == ETH_ADDRESS) { require(msg.value == amount, "Incorrect ETH amount"); } else { require(msg.value == 0, "Unexpected ETH amount"); @@ -67,7 +67,7 @@ contract PushDistributor { ) = party.getProposalStateInfo(proposalId); require( - status == PartyGovernance.ProposalStatus.Ready && + status == PartyGovernance.ProposalStatus.Complete && proposal.executedTime == block.timestamp, "Wrong proposal ID" ); @@ -92,17 +92,14 @@ contract PushDistributor { totalIntrinsicVotingPower += intrinsicVotingPower; - // We round up here to prevent dust amounts getting trapped in this contract. - uint256 shareAmount = ((((intrinsicVotingPower) * 1e18) / totalVotingPower) * - amount + - (1e18 - 1)) / 1e18; + uint256 shareAmount = (amount * intrinsicVotingPower) / totalVotingPower; if (shareAmount > 0) { // Transfer the share of the distribution to the member. - if (address(token) == ETH_ADDRESS) { - payable(member).transferEth(amount); + if (token == ETH_ADDRESS) { + payable(member).transferEth(shareAmount); } else { - token.compatTransfer(member, amount); + token.compatTransfer(member, shareAmount); } } } diff --git a/lib/forge-std b/lib/forge-std index 2f43c7e69..ae570fec0 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 2f43c7e69b820910e9d4f3b8cc8d3b4e6382786e +Subproject commit ae570fec082bfe1c1f45b0acca4a2b4f84d345ce diff --git a/test/distribution/PushDistributor.t.sol b/test/distribution/PushDistributor.t.sol new file mode 100644 index 000000000..7d7d3bf13 --- /dev/null +++ b/test/distribution/PushDistributor.t.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8; + +import "forge-std/Test.sol"; +import { IERC20 } from "./../../contracts/tokens/IERC20.sol"; +import { MockERC20 } from "forge-std/mocks/MockERC20.sol"; +import { SetupPartyHelper } from "../utils/SetupPartyHelper.sol"; +import { PushDistributor } from "./../../contracts/distribution/PushDistributor.sol"; +import { ArbitraryCallsProposal } from "./../../contracts/proposals/ArbitraryCallsProposal.sol"; +import { PartyGovernance } from "./../../contracts/party/PartyGovernance.sol"; +import { ProposalExecutionEngine } from "./../../contracts/proposals/ProposalExecutionEngine.sol"; + +contract PushDistributorTest is SetupPartyHelper { + PushDistributor pushDistributor; + IERC20 erc20; + address[] members; + + IERC20 private constant ETH_ADDRESS = IERC20(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + + constructor() SetupPartyHelper(false) {} + + function setUp() public override { + super.setUp(); + + // Deploy PushDistributor + pushDistributor = new PushDistributor(); + + // Deploy mock ERC20 + erc20 = IERC20(address(new MockERC20())); + + // Setup Party with 100 ETH and 1000 mock ERC20 + deal(address(party), 100e18); + deal(address(erc20), address(party), 1000e18); + + // Setup members + members = new address[](4); + members[0] = john; + members[1] = danny; + members[2] = steve; + members[3] = address(this); + + // Sort the addresses from lowest to highest. + for (uint256 i = 0; i < members.length; i++) { + for (uint256 j = i + 1; j < members.length; j++) { + if (members[i] > members[j]) { + (members[i], members[j]) = (members[j], members[i]); + } + } + } + + // Reset this contract's ETH balance for testing + deal(address(this), 0); + } + + function test_distribute_withERC20() public { + uint256 amountToDistribute = 100e18; + + // Create a proposal to distribute the tokens + PartyGovernance.Proposal memory proposal = _createProposal(erc20, amountToDistribute); + + // Propose and execute the proposal + _proposePassAndExecuteProposal(proposal); + + // Check if the distribution was successful + // John, Danny, Steve who each have 100 / 301 voting power should + // receive 100 / 301 * 100e18 tokens + assertEq(erc20.balanceOf(john), (100 * amountToDistribute) / 301); + assertEq(erc20.balanceOf(danny), (100 * amountToDistribute) / 301); + assertEq(erc20.balanceOf(steve), (100 * amountToDistribute) / 301); + // The contract which has 1 / 301 voting power should receive + // 1 / 301 * 100e18 tokens + assertEq(erc20.balanceOf(address(this)), (1 * amountToDistribute) / 301); + } + + function test_distribute_withETH() public { + uint256 amountToDistribute = 10e18; + + // Create a proposal to distribute the tokens + PartyGovernance.Proposal memory proposal = _createProposal(ETH_ADDRESS, amountToDistribute); + + // Propose and execute the proposal + _proposePassAndExecuteProposal(proposal); + + // Check if the distribution was successful + // John, Danny, Steve who each have 100 / 301 voting power should + // receive 100 / 301 * 10e18 ETH + assertEq(john.balance, (100 * amountToDistribute) / 301); + assertEq(danny.balance, (100 * amountToDistribute) / 301); + assertEq(steve.balance, (100 * amountToDistribute) / 301); + // The contract which has 1 / 301 voting power should receive + // 1 / 301 * 10e18 ETH + assertEq(address(this).balance, (1 * amountToDistribute) / 301); + } + + function _createProposal( + IERC20 token, + uint256 amount + ) internal view returns (PartyGovernance.Proposal memory proposal) { + if (token != ETH_ADDRESS) { + ArbitraryCallsProposal.ArbitraryCall[] + memory arbCalls = new ArbitraryCallsProposal.ArbitraryCall[](2); + + arbCalls[0] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(token)), + value: 0, + data: abi.encodeCall(IERC20.approve, (address(pushDistributor), amount)), + expectedResultHash: "" + }); + arbCalls[1] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(pushDistributor)), + value: 0, + data: abi.encodeCall( + PushDistributor.distribute, + (token, members, amount, party.lastProposalId() + 1) + ), + expectedResultHash: "" + }); + + return + PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + } else { + ArbitraryCallsProposal.ArbitraryCall[] + memory arbCalls = new ArbitraryCallsProposal.ArbitraryCall[](1); + + arbCalls[0] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(pushDistributor)), + value: amount, + data: abi.encodeCall( + PushDistributor.distribute, + (token, members, amount, party.lastProposalId() + 1) + ), + expectedResultHash: "" + }); + + return + PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + } + } + + receive() external payable {} +} From 2b6250ece602f1e2190a91057de83b6691f6217e Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 16 Feb 2024 14:17:40 -0500 Subject: [PATCH 08/14] Make `getIntrinsicVotingPowerAt` view Co-authored-by: Brian Le --- contracts/proposals/ProposalExecutionEngine.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/proposals/ProposalExecutionEngine.sol b/contracts/proposals/ProposalExecutionEngine.sol index 4766e61ae..4671e1466 100644 --- a/contracts/proposals/ProposalExecutionEngine.sol +++ b/contracts/proposals/ProposalExecutionEngine.sol @@ -253,7 +253,7 @@ contract ProposalExecutionEngine is address voter, uint40 timestamp, uint256 hintIndex - ) public returns (uint96) { + ) public view returns (uint96) { PartyGovernance.VotingPowerSnapshot[] storage snaps; // Derive the storage slot for the voting power snapshots mapping. From f1a13b028cd3c1b035fec74aae40aa8393460a0d Mon Sep 17 00:00:00 2001 From: Brian Le Date: Fri, 16 Feb 2024 14:30:57 -0500 Subject: [PATCH 09/14] fix issues and add edge case tests --- contracts/distribution/PushDistributor.sol | 63 +++-- test/distribution/PushDistributor.t.sol | 298 +++++++++++++++++++++ 2 files changed, 338 insertions(+), 23 deletions(-) diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol index 1bba7dd7f..7d7412487 100644 --- a/contracts/distribution/PushDistributor.sol +++ b/contracts/distribution/PushDistributor.sol @@ -7,10 +7,6 @@ import { LibSafeCast } from "../utils/LibSafeCast.sol"; import { LibERC20Compat } from "./../utils/LibERC20Compat.sol"; import { LibAddress } from "./../utils/LibAddress.sol"; -// TODO: Add tests -// TODO: Add events -// TODO: Add custom errors - interface IParty { struct GovernanceValues { uint40 voteDuration; @@ -37,6 +33,14 @@ interface IParty { } contract PushDistributor { + event Distributed(IERC20 token, address[] members, uint256 amount); + + error NotEnoughETH(uint256 expectedAmount, uint256 receivedAmount); + error UnexpectedETH(uint256 amount); + error WrongProposalId(uint256 proposalId); + error WrongMembers(); + error MembersNotSorted(); + using LibSafeCast for uint256; using LibERC20Compat for IERC20; using LibAddress for address payable; @@ -52,28 +56,23 @@ contract PushDistributor { ) external payable { IParty party = IParty(payable(msg.sender)); if (token == ETH_ADDRESS) { - require(msg.value == amount, "Incorrect ETH amount"); + if (msg.value < amount) revert NotEnoughETH(amount, msg.value); } else { - require(msg.value == 0, "Unexpected ETH amount"); + if (msg.value != 0) revert UnexpectedETH(msg.value); token.compatTransferFrom(msg.sender, address(this), amount); } uint40 proposedTime; uint96 totalVotingPower; { - ( - PartyGovernance.ProposalStatus status, - PartyGovernance.ProposalStateValues memory proposal - ) = party.getProposalStateInfo(proposalId); - - require( - status == PartyGovernance.ProposalStatus.Complete && - proposal.executedTime == block.timestamp, - "Wrong proposal ID" + (, PartyGovernance.ProposalStateValues memory proposal) = party.getProposalStateInfo( + proposalId ); + if (proposal.executedTime != block.timestamp) revert WrongProposalId(proposalId); + proposedTime = proposal.proposedTime; - totalVotingPower = party.getGovernanceValues().totalVotingPower; + totalVotingPower = proposal.totalVotingPower; } address prevMember; @@ -84,7 +83,7 @@ contract PushDistributor { // Prevent duplicate members to prevent members array manipulation. // For example, a member being replace with another duplicate member // that has the same voting power. - require(member > prevMember, "Members not sorted"); + if (member <= prevMember) revert MembersNotSorted(); prevMember = member; @@ -96,14 +95,32 @@ contract PushDistributor { if (shareAmount > 0) { // Transfer the share of the distribution to the member. - if (token == ETH_ADDRESS) { - payable(member).transferEth(shareAmount); - } else { - token.compatTransfer(member, shareAmount); - } + _transfer(token, member, shareAmount); } } - require(totalIntrinsicVotingPower == totalVotingPower, "Missing member"); + // If the total intrinsic voting power is not equal to the total voting power, + // it means that the members array is incorrect. + if (totalIntrinsicVotingPower != totalVotingPower) revert WrongMembers(); + + // Send back any remaining tokens to the sender. + uint256 remainingAmount = token == ETH_ADDRESS + ? address(this).balance + : token.balanceOf(address(this)); + if (remainingAmount > 0) { + _transfer(token, msg.sender, remainingAmount); + } + + emit Distributed(token, members, amount); + } + + function _transfer(IERC20 token, address to, uint256 amount) internal { + if (token == ETH_ADDRESS) { + // Do not revert on failure. Set gas to 100k to prevent consuming + // all gas. + payable(to).call{ value: amount, gas: 100_000 }(""); + } else { + token.compatTransfer(to, amount); + } } } diff --git a/test/distribution/PushDistributor.t.sol b/test/distribution/PushDistributor.t.sol index 7d7d3bf13..8f693f7f0 100644 --- a/test/distribution/PushDistributor.t.sol +++ b/test/distribution/PushDistributor.t.sol @@ -9,8 +9,11 @@ import { PushDistributor } from "./../../contracts/distribution/PushDistributor. import { ArbitraryCallsProposal } from "./../../contracts/proposals/ArbitraryCallsProposal.sol"; import { PartyGovernance } from "./../../contracts/party/PartyGovernance.sol"; import { ProposalExecutionEngine } from "./../../contracts/proposals/ProposalExecutionEngine.sol"; +import { ERC721Receiver } from "./../../contracts/tokens/ERC721Receiver.sol"; contract PushDistributorTest is SetupPartyHelper { + event Distributed(IERC20 token, address[] members, uint256 amount); + PushDistributor pushDistributor; IERC20 erc20; address[] members; @@ -92,6 +95,295 @@ contract PushDistributorTest is SetupPartyHelper { assertEq(address(this).balance, (1 * amountToDistribute) / 301); } + function test_distribute_withChangingVotingPowerAndTotalVotingPower() public { + uint256 amountToDistribute = 100e18; + + // Create a proposal to distribute the tokens + PartyGovernance.Proposal memory proposal = _createProposal(erc20, amountToDistribute); + + // Propose the proposal + _proposeAndPassProposal(proposal); + + // Mint new members (should not affect the distribution) + address brian = _randomAddress(); + address aryeh = _randomAddress(); + party.increaseTotalVotingPower(200); + party.mint(brian, 100, brian); + party.mint(aryeh, 100, aryeh); + + // Execute the proposal + _executeProposal(party.lastProposalId(), proposal); + + // Check if the distribution was successful + // John, Danny, Steve who each have 100 / 301 voting power at time of + // proposal should receive 100 / 301 * 100e18 tokens + assertEq(erc20.balanceOf(john), (100 * amountToDistribute) / 301); + assertEq(erc20.balanceOf(danny), (100 * amountToDistribute) / 301); + assertEq(erc20.balanceOf(steve), (100 * amountToDistribute) / 301); + // The contract which has 1 / 301 voting power at time of proposal + // should receive 1 / 301 * 100e18 tokens + assertEq(erc20.balanceOf(address(this)), (1 * amountToDistribute) / 301); + // Brian and Aryeh should not receive any tokens + assertEq(erc20.balanceOf(brian), 0); + assertEq(erc20.balanceOf(aryeh), 0); + } + + function test_distribute_doesNotRevertIfMemberCannotReceive() public { + address newMember = address(new CannotReceiveETH()); + + party.increaseTotalVotingPower(100); + party.mint(newMember, 100, newMember); + + members.push(newMember); + + // Sort the addresses from lowest to highest. + for (uint256 i = 0; i < members.length; i++) { + for (uint256 j = i + 1; j < members.length; j++) { + if (members[i] > members[j]) { + (members[i], members[j]) = (members[j], members[i]); + } + } + } + + uint256 amountToDistribute = 100e18; + + // Create a proposal to distribute the tokens + PartyGovernance.Proposal memory proposal = _createProposal(ETH_ADDRESS, amountToDistribute); + + // Propose and execute the proposal + _proposePassAndExecuteProposal(proposal); + + // Check if the distribution was successful + // John, Danny, Steve who each have 100 / 401 voting power should + // receive 100 / 401 * 100e18 tokens + assertEq(john.balance, (100 * amountToDistribute) / 401); + assertEq(danny.balance, (100 * amountToDistribute) / 401); + assertEq(steve.balance, (100 * amountToDistribute) / 401); + // The contract which has 1 / 401 voting power should receive + // 1 / 401 * 100e18 tokens + assertEq(address(this).balance, (1 * amountToDistribute) / 401); + // The new member should not receive any tokens because it cannot receive + assertEq(newMember.balance, 0); + // The Party should receive any remaining tokens + assertEq(address(party).balance, (100 * amountToDistribute) / 401 + 1); + } + + function test_distribute_revertIfNotEnoughETH() public { + uint256 amountToDistribute = 10e18; + + ArbitraryCallsProposal.ArbitraryCall[] + memory arbCalls = new ArbitraryCallsProposal.ArbitraryCall[](1); + + arbCalls[0] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(pushDistributor)), + value: amountToDistribute - 1, // Not enough ETH + data: abi.encodeCall( + PushDistributor.distribute, + (ETH_ADDRESS, members, amountToDistribute, party.lastProposalId() + 1) + ), + expectedResultHash: "" + }); + + { + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + uint256 proposalId = _proposeAndPassProposal(proposal); + + vm.expectRevert(); + _executeProposal(proposalId, proposal); + } + + // Try with enough ETH this time and pass + { + arbCalls[0].value = amountToDistribute; + arbCalls[0].data = abi.encodeCall( + PushDistributor.distribute, + (ETH_ADDRESS, members, amountToDistribute, party.lastProposalId() + 1) + ); + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + _proposePassAndExecuteProposal(proposal); + } + } + + function test_distribute_revertIfUnexpectedETH() public { + uint256 amountToDistribute = 100e18; + + ArbitraryCallsProposal.ArbitraryCall[] + memory arbCalls = new ArbitraryCallsProposal.ArbitraryCall[](2); + + arbCalls[0] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(erc20)), + value: 0, + data: abi.encodeCall(IERC20.approve, (address(pushDistributor), amountToDistribute)), + expectedResultHash: "" + }); + arbCalls[1] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(pushDistributor)), + value: 1, // Unexpected ETH + data: abi.encodeCall( + PushDistributor.distribute, + (erc20, members, amountToDistribute, party.lastProposalId() + 1) + ), + expectedResultHash: "" + }); + + { + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + uint256 proposalId = _proposeAndPassProposal(proposal); + + vm.expectRevert(); + _executeProposal(proposalId, proposal); + } + + // Try with no ETH this time and pass + { + arbCalls[1].value = 0; + arbCalls[1].data = abi.encodeCall( + PushDistributor.distribute, + (erc20, members, amountToDistribute, party.lastProposalId() + 1) + ); + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + _proposePassAndExecuteProposal(proposal); + } + } + + function test_distribute_revertIfWrongProposalId() public { + uint256 amountToDistribute = 10e18; + + ArbitraryCallsProposal.ArbitraryCall[] + memory arbCalls = new ArbitraryCallsProposal.ArbitraryCall[](1); + + arbCalls[0] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(pushDistributor)), + value: amountToDistribute, + data: abi.encodeCall( + PushDistributor.distribute, + (ETH_ADDRESS, members, amountToDistribute, 2) // Wrong proposal ID + ), + expectedResultHash: "" + }); + + { + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + uint256 proposalId = _proposeAndPassProposal(proposal); + + vm.expectRevert(); + _executeProposal(proposalId, proposal); + } + + // Try with the correct proposal ID this time and pass + { + arbCalls[0].data = abi.encodeCall( + PushDistributor.distribute, + (ETH_ADDRESS, members, amountToDistribute, party.lastProposalId() + 1) + ); + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + _proposePassAndExecuteProposal(proposal); + } + } + + function test_distribute_revertIfMembersNotSorted() public { + uint256 amountToDistribute = 10e18; + + address[] memory membersNotSorted = members; + (membersNotSorted[0], membersNotSorted[1]) = (membersNotSorted[1], membersNotSorted[0]); + + ArbitraryCallsProposal.ArbitraryCall[] + memory arbCalls = new ArbitraryCallsProposal.ArbitraryCall[](1); + + arbCalls[0] = ArbitraryCallsProposal.ArbitraryCall({ + target: payable(address(pushDistributor)), + value: amountToDistribute, + data: abi.encodeCall( + PushDistributor.distribute, + // Members not sorted + (ETH_ADDRESS, membersNotSorted, amountToDistribute, party.lastProposalId() + 1) + ), + expectedResultHash: "" + }); + + { + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + uint256 proposalId = _proposeAndPassProposal(proposal); + + vm.expectRevert(); + _executeProposal(proposalId, proposal); + } + + // Try with sorted members this time and pass + { + arbCalls[0].data = abi.encodeCall( + PushDistributor.distribute, + (ETH_ADDRESS, members, amountToDistribute, party.lastProposalId() + 1) + ); + PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ + maxExecutableTime: type(uint40).max, + cancelDelay: 0, + proposalData: abi.encodeWithSelector( + bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), + arbCalls + ) + }); + + _proposePassAndExecuteProposal(proposal); + } + } + function _createProposal( IERC20 token, uint256 amount @@ -153,3 +445,9 @@ contract PushDistributorTest is SetupPartyHelper { receive() external payable {} } + +contract CannotReceiveETH is ERC721Receiver { + receive() external payable { + revert("Cannot receive ETH"); + } +} From 7c2c5c5dc8ffcfa330c00ce7a3dd7faa351d0067 Mon Sep 17 00:00:00 2001 From: Brian Le Date: Fri, 16 Feb 2024 15:52:22 -0500 Subject: [PATCH 10/14] address feedback --- contracts/distribution/PushDistributor.sol | 55 +++++----------------- test/distribution/PushDistributor.t.sol | 48 ++++++++++--------- 2 files changed, 40 insertions(+), 63 deletions(-) diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol index 7d7412487..419cade4d 100644 --- a/contracts/distribution/PushDistributor.sol +++ b/contracts/distribution/PushDistributor.sol @@ -1,39 +1,16 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.20; +import { Party } from "./../party/Party.sol"; import { PartyGovernance } from "./../party/PartyGovernance.sol"; +import { ProposalExecutionEngine } from "./../proposals/ProposalExecutionEngine.sol"; import { IERC20 } from "../tokens/IERC20.sol"; import { LibSafeCast } from "../utils/LibSafeCast.sol"; import { LibERC20Compat } from "./../utils/LibERC20Compat.sol"; import { LibAddress } from "./../utils/LibAddress.sol"; -interface IParty { - struct GovernanceValues { - uint40 voteDuration; - uint40 executionDelay; - uint16 passThresholdBps; - uint96 totalVotingPower; - } - - function getGovernanceValues() external view returns (GovernanceValues memory); - function getIntrinsicVotingPowerAt( - address voter, - uint40 timestamp, - uint256 hintIndex - ) external view returns (uint96); - function getProposalStateInfo( - uint256 proposalId - ) - external - view - returns ( - PartyGovernance.ProposalStatus status, - PartyGovernance.ProposalStateValues memory values - ); -} - contract PushDistributor { - event Distributed(IERC20 token, address[] members, uint256 amount); + event Distributed(Party party, IERC20 token, address[] members, uint256 amount); error NotEnoughETH(uint256 expectedAmount, uint256 receivedAmount); error UnexpectedETH(uint256 amount); @@ -54,13 +31,8 @@ contract PushDistributor { uint256 amount, uint256 proposalId ) external payable { - IParty party = IParty(payable(msg.sender)); - if (token == ETH_ADDRESS) { - if (msg.value < amount) revert NotEnoughETH(amount, msg.value); - } else { - if (msg.value != 0) revert UnexpectedETH(msg.value); - token.compatTransferFrom(msg.sender, address(this), amount); - } + Party party = Party(payable(msg.sender)); + if (token == ETH_ADDRESS && msg.value < amount) revert NotEnoughETH(amount, msg.value); uint40 proposedTime; uint96 totalVotingPower; @@ -87,7 +59,8 @@ contract PushDistributor { prevMember = member; - uint96 intrinsicVotingPower = party.getIntrinsicVotingPowerAt(member, proposedTime, 0); + uint96 intrinsicVotingPower = ProposalExecutionEngine(address(party)) + .getIntrinsicVotingPowerAt(member, proposedTime, 0); totalIntrinsicVotingPower += intrinsicVotingPower; @@ -103,24 +76,22 @@ contract PushDistributor { // it means that the members array is incorrect. if (totalIntrinsicVotingPower != totalVotingPower) revert WrongMembers(); - // Send back any remaining tokens to the sender. - uint256 remainingAmount = token == ETH_ADDRESS - ? address(this).balance - : token.balanceOf(address(this)); + // Send back any remaining ETH to the sender. + uint256 remainingAmount = address(this).balance; if (remainingAmount > 0) { - _transfer(token, msg.sender, remainingAmount); + _transfer(ETH_ADDRESS, msg.sender, remainingAmount); } - emit Distributed(token, members, amount); + emit Distributed(party, token, members, amount); } function _transfer(IERC20 token, address to, uint256 amount) internal { if (token == ETH_ADDRESS) { // Do not revert on failure. Set gas to 100k to prevent consuming // all gas. - payable(to).call{ value: amount, gas: 100_000 }(""); + to.call{ value: amount, gas: 100_000 }(""); } else { - token.compatTransfer(to, amount); + token.compatTransferFrom(msg.sender, to, amount); } } } diff --git a/test/distribution/PushDistributor.t.sol b/test/distribution/PushDistributor.t.sol index 8f693f7f0..69663a2da 100644 --- a/test/distribution/PushDistributor.t.sol +++ b/test/distribution/PushDistributor.t.sol @@ -220,7 +220,7 @@ contract PushDistributorTest is SetupPartyHelper { } } - function test_distribute_revertIfUnexpectedETH() public { + function test_distribute_withERC20AndETH() public { uint256 amountToDistribute = 100e18; ArbitraryCallsProposal.ArbitraryCall[] @@ -234,7 +234,7 @@ contract PushDistributorTest is SetupPartyHelper { }); arbCalls[1] = ArbitraryCallsProposal.ArbitraryCall({ target: payable(address(pushDistributor)), - value: 1, // Unexpected ETH + value: 10e18, // 10 ETH data: abi.encodeCall( PushDistributor.distribute, (erc20, members, amountToDistribute, party.lastProposalId() + 1) @@ -254,28 +254,11 @@ contract PushDistributorTest is SetupPartyHelper { uint256 proposalId = _proposeAndPassProposal(proposal); - vm.expectRevert(); _executeProposal(proposalId, proposal); } - // Try with no ETH this time and pass - { - arbCalls[1].value = 0; - arbCalls[1].data = abi.encodeCall( - PushDistributor.distribute, - (erc20, members, amountToDistribute, party.lastProposalId() + 1) - ); - PartyGovernance.Proposal memory proposal = PartyGovernance.Proposal({ - maxExecutableTime: type(uint40).max, - cancelDelay: 0, - proposalData: abi.encodeWithSelector( - bytes4(uint32(ProposalExecutionEngine.ProposalType.ArbitraryCalls)), - arbCalls - ) - }); - - _proposePassAndExecuteProposal(proposal); - } + // Check that ETH was sent back + assertEq(address(party).balance, 100e18); } function test_distribute_revertIfWrongProposalId() public { @@ -384,6 +367,29 @@ contract PushDistributorTest is SetupPartyHelper { } } + function test_distribute_revertIfWrongMembers() public { + address lastMember = members[members.length - 1]; + + // Remove the last member + members.pop(); + + uint256 amountToDistribute = 10e18; + + PartyGovernance.Proposal memory proposal = _createProposal(ETH_ADDRESS, amountToDistribute); + + uint256 proposalId = _proposeAndPassProposal(proposal); + + vm.expectRevert(); + _executeProposal(proposalId, proposal); + + // Add the last member back and pass + members.push(lastMember); + + proposal = _createProposal(ETH_ADDRESS, amountToDistribute); + + _proposePassAndExecuteProposal(proposal); + } + function _createProposal( IERC20 token, uint256 amount From b2ca09cb5d1af255b8f0fcb74ecfa76c9aa7c226 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 16 Feb 2024 16:25:21 -0500 Subject: [PATCH 11/14] Verify revert message --- test/distribution/PushDistributor.t.sol | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/distribution/PushDistributor.t.sol b/test/distribution/PushDistributor.t.sol index 69663a2da..1c589c81e 100644 --- a/test/distribution/PushDistributor.t.sol +++ b/test/distribution/PushDistributor.t.sol @@ -196,7 +196,16 @@ contract PushDistributorTest is SetupPartyHelper { uint256 proposalId = _proposeAndPassProposal(proposal); - vm.expectRevert(); + vm.expectRevert( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodeWithSelector( + PushDistributor.NotEnoughETH.selector, + amountToDistribute, + amountToDistribute - 1 + ) + ) + ); _executeProposal(proposalId, proposal); } From 8b67ebb8ddea0149fda3a96d557e21425724ad8d Mon Sep 17 00:00:00 2001 From: Brian Le Date: Fri, 16 Feb 2024 16:58:09 -0500 Subject: [PATCH 12/14] delete unused error and update tests --- contracts/distribution/PushDistributor.sol | 1 - test/distribution/PushDistributor.t.sol | 21 ++++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol index 419cade4d..cf080cc7e 100644 --- a/contracts/distribution/PushDistributor.sol +++ b/contracts/distribution/PushDistributor.sol @@ -13,7 +13,6 @@ contract PushDistributor { event Distributed(Party party, IERC20 token, address[] members, uint256 amount); error NotEnoughETH(uint256 expectedAmount, uint256 receivedAmount); - error UnexpectedETH(uint256 amount); error WrongProposalId(uint256 proposalId); error WrongMembers(); error MembersNotSorted(); diff --git a/test/distribution/PushDistributor.t.sol b/test/distribution/PushDistributor.t.sol index 1c589c81e..094309f5f 100644 --- a/test/distribution/PushDistributor.t.sol +++ b/test/distribution/PushDistributor.t.sol @@ -298,7 +298,12 @@ contract PushDistributorTest is SetupPartyHelper { uint256 proposalId = _proposeAndPassProposal(proposal); - vm.expectRevert(); + vm.expectRevert( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodeWithSelector(PushDistributor.WrongProposalId.selector, 2) + ) + ); _executeProposal(proposalId, proposal); } @@ -353,7 +358,12 @@ contract PushDistributorTest is SetupPartyHelper { uint256 proposalId = _proposeAndPassProposal(proposal); - vm.expectRevert(); + vm.expectRevert( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodePacked(PushDistributor.MembersNotSorted.selector) + ) + ); _executeProposal(proposalId, proposal); } @@ -388,7 +398,12 @@ contract PushDistributorTest is SetupPartyHelper { uint256 proposalId = _proposeAndPassProposal(proposal); - vm.expectRevert(); + vm.expectRevert( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodePacked(PushDistributor.WrongMembers.selector) + ) + ); _executeProposal(proposalId, proposal); // Add the last member back and pass From 3901c52ad1effdb76e072ec64eae677d40dd69fe Mon Sep 17 00:00:00 2001 From: Brian Le Date: Thu, 22 Feb 2024 14:00:41 -0500 Subject: [PATCH 13/14] mitigations: Rooms distributor (#378) Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com> --- contracts/distribution/PushDistributor.sol | 9 +- test/distribution/PushDistributor.t.sol | 104 ++++++++++++++++++--- 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol index cf080cc7e..a1fb86c98 100644 --- a/contracts/distribution/PushDistributor.sol +++ b/contracts/distribution/PushDistributor.sol @@ -5,11 +5,10 @@ import { Party } from "./../party/Party.sol"; import { PartyGovernance } from "./../party/PartyGovernance.sol"; import { ProposalExecutionEngine } from "./../proposals/ProposalExecutionEngine.sol"; import { IERC20 } from "../tokens/IERC20.sol"; -import { LibSafeCast } from "../utils/LibSafeCast.sol"; import { LibERC20Compat } from "./../utils/LibERC20Compat.sol"; -import { LibAddress } from "./../utils/LibAddress.sol"; +import { ReentrancyGuard } from "openzeppelin/contracts/security/ReentrancyGuard.sol"; -contract PushDistributor { +contract PushDistributor is ReentrancyGuard { event Distributed(Party party, IERC20 token, address[] members, uint256 amount); error NotEnoughETH(uint256 expectedAmount, uint256 receivedAmount); @@ -17,9 +16,7 @@ contract PushDistributor { error WrongMembers(); error MembersNotSorted(); - using LibSafeCast for uint256; using LibERC20Compat for IERC20; - using LibAddress for address payable; // Token address used to indicate ETH. IERC20 private constant ETH_ADDRESS = IERC20(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); @@ -29,7 +26,7 @@ contract PushDistributor { address[] memory members, uint256 amount, uint256 proposalId - ) external payable { + ) external payable nonReentrant { Party party = Party(payable(msg.sender)); if (token == ETH_ADDRESS && msg.value < amount) revert NotEnoughETH(amount, msg.value); diff --git a/test/distribution/PushDistributor.t.sol b/test/distribution/PushDistributor.t.sol index 094309f5f..63a218894 100644 --- a/test/distribution/PushDistributor.t.sol +++ b/test/distribution/PushDistributor.t.sol @@ -131,19 +131,7 @@ contract PushDistributorTest is SetupPartyHelper { function test_distribute_doesNotRevertIfMemberCannotReceive() public { address newMember = address(new CannotReceiveETH()); - party.increaseTotalVotingPower(100); - party.mint(newMember, 100, newMember); - - members.push(newMember); - - // Sort the addresses from lowest to highest. - for (uint256 i = 0; i < members.length; i++) { - for (uint256 j = i + 1; j < members.length; j++) { - if (members[i] > members[j]) { - (members[i], members[j]) = (members[j], members[i]); - } - } - } + _addMember(newMember); uint256 amountToDistribute = 100e18; @@ -414,6 +402,33 @@ contract PushDistributorTest is SetupPartyHelper { _proposePassAndExecuteProposal(proposal); } + function test_distribute_cannotReenter() external { + // Deploy a malicious contract that will attempt to re-enter the distribute function + address reenteringMember = address(new ReenteringMember(address(pushDistributor), 3 ether)); + + // Add the malicious contract as a member to simulate a scenario where it can receive funds and re-enter + _addMember(reenteringMember); + + uint256 amountToDistribute = 10e18; + + PartyGovernance.Proposal memory proposal = _createProposal(ETH_ADDRESS, amountToDistribute); + + _proposePassAndExecuteProposal(proposal); + + // Check if the distribution was successful + // John, Danny, Steve who each have 100 / 401 voting power should + // receive 100 / 401 * 10e18 ETH + assertEq(john.balance, (100 * amountToDistribute) / 401); + assertEq(danny.balance, (100 * amountToDistribute) / 401); + assertEq(steve.balance, (100 * amountToDistribute) / 401); + // The contract which has 1 / 401 voting power should receive + // 1 / 401 * 10e18 ETH + assertEq(address(this).balance, (1 * amountToDistribute) / 401); + // The reentering member should not receive any ETH because their + // re-entrancy attempt reverted + assertEq(reenteringMember.balance, 0); + } + function _createProposal( IERC20 token, uint256 amount @@ -473,6 +488,23 @@ contract PushDistributorTest is SetupPartyHelper { } } + function _addMember(address member) internal { + // Update Party state + party.increaseTotalVotingPower(100); + party.mint(member, 100, member); + + members.push(member); + + // Sort the addresses from lowest to highest. + for (uint256 i = 0; i < members.length; i++) { + for (uint256 j = i + 1; j < members.length; j++) { + if (members[i] > members[j]) { + (members[i], members[j]) = (members[j], members[i]); + } + } + } + } + receive() external payable {} } @@ -481,3 +513,49 @@ contract CannotReceiveETH is ERC721Receiver { revert("Cannot receive ETH"); } } + +contract ReenteringMember is ERC721Receiver { + PushDistributor public immutable pushDistributor; + IERC20 public constant token = IERC20(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + address[] public members; + uint256 public immutable cutoffAmount; + + constructor(address _pushDistributor, uint256 _cutoffAmount) { + pushDistributor = PushDistributor(_pushDistributor); + members = new address[](0); + cutoffAmount = _cutoffAmount; + } + + function getProposalStateInfo( + uint256 proposalId + ) + external + view + returns ( + PartyGovernance.ProposalStatus status, + PartyGovernance.ProposalStateValues memory values + ) + { + values = PartyGovernance.ProposalStateValues({ + proposedTime: 0, + passedTime: 0, + executedTime: uint40(block.timestamp), + completedTime: 0, + votes: 0, + totalVotingPower: 0, + numHosts: 0, + numHostsAccepted: 0, + voteDuration: 0, + executionDelay: 0, + passThresholdBps: 0 + }); + status = PartyGovernance.ProposalStatus.Ready; + } + + // Fallback function used to attempt re-entrancy + receive() external payable { + if (msg.value < cutoffAmount) { + pushDistributor.distribute(token, members, 0, 0); + } + } +} From ba5411f2c11de3cf4ddf4a12f02b2c53137cd6ad Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:53:11 -0500 Subject: [PATCH 14/14] Add to deploy --- deploy/Deploy.s.sol | 14 +++++++++++++- utils/output-abis.ts | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/deploy/Deploy.s.sol b/deploy/Deploy.s.sol index 68a559f9b..b703d2baa 100644 --- a/deploy/Deploy.s.sol +++ b/deploy/Deploy.s.sol @@ -35,6 +35,7 @@ import { SSTORE2MetadataProvider } from "../contracts/renderers/SSTORE2MetadataP import { BasicMetadataProvider } from "../contracts/renderers/BasicMetadataProvider.sol"; import { OffChainSignatureValidator } from "../contracts/signature-validators/OffChainSignatureValidator.sol"; import { BondingCurveAuthority } from "../contracts/authorities/BondingCurveAuthority.sol"; +import { PushDistributor } from "../contracts/distribution/PushDistributor.sol"; import "./LibDeployConstants.sol"; abstract contract Deploy { @@ -85,6 +86,7 @@ abstract contract Deploy { SellPartyCardsAuthority public sellPartyCardsAuthority; OffChainSignatureValidator public offChainSignatureValidator; BondingCurveAuthority public bondingCurveAuthority; + PushDistributor public pushDistributor; function deploy(LibDeployConstants.DeployConstants memory deployConstants) public virtual { _switchDeployer(DeployerRole.Default); @@ -110,6 +112,15 @@ abstract contract Deploy { console.log(" Deployed - TokenDistributor", address(tokenDistributor)); _switchDeployer(DeployerRole.Default); + // DEPLOY_PUSH_DISTRIBUTOR + console.log(""); + console.log("### PushDistributor"); + console.log(" Deploying - PushDistributor"); + _trackDeployerGasBefore(); + pushDistributor = new PushDistributor(); + _trackDeployerGasAfter(); + console.log(" Deployed - PushDistributor", address(pushDistributor)); + // DEPLOY_PROPOSAL_EXECUTION_ENGINE console.log(""); console.log("### ProposalExecutionEngine"); @@ -715,7 +726,7 @@ contract DeployScript is Script, Deploy { Deploy.deploy(deployConstants); vm.stopBroadcast(); - AddressMapping[] memory addressMapping = new AddressMapping[](30); + AddressMapping[] memory addressMapping = new AddressMapping[](31); addressMapping[0] = AddressMapping("Globals", address(globals)); addressMapping[1] = AddressMapping("TokenDistributor", address(tokenDistributor)); addressMapping[2] = AddressMapping( @@ -782,6 +793,7 @@ contract DeployScript is Script, Deploy { "OffChainSignatureValidator", address(offChainSignatureValidator) ); + addressMapping[30] = AddressMapping("PushDistributor", address(pushDistributor)); console.log(""); console.log("### Deployed addresses"); diff --git a/utils/output-abis.ts b/utils/output-abis.ts index 4044093bd..cea41cd62 100644 --- a/utils/output-abis.ts +++ b/utils/output-abis.ts @@ -32,6 +32,7 @@ const RELEVANT_ABIS = [ "SellPartyCardsAuthority", "OffChainSignatureValidator", "BondingCurveAuthority", + "PushDistributor", ]; // AFileName -> a_file_name