diff --git a/contracts/distribution/PushDistributor.sol b/contracts/distribution/PushDistributor.sol new file mode 100644 index 000000000..a1fb86c98 --- /dev/null +++ b/contracts/distribution/PushDistributor.sol @@ -0,0 +1,93 @@ +// 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 { LibERC20Compat } from "./../utils/LibERC20Compat.sol"; +import { ReentrancyGuard } from "openzeppelin/contracts/security/ReentrancyGuard.sol"; + +contract PushDistributor is ReentrancyGuard { + event Distributed(Party party, IERC20 token, address[] members, uint256 amount); + + error NotEnoughETH(uint256 expectedAmount, uint256 receivedAmount); + error WrongProposalId(uint256 proposalId); + error WrongMembers(); + error MembersNotSorted(); + + using LibERC20Compat for IERC20; + + // Token address used to indicate ETH. + IERC20 private constant ETH_ADDRESS = IERC20(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + + function distribute( + IERC20 token, + address[] memory members, + uint256 amount, + uint256 proposalId + ) external payable nonReentrant { + Party party = Party(payable(msg.sender)); + if (token == ETH_ADDRESS && msg.value < amount) revert NotEnoughETH(amount, msg.value); + + uint40 proposedTime; + uint96 totalVotingPower; + { + (, PartyGovernance.ProposalStateValues memory proposal) = party.getProposalStateInfo( + proposalId + ); + + if (proposal.executedTime != block.timestamp) revert WrongProposalId(proposalId); + + proposedTime = proposal.proposedTime; + totalVotingPower = proposal.totalVotingPower; + } + + address prevMember; + uint96 totalIntrinsicVotingPower; + 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. + if (member <= prevMember) revert MembersNotSorted(); + + prevMember = member; + + uint96 intrinsicVotingPower = ProposalExecutionEngine(address(party)) + .getIntrinsicVotingPowerAt(member, proposedTime, 0); + + totalIntrinsicVotingPower += intrinsicVotingPower; + + uint256 shareAmount = (amount * intrinsicVotingPower) / totalVotingPower; + + if (shareAmount > 0) { + // Transfer the share of the distribution to the member. + _transfer(token, member, shareAmount); + } + } + + // 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 ETH to the sender. + uint256 remainingAmount = address(this).balance; + if (remainingAmount > 0) { + _transfer(ETH_ADDRESS, msg.sender, remainingAmount); + } + + 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. + to.call{ value: amount, gas: 100_000 }(""); + } else { + token.compatTransferFrom(msg.sender, to, amount); + } + } +} diff --git a/contracts/proposals/ProposalExecutionEngine.sol b/contracts/proposals/ProposalExecutionEngine.sol index c2a897357..4671e1466 100644 --- a/contracts/proposals/ProposalExecutionEngine.sol +++ b/contracts/proposals/ProposalExecutionEngine.sol @@ -244,6 +244,63 @@ 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, + uint256 hintIndex + ) public view 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 ("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 ( + // 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; + } + + // 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) { + 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, 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/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..63a218894 --- /dev/null +++ b/test/distribution/PushDistributor.t.sol @@ -0,0 +1,561 @@ +// 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"; +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; + + 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 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()); + + _addMember(newMember); + + 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( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodeWithSelector( + PushDistributor.NotEnoughETH.selector, + amountToDistribute, + amountToDistribute - 1 + ) + ) + ); + _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_withERC20AndETH() 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: 10e18, // 10 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); + + _executeProposal(proposalId, proposal); + } + + // Check that ETH was sent back + assertEq(address(party).balance, 100e18); + } + + 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( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodeWithSelector(PushDistributor.WrongProposalId.selector, 2) + ) + ); + _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( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodePacked(PushDistributor.MembersNotSorted.selector) + ) + ); + _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 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( + abi.encodeWithSelector( + ArbitraryCallsProposal.ArbitraryCallFailedError.selector, + abi.encodePacked(PushDistributor.WrongMembers.selector) + ) + ); + _executeProposal(proposalId, proposal); + + // Add the last member back and pass + members.push(lastMember); + + proposal = _createProposal(ETH_ADDRESS, amountToDistribute); + + _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 + ) 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 + ) + }); + } + } + + 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 {} +} + +contract CannotReceiveETH is ERC721Receiver { + receive() external payable { + 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); + } + } +} diff --git a/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol b/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol new file mode 100644 index 000000000..a30ff6a14 --- /dev/null +++ b/test/proposals/ProposalExecutionEngineIntrinsicPower.t.sol @@ -0,0 +1,142 @@ +// 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) {} + + 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); + 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 + ); + } + } +} diff --git a/utils/output-abis.ts b/utils/output-abis.ts index 296aaf092..5d1a5c97d 100644 --- a/utils/output-abis.ts +++ b/utils/output-abis.ts @@ -32,6 +32,7 @@ const RELEVANT_ABIS = [ "SellPartyCardsAuthority", "OffChainSignatureValidator", "BondingCurveAuthority", + "PushDistributor", "EZPartyBuilder", ];