// SPDX-License-Identifier: Unlicense pragma solidity ^0.8.24; import "./DAO.sol"; import "./IOnValidate.sol"; import "hardhat/console.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; uint postIndex; uint startTime; Stage stage; mapping(address => uint) attestations; uint attestationTotal; Referendum[3] referenda; bool callbackOnValidate; 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 function propose( string calldata contentId, uint referendum0Duration, uint referendum1Duration, uint referendum100Duration, bool callbackOnValidate, bytes calldata callbackData ) external payable returns (uint proposalIndex) { // TODO: Consider taking author as a parameter, // or else accepting a postIndex instead of contentId, // or support post lookup by contentId uint postIndex = dao.addPost(msg.sender, contentId); proposalIndex = proposalCount++; Proposal storage proposal = proposals[proposalIndex]; proposal.postIndex = postIndex; proposal.startTime = block.timestamp; proposal.referenda[0].duration = referendum0Duration; proposal.referenda[1].duration = referendum1Duration; proposal.referenda[2].duration = referendum100Duration; proposal.fee = msg.value; proposal.remainingFee = proposal.fee; proposal.callbackOnValidate = callbackOnValidate; proposal.callbackData = callbackData; emit NewProposal(proposalIndex); } 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.postIndex, 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 100% } else if (proposal.stage == Stage.Referendum100) { require(referendumIndex == 2, "Stage 2 index mismatch"); // Note that no retries are attempted for referendum 100% 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 // We should at least emit an event emit ProposalAccepted(proposalIndex); // We also execute a callback, if requested if (proposal.callbackOnValidate) { try // Note: We're directly reusing the onValidate hook we established for valdiation pools. // if any contracts want to use both callbacks, distinct interfaces should be defined. IOnValidate(proposal.sender).onValidate( votePasses, false, stakedFor, stakedAgainst, proposal.callbackData ) { console.log("proposal callbackOnValidate succeed"); } catch Error(string memory reason) { console.log( "proposal callbackOnValidate failed:", reason ); } } } 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); } } /// 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); } }