diff --git a/contracts/governance/GoodDaoHouses.sol b/contracts/governance/GoodDaoHouses.sol new file mode 100644 index 00000000..e95abf83 --- /dev/null +++ b/contracts/governance/GoodDaoHouses.sol @@ -0,0 +1,833 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.19; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; + +import "../Interfaces.sol"; +import "../token/ERC677.sol"; +import "../utils/DAOUpgradeableContract.sol"; +import "./IFlowSplitter.sol"; + +interface IFlowSplitterCounter is IFlowSplitter { + function poolCounter() external view returns (uint256); +} + +contract GoodDaoHouses is + AccessControlUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable, + DAOUpgradeableContract, + ERC677Receiver +{ + bytes32 public constant GOVERNANCE_COMMITTEE_ROLE = + keccak256("GOVERNANCE_COMMITTEE_ROLE"); + + uint256 public constant HOUSE_ALIGNMENT_WEIGHT = 40; + uint256 public constant HOUSE_CITIZENS_WEIGHT = 4; + uint256 public constant BASIS_POINTS = 10000; + uint64 public constant DEFAULT_TERM_DURATION = 90 days; + uint64 public constant DEFAULT_VOTING_TERM_LENGTH = 7 days; + + enum House { + Citizens, + Alignment + } + + enum MemberStatus { + None, + Pending, + Active, + Revoked, + Unstaked + } + + struct MemberRecord { + House house; + MemberStatus status; + uint256 stakedAmount; + uint64 joinedAt; + uint64 updatedAt; + uint64 unstakedAt; + string name; + string socialLinks; + string projectWebpage; + string missionStatement; + string distributionStrategy; + } + + struct EligibilityRecord { + bool isEligible; + uint64 listedAt; + uint64 updatedAt; + uint64 delistedAt; + } + + struct VoteConfig { + uint64 startTime; + uint64 endTime; + uint64 executedAt; + bool executed; + } + + struct FlowSplitterConfig { + address splitter; + address superToken; + string metadata; + string poolName; + string poolSymbol; + uint8 poolDecimals; + bool transferabilityForUnitsOwner; + bool distributionFromAnyAddress; + uint256 poolId; + address poolAddress; + bool poolInitialized; + } + + mapping(address => MemberRecord) private _members; + mapping(address => EligibilityRecord) private _alignmentEligibility; + mapping(House => uint256) public minimumStake; + address[] private _memberAccounts; + mapping(address => bool) private _knownMember; + uint64 public termDuration; + uint64 public votingTermLength; + + uint256 public voteCount; + mapping(uint256 => VoteConfig) private _votes; + mapping(uint256 => address[]) private _voteRecipients; + mapping(uint256 => mapping(address => bool)) private _isVoteRecipient; + mapping(uint256 => mapping(address => uint256)) + private _voteRecipientWeightedVotes; + mapping(uint256 => mapping(address => address[])) private _voterBallotRecipients; + mapping(uint256 => mapping(address => mapping(address => uint256))) + private _voterBallotBps; + + FlowSplitterConfig private _flowSplitterConfig; + + event StakeRequirementSet(House indexed house, uint256 amount); + event AlignmentEligibilityUpdated(address indexed account, bool isEligible); + event MemberRegistered( + address indexed account, + House indexed house, + MemberStatus status, + uint256 amount + ); + event MemberApproved(address indexed account, House indexed house); + event MemberRevoked(address indexed account, House indexed house); + event MemberStaked(address indexed account, House indexed house, uint256 amount); + event MemberUnstaked(address indexed account, House indexed house, uint256 amount); + event VoteCreated( + uint256 indexed voteId, + uint64 startTime, + uint64 endTime + ); + event VoteUpdated(uint256 indexed voteId, address indexed voter); + event VoteExecuted(uint256 indexed voteId, uint256 poolId, address poolAddress); + event FlowSplitterConfigured(address indexed splitter, address indexed superToken); + event FlowSplitterPoolCreated(uint256 indexed poolId, address poolAddress); + event FlowSplitterMetadataUpdated(uint256 indexed poolId, string metadata); + + function initialize( + INameService _ns, + address admin, + address committee, + uint256 citizensMinimumStake, + uint256 alignmentMinimumStake + ) public initializer { + __AccessControl_init(); + __Pausable_init(); + __ReentrancyGuard_init(); + __UUPSUpgradeable_init(); + + setDAO(_ns); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(GOVERNANCE_COMMITTEE_ROLE, committee); + if (admin != committee) { + _grantRole(GOVERNANCE_COMMITTEE_ROLE, admin); + } + + minimumStake[House.Citizens] = citizensMinimumStake; + minimumStake[House.Alignment] = alignmentMinimumStake; + termDuration = DEFAULT_TERM_DURATION; + votingTermLength = DEFAULT_VOTING_TERM_LENGTH; + + emit StakeRequirementSet(House.Citizens, citizensMinimumStake); + emit StakeRequirementSet(House.Alignment, alignmentMinimumStake); + } + + function setStakeRequirement(House house, uint256 amount) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + { + minimumStake[house] = amount; + emit StakeRequirementSet(house, amount); + } + + function setAlignmentEligibility(address account, bool isEligible) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + { + EligibilityRecord storage eligibility = _alignmentEligibility[account]; + eligibility.isEligible = isEligible; + eligibility.updatedAt = uint64(block.timestamp); + if (isEligible) { + if (eligibility.listedAt == 0) { + eligibility.listedAt = uint64(block.timestamp); + } + eligibility.delistedAt = 0; + } else { + eligibility.delistedAt = uint64(block.timestamp); + } + + emit AlignmentEligibilityUpdated(account, isEligible); + } + + function registerAndStake( + House house, + uint256 amount, + string calldata name, + string calldata socialLinks, + string calldata projectWebpage, + string calldata missionStatement, + string calldata distributionStrategy + ) external whenNotPaused { + require( + _goodDollar().transferFrom(msg.sender, address(this), amount), + "TF" + ); + _registerMember( + msg.sender, + house, + amount, + name, + socialLinks, + projectWebpage, + missionStatement, + distributionStrategy + ); + } + + function stake(uint256 amount) external whenNotPaused { + require( + _goodDollar().transferFrom(msg.sender, address(this), amount), + "TF" + ); + _addStake(msg.sender, amount); + } + + function onTokenTransfer( + address _from, + uint256 _amount, + bytes calldata _data + ) external override whenNotPaused returns (bool success) { + require(msg.sender == address(_goodDollar()), "UT"); + + if (_data.length == 0) { + _addStake(_from, _amount); + return true; + } + + ( + House house, + string memory name, + string memory socialLinks, + string memory projectWebpage, + string memory missionStatement, + string memory distributionStrategy + ) = abi.decode( + _data, + (House, string, string, string, string, string) + ); + _registerMember( + _from, + house, + _amount, + name, + socialLinks, + projectWebpage, + missionStatement, + distributionStrategy + ); + return true; + } + + function approveAlignmentMember(address account) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + whenNotPaused + { + MemberRecord storage member = _members[account]; + require(member.house == House.Alignment, "WH"); + require(member.status == MemberStatus.Pending, "NP"); + require( + _alignmentEligibility[account].isEligible, + "NE" + ); + require( + member.stakedAmount >= minimumStake[House.Alignment], + "SBM" + ); + + member.status = MemberStatus.Active; + member.updatedAt = uint64(block.timestamp); + + emit MemberApproved(account, member.house); + } + + function revokeMember(address account) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + whenNotPaused + { + MemberRecord storage member = _members[account]; + require( + member.status == MemberStatus.Active || + member.status == MemberStatus.Pending, + "NA" + ); + + member.status = MemberStatus.Revoked; + member.updatedAt = uint64(block.timestamp); + + _clearMemberUnits(account); + emit MemberRevoked(account, member.house); + } + + function unstake() external nonReentrant whenNotPaused { + MemberRecord storage member = _members[msg.sender]; + uint256 amount = member.stakedAmount; + + require(amount > 0, "NS"); + + member.stakedAmount = 0; + member.status = MemberStatus.Unstaked; + member.updatedAt = uint64(block.timestamp); + member.unstakedAt = uint64(block.timestamp); + + _clearMemberUnits(msg.sender); + + require(_goodDollar().transfer(msg.sender, amount), "WTF"); + + emit MemberUnstaked(msg.sender, member.house, amount); + } + + function castVote(address[] calldata recipients, uint256[] calldata allocations) + external + whenNotPaused + { + (uint256 voteId, uint64 voteStartTime) = _getCurrentVoteWindow(); + uint256 voterWeight = _getVoterWeight(msg.sender, voteStartTime); + + require(_isVotingPeriod(block.timestamp), "VNP"); + require(voterWeight > 0, "VE"); + + if (_votes[voteId].startTime == 0) { + _createAlignmentVote(voteId, voteStartTime); + } + + VoteConfig storage vote = _votes[voteId]; + require(!vote.executed, "VAE"); + require(recipients.length == allocations.length, "LM"); + require(recipients.length > 0, "EB"); + + uint256 allocationTotal; + for (uint256 i = 0; i < recipients.length; i++) { + require(_isVoteRecipient[voteId][recipients[i]], "IR"); + for (uint256 j = i + 1; j < recipients.length; j++) { + require(recipients[i] != recipients[j], "DR"); + } + allocationTotal += allocations[i]; + } + require(allocationTotal == BASIS_POINTS, "ASI"); + + _clearBallot(voteId, msg.sender, voterWeight); + + address[] storage storedRecipients = _voterBallotRecipients[voteId][ + msg.sender + ]; + for (uint256 i = 0; i < recipients.length; i++) { + address recipient = recipients[i]; + uint256 allocation = allocations[i]; + storedRecipients.push(recipient); + _voterBallotBps[voteId][msg.sender][recipient] = allocation; + _voteRecipientWeightedVotes[voteId][recipient] += + (allocation * voterWeight) / + BASIS_POINTS; + } + + emit VoteUpdated(voteId, msg.sender); + } + + function executeVote(uint256 voteId) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + whenNotPaused + { + VoteConfig storage vote = _votes[voteId]; + FlowSplitterConfig storage flowConfig = _flowSplitterConfig; + address[] storage recipients = _voteRecipients[voteId]; + IFlowSplitter.Member[] memory members = new IFlowSplitter.Member[]( + recipients.length + ); + + require(vote.startTime > 0, "VNF"); + require(block.timestamp > vote.endTime, "VSO"); + require(!vote.executed, "VAE"); + require(flowConfig.splitter != address(0), "FNC"); + require(flowConfig.superToken != address(0), "STNC"); + + for (uint256 i = 0; i < recipients.length; i++) { + address recipient = recipients[i]; + members[i] = IFlowSplitter.Member({ + account: recipient, + units: uint128(_voteRecipientWeightedVotes[voteId][recipient]) + }); + } + + if (!flowConfig.poolInitialized) { + address[] memory admins = new address[](1); + admins[0] = address(this); + + ISuperfluidPool pool = IFlowSplitter(flowConfig.splitter).createPool( + ISuperToken(flowConfig.superToken), + PoolConfig({ + transferabilityForUnitsOwner: flowConfig + .transferabilityForUnitsOwner, + distributionFromAnyAddress: flowConfig.distributionFromAnyAddress + }), + PoolERC20Metadata({ + name: flowConfig.poolName, + symbol: flowConfig.poolSymbol, + decimals: flowConfig.poolDecimals + }), + members, + admins, + flowConfig.metadata + ); + + flowConfig.poolId = IFlowSplitterCounter(flowConfig.splitter) + .poolCounter(); + IFlowSplitter.Pool memory poolInfo = IFlowSplitter(flowConfig.splitter) + .getPoolById(flowConfig.poolId); + + flowConfig.poolInitialized = true; + flowConfig.poolAddress = poolInfo.poolAddress == address(0) + ? address(pool) + : poolInfo.poolAddress; + + emit FlowSplitterPoolCreated( + flowConfig.poolId, + flowConfig.poolAddress + ); + } else { + IFlowSplitter(flowConfig.splitter).updateMembersUnits( + flowConfig.poolId, + members + ); + } + + vote.executed = true; + vote.executedAt = uint64(block.timestamp); + + emit VoteExecuted(voteId, flowConfig.poolId, flowConfig.poolAddress); + } + + function configureFlowSplitter( + address splitter, + address superToken, + string calldata metadata, + string calldata poolName, + string calldata poolSymbol, + uint8 poolDecimals, + bool transferabilityForUnitsOwner, + bool distributionFromAnyAddress + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) { + _flowSplitterConfig.splitter = splitter; + _flowSplitterConfig.superToken = superToken; + _flowSplitterConfig.metadata = metadata; + _flowSplitterConfig.poolName = poolName; + _flowSplitterConfig.poolSymbol = poolSymbol; + _flowSplitterConfig.poolDecimals = poolDecimals; + _flowSplitterConfig.transferabilityForUnitsOwner = transferabilityForUnitsOwner; + _flowSplitterConfig.distributionFromAnyAddress = distributionFromAnyAddress; + + emit FlowSplitterConfigured(splitter, superToken); + } + + function syncFlowSplitterPool(uint256 poolId) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + { + IFlowSplitter.Pool memory pool = IFlowSplitter(_flowSplitterConfig.splitter) + .getPoolById(poolId); + require(pool.poolAddress != address(0), "PNF"); + + _flowSplitterConfig.poolId = pool.id; + _flowSplitterConfig.poolAddress = pool.poolAddress; + _flowSplitterConfig.poolInitialized = true; + } + + function syncFlowSplitterMetadata(string calldata metadata) + external + onlyRole(GOVERNANCE_COMMITTEE_ROLE) + { + _flowSplitterConfig.metadata = metadata; + if (_flowSplitterConfig.poolInitialized) { + IFlowSplitter(_flowSplitterConfig.splitter).updatePoolMetadata( + _flowSplitterConfig.poolId, + metadata + ); + emit FlowSplitterMetadataUpdated( + _flowSplitterConfig.poolId, + metadata + ); + } + } + + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _pause(); + } + + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + + function getMember(address account) + external + view + returns (MemberRecord memory) + { + return _members[account]; + } + + function getAlignmentEligibility(address account) + external + view + returns (EligibilityRecord memory) + { + return _alignmentEligibility[account]; + } + + function isActiveMember(address account, House house) + external + view + returns (bool) + { + MemberRecord storage member = _members[account]; + return member.house == house && member.status == MemberStatus.Active; + } + + function getActiveMembers(House house) + external + view + returns (address[] memory) + { + uint256 activeCount; + for (uint256 i = 0; i < _memberAccounts.length; i++) { + MemberRecord storage member = _members[_memberAccounts[i]]; + if (member.house == house && member.status == MemberStatus.Active) { + activeCount++; + } + } + + address[] memory activeMembers = new address[](activeCount); + uint256 index; + for (uint256 i = 0; i < _memberAccounts.length; i++) { + address account = _memberAccounts[i]; + MemberRecord storage member = _members[account]; + if (member.house == house && member.status == MemberStatus.Active) { + activeMembers[index] = account; + index++; + } + } + + return activeMembers; + } + + function getVote(uint256 voteId) external view returns (VoteConfig memory) { + return _votes[voteId]; + } + + function getVoteRecipients(uint256 voteId) + external + view + returns (address[] memory) + { + return _voteRecipients[voteId]; + } + + function getVoteVoters(uint256 voteId) + external + view + returns ( + address[] memory alignmentVoters, + address[] memory citizensVoters + ) + { + VoteConfig storage vote = _votes[voteId]; + uint256 alignmentCount; + uint256 citizensCount; + + for (uint256 i = 0; i < _memberAccounts.length; i++) { + MemberRecord storage member = _members[_memberAccounts[i]]; + if ( + member.status != MemberStatus.Active || member.joinedAt > vote.startTime + ) { + continue; + } + + if (member.house == House.Alignment) { + alignmentCount++; + } else if (member.house == House.Citizens) { + citizensCount++; + } + } + + alignmentVoters = new address[](alignmentCount); + citizensVoters = new address[](citizensCount); + uint256 alignmentIndex; + uint256 citizensIndex; + + for (uint256 i = 0; i < _memberAccounts.length; i++) { + address account = _memberAccounts[i]; + MemberRecord storage member = _members[account]; + if ( + member.status != MemberStatus.Active || member.joinedAt > vote.startTime + ) { + continue; + } + + if (member.house == House.Alignment) { + alignmentVoters[alignmentIndex] = account; + alignmentIndex++; + } else if (member.house == House.Citizens) { + citizensVoters[citizensIndex] = account; + citizensIndex++; + } + } + } + + function getBallot(uint256 voteId, address voter) + external + view + returns (address[] memory recipients, uint256[] memory allocations) + { + recipients = _voterBallotRecipients[voteId][voter]; + allocations = new uint256[](recipients.length); + for (uint256 i = 0; i < recipients.length; i++) { + allocations[i] = _voterBallotBps[voteId][voter][recipients[i]]; + } + } + + function getFinalizedUnits(uint256 voteId, address recipient) + external + view + returns (uint128) + { + return uint128(_voteRecipientWeightedVotes[voteId][recipient]); + } + + function getCurrentVoteId() external view returns (uint256) { + (uint256 voteId, ) = _getCurrentVoteWindow(); + return voteId; + } + + function isVotingPeriod() external view returns (bool) { + return _isVotingPeriod(block.timestamp); + } + + function getFlowSplitterConfig() + external + view + returns (FlowSplitterConfig memory) + { + return _flowSplitterConfig; + } + + function _registerMember( + address account, + House house, + uint256 amount, + string memory name, + string memory socialLinks, + string memory projectWebpage, + string memory missionStatement, + string memory distributionStrategy + ) internal { + MemberRecord storage member = _members[account]; + uint64 joinedAt = member.joinedAt == 0 + ? uint64(block.timestamp) + : member.joinedAt; + + require(amount >= minimumStake[house], "SBM"); + require( + member.status == MemberStatus.None || + member.status == MemberStatus.Unstaked, + "MAR" + ); + + if (house == House.Alignment) { + require( + _alignmentEligibility[account].isEligible, + "NE" + ); + } + + _members[account] = MemberRecord({ + house: house, + status: house == House.Alignment + ? MemberStatus.Pending + : MemberStatus.Active, + stakedAmount: amount, + joinedAt: joinedAt, + updatedAt: uint64(block.timestamp), + unstakedAt: 0, + name: name, + socialLinks: socialLinks, + projectWebpage: projectWebpage, + missionStatement: missionStatement, + distributionStrategy: distributionStrategy + }); + + if (!_knownMember[account]) { + _knownMember[account] = true; + _memberAccounts.push(account); + } + + emit MemberRegistered(account, house, _members[account].status, amount); + } + + function _addStake(address account, uint256 amount) internal { + MemberRecord storage member = _members[account]; + require(member.status != MemberStatus.None, "MNF"); + require(member.status != MemberStatus.Unstaked, "MU"); + + member.stakedAmount += amount; + member.updatedAt = uint64(block.timestamp); + + emit MemberStaked(account, member.house, amount); + } + + function _clearBallot( + uint256 voteId, + address voter, + uint256 voterWeight + ) internal { + address[] storage previousRecipients = _voterBallotRecipients[voteId][voter]; + + for (uint256 i = 0; i < previousRecipients.length; i++) { + address recipient = previousRecipients[i]; + uint256 previousAllocation = _voterBallotBps[voteId][voter][recipient]; + if (previousAllocation > 0) { + _voteRecipientWeightedVotes[voteId][recipient] -= + (previousAllocation * voterWeight) / + BASIS_POINTS; + delete _voterBallotBps[voteId][voter][recipient]; + } + } + + delete _voterBallotRecipients[voteId][voter]; + } + + function _createAlignmentVote(uint256 voteId, uint64 voteStartTime) internal { + uint64 voteEndTime = voteStartTime + votingTermLength; + uint256 recipientCount; + + for (uint256 i = 0; i < _memberAccounts.length; i++) { + MemberRecord storage member = _members[_memberAccounts[i]]; + if ( + member.house == House.Alignment && + member.status == MemberStatus.Active && + member.joinedAt <= voteStartTime + ) { + recipientCount++; + } + } + + require(recipientCount > 0, "NAM"); + + VoteConfig storage vote = _votes[voteId]; + vote.startTime = voteStartTime; + vote.endTime = voteEndTime; + + for (uint256 i = 0; i < _memberAccounts.length; i++) { + address account = _memberAccounts[i]; + MemberRecord storage member = _members[account]; + if ( + member.house == House.Alignment && + member.status == MemberStatus.Active && + member.joinedAt <= voteStartTime + ) { + _voteRecipients[voteId].push(account); + _isVoteRecipient[voteId][account] = true; + } + } + + if (voteId > voteCount) { + voteCount = voteId; + } + + emit VoteCreated(voteId, voteStartTime, voteEndTime); + } + + function _clearMemberUnits(address account) internal { + if (!_flowSplitterConfig.poolInitialized) { + return; + } + + IFlowSplitter.Member[] + memory members = new IFlowSplitter.Member[](1); + members[0] = IFlowSplitter.Member({ account: account, units: 0 }); + IFlowSplitter(_flowSplitterConfig.splitter).updateMembersUnits( + _flowSplitterConfig.poolId, + members + ); + } + + function _getCurrentVoteWindow() + internal + view + returns (uint256 voteId, uint64 voteStartTime) + { + voteId = block.timestamp / termDuration; + voteStartTime = uint64(voteId * termDuration); + } + + function _getVoterWeight(address voter, uint64 voteStartTime) + internal + view + returns (uint256) + { + MemberRecord storage member = _members[voter]; + if ( + member.status != MemberStatus.Active || + member.joinedAt == 0 || + member.joinedAt > voteStartTime + ) { + return 0; + } + + if (member.house == House.Alignment) { + return HOUSE_ALIGNMENT_WEIGHT; + } + + if (member.house == House.Citizens) { + return HOUSE_CITIZENS_WEIGHT; + } + + return 0; + } + + function _isVotingPeriod(uint256 timestamp) internal view returns (bool) { + return timestamp % termDuration <= votingTermLength; + } + + function _goodDollar() internal view returns (IGoodDollar) { + return IGoodDollar(nameService.getAddress("GOODDOLLAR")); + } + + uint256[42] private __gap; +} diff --git a/contracts/governance/IFlowSplitter.sol b/contracts/governance/IFlowSplitter.sol new file mode 100644 index 00000000..bdb54aca --- /dev/null +++ b/contracts/governance/IFlowSplitter.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.0; + +interface ISuperToken {} + +interface ISuperfluidPool { + function updateMemberUnits(address member, uint128 units) external; + + function name() external view returns (string memory); + + function symbol() external view returns (string memory); +} + +struct PoolConfig { + bool transferabilityForUnitsOwner; + bool distributionFromAnyAddress; +} + +struct PoolERC20Metadata { + string name; + string symbol; + uint8 decimals; +} + +/// @title FlowSplitter Interface +/// @notice Interface for the Flow Splitter contract. +interface IFlowSplitter { + struct Pool { + uint256 id; + address poolAddress; + address token; + string metadata; + bytes32 adminRole; + } + + struct Member { + address account; + uint128 units; + } + + struct Admin { + address account; + AdminStatus status; + } + + enum AdminStatus { + Added, + Removed + } + + event PoolCreated( + uint256 indexed poolId, + address poolAddress, + address token, + string metadata + ); + event PoolMetadataUpdated(uint256 indexed poolId, string metadata); + + error NOT_POOL_ADMIN(); + error ZERO_ADDRESS(); + + function createPool( + ISuperToken _poolSuperToken, + PoolConfig memory _poolConfig, + PoolERC20Metadata memory _erc20Metadata, + Member[] memory _members, + address[] memory _admins, + string memory _metadata + ) external returns (ISuperfluidPool gdaPool); + + function addPoolAdmin(uint256 poolId, address admin) external; + + function removePoolAdmin(uint256 poolId, address admin) external; + + function updatePoolAdmins(uint256 poolId, Admin[] memory admins) external; + + function updateMembersUnits(uint256 poolId, Member[] memory members) external; + + function updatePoolMetadata(uint256 poolId, string memory metadata) external; + + function isPoolAdmin(uint256 poolId, address account) + external + view + returns (bool); + + function getPoolById(uint256 poolId) external view returns (Pool memory pool); + + function getPoolByAdminRole(bytes32 adminRole) + external + view + returns (Pool memory pool); + + function getPoolNameById(uint256 _poolId) + external + view + returns (string memory name); + + function getPoolSymbolById(uint256 _poolId) + external + view + returns (string memory symbol); +} diff --git a/contracts/mocks/MockFlowSplitter.sol b/contracts/mocks/MockFlowSplitter.sol new file mode 100644 index 00000000..1317371c --- /dev/null +++ b/contracts/mocks/MockFlowSplitter.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.19; + +import "../governance/IFlowSplitter.sol"; + +contract MockSuperfluidPool { + string public name; + string public symbol; + mapping(address => uint128) public memberUnits; + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + } + + function updateMemberUnits(address member, uint128 units) external { + memberUnits[member] = units; + } +} + +contract MockFlowSplitter is IFlowSplitter { + uint256 public poolCounter; + + mapping(uint256 => Pool) private _poolsById; + mapping(bytes32 => Pool) private _poolsByAdminRole; + mapping(uint256 => mapping(address => bool)) private _poolAdmins; + mapping(uint256 => address[]) private _poolAdminList; + mapping(uint256 => MockSuperfluidPool) private _poolContracts; + + modifier onlyPoolAdmin(uint256 poolId) { + if (!_poolAdmins[poolId][msg.sender]) { + revert NOT_POOL_ADMIN(); + } + _; + } + + function createPool( + ISuperToken _poolSuperToken, + PoolConfig memory, + PoolERC20Metadata memory _erc20Metadata, + Member[] memory _members, + address[] memory _admins, + string memory _metadata + ) external returns (ISuperfluidPool gdaPool) { + poolCounter++; + bytes32 adminRole = keccak256(abi.encodePacked(poolCounter, "admin")); + MockSuperfluidPool pool = new MockSuperfluidPool( + _erc20Metadata.name, + _erc20Metadata.symbol + ); + + _poolsById[poolCounter] = Pool({ + id: poolCounter, + poolAddress: address(pool), + token: address(_poolSuperToken), + metadata: _metadata, + adminRole: adminRole + }); + _poolsByAdminRole[adminRole] = _poolsById[poolCounter]; + _poolContracts[poolCounter] = pool; + + for (uint256 i = 0; i < _admins.length; i++) { + _poolAdmins[poolCounter][_admins[i]] = true; + _poolAdminList[poolCounter].push(_admins[i]); + } + + _updateMembers(poolCounter, _members); + + emit PoolCreated( + poolCounter, + address(pool), + address(_poolSuperToken), + _metadata + ); + + return ISuperfluidPool(address(pool)); + } + + function addPoolAdmin(uint256 poolId, address admin) + external + onlyPoolAdmin(poolId) + { + if (admin == address(0)) revert ZERO_ADDRESS(); + _poolAdmins[poolId][admin] = true; + _poolAdminList[poolId].push(admin); + } + + function removePoolAdmin(uint256 poolId, address admin) + external + onlyPoolAdmin(poolId) + { + _poolAdmins[poolId][admin] = false; + } + + function updatePoolAdmins(uint256 poolId, Admin[] memory admins) + external + onlyPoolAdmin(poolId) + { + for (uint256 i = 0; i < admins.length; i++) { + if (admins[i].status == AdminStatus.Added) { + _poolAdmins[poolId][admins[i].account] = true; + _poolAdminList[poolId].push(admins[i].account); + } else { + _poolAdmins[poolId][admins[i].account] = false; + } + } + } + + function updateMembersUnits(uint256 poolId, Member[] memory members) + external + onlyPoolAdmin(poolId) + { + _updateMembers(poolId, members); + } + + function updatePoolMetadata(uint256 poolId, string memory metadata) + external + onlyPoolAdmin(poolId) + { + _poolsById[poolId].metadata = metadata; + emit PoolMetadataUpdated(poolId, metadata); + } + + function isPoolAdmin(uint256 poolId, address account) + external + view + returns (bool) + { + return _poolAdmins[poolId][account]; + } + + function getPoolById(uint256 poolId) external view returns (Pool memory pool) { + return _poolsById[poolId]; + } + + function getPoolByAdminRole(bytes32 adminRole) + external + view + returns (Pool memory pool) + { + return _poolsByAdminRole[adminRole]; + } + + function getPoolNameById(uint256 poolId) + external + view + returns (string memory name) + { + return _poolContracts[poolId].name(); + } + + function getPoolSymbolById(uint256 poolId) + external + view + returns (string memory symbol) + { + return _poolContracts[poolId].symbol(); + } + + function getMemberUnits(uint256 poolId, address account) + external + view + returns (uint128) + { + return _poolContracts[poolId].memberUnits(account); + } + + function _updateMembers(uint256 poolId, Member[] memory members) internal { + for (uint256 i = 0; i < members.length; i++) { + _poolContracts[poolId].updateMemberUnits( + members[i].account, + members[i].units + ); + } + } +} diff --git a/test/governance/GoodDaoHouses.test.ts b/test/governance/GoodDaoHouses.test.ts new file mode 100644 index 00000000..425de8cb --- /dev/null +++ b/test/governance/GoodDaoHouses.test.ts @@ -0,0 +1,373 @@ +import { expect } from "chai"; +import { ethers, upgrades } from "hardhat"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; + +import { createDAO, increaseTime } from "../helpers"; + +const CITIZENS = 0; +const ALIGNMENT = 1; +const PENDING = 1; +const ACTIVE = 2; +const UNSTAKED = 4; + +describe("GoodDaoHouses", () => { + const citizensMinimumStake = 1000; + const alignmentMinimumStake = 2000; + const alignmentForumUrl = "https://forum.gooddollar.org/t/alignment-one"; + + const fixture = async () => { + const [ + admin, + committee, + citizenOne, + citizenTwo, + alignmentOne, + alignmentTwo, + lateCitizen + ] = + await ethers.getSigners(); + + const { gd, nameService } = await loadFixture(createDAO); + + const goodDollar = await ethers.getContractAt("IGoodDollar", gd); + const flowSplitter = await ethers.deployContract("MockFlowSplitter"); + const houses = await upgrades.deployProxy( + await ethers.getContractFactory("GoodDaoHouses"), + [ + nameService.address, + admin.address, + committee.address, + citizensMinimumStake, + alignmentMinimumStake + ], + { kind: "uups" } + ); + + return { + admin, + committee, + citizenOne, + citizenTwo, + alignmentOne, + alignmentTwo, + lateCitizen, + goodDollar, + flowSplitter, + houses + }; + }; + + const registerViaTransferAndCall = async ( + goodDollar, + houses, + signer, + house, + amount, + details + ) => { + const data = ethers.utils.defaultAbiCoder.encode( + ["uint8", "string", "string", "string", "string", "string"], + [ + house, + details.name, + details.socialLinks ?? "", + details.projectWebpage ?? "", + details.missionStatement ?? "", + details.distributionStrategy ?? "" + ] + ); + + await goodDollar.mint(signer.address, amount); + await goodDollar.connect(signer).transferAndCall(houses.address, amount, data); + }; + + const registerCitizen = async (goodDollar, houses, signer, name = "citizen") => + registerViaTransferAndCall(goodDollar, houses, signer, CITIZENS, citizensMinimumStake, { + name, + socialLinks: "https://social.example/" + name + }); + + const registerAlignment = async ( + goodDollar, + houses, + signer, + name, + distributionStrategy = alignmentForumUrl + ) => + registerViaTransferAndCall( + goodDollar, + houses, + signer, + ALIGNMENT, + alignmentMinimumStake, + { + name, + projectWebpage: `https://${name}.example`, + missionStatement: `${name} mission`, + distributionStrategy + } + ); + + const moveToNextVotingWindow = async houses => { + const latestBlock = await ethers.provider.getBlock("latest"); + const termDuration = (await houses.termDuration()).toNumber(); + const offset = latestBlock.timestamp % termDuration; + const delta = offset === 0 ? 1 : termDuration - offset + 1; + + await increaseTime(delta); + + return houses.getCurrentVoteId(); + }; + + const movePastVotingWindow = async houses => { + const latestBlock = await ethers.provider.getBlock("latest"); + const termDuration = (await houses.termDuration()).toNumber(); + const votingTermLength = (await houses.votingTermLength()).toNumber(); + const offset = latestBlock.timestamp % termDuration; + + if (offset <= votingTermLength) { + await increaseTime(votingTermLength - offset + 1); + } + }; + + it("writes house fields on chain and approves alignment members after eligibility", async () => { + const { committee, citizenOne, alignmentOne, goodDollar, houses } = + await loadFixture(fixture); + + await houses + .connect(committee) + .setAlignmentEligibility(alignmentOne.address, true); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerAlignment( + goodDollar, + houses, + alignmentOne, + "alignment-one", + alignmentForumUrl + ); + + const citizenMember = await houses.getMember(citizenOne.address); + const alignmentMemberBeforeApproval = await houses.getMember( + alignmentOne.address + ); + + expect(citizenMember.status).to.equal(ACTIVE); + expect(citizenMember.name).to.equal("citizen-one"); + expect(citizenMember.socialLinks).to.equal( + "https://social.example/citizen-one" + ); + expect(alignmentMemberBeforeApproval.status).to.equal(PENDING); + expect(alignmentMemberBeforeApproval.projectWebpage).to.equal( + "https://alignment-one.example" + ); + expect(alignmentMemberBeforeApproval.missionStatement).to.equal( + "alignment-one mission" + ); + expect(alignmentMemberBeforeApproval.distributionStrategy).to.equal( + alignmentForumUrl + ); + + await houses.connect(committee).approveAlignmentMember(alignmentOne.address); + + const alignmentMemberAfterApproval = await houses.getMember( + alignmentOne.address + ); + const activeAlignmentMembers = await houses.getActiveMembers(ALIGNMENT); + + expect(alignmentMemberAfterApproval.status).to.equal(ACTIVE); + expect(activeAlignmentMembers).to.deep.equal([alignmentOne.address]); + }); + + it("creates the term vote on first ballot, blocks late joiners, and stores direct weighted units", async () => { + const { + committee, + citizenOne, + citizenTwo, + alignmentOne, + alignmentTwo, + lateCitizen, + goodDollar, + houses + } = await loadFixture(fixture); + + await houses + .connect(committee) + .setAlignmentEligibility(alignmentOne.address, true); + await houses + .connect(committee) + .setAlignmentEligibility(alignmentTwo.address, true); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerCitizen(goodDollar, houses, citizenTwo, "citizen-two"); + await registerAlignment(goodDollar, houses, alignmentOne, "alignment-one"); + await registerAlignment(goodDollar, houses, alignmentTwo, "alignment-two"); + + await houses.connect(committee).approveAlignmentMember(alignmentOne.address); + await houses.connect(committee).approveAlignmentMember(alignmentTwo.address); + + const voteId = await moveToNextVotingWindow(houses); + + const [alignmentVoters, citizensVoters] = await houses.getVoteVoters(voteId); + + expect(alignmentVoters).to.deep.equal([]); + expect(citizensVoters).to.deep.equal([]); + + await houses + .connect(alignmentOne) + .castVote([alignmentOne.address], [10000]); + await houses + .connect(citizenOne) + .castVote([alignmentTwo.address], [10000]); + await houses + .connect(citizenTwo) + .castVote([alignmentTwo.address], [10000]); + + await houses + .connect(alignmentTwo) + .castVote([alignmentTwo.address], [10000]); + await houses + .connect(alignmentTwo) + .castVote([alignmentOne.address], [10000]); + + const createdVoteId = await houses.getCurrentVoteId(); + const vote = await houses.getVote(createdVoteId); + const [createdAlignmentVoters, createdCitizensVoters] = + await houses.getVoteVoters(createdVoteId); + + expect(createdVoteId).to.equal(voteId); + expect(createdAlignmentVoters).to.deep.equal([ + alignmentOne.address, + alignmentTwo.address + ]); + expect(createdCitizensVoters).to.deep.equal([ + citizenOne.address, + citizenTwo.address + ]); + expect(vote.startTime).to.equal( + createdVoteId.mul(await houses.termDuration()) + ); + + await registerCitizen(goodDollar, houses, lateCitizen, "late-citizen"); + + await expect( + houses.connect(lateCitizen).castVote([alignmentOne.address], [10000]) + ).to.be.revertedWith("VE"); + + const [ballotRecipients, ballotAllocations] = await houses.getBallot( + createdVoteId, + alignmentTwo.address + ); + + expect(ballotRecipients).to.deep.equal([alignmentOne.address]); + expect(ballotAllocations[0]).to.equal(10000); + + expect( + await houses.getFinalizedUnits(createdVoteId, alignmentOne.address) + ).to.equal( + 80 + ); + expect( + await houses.getFinalizedUnits(createdVoteId, alignmentTwo.address) + ).to.equal( + 8 + ); + }); + + it("creates the flow splitter pool once, updates units on later votes, and zeroes units on unstake", async () => { + const { + committee, + citizenOne, + citizenTwo, + alignmentOne, + alignmentTwo, + goodDollar, + flowSplitter, + houses + } = await loadFixture(fixture); + + await houses + .connect(committee) + .setAlignmentEligibility(alignmentOne.address, true); + await houses + .connect(committee) + .setAlignmentEligibility(alignmentTwo.address, true); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerCitizen(goodDollar, houses, citizenTwo, "citizen-two"); + await registerAlignment(goodDollar, houses, alignmentOne, "alignment-one"); + await registerAlignment(goodDollar, houses, alignmentTwo, "alignment-two"); + + await houses.connect(committee).approveAlignmentMember(alignmentOne.address); + await houses.connect(committee).approveAlignmentMember(alignmentTwo.address); + + await houses.connect(committee).configureFlowSplitter( + flowSplitter.address, + goodDollar.address, + "GoodDao Houses pool", + "GoodDao Houses", + "GDH", + 18, + false, + false + ); + + let voteId = await moveToNextVotingWindow(houses); + + await houses + .connect(alignmentOne) + .castVote([alignmentOne.address], [10000]); + await houses + .connect(alignmentTwo) + .castVote([alignmentOne.address], [10000]); + await houses + .connect(citizenOne) + .castVote([alignmentOne.address], [10000]); + await houses + .connect(citizenTwo) + .castVote([alignmentOne.address], [10000]); + + await movePastVotingWindow(houses); + await houses.connect(committee).executeVote(voteId); + + let flowConfig = await houses.getFlowSplitterConfig(); + + expect(flowConfig.poolInitialized).to.equal(true); + expect(flowConfig.poolId).to.equal(1); + expect(await flowSplitter.getMemberUnits(1, alignmentOne.address)).to.equal( + 88 + ); + expect(await flowSplitter.getMemberUnits(1, alignmentTwo.address)).to.equal(0); + + voteId = await moveToNextVotingWindow(houses); + + await houses + .connect(alignmentOne) + .castVote([alignmentTwo.address], [10000]); + await houses + .connect(alignmentTwo) + .castVote([alignmentTwo.address], [10000]); + await houses + .connect(citizenOne) + .castVote([alignmentTwo.address], [10000]); + await houses + .connect(citizenTwo) + .castVote([alignmentTwo.address], [10000]); + + await movePastVotingWindow(houses); + await houses.connect(committee).executeVote(voteId); + + flowConfig = await houses.getFlowSplitterConfig(); + expect(flowConfig.poolId).to.equal(1); + expect(await flowSplitter.getMemberUnits(1, alignmentOne.address)).to.equal(0); + expect(await flowSplitter.getMemberUnits(1, alignmentTwo.address)).to.equal( + 88 + ); + + await houses.connect(alignmentTwo).unstake(); + + const alignmentMember = await houses.getMember(alignmentTwo.address); + expect(alignmentMember.status).to.equal(UNSTAKED); + expect(await flowSplitter.getMemberUnits(1, alignmentTwo.address)).to.equal(0); + }); +});