// 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 duration; uint[2] quorum; // [ Numerator, Denominator ] uint[2] winRatio; // [ Numerator, Denominator ] uint bindingPercent; bool redistributeLosingStakes; } struct ValidationPool { uint id; uint postIndex; address sender; mapping(uint => Stake) stakes; uint stakeCount; ValidationPoolParams params; uint256 fee; 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: Add forum parameters uint constant minDuration = 1; // 1 second uint constant maxDuration = 365_000_000 days; // 1 million years uint[2] minQuorum = [1, 10]; event PostAdded(uint postIndex); event ValidationPoolInitiated(uint poolIndex); event ValidationPoolResolved( uint poolIndex, bool votePasses, bool quorumMet ); function decimals() public pure override returns (uint8) { return 9; } 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( minQuorum[1] * quorum[0] >= minQuorum[0] * quorum[1], "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.quorum = quorum; pool.params.winRatio = winRatio; pool.params.bindingPercent = bindingPercent; pool.params.redistributeLosingStakes = redistributeLosingStakes; pool.params.duration = duration; pool.endTime = block.timestamp + duration; pool.id = poolIndex; pool.callbackOnValidate = callbackOnValidate; pool.callbackData = callbackData; // We use our privilege as the DAO contract to mint reputation in proportion with the fee. // Here we assume a minting ratio of 1 // TODO: Make minting ratio an adjustable parameter _mint(post.author, msg.value); // Here we assume a stakeForAuthor ratio of 0.5 // TODO: Make stakeForAuthor an adjustable parameter _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(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; } } // Special case for early evaluation if dao.totalSupply has been staked require( block.timestamp > pool.endTime || stakedFor + stakedAgainst == totalSupply(), "Pool end time has not yet arrived" ); // Check that quorum is met if ( pool.params.quorum[1] * (stakedFor + stakedAgainst) <= totalSupply() * pool.params.quorum[0] ) { // Refund fee // TODO: this could be made available for the sender to withdraw // payable(pool.sender).transfer(pool.fee); // Refund stakes for (uint i = 0; i < pool.stakeCount; i++) { s = pool.stakes[i]; _update(address(this), s.sender, s.amount); } pool.resolved = true; emit ValidationPoolResolved(poolIndex, false, false); // Callback if requested if (pool.callbackOnValidate) { IOnValidate(pool.sender).onValidate( votePasses, false, stakedFor, stakedAgainst, pool.callbackData ); } 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. 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]; 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 bool redistributeLosingStakes = s.fromMint || pool.params.redistributeLosingStakes; uint reward = redistributeLosingStakes ? ((s.amount * amountFromLosers) / amountFromWinners) * (bindingPercent / 100) : 0; _update(address(this), s.sender, s.amount + reward); totalAllocated += reward; } else { // Losing stake // If this stake is from the minted fee, don't burn it if (!s.fromMint) { uint refund = (s.amount * (100 - bindingPercent)) / 100; if (refund > 0) { _update(address(this), s.sender, refund); } if (!pool.params.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 // TODO: Exclude encumbered reputation from totalSupply 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_; } }