// SPDX-License-Identifier: Unlicense pragma solidity ^0.8.24; import "./core/DAO.sol"; import "./interfaces/IOnValidate.sol"; import "./interfaces/IOnProposalAccepted.sol"; contract Proposals is DAOContract, IOnValidate { enum Stage { Proposal, Referendum0, Referendum1, Referendum100, Failed, Accepted } struct Pool { uint poolIndex; bool started; bool completed; uint stakedFor; uint stakedAgainst; bool votePasses; bool quorumMet; } struct Referendum { uint duration; // Each referendum may retry up to 3x Pool[3] pools; uint retryCount; } struct Proposal { address sender; uint fee; uint remainingFee; string postId; uint startTime; Stage stage; mapping(address => uint) attestations; uint attestationTotal; Referendum[3] referenda; bool callbackOnAccepted; bytes callbackData; } mapping(uint => Proposal) public proposals; uint public proposalCount; event NewProposal(uint proposalIndex); event Attestation(uint proposalIndex); event ReferendumStarted(uint proposalIndex, uint poolIndex); event ProposalFailed(uint proposalIndex, string reason); event ProposalAccepted(uint proposalIndex); constructor(DAO dao) DAOContract(dao) {} // TODO receive : we want to be able to accept refunds from validation pools /// Submit a post as a proposal. forum.addPost should be called before this. function propose( string calldata postId, uint[3] calldata durations, bool callbackOnAccepted, bytes calldata callbackData ) external payable returns (uint proposalIndex) { proposalIndex = proposalCount++; Proposal storage proposal = proposals[proposalIndex]; proposal.sender = msg.sender; proposal.postId = postId; proposal.startTime = block.timestamp; proposal.referenda[0].duration = durations[0]; proposal.referenda[1].duration = durations[1]; proposal.referenda[2].duration = durations[2]; proposal.fee = msg.value; proposal.remainingFee = proposal.fee; proposal.callbackOnAccepted = callbackOnAccepted; proposal.callbackData = callbackData; emit NewProposal(proposalIndex); } /// Provides a summary of pools for a given proposal. Useful for displaying a summary. function getPools( uint proposalIndex ) public view returns (Pool[3][3] memory pools) { Proposal storage proposal = proposals[proposalIndex]; pools[0] = proposal.referenda[0].pools; pools[1] = proposal.referenda[1].pools; pools[2] = proposal.referenda[2].pools; } // TODO: function getProposals() // Enumerate timing so clients can render it /// External function for reputation holders to attest toward a given proposal; /// This is non-binding and non-encumbering, so it does not transfer any reputation. function attest(uint proposalIndex, uint amount) external { // Since this is non-binding, non-encumbering, we only need to verify that // the sender actually has the rep they claim to stake. require( dao.balanceOf(msg.sender) >= amount, "Sender has insufficient REP balance" ); Proposal storage proposal = proposals[proposalIndex]; proposal.attestationTotal -= proposal.attestations[msg.sender]; proposal.attestations[msg.sender] = amount; proposal.attestationTotal += amount; emit Attestation(proposalIndex); } // --- Sequences of validation pool parameters --- // Percentage that each referendum is binding uint[3] referendaBindingPercent = [0, 1, 100]; // Whether to redistribute the binding portion of losing stakes in each referendum bool[3] referendaRedistributeLosingStakes = [false, false, true]; // For each referendum, a numerator-denominator pair representing its quorum uint[2][3] referendaQuora = [[1, 10], [1, 10], [1, 10]]; // Win ratios uint[2][3] referendaWinRatio = [[2, 3], [2, 3], [2, 3]]; /// Internal convenience function to wrap our call to dao.initiateValidationPool /// and to emit an event function initiateValidationPool( uint proposalIndex, uint referendumIndex, uint fee ) internal { Proposal storage proposal = proposals[proposalIndex]; proposal.remainingFee -= fee; uint poolIndex = dao.initiateValidationPool{value: fee}( proposal.postId, proposal.referenda[referendumIndex].duration, referendaQuora[referendumIndex], referendaWinRatio[referendumIndex], referendaBindingPercent[referendumIndex], referendaRedistributeLosingStakes[referendumIndex], true, abi.encode(proposalIndex, referendumIndex, fee) ); Referendum storage referendum = proposal.referenda[referendumIndex]; Pool storage pool = referendum.pools[referendum.retryCount]; pool.poolIndex = poolIndex; pool.started = true; emit ReferendumStarted(proposalIndex, poolIndex); } /// Callback to be executed when referenda pools complete function onValidate( bool votePasses, bool quorumMet, uint stakedFor, uint stakedAgainst, bytes calldata callbackData ) external { require( msg.sender == address(dao), "onValidate may only be called by the DAO contract" ); (uint proposalIndex, uint referendumIndex, uint fee) = abi.decode( callbackData, (uint, uint, uint) ); Proposal storage proposal = proposals[proposalIndex]; Referendum storage referendum = proposal.referenda[referendumIndex]; Pool storage pool = referendum.pools[referendum.retryCount]; // Make a record of this result pool.completed = true; pool.stakedFor = stakedFor; pool.stakedAgainst = stakedAgainst; pool.quorumMet = quorumMet; pool.votePasses = votePasses; if (!quorumMet) { proposal.stage = Stage.Failed; emit ProposalFailed(proposalIndex, "Quorum not met"); proposal.remainingFee += fee; return; } // Participation threshold of 50% bool participationAboveThreshold = 2 * (stakedFor + stakedAgainst) >= dao.totalSupply(); // Handle Referendum 0% if (proposal.stage == Stage.Referendum0) { require(referendumIndex == 0, "Stage 0 index mismatch"); // If vote passes (2/3 majority) and has >= 50% participation if (votePasses && participationAboveThreshold) { proposal.stage = Stage.Referendum1; } else if (referendum.retryCount >= 2) { proposal.stage = Stage.Failed; emit ProposalFailed(proposalIndex, "Retry count exceeded"); } else { referendum.retryCount += 1; } // Handle Referendum 1% } else if (proposal.stage == Stage.Referendum1) { require(referendumIndex == 1, "Stage 1 index mismatch"); if (votePasses && participationAboveThreshold) { proposal.stage = Stage.Referendum100; } else if (referendum.retryCount >= 2) { proposal.stage = Stage.Failed; emit ProposalFailed(proposalIndex, "Retry count exceeded"); } else { referendum.retryCount += 1; } // Handle Referendum 1000000% } else if (proposal.stage == Stage.Referendum100) { require(referendumIndex == 2, "Stage 2 index mismatch"); if (votePasses && participationAboveThreshold) { // The proposal has passed all referenda and should become "law" proposal.stage = Stage.Accepted; // This is an opportunity for some actions to occur // Emit an event emit ProposalAccepted(proposalIndex); // Execute a callback, if requested if (proposal.callbackOnAccepted) { IOnProposalAccepted(proposal.sender).onProposalAccepted( stakedFor, stakedAgainst, proposal.callbackData ); } } else if (referendum.retryCount >= 2) { proposal.stage = Stage.Failed; emit ProposalFailed(proposalIndex, "Retry count exceeded"); } else { referendum.retryCount += 1; } } if (proposal.stage == Stage.Referendum0) { initiateValidationPool(proposalIndex, 0, proposal.fee / 10); } else if (proposal.stage == Stage.Referendum1) { initiateValidationPool(proposalIndex, 1, proposal.fee / 10); } else if (proposal.stage == Stage.Referendum100) { initiateValidationPool(proposalIndex, 2, proposal.fee / 10); } return; } /// External function that will advance a proposal to the referendum process /// if attestation threshold has been reached function evaluateAttestation(uint proposalIndex) external returns (bool) { Proposal storage proposal = proposals[proposalIndex]; require( proposal.stage == Stage.Proposal, "Attestation only pertains to Proposal stage" ); bool meetsAttestation = 10 * proposal.attestationTotal >= dao.totalSupply(); bool expired = block.timestamp > proposal.startTime + 365 days; if (!meetsAttestation) { if (expired) { // Expired without meeting attestation threshold proposal.stage = Stage.Failed; emit ProposalFailed( proposalIndex, "Expired without meeting attestation threshold" ); return false; } // Not yet expired, but has not met attestation threshold return false; } // Attestation threshold is met. // Note that this may succeed even after expiry // It can only happen once because the stage advances, and we required it above. proposal.stage = Stage.Referendum0; // Initiate validation pool initiateValidationPool(proposalIndex, 0, proposal.fee / 10); return true; } /// External function to reclaim remaining fees after a proposal has completed all referenda function reclaimRemainingFee(uint proposalIndex) external { Proposal storage proposal = proposals[proposalIndex]; require( proposal.stage == Stage.Failed || proposal.stage == Stage.Accepted, "Remaining fees can only be reclaimed when proposal has been accepted or failed" ); uint amount = proposal.remainingFee; proposal.remainingFee = 0; payable(msg.sender).transfer(amount); } }