Add onboarding contract
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 37s Details

This commit is contained in:
Ladd Hoffman 2024-03-17 21:00:31 -05:00
parent 4674a184ce
commit 591a2c3627
5 changed files with 521 additions and 4 deletions

View File

@ -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

View File

@ -0,0 +1,6 @@
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.24;
interface IOnValidate {
function onValidate(bool votePasses, bytes calldata callbackData) external;
}

View File

@ -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, "");
}
}

View File

@ -169,7 +169,9 @@ contract Work1 is IAcceptAvailability {
// Initiate validation pool
request.poolIndex = dao.initiateValidationPool{value: request.fee}(
postIndex,
POOL_DURATION
POOL_DURATION,
false,
""
);
}
}

308
ethereum/test/Onboarding.js Normal file
View File

@ -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);
});
});
});