// 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; uint[2] winRatio; // [ Numerator, Denominator ] } 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 = 100_000_000; // Parts per billion event PostAdded(uint postIndex); event ValidationPoolInitiated(uint poolIndex); event ValidationPoolResolved( uint poolIndex, bool votePasses, bool quorumMet ); 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[2] calldata quorum, // [Numerator, Denominator] uint[2] calldata winRatio, // [Numerator, Denominator] 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 * quorum[0]) / quorum[1] >= minQuorumPPB, "Quorum is below minimum" ); require(quorum[0] <= quorum[1], "Quorum is greater than one"); require(winRatio[0] <= winRatio[1], "Win ratio 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 * quorum[0]) / quorum[1]; pool.params.winRatio = winRatio; 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 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, stakedFor, stakedAgainst, 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. // jconsole.log( // "staked for %d against %d, win ratio %d / %d", console.log("stakedFor", stakedFor); console.log("stakedAgainst", stakedAgainst); console.log( "winRatio", pool.params.winRatio[0], pool.params.winRatio[1] ); votePasses = stakedFor * pool.params.winRatio[1] >= (stakedFor + stakedAgainst) * pool.params.winRatio[0]; if (votePasses && !isMember[post.author]) { members[memberCount++] = post.author; isMember[post.author] = true; } pool.resolved = true; pool.outcome = 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; // 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, true, stakedFor, stakedAgainst, 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_; } }