diff --git a/contracts/governance/GoodDaoHouses.sol b/contracts/governance/GoodDaoHouses.sol new file mode 100644 index 00000000..8244faeb --- /dev/null +++ b/contracts/governance/GoodDaoHouses.sol @@ -0,0 +1,805 @@ +// 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"; + +contract GoodDaoHouses is + AccessControlUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable, + DAOUpgradeableContract, + ERC677Receiver +{ + bytes32 public constant GOVERNANCE_COMMITTEE_ROLE = + keccak256("GOVERNANCE_COMMITTEE_ROLE"); + + uint256 public constant BASIS_POINTS = 10000; + + // multiply by BASIS_POINTS to get the weight in basis points for each house so a 1 basis-point vote is non-zero + uint256 public constant HOUSE_ALIGNMENT_WEIGHT = 40 * BASIS_POINTS; + uint256 public constant HOUSE_CITIZENS_WEIGHT = 4 * BASIS_POINTS; + 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; + uint256 memberIndex; + string name; + string socialLinks; + string projectWebpage; + string missionStatement; + string distributionStrategy; + } + + struct VoteConfig { + uint64 startTime; + uint64 endTime; + uint64 executedAt; + bool executed; + } + + struct FlowSplitterConfig { + address splitter; + uint256 poolId; + address poolAddress; + } + + mapping(address => MemberRecord) private members; + mapping(House => uint256) public minimumStake; + address[] private hoaMembers; + address[] private hocMembers; + uint64 public cycleStartTime; + 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)) + internal voteRecipientWeightedVotes; + mapping(uint256 => mapping(address => bool)) private hasVoted; + + FlowSplitterConfig public flowSplitterConfig; + + uint256[50] private __gap; + + event StakeRequirementSet(House indexed house, uint256 amount); + 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, + address[] recipients + ); + event VoteCast( + uint256 indexed voteId, + address indexed voter, + address[] recipients, + uint256[] allocations + ); + event VoteExecuted( + uint256 indexed voteId, + uint256 poolId, + address poolAddress, + address[] recipients, + uint128[] weights + ); + event FlowSplitterConfigured( + address indexed splitter, + uint256 indexed poolId, + address poolAddress + ); + event VotingScheduleUpdated( + uint64 cycleStartTime, + uint64 termDuration, + uint64 votingTermLength + ); + + modifier onlyAdminOrCommittee() { + require( + hasRole(DEFAULT_ADMIN_ROLE, msg.sender) || + hasRole(GOVERNANCE_COMMITTEE_ROLE, msg.sender), + "Not admin/committee" + ); + _; + } + + /// @notice Initializes the governance houses contract and role assignments. + /// @param _ns NameService registry used to resolve protocol contract addresses. + /// @param admin Address receiving the default admin role. + /// @param committee Address receiving the governance committee role. + /// @param citizensMinimumStake Minimum G$ stake required for Citizens members. + /// @param alignmentMinimumStake Minimum G$ stake required for Alignment members. + 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; + cycleStartTime = uint64(block.timestamp); + termDuration = DEFAULT_TERM_DURATION; + votingTermLength = DEFAULT_VOTING_TERM_LENGTH; + + emit StakeRequirementSet(House.Citizens, citizensMinimumStake); + emit StakeRequirementSet(House.Alignment, alignmentMinimumStake); + emit VotingScheduleUpdated(cycleStartTime, termDuration, votingTermLength); + } + + /// @notice Updates the minimum stake required for a house. + /// @param house House whose threshold is being updated. + /// @param amount New minimum stake amount in G$. + function setStakeRequirement( + House house, + uint256 amount + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) { + minimumStake[house] = amount; + emit StakeRequirementSet(house, amount); + } + + /// @notice Updates the recurring voting schedule parameters. + /// @param newCycleStartTime Start timestamp used as the cycle anchor. + /// @param newTermDuration Duration of a term in seconds. + /// @param newVotingTermLength Duration of the voting window in each term. + function setVotingSchedule( + uint64 newCycleStartTime, + uint64 newTermDuration, + uint64 newVotingTermLength + ) external onlyAdminOrCommittee { + require(newTermDuration > 0, "Term=0"); + require(newVotingTermLength > 0, "Vote term=0"); + require(newVotingTermLength <= newTermDuration, "Vote term > term"); + + cycleStartTime = newCycleStartTime; + termDuration = newTermDuration; + votingTermLength = newVotingTermLength; + + emit VotingScheduleUpdated(cycleStartTime, termDuration, votingTermLength); + } + + /// @notice Registers the caller in a house and tops up stake to the minimum if needed. + /// @param house Target house for membership. + /// @param name Display name persisted for the member profile. + /// @param socialLinks Social links metadata for the member profile. + /// @param projectWebpage Project webpage metadata for the member profile. + /// @param missionStatement Mission statement metadata for the member profile. + /// @param distributionStrategy Distribution strategy metadata for the member profile. + function registerAndStake( + House house, + string calldata name, + string calldata socialLinks, + string calldata projectWebpage, + string calldata missionStatement, + string calldata distributionStrategy + ) external whenNotPaused { + // Collect only the missing delta between current stake and the house minimum. + int256 delta = int256(minimumStake[house]) - + int256(members[msg.sender].stakedAmount); + uint256 transferAmount = 0; + if (delta > 0) { + transferAmount = uint256(delta); + require( + _goodDollar().transferFrom(msg.sender, address(this), transferAmount), + "G$ transferFrom" + ); + } + + _registerMember( + msg.sender, + house, + transferAmount, + name, + socialLinks, + projectWebpage, + missionStatement, + distributionStrategy + ); + } + + /// @notice Adds stake to an existing member account. + /// @param amount Additional G$ amount to stake. + function stake(uint256 amount) external whenNotPaused { + require( + _goodDollar().transferFrom(msg.sender, address(this), amount), + "G$ transferFrom" + ); + _addStake(msg.sender, amount); + } + + /// @notice Handles ERC677 transfers to stake or register members. + /// @dev Empty `_data` stakes for an existing member; otherwise payload is decoded for registration. + /// @param _from Original token sender. + /// @param _amount Amount transferred. + /// @param _data Encoded registration payload or empty bytes for plain staking. + /// @return success True when transfer handling succeeds. + function onTokenTransfer( + address _from, + uint256 _amount, + bytes calldata _data + ) external override whenNotPaused returns (bool success) { + require(msg.sender == address(_goodDollar()), "Only G$"); + + 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; + } + + /// @notice Approves a pending Alignment member once minimum stake is satisfied. + /// @param account Member address to approve. + function approveAlignmentMember( + address account + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) whenNotPaused { + require(members[account].house == House.Alignment, "Not Alignment"); + require(members[account].status == MemberStatus.Pending, "Not pending"); + require( + members[account].stakedAmount >= minimumStake[House.Alignment], + "Stake < Alignment min" + ); + + members[account].status = MemberStatus.Active; + members[account].updatedAt = uint64(block.timestamp); + + emit MemberApproved(account, members[account].house); + } + + /// @notice Revokes an existing member. + /// @param account Member address to revoke. + function revokeMember( + address account + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) whenNotPaused { + require(members[account].status != MemberStatus.None, "Not a member"); + members[account].status = MemberStatus.Revoked; + members[account].updatedAt = uint64(block.timestamp); + + if (members[account].house == House.Alignment) { + _clearMemberUnits(account); + } + emit MemberRevoked(account, members[account].house); + } + + /// @notice Removes caller membership and returns all staked G$ after lock period. + function unstake() external nonReentrant whenNotPaused { + uint256 amount = members[msg.sender].stakedAmount; + House house = members[msg.sender].house; + uint memberIndex = members[msg.sender].memberIndex; + // Require at least one full term since the last member update. + require( + block.timestamp >= members[msg.sender].updatedAt + termDuration, + "Term not passed" + ); + require(amount > 0, "No stake"); + // Remove member with swap-and-pop and rewrite moved member index. + if (house == House.Alignment) { + require(hoaMembers[memberIndex] == msg.sender, "Bad member index"); + hoaMembers[memberIndex] = hoaMembers[hoaMembers.length - 1]; + members[hoaMembers[memberIndex]].memberIndex = memberIndex; + hoaMembers.pop(); + } else if (house == House.Citizens) { + require(hocMembers[memberIndex] == msg.sender, "Bad member index"); + hocMembers[memberIndex] = hocMembers[hocMembers.length - 1]; + members[hocMembers[memberIndex]].memberIndex = memberIndex; + hocMembers.pop(); + } + + delete members[msg.sender]; + + if (house == House.Alignment) { + _clearMemberUnits(msg.sender); + } + + require(_goodDollar().transfer(msg.sender, amount), "G$ transfer"); + + emit MemberUnstaked(msg.sender, house, amount); + } + + // Returns the whitelisted identity root for a citizen account. + function _getWhitelistedRoot( + address account + ) internal view returns (address) { + IIdentityV2 identity = IIdentityV2(nameService.getAddress("IDENTITY")); + return identity.getWhitelistedRoot(account); + } + + /// @notice Casts a weighted allocation vote in the current voting window. + /// @param recipients Alignment member recipients included in this ballot. + /// @param allocations Basis-point allocations per recipient, summing to 10,000. + function castVote( + address[] calldata recipients, + uint256[] calldata allocations + ) external whenNotPaused { + (uint256 voteId, uint64 voteStartTime) = _getCurrentVoteWindow(); + uint256 voterWeight = _getVoterWeight(msg.sender, voteStartTime); + + // Citizens vote by root identity so one identity can vote only once. + House house = members[msg.sender].house; + address voterRoot = house == House.Citizens + ? _getWhitelistedRoot(msg.sender) + : msg.sender; + + require( + members[msg.sender].stakedAmount >= minimumStake[house], + "Stake < house min" + ); + require( + house == House.Alignment || voterRoot != address(0), + "Citizen not whitelisted" + ); + require(isVotingPeriod(), "Voting closed"); + require(voterWeight > 0, "Not eligible"); + + if (votes[voteId].startTime == 0) { + _createAlignmentVote(voteId, voteStartTime); + } + + require(recipients.length == allocations.length, "Length mismatch"); + require(recipients.length > 0, "No recipients"); + + require(!hasVoted[voteId][voterRoot], "Already voted"); + + uint256 allocationTotal; + for (uint256 i = 0; i < recipients.length; i++) { + require(isVoteRecipient[voteId][recipients[i]], "Invalid recipient"); + allocationTotal += allocations[i]; + } + // Enforce exact basis-point budget per ballot. + require(allocationTotal == BASIS_POINTS, "Alloc != 10000"); + + hasVoted[voteId][voterRoot] = true; + + // Accumulate weighted recipient totals for vote execution. + for (uint256 i = 0; i < recipients.length; i++) { + voteRecipientWeightedVotes[voteId][recipients[i]] += + (allocations[i] * voterWeight) / + BASIS_POINTS; + } + + emit VoteCast(voteId, msg.sender, recipients, allocations); + } + + /// @notice Finalizes a completed vote and updates FlowSplitter units. + /// @param voteId Vote identifier to execute. + function executeVote( + uint256 voteId + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) whenNotPaused { + VoteConfig storage vote = votes[voteId]; + FlowSplitterConfig memory flowConfig = flowSplitterConfig; + address[] memory recipients = voteRecipients[voteId]; + uint256 count = recipients.length; + IFlowSplitter.Member[] memory flowMembers = new IFlowSplitter.Member[]( + count + ); + uint128[] memory weights = new uint128[](count); + + require(vote.startTime > 0, "Vote missing"); + require(block.timestamp > vote.endTime, "Vote still open"); + require(!vote.executed, "Vote executed"); + + // Translate finalized weighted votes into FlowSplitter unit updates. + for (uint256 i = 0; i < count; i++) { + address recipient = recipients[i]; + uint256 raw = voteRecipientWeightedVotes[voteId][recipient]; + require(raw <= type(uint128).max, "Units overflow"); + uint128 units = uint128(raw); + flowMembers[i] = IFlowSplitter.Member({ + account: recipient, + units: units + }); + weights[i] = units; + } + + IFlowSplitter(flowConfig.splitter).updateMembersUnits( + flowConfig.poolId, + flowMembers + ); + + // Persist execution status to prevent re-execution. + vote.executed = true; + vote.executedAt = uint64(block.timestamp); + + emit VoteExecuted( + voteId, + flowConfig.poolId, + flowConfig.poolAddress, + recipients, + weights + ); + } + + /// @notice Configures the FlowSplitter pool used for distributing vote outcomes. + /// @param splitter FlowSplitter contract address. + /// @param poolId Pool identifier managed by this contract. + function configureFlowSplitter( + address splitter, + uint256 poolId + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) { + require(splitter != address(0), "Splitter=0"); + require(poolId > 0, "PoolId=0"); + + IFlowSplitter.Pool memory pool = IFlowSplitter(splitter).getPoolById( + poolId + ); + require(pool.poolAddress != address(0), "Pool missing"); + require( + IFlowSplitter(splitter).isPoolAdmin(poolId, address(this)), + "Not pool admin" + ); + + flowSplitterConfig.splitter = splitter; + flowSplitterConfig.poolId = pool.id; + flowSplitterConfig.poolAddress = pool.poolAddress; + + emit FlowSplitterConfigured(splitter, pool.id, pool.poolAddress); + } + + /// @notice Pauses staking, voting and committee actions guarded by `whenNotPaused`. + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _pause(); + } + + /// @notice Unpauses contract operations guarded by `whenNotPaused`. + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + + /// @notice Returns the membership record for an account. + /// @param account Member account to query. + /// @return Member metadata and staking status. + function getMember( + address account + ) external view returns (MemberRecord memory) { + return members[account]; + } + + /// @notice Returns active members in a paginated range for a house. + /// @param house House to query. + /// @param startIndex Inclusive start index in the house member list. + /// @param endIndex Exclusive end index in the house member list. + /// @return Active member accounts in the requested range. + function getActiveMembers( + House house, + uint256 startIndex, + uint256 endIndex + ) public view returns (address[] memory) { + address[] memory memberAccounts = house == House.Alignment + ? hoaMembers + : hocMembers; + + // Clamp end index to array length to avoid out-of-bounds reads. + endIndex = endIndex > memberAccounts.length + ? memberAccounts.length + : endIndex; + + // First pass counts active members to pre-size the return array. + uint256 activeCount; + for (uint256 i = startIndex; i < endIndex; i++) { + if (members[memberAccounts[i]].status == MemberStatus.Active) { + activeCount++; + } + } + + // Second pass writes active members into the exact-sized array. + address[] memory activeMembers = new address[](activeCount); + uint256 index; + for (uint256 i = startIndex; i < endIndex; i++) { + address account = memberAccounts[i]; + if (members[account].status == MemberStatus.Active) { + activeMembers[index] = account; + index++; + } + } + + return activeMembers; + } + + /// @notice Returns all active members for a house. + /// @param house House to query. + /// @return Active member accounts for the entire house. + function getActiveMembers( + House house + ) external view returns (address[] memory) { + return + getActiveMembers( + house, + 0, + house == House.Alignment ? hoaMembers.length : hocMembers.length + ); + } + + /// @notice Returns timing and execution metadata for a vote. + /// @param voteId Vote identifier. + /// @return Vote configuration data. + function getVoteConfig( + uint256 voteId + ) external view returns (VoteConfig memory) { + return votes[voteId]; + } + + /// @notice Returns the recipient set for a vote. + /// @param voteId Vote identifier. + /// @return Recipient addresses snapshot used by the vote. + function getVoteRecipients( + uint256 voteId + ) external view returns (address[] memory) { + return voteRecipients[voteId]; + } + + /// @notice Returns whether a voter identity has already voted in a vote. + /// @param voteId Vote identifier. + /// @param voter Voter identity key (address or citizen root). + /// @return True if already voted. + function getHasVoted( + uint256 voteId, + address voter + ) external view returns (bool) { + return hasVoted[voteId][voter]; + } + + /// @notice Returns finalized weighted units for a vote recipient. + /// @param voteId Vote identifier. + /// @param recipient Recipient address. + /// @return Final weighted units assigned to recipient. + function getFinalizedUnits( + uint256 voteId, + address recipient + ) external view returns (uint128) { + return uint128(voteRecipientWeightedVotes[voteId][recipient]); + } + + /// @notice Returns the current vote id derived from the cycle schedule. + /// @return Current vote identifier. + function getCurrentVoteId() external view returns (uint256) { + (uint256 voteId, ) = _getCurrentVoteWindow(); + return voteId; + } + + // Registers or updates membership and persists profile metadata. + function _registerMember( + address account, + House house, + uint256 amount, + string memory name, + string memory socialLinks, + string memory projectWebpage, + string memory missionStatement, + string memory distributionStrategy + ) internal { + require(uint8(house) <= uint8(House.Alignment), "Invalid house"); + bool isNewMember = members[account].status == MemberStatus.None; + uint64 joinedAt = isNewMember + ? uint64(block.timestamp) + : members[account].joinedAt; + + uint totalStake = members[account].stakedAmount + amount; + require(totalStake >= minimumStake[house], "Stake < house min"); + require( + isNewMember || members[account].house == house, + "Cannot switch houses" + ); + + uint memberIndex = members[account].memberIndex; + if (house == House.Alignment && isNewMember) { + hoaMembers.push(account); + memberIndex = hoaMembers.length - 1; + } else if (house == House.Citizens && isNewMember) { + hocMembers.push(account); + memberIndex = hocMembers.length - 1; + } + MemberStatus status = isNewMember + ? house == House.Alignment ? MemberStatus.Pending : MemberStatus.Active + : members[account].status; + + members[account] = MemberRecord({ + house: house, + // Keep existing status on update; initialize by house-specific default. + status: status, + stakedAmount: totalStake, + joinedAt: joinedAt, + updatedAt: uint64(block.timestamp), + unstakedAt: 0, + memberIndex: memberIndex, + name: name, + socialLinks: socialLinks, + projectWebpage: projectWebpage, + missionStatement: missionStatement, + distributionStrategy: distributionStrategy + }); + + emit MemberRegistered(account, house, status, totalStake); + } + + // Adds stake to an existing member and refreshes activity timestamp. + function _addStake(address account, uint256 amount) internal { + require(members[account].status != MemberStatus.None, "not member"); + + members[account].stakedAmount += amount; + members[account].updatedAt = uint64(block.timestamp); + + emit MemberStaked(account, members[account].house, amount); + } + + // Creates vote state and snapshots eligible Alignment recipients at vote start. + function _createAlignmentVote(uint256 voteId, uint64 voteStartTime) internal { + uint64 voteEndTime = voteStartTime + votingTermLength; + + votes[voteId].startTime = voteStartTime; + votes[voteId].endTime = voteEndTime; + + for (uint256 i = 0; i < hoaMembers.length; i++) { + address account = hoaMembers[i]; + if ( + members[account].status == MemberStatus.Active && + members[account].joinedAt <= voteStartTime + ) { + voteRecipients[voteId].push(account); + isVoteRecipient[voteId][account] = true; + } + } + + require(voteRecipients[voteId].length > 0, "No Alignment recipients"); + + if (voteId > voteCount) { + voteCount = voteId; + } + + emit VoteCreated( + voteId, + voteStartTime, + voteEndTime, + voteRecipients[voteId] + ); + } + + // Clears a member's flow units when splitter configuration is available. + function _clearMemberUnits(address account) internal { + if ( + flowSplitterConfig.splitter == address(0) || + flowSplitterConfig.poolId == 0 + ) { + return; + } + + IFlowSplitter.Member[] memory flowmembers = new IFlowSplitter.Member[](1); + flowmembers[0] = IFlowSplitter.Member({ account: account, units: 0 }); + IFlowSplitter(flowSplitterConfig.splitter).updateMembersUnits( + flowSplitterConfig.poolId, + flowmembers + ); + } + + // Computes current vote id and aligned term start from cycle schedule. + function _getCurrentVoteWindow() + internal + view + returns (uint256 voteId, uint64 voteStartTime) + { + if (block.timestamp < cycleStartTime) { + return (0, cycleStartTime); + } + + uint256 elapsed = block.timestamp - cycleStartTime; + voteId = elapsed / termDuration; + voteStartTime = cycleStartTime + uint64(voteId * termDuration); + } + + // Returns voting weight if member was active before the vote window opened. + function _getVoterWeight( + address voter, + uint64 voteStartTime + ) internal view returns (uint256) { + MemberRecord memory 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; + } + + /// @notice Returns whether the current timestamp is inside the voting window. + /// @return True when timestamp falls within the modulo voting segment of the term. + function isVotingPeriod() public view returns (bool) { + uint timestamp = block.timestamp; + if (timestamp < cycleStartTime) { + return false; + } + + // Voting is open for the first `votingTermLength` seconds of each term cycle. + return ((timestamp - cycleStartTime) % termDuration) <= votingTermLength; + } + + // Resolves the configured GoodDollar token from NameService. + function _goodDollar() internal view returns (IGoodDollar) { + return IGoodDollar(nameService.getAddress("GOODDOLLAR")); + } +} diff --git a/contracts/governance/IFlowSplitter.sol b/contracts/governance/IFlowSplitter.sol new file mode 100644 index 00000000..85163ba0 --- /dev/null +++ b/contracts/governance/IFlowSplitter.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.4; + +import "../token/superfluid/ISuperToken.sol"; + +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/GoodDaoHousesHarness.sol b/contracts/mocks/GoodDaoHousesHarness.sol new file mode 100644 index 00000000..85cfe368 --- /dev/null +++ b/contracts/mocks/GoodDaoHousesHarness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.19; + +import "../governance/GoodDaoHouses.sol"; + +/// @dev Test-only harness that exposes a setter for voteRecipientWeightedVotes. +/// Never deploy this contract in production. +contract GoodDaoHousesHarness is GoodDaoHouses { + /// @notice Overwrite a recipient's accumulated vote weight for a given voteId. + /// Callable only by the governance committee; used in tests to inject + /// values that cannot be reached via normal voting (e.g. > type(uint128).max). + function setVoteWeightForTest( + uint256 voteId, + address recipient, + uint256 amount + ) external onlyRole(GOVERNANCE_COMMITTEE_ROLE) { + voteRecipientWeightedVotes[voteId][recipient] = amount; + } +} 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..4b17ffdb --- /dev/null +++ b/test/governance/GoodDaoHouses.test.ts @@ -0,0 +1,704 @@ +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 REVOKED = 3; + +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, stranger] = + await ethers.getSigners(); + + const { gd, nameService, addWhitelisted } = 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, + stranger, + goodDollar, + flowSplitter, + houses, + addWhitelisted + }; + }; + + 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 cycleStartTime = (await houses.cycleStartTime()).toNumber(); + const termDuration = (await houses.termDuration()).toNumber(); + const delta = + latestBlock.timestamp < cycleStartTime + ? cycleStartTime - latestBlock.timestamp + : (() => { + const offset = (latestBlock.timestamp - cycleStartTime) % termDuration; + return offset === 0 ? 1 : termDuration - offset + 1; + })(); + + await increaseTime(delta); + + return houses.getCurrentVoteId(); + }; + + const movePastVotingWindow = async houses => { + const latestBlock = await ethers.provider.getBlock("latest"); + const cycleStartTime = (await houses.cycleStartTime()).toNumber(); + const termDuration = (await houses.termDuration()).toNumber(); + const votingTermLength = (await houses.votingTermLength()).toNumber(); + const offset = latestBlock.timestamp < cycleStartTime ? 0 : (latestBlock.timestamp - cycleStartTime) % termDuration; + + if (offset <= votingTermLength) { + await increaseTime(votingTermLength - offset + 1); + } + }; + + const createManagedFlowSplitterPool = async (flowSplitter, goodDollar, houses) => { + await flowSplitter.createPool( + goodDollar.address, + { + transferabilityForUnitsOwner: false, + distributionFromAnyAddress: false + }, + { + name: "GoodDao Houses", + symbol: "GDH", + decimals: 18 + }, + [], + [houses.address], + "GoodDao Houses pool" + ); + + return flowSplitter.poolCounter(); + }; + + it("writes house fields on chain and approves alignment members", async () => { + const { committee, citizenOne, alignmentOne, goodDollar, houses } = await loadFixture(fixture); + + 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(uint8)"](ALIGNMENT); + + expect(alignmentMemberAfterApproval.status).to.equal(ACTIVE); + expect(activeAlignmentMembers).to.deep.equal([alignmentOne.address]); + }); + + it("lets admin or committee set the voting schedule anchor and term lengths", async () => { + const { admin, committee, houses } = await loadFixture(fixture); + const latestBlock = await ethers.provider.getBlock("latest"); + const nextCycleStart = latestBlock.timestamp + 3 * 24 * 60 * 60; + + await houses.connect(committee).setVotingSchedule(nextCycleStart, 120 * 24 * 60 * 60, 10 * 24 * 60 * 60); + + expect(await houses.cycleStartTime()).to.equal(nextCycleStart); + expect(await houses.termDuration()).to.equal(120 * 24 * 60 * 60); + expect(await houses.votingTermLength()).to.equal(10 * 24 * 60 * 60); + + const updatedCycleStart = nextCycleStart + 24 * 60 * 60; + await houses.connect(admin).setVotingSchedule(updatedCycleStart, 90 * 24 * 60 * 60, 7 * 24 * 60 * 60); + + expect(await houses.cycleStartTime()).to.equal(updatedCycleStart); + expect(await houses.termDuration()).to.equal(90 * 24 * 60 * 60); + expect(await houses.votingTermLength()).to.equal(7 * 24 * 60 * 60); + }); + + 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, + addWhitelisted + } = await loadFixture(fixture); + + await addWhitelisted(citizenOne.address, "did:gooddollar:citizen-one"); + await addWhitelisted(citizenTwo.address, "did:gooddollar:citizen-two"); + await addWhitelisted(lateCitizen.address, "did:gooddollar:late-citizen"); + + 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); + + expect(await houses.getVoteRecipients(voteId)).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]); + + const createdVoteId = await houses.getCurrentVoteId(); + const vote = await houses.getVoteConfig(createdVoteId); + const recipients = await houses.getVoteRecipients(createdVoteId); + + expect(createdVoteId).to.equal(voteId); + expect(recipients).to.deep.equal([alignmentOne.address, alignmentTwo.address]); + expect(vote.startTime).to.equal( + (await houses.cycleStartTime()).add(createdVoteId.mul(await houses.termDuration())) + ); + + await registerCitizen(goodDollar, houses, lateCitizen, "late-citizen"); + + await expect(houses.connect(lateCitizen).castVote([alignmentOne.address], [10000])).to.be.revertedWith( + "Not eligible" + ); + + expect(await houses.getFinalizedUnits(createdVoteId, alignmentOne.address)).to.equal(40 * 1e4); + expect(await houses.getFinalizedUnits(createdVoteId, alignmentTwo.address)).to.equal(48 * 1e4); + }); + + it("updates units on the managed flow splitter pool and zeroes units on unstake", async () => { + const { + committee, + citizenOne, + citizenTwo, + alignmentOne, + alignmentTwo, + goodDollar, + flowSplitter, + houses, + addWhitelisted + } = await loadFixture(fixture); + + await addWhitelisted(citizenOne.address, "did:gooddollar:citizen-onex"); + await addWhitelisted(citizenTwo.address, "did:gooddollar:citizen-twox"); + + 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); + + // Keep term windows short so identity whitelisting does not expire during this test. + const latestBlock = await ethers.provider.getBlock("latest"); + await houses.connect(committee).setVotingSchedule(latestBlock.timestamp + 10, 24 * 60 * 60, 12 * 60 * 60); + + const poolId = await createManagedFlowSplitterPool(flowSplitter, goodDollar, houses); + + await houses.connect(committee).configureFlowSplitter(flowSplitter.address, poolId); + + 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.flowSplitterConfig(); + + expect(flowConfig.poolId).to.equal(1); + expect(await flowSplitter.getMemberUnits(1, alignmentOne.address)).to.equal(88 * 1e4); + 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.flowSplitterConfig(); + 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 * 1e4); + + const termDuration = await houses.termDuration(); + await increaseTime(termDuration.toNumber()); + await houses.connect(alignmentTwo).unstake(); + + const alignmentMember = await houses.getMember(alignmentTwo.address); + expect(alignmentMember.status).to.equal(0); + expect(await flowSplitter.getMemberUnits(1, alignmentTwo.address)).to.equal(0); + }); + + it("prevents voting twice in the same term", async () => { + const { committee, alignmentOne, alignmentTwo, goodDollar, houses } = await loadFixture(fixture); + + 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 moveToNextVotingWindow(houses); + + await houses.connect(alignmentOne).castVote([alignmentOne.address], [10000]); + + await expect(houses.connect(alignmentOne).castVote([alignmentTwo.address], [10000])).to.be.revertedWith( + "Already voted" + ); + }); + + it("blocks unwhitelisted citizens from voting", async () => { + const { committee, citizenOne, alignmentOne, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerAlignment(goodDollar, houses, alignmentOne, "alignment-one"); + await houses.connect(committee).approveAlignmentMember(alignmentOne.address); + + await moveToNextVotingWindow(houses); + + await expect(houses.connect(citizenOne).castVote([alignmentOne.address], [10000])).to.be.revertedWith( + "Citizen not whitelisted" + ); + }); + + it("reverts unstake before term lock expires, then fully deletes the member record", async () => { + const { citizenOne, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + + // Cannot unstake immediately — term has not elapsed yet + await expect(houses.connect(citizenOne).unstake()).to.be.revertedWith("Term not passed"); + + const termDuration = (await houses.termDuration()).toNumber(); + await increaseTime(termDuration); + + await houses.connect(citizenOne).unstake(); + + const member = await houses.getMember(citizenOne.address); + // `delete` zeroes the whole struct; status is None (0), not Unstaked (4) + expect(member.status).to.equal(0); + expect(member.stakedAmount).to.equal(0); + expect(member.name).to.equal(""); + }); + + it("swap-removes the unstaked member and updates the remaining member's index", async () => { + const { citizenOne, citizenTwo, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerCitizen(goodDollar, houses, citizenTwo, "citizen-two"); + + expect((await houses.getMember(citizenOne.address)).memberIndex).to.equal(0); + expect((await houses.getMember(citizenTwo.address)).memberIndex).to.equal(1); + + const termDuration = (await houses.termDuration()).toNumber(); + await increaseTime(termDuration); + + // Unstake the first member; citizenTwo (slot 1) is moved into slot 0 + await houses.connect(citizenOne).unstake(); + + expect((await houses.getMember(citizenTwo.address)).memberIndex).to.equal(0); + const activeMembers = await houses["getActiveMembers(uint8)"](CITIZENS); + expect(activeMembers).to.deep.equal([citizenTwo.address]); + }); + + it("blocks switching houses on re-registration", async () => { + const { citizenOne, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + + await expect( + registerViaTransferAndCall(goodDollar, houses, citizenOne, ALIGNMENT, alignmentMinimumStake, { + name: "citizen-as-alignment", + distributionStrategy: alignmentForumUrl + }) + ).to.be.revertedWith("Cannot switch houses"); + }); + + it("accumulates stake and updates profile on re-registration in the same house", async () => { + const { citizenOne, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + + const memberBefore = await houses.getMember(citizenOne.address); + expect(memberBefore.stakedAmount).to.equal(citizensMinimumStake); + expect(memberBefore.status).to.equal(ACTIVE); + const joinedAt = memberBefore.joinedAt; + + // Re-register in the same house with extra stake — profile and cumulative stake update + const extraStake = 500; + await registerViaTransferAndCall(goodDollar, houses, citizenOne, CITIZENS, extraStake, { + name: "citizen-one-v2", + socialLinks: "https://social.example/citizen-one-v2" + }); + + const memberAfter = await houses.getMember(citizenOne.address); + expect(memberAfter.stakedAmount).to.equal(citizensMinimumStake + extraStake); + expect(memberAfter.name).to.equal("citizen-one-v2"); + expect(memberAfter.status).to.equal(ACTIVE); + expect(memberAfter.joinedAt).to.equal(joinedAt); + }); + + it("returns the correct paginated subset from getActiveMembers", async () => { + const { citizenOne, citizenTwo, lateCitizen, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerCitizen(goodDollar, houses, citizenTwo, "citizen-two"); + await registerCitizen(goodDollar, houses, lateCitizen, "late-citizen"); + + const first2 = await houses["getActiveMembers(uint8,uint256,uint256)"](CITIZENS, 0, 2); + expect(first2).to.deep.equal([citizenOne.address, citizenTwo.address]); + + const last2 = await houses["getActiveMembers(uint8,uint256,uint256)"](CITIZENS, 1, 3); + expect(last2).to.deep.equal([citizenTwo.address, lateCitizen.address]); + + // endIndex beyond array length is clamped to the actual length + const all = await houses["getActiveMembers(uint8,uint256,uint256)"](CITIZENS, 0, 100); + expect(all).to.deep.equal([citizenOne.address, citizenTwo.address, lateCitizen.address]); + }); + + it("revoking a citizen sets status to Revoked and does not zero FlowSplitter units", async () => { + const { committee, citizenOne, alignmentOne, goodDollar, flowSplitter, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + await registerAlignment(goodDollar, houses, alignmentOne, "alignment-one"); + await houses.connect(committee).approveAlignmentMember(alignmentOne.address); + + const poolId = await createManagedFlowSplitterPool(flowSplitter, goodDollar, houses); + await houses.connect(committee).configureFlowSplitter(flowSplitter.address, poolId); + + await houses.connect(committee).revokeMember(citizenOne.address); + + expect((await houses.getMember(citizenOne.address)).status).to.equal(REVOKED); + // No FlowSplitter call for a Citizens revoke — alignment units are untouched + expect(await flowSplitter.getMemberUnits(1, alignmentOne.address)).to.equal(0); + }); + + it("revoking an alignment member clears their FlowSplitter units", async () => { + const { committee, alignmentOne, alignmentTwo, goodDollar, flowSplitter, houses } = await loadFixture(fixture); + + 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 poolId = await createManagedFlowSplitterPool(flowSplitter, goodDollar, houses); + await houses.connect(committee).configureFlowSplitter(flowSplitter.address, poolId); + + const voteId = await moveToNextVotingWindow(houses); + await houses.connect(alignmentOne).castVote([alignmentOne.address], [10000]); + await houses.connect(alignmentTwo).castVote([alignmentOne.address], [10000]); + + await movePastVotingWindow(houses); + await houses.connect(committee).executeVote(voteId); + + expect(await flowSplitter.getMemberUnits(1, alignmentOne.address)).to.be.gt(0); + + await houses.connect(committee).revokeMember(alignmentOne.address); + + expect(await flowSplitter.getMemberUnits(1, alignmentOne.address)).to.equal(0); + expect((await houses.getMember(alignmentOne.address)).status).to.equal(REVOKED); + }); + + it("emits VoteCreated with recipients and VoteCast with voter details on first ballot", async () => { + const { committee, alignmentOne, alignmentTwo, goodDollar, houses } = await loadFixture(fixture); + + 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 expectedStart = (await houses.cycleStartTime()).add((await houses.termDuration()).mul(voteId)); + const expectedEnd = expectedStart.add(await houses.votingTermLength()); + + await expect(houses.connect(alignmentOne).castVote([alignmentOne.address], [10000])) + .to.emit(houses, "VoteCreated") + .withArgs(voteId, expectedStart, expectedEnd, [alignmentOne.address, alignmentTwo.address]) + .and.to.emit(houses, "VoteCast") + .withArgs(voteId, alignmentOne.address, [alignmentOne.address], [10000]); + }); + + it("persists the executed flag and blocks re-execution for the same voteId", async () => { + const { committee, alignmentOne, alignmentTwo, goodDollar, flowSplitter, houses } = await loadFixture(fixture); + + 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 poolId = await createManagedFlowSplitterPool(flowSplitter, goodDollar, houses); + await houses.connect(committee).configureFlowSplitter(flowSplitter.address, poolId); + + const voteId = await moveToNextVotingWindow(houses); + await houses.connect(alignmentOne).castVote([alignmentOne.address], [10000]); + + await movePastVotingWindow(houses); + await houses.connect(committee).executeVote(voteId); + + expect((await houses.getVoteConfig(voteId)).executed).to.equal(true); + + await expect(houses.connect(committee).executeVote(voteId)).to.be.revertedWith("Vote executed"); + }); + + it("registerAndStake only pulls the stake delta when the member is below the new minimum", async () => { + const { committee, citizenOne, goodDollar, houses } = await loadFixture(fixture); + + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + expect((await houses.getMember(citizenOne.address)).stakedAmount).to.equal(citizensMinimumStake); + + // Raise the minimum — member now has a deficit of 500 + const newMinimum = citizensMinimumStake + 500; + await houses.connect(committee).setStakeRequirement(CITIZENS, newMinimum); + + // Mint the exact deficit and approve + await goodDollar.mint(citizenOne.address, 500); + await goodDollar.connect(citizenOne).approve(houses.address, 500); + + const balanceBefore = await goodDollar.balanceOf(citizenOne.address); + await houses.connect(citizenOne).registerAndStake(CITIZENS, "citizen-one-v2", "", "", "", ""); + const balanceAfter = await goodDollar.balanceOf(citizenOne.address); + + expect(balanceBefore.sub(balanceAfter)).to.equal(500); + expect((await houses.getMember(citizenOne.address)).stakedAmount).to.equal(newMinimum); + expect((await houses.getMember(citizenOne.address)).name).to.equal("citizen-one-v2"); + }); + + it("stores a non-zero finalized unit for a 1 basis-point allocation", async () => { + const { committee, alignmentOne, alignmentTwo, goodDollar, houses } = await loadFixture(fixture); + + 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); + + await houses.connect(alignmentOne).castVote([alignmentOne.address, alignmentTwo.address], [1, 9999]); + + expect(await houses.getFinalizedUnits(voteId, alignmentOne.address)).to.be.gt(0); + }); + + it("reverts when revokeMember is called on an address with no membership", async () => { + const { committee, citizenOne, houses } = await loadFixture(fixture); + + // citizenOne has never registered — MemberStatus.None + await expect( + houses.connect(committee).revokeMember(citizenOne.address) + ).to.be.revertedWith("Not a member"); + }); + + it("registerAndStake transfers no tokens when caller already meets the minimum stake", async () => { + const { committee, citizenOne, goodDollar, houses } = await loadFixture(fixture); + + // Register with the exact minimum via transferAndCall + await registerCitizen(goodDollar, houses, citizenOne, "citizen-one"); + expect((await houses.getMember(citizenOne.address)).stakedAmount).to.equal(citizensMinimumStake); + + // Lower the minimum so the caller is already above it — no additional transfer should occur + await houses.connect(committee).setStakeRequirement(CITIZENS, citizensMinimumStake - 100); + + await goodDollar.connect(citizenOne).approve(houses.address, citizensMinimumStake); + + const balanceBefore = await goodDollar.balanceOf(citizenOne.address); + await houses.connect(citizenOne).registerAndStake(CITIZENS, "citizen-one-v2", "", "", "", ""); + const balanceAfter = await goodDollar.balanceOf(citizenOne.address); + + // No tokens transferred — stake unchanged at the original amount + expect(balanceBefore).to.equal(balanceAfter); + expect((await houses.getMember(citizenOne.address)).stakedAmount).to.equal(citizensMinimumStake); + expect((await houses.getMember(citizenOne.address)).name).to.equal("citizen-one-v2"); + }); + + it("rejects registration with an out-of-range house value via transferAndCall", async () => { + const { citizenOne, goodDollar, houses } = await loadFixture(fixture); + + const invalidHouse = 2; // beyond House.Alignment (max = 1) + const data = ethers.utils.defaultAbiCoder.encode( + ["uint8", "string", "string", "string", "string", "string"], + [invalidHouse, "bad", "", "", "", ""] + ); + + await goodDollar.mint(citizenOne.address, citizensMinimumStake); + await expect( + goodDollar.connect(citizenOne).transferAndCall(houses.address, citizensMinimumStake, data) + ).to.be.revertedWith("Invalid house"); + }); + + it("setVotingSchedule: non-admin/committee reverts", async () => { + const { stranger, houses } = await loadFixture(fixture); + + const latestBlock = await ethers.provider.getBlock("latest"); + const termDuration = (await houses.termDuration()).toNumber(); + const votingTermLength = (await houses.votingTermLength()).toNumber(); + + await expect( + houses.connect(stranger).setVotingSchedule(latestBlock.timestamp + 60, termDuration, votingTermLength) + ).to.be.revertedWith("Not admin/committee"); + }); + + it("setVotingSchedule: votingTermLength > termDuration reverts", async () => { + const { admin, houses } = await loadFixture(fixture); + + const latestBlock = await ethers.provider.getBlock("latest"); + const termDuration = (await houses.termDuration()).toNumber(); + + await expect( + houses.connect(admin).setVotingSchedule(latestBlock.timestamp + 60, termDuration, termDuration + 1) + ).to.be.revertedWith("Vote term > term"); + }); + + it("setVotingSchedule: zero termDuration reverts", async () => { + const { admin, houses } = await loadFixture(fixture); + + const latestBlock = await ethers.provider.getBlock("latest"); + + await expect( + houses.connect(admin).setVotingSchedule(latestBlock.timestamp + 60, 0, 0) + ).to.be.revertedWith("Term=0"); + }); + + it("setVotingSchedule: zero votingTermLength reverts", async () => { + const { admin, houses } = await loadFixture(fixture); + + const latestBlock = await ethers.provider.getBlock("latest"); + const termDuration = (await houses.termDuration()).toNumber(); + + await expect( + houses.connect(admin).setVotingSchedule(latestBlock.timestamp + 60, termDuration, 0) + ).to.be.revertedWith("Vote term=0"); + }); + + it("executeVote: uint128 cast safety – accumulated weighted votes fit in uint128", async () => { + // Weighted vote per voter = (allocation * HOUSE_ALIGNMENT_WEIGHT) / BASIS_POINTS + // = (10000 * 400000) / 10000 = 400000, which is well within uint128 max. + // This test verifies that executeVote completes without reverting on the bounds check + // and that the stored units are correct. + const { committee, alignmentOne, alignmentTwo, goodDollar, flowSplitter, houses } = await loadFixture(fixture); + + 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 poolId = await createManagedFlowSplitterPool(flowSplitter, goodDollar, houses); + await houses.connect(committee).configureFlowSplitter(flowSplitter.address, poolId); + + const voteId = await moveToNextVotingWindow(houses); + // Both alignment members vote 100 % for alignmentOne + await houses.connect(alignmentOne).castVote([alignmentOne.address], [10000]); + await houses.connect(alignmentTwo).castVote([alignmentOne.address], [10000]); + + await movePastVotingWindow(houses); + + // Should not revert on "Units overflow" + await expect(houses.connect(committee).executeVote(voteId)).to.not.be.reverted; + + const units = await flowSplitter.getMemberUnits(poolId, alignmentOne.address); + expect(units).to.be.gt(0); + }); + + it("executeVote: uint128 cast safety – reverts when accumulated weight exceeds uint128 max", async () => { + // Normal voting can never accumulate enough weight to overflow uint128, so this + // test injects a value above 2**128 - 1 directly via GoodDaoHousesHarness. + const [admin, committee, , , alignmentOne, alignmentTwo] = await ethers.getSigners(); + const { gd, nameService } = await loadFixture(createDAO); + + const goodDollar = await ethers.getContractAt("IGoodDollar", gd); + const flowSplitter = await ethers.deployContract("MockFlowSplitter"); + + // Deploy the harness (extends GoodDaoHouses with a test-only weight setter) + const harness = await upgrades.deployProxy( + await ethers.getContractFactory("GoodDaoHousesHarness"), + [nameService.address, admin.address, committee.address, citizensMinimumStake, alignmentMinimumStake], + { kind: "uups" } + ); + + await registerAlignment(goodDollar, harness, alignmentOne, "alignment-one"); + await registerAlignment(goodDollar, harness, alignmentTwo, "alignment-two"); + await harness.connect(committee).approveAlignmentMember(alignmentOne.address); + await harness.connect(committee).approveAlignmentMember(alignmentTwo.address); + + const poolId = await createManagedFlowSplitterPool(flowSplitter, goodDollar, harness); + await harness.connect(committee).configureFlowSplitter(flowSplitter.address, poolId); + + // Cast a normal vote first to create the vote record and register alignmentOne as a recipient + const voteId = await moveToNextVotingWindow(harness); + await harness.connect(alignmentOne).castVote([alignmentOne.address], [10000]); + + await movePastVotingWindow(harness); + + // Inject a weight above type(uint128).max (= 2**128) to simulate the overflow scenario + const overflow = ethers.BigNumber.from(2).pow(128); + await harness.connect(committee).setVoteWeightForTest(voteId, alignmentOne.address, overflow); + + // executeVote must revert rather than silently truncate the cast + await expect(harness.connect(committee).executeVote(voteId)).to.be.revertedWith("Units overflow"); + }); +});