Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new distributor #377

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
109 changes: 109 additions & 0 deletions contracts/distribution/PushDistributor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// 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";
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 voter,
uint40 timestamp,
uint256 hintIndex
) external view returns (uint96);
function getProposalStateInfo(
uint256 proposalId
)
external
view
returns (
PartyGovernance.ProposalStatus status,
PartyGovernance.ProposalStateValues memory values
);
}
0xble marked this conversation as resolved.
Show resolved Hide resolved

contract PushDistributor {
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);

function distribute(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would like detailed natspec here. It's a pretty advanced function. Otherwise PR looks good.

IERC20 token,
address[] memory members,
uint256 amount,
uint256 proposalId
) external payable {
IParty party = IParty(payable(msg.sender));
if (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);
0xble marked this conversation as resolved.
Show resolved Hide resolved
}

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"
);

proposedTime = proposal.proposedTime;
totalVotingPower = party.getGovernanceValues().totalVotingPower;
0xble marked this conversation as resolved.
Show resolved Hide resolved
}

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.
require(member > prevMember, "Members not sorted");

prevMember = member;

uint96 intrinsicVotingPower = party.getIntrinsicVotingPowerAt(member, proposedTime, 0);

totalIntrinsicVotingPower += intrinsicVotingPower;

uint256 shareAmount = (amount * intrinsicVotingPower) / totalVotingPower;

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

require(totalIntrinsicVotingPower == totalVotingPower, "Missing member");
}
}
57 changes: 57 additions & 0 deletions contracts/proposals/ProposalExecutionEngine.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 returns (uint96) {
arr00 marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand Down
155 changes: 155 additions & 0 deletions test/distribution/PushDistributor.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
arr00 marked this conversation as resolved.
Show resolved Hide resolved
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 {}
}
Loading
Loading