diff --git a/ethereum/contracts/DAO.sol b/ethereum/contracts/DAO.sol index db29827..56d6331 100644 --- a/ethereum/contracts/DAO.sol +++ b/ethereum/contracts/DAO.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "./IAcceptAvailability.sol"; +import "./IOnValidate.sol"; import "hardhat/console.sol"; @@ -26,12 +27,12 @@ struct ValidationPool { mapping(uint => Stake) stakes; uint stakeCount; uint256 fee; - uint256 initialStakedFor; - uint256 initialStakedAgainst; uint duration; uint endTime; bool resolved; bool outcome; + bool callbackOnValidate; + bytes callbackData; } /// This contract must manage validation pools and reputation, @@ -74,7 +75,9 @@ contract DAO is ERC20("Reputation", "REP") { /// TODO: Constrain duration to allowable range function initiateValidationPool( uint postIndex, - uint duration + uint duration, + bool callbackOnValidate, + bytes calldata callbackData ) public payable returns (uint poolIndex) { require(msg.value > 0, "Fee is required to initiate validation pool"); require(duration >= minDuration, "Duration is too short"); @@ -89,6 +92,9 @@ contract DAO is ERC20("Reputation", "REP") { 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 @@ -183,6 +189,10 @@ contract DAO is ERC20("Reputation", "REP") { // 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 diff --git a/ethereum/contracts/IOnValidate.sol b/ethereum/contracts/IOnValidate.sol new file mode 100644 index 0000000..c0d3c38 --- /dev/null +++ b/ethereum/contracts/IOnValidate.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.24; + +interface IOnValidate { + function onValidate(bool votePasses, bytes calldata callbackData) external; +} diff --git a/ethereum/contracts/Onboarding.sol b/ethereum/contracts/Onboarding.sol new file mode 100644 index 0000000..15aa38c --- /dev/null +++ b/ethereum/contracts/Onboarding.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.24; + +import "./DAO.sol"; +import "./IAcceptAvailability.sol"; +import "./IOnValidate.sol"; + +struct AvailabilityStake { + address worker; + uint256 amount; + uint endTime; + bool assigned; + bool reclaimed; +} + +enum WorkStatus { + Requested, + EvidenceSubmitted, + ApprovalSubmitted, + Complete +} + +struct WorkRequest { + address customer; + uint256 fee; + WorkStatus status; + uint stakeIndex; + bool approval; + uint reviewPoolIndex; + uint onboardPoolIndex; +} + +contract Onboarding is IAcceptAvailability, IOnValidate { + DAO immutable dao; + uint public immutable price; + mapping(uint => AvailabilityStake) public stakes; + uint public stakeCount; + mapping(uint => WorkRequest) public requests; + uint public requestCount; + + // TODO: Make parameters configurable + uint constant POOL_DURATION = 1 days; + + event AvailabilityStaked(uint stakeIndex); + event WorkAssigned(address worker, uint requestIndex); + event WorkEvidenceSubmitted(uint requestIndex); + event WorkApprovalSubmitted(uint requestIndex, bool approval); + + constructor(DAO dao_, uint price_) { + dao = dao_; + price = price_; + } + + /// Accept availability stakes as reputation token transfer + function acceptAvailability( + address sender, + uint256 amount, + uint duration + ) external { + require(amount > 0, "No stake provided"); + uint stakeIndex = stakeCount++; + AvailabilityStake storage stake = stakes[stakeIndex]; + stake.worker = sender; + stake.amount = amount; + stake.endTime = block.timestamp + duration; + emit AvailabilityStaked(stakeIndex); + } + + function extendAvailability(uint stakeIndex, uint duration) external { + AvailabilityStake storage stake = stakes[stakeIndex]; + require( + msg.sender == stake.worker, + "Worker can only extend their own availability stake" + ); + require(!stake.reclaimed, "Stake has already been reclaimed"); + require(!stake.assigned, "Stake has already been assigned work"); + if (block.timestamp > stake.endTime) { + stake.endTime = block.timestamp + duration; + } else { + stake.endTime = stake.endTime + duration; + } + emit AvailabilityStaked(stakeIndex); + } + + function reclaimAvailability(uint stakeIndex) external { + AvailabilityStake storage stake = stakes[stakeIndex]; + require( + msg.sender == stake.worker, + "Worker can only reclaim their own availability stake" + ); + require( + block.timestamp > stake.endTime, + "Stake duration has not yet elapsed" + ); + require(!stake.reclaimed, "Stake has already been reclaimed"); + require(!stake.assigned, "Stake has already been assigned work"); + stake.reclaimed = true; + dao.transfer(msg.sender, stake.amount); + emit AvailabilityStaked(stakeIndex); + } + + /// Select a worker randomly from among the available workers, weighted by amount staked + function randomWeightedSelection() internal view returns (uint stakeIndex) { + uint totalStakes; + for (uint i = 0; i < stakeCount; i++) { + if (stakes[i].assigned) continue; + if (block.timestamp > stakes[i].endTime) continue; + totalStakes += stakes[i].amount; + } + require(totalStakes > 0, "No available worker stakes"); + uint select = block.prevrandao % totalStakes; + uint acc; + for (uint i = 0; i < stakeCount; i++) { + if (stakes[i].assigned) continue; + if (block.timestamp > stakes[i].endTime) continue; + acc += stakes[i].amount; + if (acc > select) { + stakeIndex = i; + break; + } + } + } + + /// Assign a random available worker + function assignWork(uint requestIndex) internal returns (uint stakeIndex) { + stakeIndex = randomWeightedSelection(); + AvailabilityStake storage stake = stakes[stakeIndex]; + stake.assigned = true; + emit WorkAssigned(stake.worker, requestIndex); + } + + /// Accept work request with fee + function requestWork() external payable { + require(msg.value >= price, "Insufficient fee"); + uint requestIndex = requestCount++; + WorkRequest storage request = requests[requestIndex]; + request.customer = msg.sender; + request.fee = msg.value; + request.stakeIndex = assignWork(requestIndex); + } + + /// Accept work evidence from worker + function submitWorkEvidence(uint requestIndex) external { + WorkRequest storage request = requests[requestIndex]; + require( + request.status == WorkStatus.Requested, + "Status must be Requested" + ); + AvailabilityStake storage stake = stakes[request.stakeIndex]; + require( + stake.worker == msg.sender, + "Worker can only submit evidence for work they are assigned" + ); + request.status = WorkStatus.EvidenceSubmitted; + emit WorkEvidenceSubmitted(requestIndex); + } + + /// Accept work approval/disapproval from customer + function submitWorkApproval(uint requestIndex, bool approval) external { + WorkRequest storage request = requests[requestIndex]; + require( + request.status == WorkStatus.EvidenceSubmitted, + "Status must be EvidenceSubmitted" + ); + AvailabilityStake storage stake = stakes[request.stakeIndex]; + request.status = WorkStatus.ApprovalSubmitted; + request.approval = approval; + // Make work evidence post + uint postIndex = dao.addPost(stake.worker); + emit WorkApprovalSubmitted(requestIndex, approval); + // Initiate validation pool + request.reviewPoolIndex = dao.initiateValidationPool{ + value: request.fee - request.fee / 10 + }(postIndex, POOL_DURATION, true, abi.encode(requestIndex)); + } + + /// Callback to be executed when review pool completes + function onValidate(bool votePasses, bytes calldata callbackData) external { + require( + msg.sender == address(dao), + "onValidate may only be called by the DAO contract" + ); + if (!votePasses) return; + uint requestIndex = abi.decode(callbackData, (uint)); + WorkRequest storage request = requests[requestIndex]; + uint postIndex = dao.addPost(request.customer); + request.onboardPoolIndex = dao.initiateValidationPool{ + value: request.fee / 10 + }(postIndex, POOL_DURATION, false, ""); + } +} diff --git a/ethereum/contracts/Work1.sol b/ethereum/contracts/Work1.sol index 8c1fab8..66ce392 100644 --- a/ethereum/contracts/Work1.sol +++ b/ethereum/contracts/Work1.sol @@ -169,7 +169,9 @@ contract Work1 is IAcceptAvailability { // Initiate validation pool request.poolIndex = dao.initiateValidationPool{value: request.fee}( postIndex, - POOL_DURATION + POOL_DURATION, + false, + "" ); } } diff --git a/ethereum/test/Onboarding.js b/ethereum/test/Onboarding.js new file mode 100644 index 0000000..9e3cb38 --- /dev/null +++ b/ethereum/test/Onboarding.js @@ -0,0 +1,308 @@ +const { + time, + loadFixture, +} = require('@nomicfoundation/hardhat-toolbox/network-helpers'); +const { expect } = require('chai'); +const { ethers } = require('hardhat'); + +describe('Onboarding', () => { + const PRICE = 100; + const STAKE_DURATION = 60; + async function deploy() { + // Contracts are deployed using the first signer/account by default + const [account1, account2] = await ethers.getSigners(); + + const DAO = await ethers.getContractFactory('DAO'); + const dao = await DAO.deploy(); + const Onboarding = await ethers.getContractFactory('Onboarding'); + const onboarding = await Onboarding.deploy(dao.target, PRICE); + + await dao.addPost(account1); + const callbackData = ethers.AbiCoder.defaultAbiCoder().encode([], []); + await dao.initiateValidationPool(0, 60, false, callbackData, { value: 100 }); + await time.increase(61); + await dao.evaluateOutcome(0); + expect(await dao.balanceOf(account1)).to.equal(100); + + return { + dao, onboarding, account1, account2, + }; + } + + it('Should deploy', async () => { + const { dao, onboarding, account1 } = await loadFixture(deploy); + expect(dao).to.exist; + expect(onboarding).to.exist; + expect(await dao.memberCount()).to.equal(1); + expect(await dao.balanceOf(account1)).to.equal(100); + expect(await dao.totalSupply()).to.equal(100); + expect(await onboarding.stakeCount()).to.equal(0); + }); + + describe('Stake availability', () => { + let dao; + let onboarding; + let account1; + let account2; + + beforeEach(async () => { + ({ + dao, onboarding, account1, account2, + } = await loadFixture(deploy)); + await expect(dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION)).to.emit(onboarding, 'AvailabilityStaked').withArgs(0); + }); + + it('Should be able to stake availability', async () => { + expect(await dao.balanceOf(account1)).to.equal(50); + expect(await dao.balanceOf(onboarding.target)).to.equal(50); + expect(await onboarding.stakeCount()).to.equal(1); + const stake = await onboarding.stakes(0); + expect(stake.worker).to.equal(account1); + expect(stake.amount).to.equal(50); + expect(stake.endTime).to.equal(await time.latest() + STAKE_DURATION); + }); + + it('should not be able to stake availability without reputation value', async () => { + await expect(dao.stakeAvailability(onboarding.target, 0, STAKE_DURATION)).to.be.revertedWith('No stake provided'); + }); + + it('should be able to reclaim staked availability after duration elapses', async () => { + expect(await dao.balanceOf(account1)).to.equal(50); + time.increase(STAKE_DURATION + 1); + await expect(onboarding.reclaimAvailability(0)).to.emit(onboarding, 'AvailabilityStaked').withArgs(0); + expect(await dao.balanceOf(account1)).to.equal(100); + }); + + it('should not be able to reclaim staked availability twice', async () => { + expect(await dao.balanceOf(account1)).to.equal(50); + time.increase(STAKE_DURATION + 1); + await onboarding.reclaimAvailability(0); + await expect(onboarding.reclaimAvailability(0)).to.be.revertedWith('Stake has already been reclaimed'); + }); + + it('should not be able to reclaim staked availability before duration elapses', async () => { + await expect(onboarding.reclaimAvailability(0)).to.be.revertedWith('Stake duration has not yet elapsed'); + }); + + it('should not be able to reclaim availability staked by another account', async () => { + time.increase(STAKE_DURATION + 1); + await expect(onboarding.connect(account2).reclaimAvailability(0)).to.be.revertedWith('Worker can only reclaim their own availability stake'); + }); + + it('should be able to extend the duration of an availability stake before it expires', async () => { + await time.increase(STAKE_DURATION / 2); + await expect(onboarding.extendAvailability(0, STAKE_DURATION)).to.emit(onboarding, 'AvailabilityStaked').withArgs(0); + }); + + it('should be able to extend the duration of an availability stake after it expires', async () => { + await time.increase(STAKE_DURATION * 2); + await onboarding.extendAvailability(0, STAKE_DURATION); + }); + + it('should not be able to extend the duration of another worker\'s availability stake', async () => { + await time.increase(STAKE_DURATION * 2); + await expect(onboarding.connect(account2).extendAvailability(0, STAKE_DURATION)).to.be.revertedWith('Worker can only extend their own availability stake'); + }); + + it('should not be able to extend a stake that has been reclaimed', async () => { + await time.increase(STAKE_DURATION * 2); + await onboarding.reclaimAvailability(0); + await expect(onboarding.extendAvailability(0, STAKE_DURATION)).to.be.revertedWith('Stake has already been reclaimed'); + }); + + it('extending a stake before expiration should increase the end time by the given duration', async () => { + await time.increase(STAKE_DURATION / 2); + await onboarding.extendAvailability(0, STAKE_DURATION * 2); + const expectedEndTime = await time.latest() + 2.5 * STAKE_DURATION; + const stake = await onboarding.stakes(0); + expect(stake.endTime).to.be.within(expectedEndTime - 1, expectedEndTime); + }); + + it('extending a stake after expiration should restart the stake for the given duration', async () => { + await time.increase(STAKE_DURATION * 2); + await onboarding.extendAvailability(0, STAKE_DURATION * 2); + const expectedEndTime = await time.latest() + STAKE_DURATION * 2; + const stake = await onboarding.stakes(0); + expect(stake.endTime).to.be.within(expectedEndTime - 1, expectedEndTime); + }); + }); + + describe('Request and assign work', () => { + it('should be able to request work and assign to a worker', async () => { + const { + dao, onboarding, account1, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + const requestWork = () => onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(requestWork()).to.emit(onboarding, 'WorkAssigned').withArgs(account1, 0); + expect(await onboarding.requestCount()).to.equal(1); + const request = await onboarding.requests(0); + expect(request.customer).to.equal(account2); + }); + + it('should not be able to reclaim stake after work is assigned', async () => { + const { + dao, onboarding, account1, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + const requestWork = () => onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(requestWork()).to.emit(onboarding, 'WorkAssigned').withArgs(account1, 0); + await time.increase(STAKE_DURATION + 1); + await expect(onboarding.reclaimAvailability(0)).to.be.revertedWith('Stake has already been assigned work'); + }); + it('should not be able to request work if there are no availability stakes', async () => { + const { + onboarding, account2, + } = await loadFixture(deploy); + const requestWork = () => onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(requestWork()).to.be.revertedWith('No available worker stakes'); + }); + + it('should not be able to request work if fee is insufficient', async () => { + const { + onboarding, account2, + } = await loadFixture(deploy); + const requestWork = () => onboarding.connect(account2).requestWork({ value: PRICE / 2 }); + await expect(requestWork()).to.be.revertedWith('Insufficient fee'); + }); + + it('should not assign work to an expired availability stake', async () => { + const { + dao, onboarding, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + const requestWork = () => onboarding.connect(account2).requestWork({ value: PRICE }); + await time.increase(STAKE_DURATION + 1); + await expect(requestWork()).to.be.revertedWith('No available worker stakes'); + }); + + it('should not assign work to the same availability stake twice', async () => { + const { + dao, onboarding, account1, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + const requestWork = () => onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(requestWork()).to.emit(onboarding, 'WorkAssigned').withArgs(account1, 0); + await expect(requestWork()).to.be.revertedWith('No available worker stakes'); + }); + + it('should not be able to extend a stake that has been assigned work', async () => { + const { + dao, onboarding, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + await onboarding.connect(account2).requestWork({ value: PRICE }); + await time.increase(STAKE_DURATION * 2); + await expect(onboarding.extendAvailability(0, STAKE_DURATION)).to.be.revertedWith('Stake has already been assigned work'); + }); + }); + + describe('Work evidence and approval/disapproval', () => { + let dao; + let onboarding; + let account1; + let account2; + + beforeEach(async () => { + ({ + dao, onboarding, account1, account2, + } = await loadFixture(deploy)); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + }); + + it('should be able to submit work evidence', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(onboarding.submitWorkEvidence(0)).to.emit(onboarding, 'WorkEvidenceSubmitted').withArgs(0); + }); + + it('should not be able to submit work evidence twice', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(onboarding.submitWorkEvidence(0)).to.emit(onboarding, 'WorkEvidenceSubmitted').withArgs(0); + await expect(onboarding.submitWorkEvidence(0)).to.be.revertedWith('Status must be Requested'); + }); + + it('should not be able to submit work evidence for a different worker', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(onboarding.connect(account2).submitWorkEvidence(0)).to.be.revertedWith('Worker can only submit evidence for work they are assigned'); + }); + + it('should be able to submit work approval', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await onboarding.submitWorkEvidence(0); + await expect(onboarding.submitWorkApproval(0, true)) + .to.emit(dao, 'ValidationPoolInitiated').withArgs(1) + .to.emit(onboarding, 'WorkApprovalSubmitted').withArgs(0, true); + const post = await dao.posts(1); + expect(post.author).to.equal(account1); + expect(post.sender).to.equal(onboarding.target); + const pool = await dao.validationPools(1); + expect(pool.postIndex).to.equal(1); + expect(pool.fee).to.equal(PRICE * 0.9); + expect(pool.sender).to.equal(onboarding.target); + }); + + it('should be able to submit work disapproval', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await onboarding.submitWorkEvidence(0); + await expect(onboarding.submitWorkApproval(0, false)) + .to.emit(dao, 'ValidationPoolInitiated').withArgs(1) + .to.emit(onboarding, 'WorkApprovalSubmitted').withArgs(0, false); + }); + + it('should not be able to submit work approval/disapproval twice', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await onboarding.submitWorkEvidence(0); + await expect(onboarding.submitWorkApproval(0, true)).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); + await expect(onboarding.submitWorkApproval(0, true)).to.be.revertedWith('Status must be EvidenceSubmitted'); + }); + + it('should not be able to submit work evidence after work approval', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await onboarding.submitWorkEvidence(0); + await expect(onboarding.submitWorkApproval(0, true)).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); + await expect(onboarding.submitWorkEvidence(0)).to.be.revertedWith('Status must be Requested'); + }); + + it('should not be able to submit work approval/disapproval before work evidence', async () => { + await onboarding.connect(account2).requestWork({ value: PRICE }); + await expect(onboarding.submitWorkApproval(0, true)).to.be.revertedWith('Status must be EvidenceSubmitted'); + }); + }); + + describe('Onboarding followup', () => { + it('resolving the first validation pool should trigger a second pool', async () => { + const { + dao, onboarding, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + await onboarding.connect(account2).requestWork({ value: PRICE }); + await onboarding.submitWorkEvidence(0); + await expect(onboarding.submitWorkApproval(0, true)).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); + await time.increase(86401); + await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); + expect(await dao.postCount()).to.equal(3); + const post = await dao.posts(2); + expect(post.author).to.equal(account2); + expect(post.sender).to.equal(onboarding.target); + const pool = await dao.validationPools(2); + expect(pool.postIndex).to.equal(2); + expect(pool.fee).to.equal(PRICE * 0.1); + expect(pool.sender).to.equal(onboarding.target); + expect(pool.fee); + }); + + it('if the first validation pool is rejected it should not trigger a second pool', async () => { + const { + dao, onboarding, account2, + } = await loadFixture(deploy); + await dao.stakeAvailability(onboarding.target, 50, STAKE_DURATION); + await onboarding.connect(account2).requestWork({ value: PRICE }); + await onboarding.submitWorkEvidence(0); + await expect(onboarding.submitWorkApproval(0, true)).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); + await dao.stake(1, 50, false); + await time.increase(86401); + await expect(dao.evaluateOutcome(1)).not.to.emit(dao, 'ValidationPoolInitiated'); + expect(await dao.postCount()).to.equal(2); + }); + }); +});