296 lines
11 KiB
Solidity
296 lines
11 KiB
Solidity
// SPDX-License-Identifier: Unlicense
|
|
pragma solidity ^0.8.24;
|
|
|
|
import "./DAO.sol";
|
|
import "./IOnValidate.sol";
|
|
import "./IOnProposalAccepted.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 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
|
|
|
|
function propose(
|
|
string calldata contentId,
|
|
address author,
|
|
uint[3] calldata durations,
|
|
bool callbackOnAccepted,
|
|
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(author, contentId);
|
|
proposalIndex = proposalCount++;
|
|
Proposal storage proposal = proposals[proposalIndex];
|
|
proposal.sender = msg.sender;
|
|
proposal.postIndex = postIndex;
|
|
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.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 returns (uint) {
|
|
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 1;
|
|
}
|
|
|
|
// 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");
|
|
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 0;
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|