diff --git a/ethereum/contracts/DAO.sol b/ethereum/contracts/DAO.sol index 39f720a..92dd9f6 100644 --- a/ethereum/contracts/DAO.sol +++ b/ethereum/contracts/DAO.sol @@ -2,295 +2,17 @@ pragma solidity ^0.8.24; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import "./IAcceptAvailability.sol"; -import "./IOnValidate.sol"; +import "./core/Reputation.sol"; +import "./core/ValidationPools.sol"; +import "./core/Forum.sol"; +import "./interfaces/IAcceptAvailability.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 - ); - } - } - +contract DAO is Reputation, Forum, ValidationPools { /// Transfer REP to a contract, and call that contract's receiveTransfer method function stakeAvailability( address to, diff --git a/ethereum/contracts/Onboarding.sol b/ethereum/contracts/Onboarding.sol index 93b0eb8..6f9efb6 100644 --- a/ethereum/contracts/Onboarding.sol +++ b/ethereum/contracts/Onboarding.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; import "./DAO.sol"; import "./WorkContract.sol"; -import "./IOnValidate.sol"; +import "./interfaces/IOnValidate.sol"; contract Onboarding is WorkContract, IOnValidate { constructor( diff --git a/ethereum/contracts/Proposals.sol b/ethereum/contracts/Proposals.sol index 8f4304e..1911f7d 100644 --- a/ethereum/contracts/Proposals.sol +++ b/ethereum/contracts/Proposals.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.24; import "./DAO.sol"; -import "./IOnValidate.sol"; -import "./IOnProposalAccepted.sol"; +import "./interfaces/IOnValidate.sol"; +import "./interfaces/IOnProposalAccepted.sol"; import "hardhat/console.sol"; diff --git a/ethereum/contracts/WorkContract.sol b/ethereum/contracts/WorkContract.sol index 5f67299..22e9058 100644 --- a/ethereum/contracts/WorkContract.sol +++ b/ethereum/contracts/WorkContract.sol @@ -2,9 +2,9 @@ pragma solidity ^0.8.24; import "./DAO.sol"; -import "./IAcceptAvailability.sol"; import "./Proposals.sol"; -import "./IOnProposalAccepted.sol"; +import "./interfaces/IAcceptAvailability.sol"; +import "./interfaces/IOnProposalAccepted.sol"; abstract contract WorkContract is DAOContract, diff --git a/ethereum/contracts/core/Forum.sol b/ethereum/contracts/core/Forum.sol new file mode 100644 index 0000000..fff1d80 --- /dev/null +++ b/ethereum/contracts/core/Forum.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.24; + +struct Post { + uint id; + address sender; + address author; + string contentId; +} + +contract Forum { + mapping(uint => Post) public posts; + uint public postCount; + + event PostAdded(uint postIndex); + + 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); + } +} diff --git a/ethereum/contracts/core/Reputation.sol b/ethereum/contracts/core/Reputation.sol new file mode 100644 index 0000000..1167844 --- /dev/null +++ b/ethereum/contracts/core/Reputation.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.24; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract Reputation is ERC20("Reputation", "REP") { + function decimals() public pure override returns (uint8) { + return 9; + } +} diff --git a/ethereum/contracts/core/ValidationPools.sol b/ethereum/contracts/core/ValidationPools.sol new file mode 100644 index 0000000..5322bca --- /dev/null +++ b/ethereum/contracts/core/ValidationPools.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.24; + +import "./Reputation.sol"; +import "./Forum.sol"; + +import "../interfaces/IOnValidate.sol"; + +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; +} + +contract ValidationPools is Reputation, Forum { + mapping(uint => ValidationPool) public validationPools; + uint public validationPoolCount; + + mapping(uint => address) public members; + uint public memberCount; + mapping(address => bool) public isMember; + + // TODO: possible parameter for minting ratio + // TODO: possible parameter for stakeForAuthor + + uint constant minDuration = 1; // 1 second + uint constant maxDuration = 365_000_000 days; // 1 million years + uint[2] minQuorum = [1, 10]; + + event ValidationPoolInitiated(uint poolIndex); + event ValidationPoolResolved( + uint poolIndex, + bool votePasses, + bool quorumMet + ); + + // TODO: Add forum parameters + /// 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); + } + + /// 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); + } + + /// 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 + ); + } + } +} diff --git a/ethereum/contracts/IAcceptAvailability.sol b/ethereum/contracts/interfaces/IAcceptAvailability.sol similarity index 100% rename from ethereum/contracts/IAcceptAvailability.sol rename to ethereum/contracts/interfaces/IAcceptAvailability.sol diff --git a/ethereum/contracts/IOnProposalAccepted.sol b/ethereum/contracts/interfaces/IOnProposalAccepted.sol similarity index 100% rename from ethereum/contracts/IOnProposalAccepted.sol rename to ethereum/contracts/interfaces/IOnProposalAccepted.sol diff --git a/ethereum/contracts/IOnValidate.sol b/ethereum/contracts/interfaces/IOnValidate.sol similarity index 100% rename from ethereum/contracts/IOnValidate.sol rename to ethereum/contracts/interfaces/IOnValidate.sol