282 lines
10 KiB
Solidity
282 lines
10 KiB
Solidity
// SPDX-License-Identifier: Unlicense
|
|
pragma solidity ^0.8.24;
|
|
|
|
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
import "./IAcceptAvailability.sol";
|
|
import "./IOnValidate.sol";
|
|
|
|
import "hardhat/console.sol";
|
|
|
|
struct Post {
|
|
uint id;
|
|
address sender;
|
|
address author;
|
|
string contentId;
|
|
}
|
|
|
|
struct Stake {
|
|
uint id;
|
|
bool inFavor;
|
|
uint256 amount;
|
|
address sender;
|
|
bool fromMint;
|
|
}
|
|
|
|
struct ValidationPoolParams {
|
|
uint quorumPPB;
|
|
uint bindingPercent;
|
|
bool redistributeLosingStakes;
|
|
}
|
|
|
|
struct ValidationPool {
|
|
uint id;
|
|
uint postIndex;
|
|
address sender;
|
|
mapping(uint => Stake) stakes;
|
|
uint stakeCount;
|
|
ValidationPoolParams params;
|
|
uint256 fee;
|
|
uint duration;
|
|
uint endTime;
|
|
bool resolved;
|
|
bool outcome;
|
|
bool callbackOnValidate;
|
|
bytes callbackData;
|
|
}
|
|
|
|
/// This contract must manage validation pools and reputation,
|
|
/// because otherwise there's no way to enforce appropriate permissions on
|
|
/// transfer of value between reputation NFTs.
|
|
contract DAO is ERC20("Reputation", "REP") {
|
|
mapping(uint => address) public members;
|
|
uint public memberCount;
|
|
mapping(address => bool) public isMember;
|
|
mapping(uint => ValidationPool) public validationPools;
|
|
uint public validationPoolCount;
|
|
mapping(uint => Post) public posts;
|
|
uint public postCount;
|
|
|
|
// TODO: possible parameter for minting ratio
|
|
// TODO: possible parameter for stakeForAuthor
|
|
// TODO: possible parameter for winningRatio
|
|
// TODO: Add forum parameters
|
|
|
|
uint public constant minDuration = 1; // 1 second
|
|
uint public constant maxDuration = 365_000_000 days; // 1 million years
|
|
uint public constant minQuorumPPB = 333_333_333; // Parts per billion
|
|
|
|
event PostAdded(uint postIndex);
|
|
event ValidationPoolInitiated(uint poolIndex);
|
|
event ValidationPoolResolved(uint poolIndex, bool votePasses);
|
|
|
|
function addPost(
|
|
address author,
|
|
string calldata contentId
|
|
) external returns (uint postIndex) {
|
|
postIndex = postCount++;
|
|
Post storage post = posts[postIndex];
|
|
post.author = author;
|
|
post.sender = msg.sender;
|
|
post.id = postIndex;
|
|
post.contentId = contentId;
|
|
emit PostAdded(postIndex);
|
|
}
|
|
|
|
/// Accept fee to initiate a validation pool
|
|
/// TODO: Handle multiple authors
|
|
function initiateValidationPool(
|
|
uint postIndex,
|
|
uint duration,
|
|
uint quorumNumerator,
|
|
uint quorumDenominator,
|
|
uint bindingPercent,
|
|
bool redistributeLosingStakes,
|
|
bool callbackOnValidate,
|
|
bytes calldata callbackData
|
|
) external payable returns (uint poolIndex) {
|
|
require(msg.value > 0, "Fee is required to initiate validation pool");
|
|
require(duration >= minDuration, "Duration is too short");
|
|
require(duration <= maxDuration, "Duration is too long");
|
|
require(
|
|
(1_000_000_000 * quorumNumerator) / quorumDenominator >=
|
|
minQuorumPPB,
|
|
"Quorum is below minimum"
|
|
);
|
|
require(
|
|
quorumNumerator <= quorumDenominator,
|
|
"Quorum is greater than one"
|
|
);
|
|
require(bindingPercent <= 100, "Binding percent must be <= 100");
|
|
Post storage post = posts[postIndex];
|
|
require(post.author != address(0), "Target post not found");
|
|
poolIndex = validationPoolCount++;
|
|
ValidationPool storage pool = validationPools[poolIndex];
|
|
pool.sender = msg.sender;
|
|
pool.postIndex = postIndex;
|
|
pool.fee = msg.value;
|
|
pool.params.quorumPPB =
|
|
(1_000_000_000 * quorumNumerator) /
|
|
quorumDenominator;
|
|
pool.params.bindingPercent = bindingPercent;
|
|
pool.params.redistributeLosingStakes = redistributeLosingStakes;
|
|
pool.duration = duration;
|
|
pool.endTime = block.timestamp + duration;
|
|
pool.id = poolIndex;
|
|
pool.callbackOnValidate = callbackOnValidate;
|
|
pool.callbackData = callbackData;
|
|
|
|
// Because we need to stake part of the mited value for the pool an part against,
|
|
// we mint two new tokens.
|
|
// Here we assume a minting ratio of 1, and a stakeForAuthor ratio of 0.5
|
|
// Implementing this with adjustable parameters will require more advanced fixed point math.
|
|
// TODO: Make minting ratio an adjustable parameter
|
|
// TODO: Make stakeForAuthor an adjustable parameter
|
|
_mint(post.author, msg.value);
|
|
// TODO: We need a way to exclude this pending reputation from the total supply when computing fee distribution
|
|
_stake(pool, post.author, msg.value / 2, true, true);
|
|
_stake(pool, post.author, msg.value / 2, false, true);
|
|
emit ValidationPoolInitiated(poolIndex);
|
|
}
|
|
|
|
/// Internal function to register a stake for/against a validation pool
|
|
function _stake(
|
|
ValidationPool storage pool,
|
|
address sender,
|
|
uint256 amount,
|
|
bool inFavor,
|
|
bool fromMint
|
|
) internal {
|
|
require(block.timestamp <= pool.endTime, "Pool end time has passed");
|
|
_update(sender, address(this), amount);
|
|
uint stakeIndex = pool.stakeCount++;
|
|
Stake storage s = pool.stakes[stakeIndex];
|
|
s.sender = sender;
|
|
s.inFavor = inFavor;
|
|
s.amount = amount;
|
|
s.id = stakeIndex;
|
|
s.fromMint = fromMint;
|
|
}
|
|
|
|
/// Accept reputation stakes toward a validation pool
|
|
function stake(uint poolIndex, uint256 amount, bool inFavor) public {
|
|
ValidationPool storage pool = validationPools[poolIndex];
|
|
_stake(pool, msg.sender, amount, inFavor, false);
|
|
}
|
|
|
|
/// Evaluate outcome of a validation pool
|
|
function evaluateOutcome(uint poolIndex) public returns (bool votePasses) {
|
|
ValidationPool storage pool = validationPools[poolIndex];
|
|
Post storage post = posts[pool.postIndex];
|
|
require(
|
|
block.timestamp > pool.endTime,
|
|
"Pool end time has not yet arrived"
|
|
);
|
|
require(pool.resolved == false, "Pool is already resolved");
|
|
uint256 stakedFor;
|
|
uint256 stakedAgainst;
|
|
Stake storage s;
|
|
for (uint i = 0; i < pool.stakeCount; i++) {
|
|
s = pool.stakes[i];
|
|
if (s.inFavor) {
|
|
stakedFor += s.amount;
|
|
} else {
|
|
stakedAgainst += s.amount;
|
|
}
|
|
}
|
|
// Check that quorum is met
|
|
require(
|
|
1_000_000_000 * (stakedFor + stakedAgainst) >=
|
|
totalSupply() * pool.params.quorumPPB,
|
|
"Quorum for this pool was not met"
|
|
);
|
|
// 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.
|
|
votePasses = stakedFor >= stakedAgainst;
|
|
if (votePasses && !isMember[post.author]) {
|
|
members[memberCount++] = post.author;
|
|
isMember[post.author] = true;
|
|
}
|
|
pool.resolved = true;
|
|
pool.outcome = votePasses;
|
|
emit ValidationPoolResolved(poolIndex, votePasses);
|
|
// Value of losing stakes should be distributed among winners, in proportion to their stakes
|
|
uint256 amountFromWinners = votePasses ? stakedFor : stakedAgainst;
|
|
uint256 amountFromLosers = votePasses ? stakedAgainst : stakedFor;
|
|
// Only bindingPercent % should be redistributed
|
|
// Stake senders should get (100-bindingPercent) % back
|
|
uint256 totalAllocated;
|
|
for (uint i = 0; i < pool.stakeCount; i++) {
|
|
s = pool.stakes[i];
|
|
bool redistributeLosingStakes = s.fromMint ||
|
|
pool.params.redistributeLosingStakes;
|
|
uint bindingPercent = s.fromMint ? 100 : pool.params.bindingPercent;
|
|
if (votePasses == s.inFavor) {
|
|
// Winning stake
|
|
// If this stake is from the minted fee, always redistribute it to the winners
|
|
uint reward = redistributeLosingStakes
|
|
? ((s.amount * amountFromLosers) / amountFromWinners) *
|
|
(bindingPercent / 100)
|
|
: 0;
|
|
_update(address(this), s.sender, s.amount + reward);
|
|
totalAllocated += reward;
|
|
} else {
|
|
// Losing stake
|
|
uint refund = (s.amount * (100 - bindingPercent)) / 100;
|
|
if (refund > 0) {
|
|
_update(address(this), s.sender, refund);
|
|
}
|
|
// If this stake is from the minted fee, don't burn it
|
|
if (!redistributeLosingStakes) {
|
|
uint amountToBurn = (s.amount *
|
|
pool.params.bindingPercent) / 100;
|
|
_burn(address(this), amountToBurn);
|
|
totalAllocated += amountToBurn;
|
|
}
|
|
totalAllocated += refund;
|
|
}
|
|
}
|
|
// Due to rounding, there may be some REP left over. Include this as a reward to the author.
|
|
uint256 remainder = amountFromLosers - totalAllocated;
|
|
if (remainder > 0) {
|
|
_update(address(this), post.author, remainder);
|
|
}
|
|
// Distribute fee proportionatly among all reputation holders
|
|
for (uint i = 0; i < memberCount; i++) {
|
|
address member = members[i];
|
|
uint256 share = (pool.fee * balanceOf(member)) / totalSupply();
|
|
// TODO: For efficiency this could be modified to hold the funds for recipients to withdraw
|
|
payable(member).transfer(share);
|
|
}
|
|
// Callback if requested
|
|
if (pool.callbackOnValidate) {
|
|
IOnValidate(pool.sender).onValidate(votePasses, pool.callbackData);
|
|
}
|
|
}
|
|
|
|
/// Transfer REP to a contract, and call that contract's receiveTransfer method
|
|
function stakeAvailability(
|
|
address to,
|
|
uint256 value,
|
|
uint duration
|
|
) external returns (bool transferred) {
|
|
transferred = super.transfer(to, value);
|
|
if (transferred)
|
|
IAcceptAvailability(to).acceptAvailability(
|
|
msg.sender,
|
|
value,
|
|
duration
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Convenience contract to extend for other contracts that will be initialized to
|
|
/// interact with a DAO contract.
|
|
contract DAOContract {
|
|
DAO immutable dao;
|
|
|
|
constructor(DAO dao_) {
|
|
dao = dao_;
|
|
}
|
|
}
|