diff --git a/ethereum/contracts/core/LightweightBench.sol b/ethereum/contracts/core/LightweightBench.sol new file mode 100644 index 0000000..c6684b1 --- /dev/null +++ b/ethereum/contracts/core/LightweightBench.sol @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.24; + +import "./DAO.sol"; + +struct LWVPoolParams { + uint duration; + uint[2] quorum; // [ Numerator, Denominator ] + uint[2] winRatio; // [ Numerator, Denominator ] + uint bindingPercent; + bool redistributeLosingStakes; +} + +struct LWVPoolProps { + string postId; + uint fee; + uint minted; + uint endTime; + bool resolved; + bool outcome; +} + +contract LightweightBench { + struct Transfer { + address from; + address to; + uint amount; + } + + struct ProposedResult { + Transfer[] transfers; + uint stakedFor; + } + + struct Stake { + uint id; + bool inFavor; + uint amount; + address sender; + string resultHash; + } + + struct Pool { + uint id; + address sender; + mapping(string => ProposedResult) proposedResults; + string[] proposedResultHashes; + mapping(uint => Stake) stakes; + uint stakeCount; + LWVPoolParams params; + LWVPoolProps props; + bool callbackOnValidate; + bytes callbackData; + } + + mapping(uint => Pool) public validationPools; + uint public validationPoolCount; + DAO dao; + + uint constant minDuration = 1; // 1 second + uint constant maxDuration = 365_000_000 days; // 1 million years + uint[2] minQuorum = [1, 10]; + + function registerDAO(DAO dao_) external { + require( + address(dao) == address(0), + "A DAO has already been registered" + ); + dao = dao_; + } + + /// Accept fee to initiate a validation pool + function initiateValidationPool( + address sender, + string calldata postId, + 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.sender == address(dao), + "Only DAO contract may call initiateValidationPool" + ); + 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"); + poolIndex = validationPoolCount++; + Pool storage pool = validationPools[poolIndex]; + pool.id = poolIndex; + pool.sender = sender; + pool.props.postId = postId; + pool.props.fee = msg.value; + pool.props.endTime = block.timestamp + duration; + pool.params.quorum = quorum; + pool.params.winRatio = winRatio; + pool.params.bindingPercent = bindingPercent; + pool.params.redistributeLosingStakes = redistributeLosingStakes; + pool.params.duration = duration; + 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 + dao.mint(address(dao), pool.props.fee); + pool.props.minted = msg.value; + dao.emitLWValidationPoolInitiated(poolIndex); + } + + function proposeResult( + uint poolIndex, + string calldata resultHash, + Transfer[] calldata transfers + ) external { + Pool storage pool = validationPools[poolIndex]; + require( + block.timestamp <= pool.props.endTime, + "Pool end time has passed" + ); + ProposedResult storage proposedResult = pool.proposedResults[ + resultHash + ]; + pool.proposedResultHashes.push(resultHash); + require( + proposedResult.transfers.length == 0, + "This result hash has already been proposed" + ); + for (uint i = 0; i < transfers.length; i++) { + proposedResult.transfers.push(transfers[i]); + } + } + + /// Register a stake for/against a validation pool + function stakeOnValidationPool( + uint poolIndex, + string calldata resultHash, + address sender, + uint256 amount, + bool inFavor + ) external { + require( + msg.sender == address(dao), + "Only DAO contract may call stakeOnValidationPool" + ); + Pool storage pool = validationPools[poolIndex]; + require( + block.timestamp <= pool.props.endTime, + "Pool end time has passed" + ); + if (inFavor) { + ProposedResult storage proposedResult = pool.proposedResults[ + resultHash + ]; + require( + proposedResult.transfers.length > 0, + "This result hash has not been proposed" + ); + } + // We don't call _update here; We defer that until evaluateOutcome. + uint stakeIndex = pool.stakeCount++; + Stake storage s = pool.stakes[stakeIndex]; + s.sender = sender; + s.inFavor = inFavor; + s.amount = amount; + s.id = stakeIndex; + s.resultHash = resultHash; + } + + /// Evaluate outcome of a validation pool + function evaluateOutcome(uint poolIndex) public returns (bool votePasses) { + require( + msg.sender == address(dao), + "Only DAO contract may call evaluateOutcome" + ); + Pool storage pool = validationPools[poolIndex]; + require(pool.props.resolved == false, "Pool is already resolved"); + uint stakedFor; + uint stakedAgainst; + Stake storage s; + for (uint i = 0; i < pool.stakeCount; i++) { + s = pool.stakes[i]; + // Make sure the sender still has the required balance. + // If not, automatically decrease the staked amount. + if (dao.balanceOf(s.sender) < s.amount) { + s.amount = dao.balanceOf(s.sender); + } + if (s.inFavor) { + ProposedResult storage proposedResult = pool.proposedResults[ + s.resultHash + ]; + proposedResult.stakedFor += s.amount; + } else { + stakedAgainst += s.amount; + } + } + // Determine the winning result hash + uint[] memory stakedForResult = new uint[]( + pool.proposedResultHashes.length + ); + uint winningResult; + for (uint i = 0; i < pool.proposedResultHashes.length; i++) { + string storage proposedResultHash = pool.proposedResultHashes[i]; + ProposedResult storage proposedResult = pool.proposedResults[ + proposedResultHash + ]; + stakedForResult[i] += proposedResult.stakedFor; + if (stakedForResult[i] > stakedForResult[winningResult]) { + winningResult = i; + } + } + // Only count stakes for the winning hash among the total staked in favor of the pool + for (uint i = 0; i < pool.stakeCount; i++) { + s = pool.stakes[i]; + if ( + s.inFavor && + keccak256(bytes(s.resultHash)) == + keccak256(bytes(pool.proposedResultHashes[winningResult])) + ) { + stakedFor += s.amount; + } + } + + stakedFor += pool.props.minted / 2; + stakedAgainst += pool.props.minted / 2; + if (pool.props.minted % 2 != 0) { + stakedFor += 1; + } + // Special case for early evaluation if dao.totalSupply has been staked + require( + block.timestamp > pool.props.endTime || + stakedFor + stakedAgainst == dao.totalSupply(), + "Pool end time has not yet arrived" + ); + // Check that quorum is met + if ( + pool.params.quorum[1] * (stakedFor + stakedAgainst) <= + dao.totalSupply() * pool.params.quorum[0] + ) { + // TODO: Refund fee + // TODO: this could be made available for the sender to withdraw + // payable(pool.sender).transfer(pool.props.fee); + pool.props.resolved = true; + dao.emitValidationPoolResolved(poolIndex, false, false); + // Callback if requested + if (pool.callbackOnValidate) { + dao.onValidate( + pool.sender, + 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]; + pool.props.resolved = true; + pool.props.outcome = votePasses; + dao.emitValidationPoolResolved(poolIndex, votePasses, true); + + // Value of losing stakes should be distributed among winners, in proportion to their stakes + // Only bindingPercent % should be redistributed + // Stake senders should get (1000000-bindingPercent) % back + uint amountFromWinners = votePasses ? stakedFor : stakedAgainst; + uint totalRewards; + uint totalAllocated; + for (uint i = 0; i < pool.stakeCount; i++) { + s = pool.stakes[i]; + if (votePasses != s.inFavor) { + // Losing stake + uint amount = (s.amount * pool.params.bindingPercent) / 100; + if (pool.params.redistributeLosingStakes) { + dao.update(s.sender, address(dao), amount); + totalRewards += amount; + } else { + dao.burn(s.sender, amount); + } + } + } + + if (votePasses) { + // If vote passes, reward the author as though they had staked the winning portion of the VP initial stake + // Here we assume a stakeForAuthor ratio of 0.5 + // TODO: Make stakeForAuthor an adjustable parameter + totalRewards += pool.props.minted / 2; + // Include the losign portion of the VP initial stake + // Issue rewards to the winners + for (uint i = 0; i < pool.stakeCount; i++) { + s = pool.stakes[i]; + if ( + pool.params.redistributeLosingStakes && + votePasses == s.inFavor + ) { + // Winning stake + uint reward = (((totalRewards * s.amount) / + amountFromWinners) * pool.params.bindingPercent) / 100; + totalAllocated += reward; + dao.update(address(dao), s.sender, reward); + } + } + // Due to rounding, there may be some excess REP. Award it to the author. + uint remainder = totalRewards - totalAllocated; + if (pool.props.minted % 2 != 0) { + // We staked the odd remainder in favor of the post, on behalf of the author. + remainder += 1; + } + + // Execute the transfers from the winning proposed result + ProposedResult storage result = pool.proposedResults[ + pool.proposedResultHashes[winningResult] + ]; + for (uint i = 0; i < result.transfers.length; i++) { + dao.update( + result.transfers[i].from, + result.transfers[i].to, + result.transfers[i].amount + ); + } + } else { + // If vote does not pass, divide the losing stake among the winners + totalRewards += pool.props.minted; + for (uint i = 0; i < pool.stakeCount; i++) { + s = pool.stakes[i]; + if ( + pool.params.redistributeLosingStakes && + votePasses == s.inFavor + ) { + // Winning stake + uint reward = (((totalRewards * s.amount) / + (amountFromWinners - pool.props.minted / 2)) * + pool.params.bindingPercent) / 100; + totalAllocated += reward; + dao.update(address(dao), s.sender, reward); + } + } + } + + // Distribute fee proportionately among all reputation holders + dao.distributeFeeAmongMembers{value: pool.props.fee}(); + + // Callback if requested + if (pool.callbackOnValidate) { + dao.onValidate( + pool.sender, + votePasses, + true, + stakedFor, + stakedAgainst, + pool.callbackData + ); + } + } +}