From a8dcbe7a35fc5841478e1f30bb79b5478d62d273 Mon Sep 17 00:00:00 2001 From: Ladd Hoffman Date: Wed, 27 Mar 2024 14:03:57 -0500 Subject: [PATCH] Proposals: complete VP workflow --- ethereum/contracts/DAO.sol | 35 ++++++-- ethereum/contracts/IOnValidate.sol | 6 +- ethereum/contracts/Onboarding.sol | 8 +- ethereum/contracts/Proposal.sol | 131 ++++++++++++++++++++++++----- ethereum/test/DAO.js | 16 ++-- ethereum/test/Work1.js | 2 +- 6 files changed, 155 insertions(+), 43 deletions(-) diff --git a/ethereum/contracts/DAO.sol b/ethereum/contracts/DAO.sol index 74b56c4..bb02621 100644 --- a/ethereum/contracts/DAO.sol +++ b/ethereum/contracts/DAO.sol @@ -67,7 +67,11 @@ contract DAO is ERC20("Reputation", "REP") { event PostAdded(uint postIndex); event ValidationPoolInitiated(uint poolIndex); - event ValidationPoolResolved(uint poolIndex, bool votePasses); + event ValidationPoolResolved( + uint poolIndex, + bool votePasses, + bool quorumMet + ); function addPost( address author, @@ -184,11 +188,22 @@ contract DAO is ERC20("Reputation", "REP") { } } // Check that quorum is met - require( - 1_000_000_000 * (stakedFor + stakedAgainst) >= - totalSupply() * pool.params.quorumPPB, - "Quorum for this pool was not met" - ); + if ( + 1_000_000_000 * (stakedFor + stakedAgainst) <= + totalSupply() * pool.params.quorumPPB + ) { + // TODO: refund stakes + // Callback if requested + if (pool.callbackOnValidate) { + IOnValidate(pool.sender).onValidate( + votePasses, + false, + pool.callbackData + ); + } + emit ValidationPoolResolved(poolIndex, false, false); + return false; + } // A tie is resolved in favor of the validation pool. // This is especially important so that the DAO's first pool can pass, // when no reputation has yet been minted. @@ -199,7 +214,7 @@ contract DAO is ERC20("Reputation", "REP") { } pool.resolved = true; pool.outcome = votePasses; - emit ValidationPoolResolved(poolIndex, votePasses); + emit ValidationPoolResolved(poolIndex, votePasses, true); // Value of losing stakes should be distributed among winners, in proportion to their stakes uint256 amountFromWinners = votePasses ? stakedFor : stakedAgainst; uint256 amountFromLosers = votePasses ? stakedAgainst : stakedFor; @@ -250,7 +265,11 @@ contract DAO is ERC20("Reputation", "REP") { } // Callback if requested if (pool.callbackOnValidate) { - IOnValidate(pool.sender).onValidate(votePasses, pool.callbackData); + IOnValidate(pool.sender).onValidate( + votePasses, + true, + pool.callbackData + ); } } diff --git a/ethereum/contracts/IOnValidate.sol b/ethereum/contracts/IOnValidate.sol index c0d3c38..fd2e090 100644 --- a/ethereum/contracts/IOnValidate.sol +++ b/ethereum/contracts/IOnValidate.sol @@ -2,5 +2,9 @@ pragma solidity ^0.8.24; interface IOnValidate { - function onValidate(bool votePasses, bytes calldata callbackData) external; + function onValidate( + bool votePasses, + bool quorumMet, + bytes calldata callbackData + ) external; } diff --git a/ethereum/contracts/Onboarding.sol b/ethereum/contracts/Onboarding.sol index dca9500..d7c8e7e 100644 --- a/ethereum/contracts/Onboarding.sol +++ b/ethereum/contracts/Onboarding.sol @@ -41,14 +41,18 @@ contract Onboarding is WorkContract, IOnValidate { } /// Callback to be executed when review pool completes - function onValidate(bool votePasses, bytes calldata callbackData) external { + function onValidate( + bool votePasses, + bool quorumMet, + bytes calldata callbackData + ) external { require( msg.sender == address(dao), "onValidate may only be called by the DAO contract" ); uint requestIndex = abi.decode(callbackData, (uint)); WorkRequest storage request = requests[requestIndex]; - if (!votePasses) { + if (!votePasses || !quorumMet) { // refund the customer the remaining amount payable(request.customer).transfer(request.fee / 10); return; diff --git a/ethereum/contracts/Proposal.sol b/ethereum/contracts/Proposal.sol index b458621..108028f 100644 --- a/ethereum/contracts/Proposal.sol +++ b/ethereum/contracts/Proposal.sol @@ -15,29 +15,39 @@ contract Proposals is DAOContract { Referendum0, Referendum1, Referendum100, - Closed + Failed, + Accepted } struct Referendum { uint duration; uint poolIndex; + uint fee; } struct Proposal { address sender; uint fee; - uint feeRemaining; uint postIndex; uint startTime; Stage stage; mapping(uint => Attestation) attestations; uint attestationCount; Referendum[3] referenda; + uint[3] retryCount; } mapping(uint => Proposal) public proposals; uint public proposalCount; + event NewProposal(uint proposalIndex); + event ReferendumStarted(uint proposalIndex, uint poolIndex); + event ProposalFailed(uint proposalIndex, string reason); + event ProposalAccepted(uint proposalIndex); + + uint[3] referendaBindingPercent = [0, 1, 100]; + bool[3] referendaRedistributeLosingStakes = [false, false, true]; + constructor(DAO dao) DAOContract(dao) {} function propose( @@ -54,7 +64,10 @@ contract Proposals is DAOContract { proposal.referenda[1].duration = referendum1Duration; proposal.referenda[2].duration = referendum100Duration; proposal.fee = msg.value; - proposal.feeRemaining = proposal.fee; + proposal.referenda[0].fee = proposal.fee / 3; + proposal.referenda[1].fee = proposal.fee / 3; + proposal.referenda[2].fee = proposal.fee - (proposal.fee * 2) / 3; + emit NewProposal(proposalIndex); } function attest(uint proposalIndex, uint amount) external { @@ -73,11 +86,86 @@ contract Proposals is DAOContract { attestation.amount = amount; } - // todo onValidate() { + function initiateValidationPool( + uint proposalIndex, + uint referendumIndex + ) internal { + uint bindingPercent = referendaBindingPercent[referendumIndex]; + bool redistributeLosingStakes = referendaRedistributeLosingStakes[ + referendumIndex + ]; + Proposal storage proposal = proposals[proposalIndex]; + uint poolIndex = dao.initiateValidationPool{ + value: proposal.referenda[referendumIndex].fee + }( + proposal.postIndex, + proposal.referenda[referendumIndex].duration, + 1, + 3, + bindingPercent, + redistributeLosingStakes, + true, + abi.encode(proposalIndex) + ); + emit ReferendumStarted(proposalIndex, poolIndex); + } - // This callback will get proposalIndex - - // todo } + /// Callback to be executed when referenda pools complete + function onValidate( + bool votePasses, + bool quorumMet, + bytes calldata callbackData + ) external { + require( + msg.sender == address(dao), + "onValidate may only be called by the DAO contract" + ); + uint proposalIndex = abi.decode(callbackData, (uint)); + Proposal storage proposal = proposals[proposalIndex]; + if (!quorumMet) { + proposal.stage = Stage.Failed; + emit ProposalFailed(proposalIndex, "Quorum not met"); + return; + } + if (proposal.stage == Stage.Referendum0) { + if (votePasses) { + proposal.stage = Stage.Referendum1; + } else if (proposal.retryCount[0] >= 3) { + proposal.stage = Stage.Failed; + emit ProposalFailed(proposalIndex, "Retry count exceeded"); + } else { + proposal.retryCount[0] += 1; + } + } else if (proposal.stage == Stage.Referendum1) { + if (votePasses) { + proposal.stage = Stage.Referendum100; + } else if (proposal.retryCount[1] >= 3) { + proposal.stage = Stage.Failed; + emit ProposalFailed(proposalIndex, "Retry count exceeded"); + } else { + proposal.retryCount[1] += 1; + } + } else if (proposal.stage == Stage.Referendum100) { + // Note that no retries are attempted for referendum 100% + if (votePasses) { + // TODO: The proposal has passed all referenda and should become "law" + // This is an opportunity for some actions to occur + // We should at least emit an event + proposal.stage = Stage.Accepted; + emit ProposalAccepted(proposalIndex); + } else { + proposal.stage = Stage.Failed; + emit ProposalFailed(proposalIndex, "Binding pool was rejected"); + } + } + if (proposal.stage == Stage.Referendum0) { + initiateValidationPool(proposalIndex, 0); + } else if (proposal.stage == Stage.Referendum1) { + initiateValidationPool(proposalIndex, 1); + } else if (proposal.stage == Stage.Referendum100) { + initiateValidationPool(proposalIndex, 2); + } + } function evaluateAttestation(uint proposalIndex) external returns (bool) { Proposal storage proposal = proposals[proposalIndex]; @@ -93,27 +181,24 @@ contract Proposals is DAOContract { bool expired = block.timestamp > proposal.startTime + 365 days; if (!meetsAttestation) { if (expired) { - proposal.stage = Stage.Closed; + // 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; } - // Initiate validation pool + + // 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; - uint thisFee = proposal.fee / 3; - proposal.feeRemaining -= thisFee; - proposal.referenda[0].poolIndex = dao.initiateValidationPool{ - value: thisFee - }( - proposal.postIndex, // uint postIndex, - proposal.referenda[0].duration, // uint duration, - 1, // uint quorumNumerator, - 3, // uint quorumDenominator, - 0, // uint bindingPercent, - false, // bool redistributeLosingStakes, - false, // TODO bool callbackOnValidate : true, - "" // TODO bytes calldata callbackData : This should probably be proposalIndex - ); + // Initiate validation pool + initiateValidationPool(proposalIndex, 0); return true; } } diff --git a/ethereum/test/DAO.js b/ethereum/test/DAO.js index 8c1434f..297452e 100644 --- a/ethereum/test/DAO.js +++ b/ethereum/test/DAO.js @@ -143,7 +143,7 @@ describe('DAO', () => { expect(await dao.balanceOf(dao.target)).to.equal(110); time.increase(POOL_DURATION + 1); console.log('evaluating second pool'); - await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true); + await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true, true); expect(await dao.balanceOf(dao.target)).to.equal(0); expect(await dao.balanceOf(account1)).to.equal(200); }); @@ -158,7 +158,7 @@ describe('DAO', () => { expect(await dao.balanceOf(account1)).to.equal(90); expect(await dao.balanceOf(dao.target)).to.equal(110); time.increase(POOL_DURATION + 1); - await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, false); + await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, false, true); expect(await dao.balanceOf(dao.target)).to.equal(0); expect(await dao.balanceOf(account1)).to.equal(200); const pool = await dao.validationPools(1); @@ -174,7 +174,7 @@ describe('DAO', () => { it('should be able to evaluate outcome after duration has elapsed', async () => { expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); - await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true); + await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); expect(await dao.memberCount()).to.equal(1); expect(await dao.balanceOf(account1)).to.equal(100); const pool = await dao.validationPools(0); @@ -184,7 +184,7 @@ describe('DAO', () => { it('should not be able to evaluate outcome more than once', async () => { time.increase(POOL_DURATION + 1); - await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true); + await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); await expect(dao.evaluateOutcome(0)).to.be.revertedWith('Pool is already resolved'); }); @@ -203,21 +203,21 @@ describe('DAO', () => { await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); expect(await dao.validationPoolCount()).to.equal(2); time.increase(POOL_DURATION + 1); - await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true); + await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); expect(await dao.balanceOf(account1)).to.equal(100); - await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true); + await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true, true); expect(await dao.balanceOf(account1)).to.equal(200); }); it('should not be able to evaluate outcome if quorum is not met', async () => { time.increase(POOL_DURATION + 1); - await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true); + await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); const init = () => initiateValidationPool({ quorumNumerator: 1, quorumDenominator: 1 }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); expect(await dao.validationPoolCount()).to.equal(2); time.increase(POOL_DURATION + 1); - await expect(dao.evaluateOutcome(1)).to.be.revertedWith('Quorum for this pool was not met'); + await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, false, false); }); describe('Validation pool options', () => { diff --git a/ethereum/test/Work1.js b/ethereum/test/Work1.js index 0c616a9..7090453 100644 --- a/ethereum/test/Work1.js +++ b/ethereum/test/Work1.js @@ -243,7 +243,7 @@ describe('Work1', () => { expect(pool.postIndex).to.equal(1); expect(pool.stakeCount).to.equal(3); await time.increase(86401); - await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true); + await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true, true); }); it('should be able to submit work disapproval', async () => {