const { time, loadFixture, } = require('@nomicfoundation/hardhat-toolbox/network-helpers'); const { expect } = require('chai'); const { ethers } = require('hardhat'); const deployDAO = require('./util/deploy-dao'); describe('Lightweight Validation Pools', () => { async function deploy() { const [account1, account2] = await ethers.getSigners(); const { dao, forum } = await deployDAO(); return { dao, forum, account1, account2, }; } let dao; let forum; let account1; let account2; const POOL_DURATION = 3600; // 1 hour const POOL_FEE = 100; const emptyCallbackData = ethers.AbiCoder.defaultAbiCoder().encode([], []); const initiateValidationPool = ({ postId, duration, quorum, winRatio, bindingPercent, redistributeLosingStakes, callbackOnValidate, callbackData, fee, } = {}) => dao.initiateLWValidationPool( postId ?? 'content-id', duration ?? POOL_DURATION, quorum ?? [1, 3], winRatio ?? [1, 2], bindingPercent ?? 100, redistributeLosingStakes ?? true, callbackOnValidate ?? false, callbackData ?? emptyCallbackData, { value: fee ?? POOL_FEE }, ); beforeEach(async () => { ({ dao, forum, account1, account2, } = await loadFixture(deploy)); await forum.addPost([{ weightPPM: 1000000, authorAddress: account1 }], 'content-id', []); const init = () => initiateValidationPool({ fee: POOL_FEE }); await expect(init()).to.emit(dao, 'LWValidationPoolInitiated').withArgs(0); expect(await dao.getLWValidationPoolCount()).to.equal(1); expect(await dao.memberCount()).to.equal(0); expect(await dao.balanceOf(account1)).to.equal(0); expect(await dao.totalSupply()).to.equal(POOL_FEE); }); describe('Initiate', () => { it('should be able to initiate a validation pool without a fee', async () => { const init = () => initiateValidationPool({ fee: 0 }); await expect(init()).to.emit(dao, 'LWValidationPoolInitiated'); }); it('should not be able to initiate a validation pool with a quorum below the minimum', async () => { const init = () => initiateValidationPool({ quorum: [1, 11] }); await expect(init()).to.be.revertedWith('Quorum is below minimum'); }); it('should not be able to initiate a validation pool with a quorum greater than 1', async () => { const init = () => initiateValidationPool({ quorum: [11, 10] }); await expect(init()).to.be.revertedWith('Quorum is greater than one'); }); it('should not be able to initiate a validation pool with duration below minimum', async () => { const init = () => initiateValidationPool({ duration: 0 }); await expect(init()).to.be.revertedWith('Duration is too short'); }); it('should not be able to initiate a validation pool with duration above maximum', async () => { const init = () => initiateValidationPool({ duration: 40000000000000 }); await expect(init()).to.be.revertedWith('Duration is too long'); }); it('should not be able to initiate a validation pool with bindingPercent above 100', async () => { const init = () => initiateValidationPool({ bindingPercent: 101 }); await expect(init()).to.be.revertedWith('Binding percent must be <= 100'); }); it('should be able to initiate a second validation pool', async () => { const init = () => initiateValidationPool(); await expect(init()).to.emit(dao, 'LWValidationPoolInitiated').withArgs(1); expect(await dao.getLWValidationPoolCount()).to.equal(2); }); it('Should be able to fetch pool instance', async () => { const pool = await dao.getLWValidationPool(0); expect(pool).to.exist; expect(pool.params.duration).to.equal(POOL_DURATION); expect(pool.props.postId).to.equal('content-id'); expect(pool.props.resolved).to.be.false; expect(pool.sender).to.equal(account1); }); }); describe('Propose Result', () => { it('should not be able to propose an empty result', async () => { await expect(dao.proposeLWResult(0, 'some-hash', [])).to.be.revertedWith('The proposed result contains no transfers'); }); it('should be able to propose a result', async () => { await expect(dao.proposeLWResult(0, 'some-hash', [{ from: account1, to: account2, amount: 0 }])).to.emit(dao, 'LWResultProposed').withArgs(0, 0, 'some-hash'); await expect(dao.proposeLWResult(0, 'some-other-hash', [{ from: account1, to: account2, amount: 0 }])).to.emit(dao, 'LWResultProposed').withArgs(0, 1, 'some-other-hash'); }); it('should not be able to propose the same result twice', async () => { await expect(dao.proposeLWResult(0, 'some-hash', [{ from: account1, to: account2, amount: 0 }])).to.emit(dao, 'LWResultProposed').withArgs(0, 0, 'some-hash'); await expect(dao.proposeLWResult(0, 'some-hash', [{ from: account1, to: account2, amount: 0 }])).to.be.revertedWith('This result hash has already been proposed'); }); }); describe('Stake', async () => { beforeEach(async () => { time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(0); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(0); await initiateValidationPool(); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); }); it('should be able to stake before validation pool has elapsed', async () => { await dao.stakeOnLWValidationPool(1, 10, true); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true, true); expect(await dao.balanceOf(account1)).to.equal(200); expect(await dao.balanceOf(dao.target)).to.equal(0); }); it('should not be able to stake after validation pool has elapsed', async () => { time.increase(POOL_DURATION + 1); await expect(dao.stakeOnValidationPool(1, 10, true)).to.be.revertedWith('Pool end time has passed'); }); it('should be able to stake against a validation pool', async () => { await dao.stakeOnValidationPool(1, 10, false); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, false, true); expect(await dao.balanceOf(account1)).to.equal(200); expect(await dao.balanceOf(dao.target)).to.equal(0); const pool = await dao.getValidationPool(1); expect(pool.props.outcome).to.be.false; }); it('should not be able to stake more REP than the sender owns', async () => { await expect(dao.stakeOnValidationPool(1, 200, true)).to.be.revertedWith('Insufficient REP balance to cover stake'); }); }); describe('Delegated stake', () => { it('should stake the lesser of the allowed amount or the owner\'s remaining balance', async () => { // TODO: owner delegates stake and then loses rep }); }); describe('Evaluate outcome', () => { it('should not be able to evaluate outcome before duration has elapsed if not all rep has been staked', async () => { time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(0)); await initiateValidationPool({ fee: 100 }); await expect(dao.evaluateOutcome(1)).to.be.revertedWith('Pool end time has not yet arrived'); }); it('should not be able to evaluate outcome before duration has elapsed unless all rep has been staked', async () => { time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(0)); await initiateValidationPool({ fee: 100 }); await dao.stakeOnValidationPool(1, 100, true); await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true, true); }); it('should be able to evaluate outcome after duration has elapsed', async () => { expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); expect(await dao.memberCount()).to.equal(1); expect(await dao.balanceOf(account1)).to.equal(100); const pool = await dao.getValidationPool(0); expect(pool.props.resolved).to.be.true; expect(pool.props.outcome).to.be.true; }); it('should not be able to evaluate outcome more than once', async () => { time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); await expect(dao.evaluateOutcome(0)).to.be.revertedWith('Pool is already resolved'); }); it('should be able to evaluate outcome of second validation pool', async () => { const init = () => initiateValidationPool(); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); expect(await dao.getValidationPoolCount()).to.equal(2); time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); expect(await dao.balanceOf(account1)).to.equal(100); await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, true, true); expect(await dao.balanceOf(account1)).to.equal(200); }); it('should not be able to evaluate outcome if quorum is not met', async () => { time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(0)).to.emit(dao, 'ValidationPoolResolved').withArgs(0, true, true); const init = () => initiateValidationPool({ quorum: [1, 1] }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); expect(await dao.getValidationPoolCount()).to.equal(2); time.increase(POOL_DURATION + 1); await expect(dao.evaluateOutcome(1)).to.emit(dao, 'ValidationPoolResolved').withArgs(1, false, false); }); describe('Validation pool options', () => { beforeEach(async () => { time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(0); await forum.addPost([{ weightPPM: 1000000, authorAddress: account2 }], 'content-id-2', []); const init = () => initiateValidationPool({ postId: 'content-id-2' }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(1); time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(1); }); it('Binding validation pool should redistribute stakes', async () => { const init = () => initiateValidationPool(); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); await dao.connect(account1).stakeOnValidationPool(2, 10, true); await dao.connect(account2).stakeOnValidationPool(2, 10, false); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(2); expect(await dao.balanceOf(account1)).to.equal(210); expect(await dao.balanceOf(account2)).to.equal(90); expect(await dao.balanceOf(dao.target)).to.equal(0); }); it('Non binding validation pool should not redistribute stakes', async () => { const init = () => initiateValidationPool({ bindingPercent: 0 }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); await dao.connect(account1).stakeOnValidationPool(2, 10, true); await dao.connect(account2).stakeOnValidationPool(2, 10, false); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(2); expect(await dao.balanceOf(account1)).to.equal(200); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(0); }); it('Partially binding validation pool should redistribute some stakes', async () => { const init = () => initiateValidationPool({ bindingPercent: 50 }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); await dao.connect(account1).stakeOnValidationPool(2, 10, true); await dao.connect(account2).stakeOnValidationPool(2, 10, false); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(2); expect(await dao.balanceOf(account1)).to.equal(205); expect(await dao.balanceOf(account2)).to.equal(95); expect(await dao.balanceOf(dao.target)).to.equal(0); expect(await dao.totalSupply()).to.equal(300); }); it('If redistributeLosingStakes is false, validation pool should burn binding portion of losing stakes', async () => { const init = () => initiateValidationPool({ bindingPercent: 50, redistributeLosingStakes: false, }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); await dao.connect(account1).stakeOnValidationPool(2, 10, true); await dao.connect(account2).stakeOnValidationPool(2, 10, false); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(2); expect(await dao.balanceOf(account1)).to.equal(200); expect(await dao.balanceOf(account2)).to.equal(95); expect(await dao.balanceOf(dao.target)).to.equal(0); expect(await dao.totalSupply()).to.equal(295); }); it('If redistributeLosingStakes is false and bindingPercent is 0, accounts should recover initial balances', async () => { const init = () => initiateValidationPool({ bindingPercent: 0, redistributeLosingStakes: false, }); await expect(init()).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); await dao.connect(account1).stakeOnValidationPool(2, 10, true); await dao.connect(account2).stakeOnValidationPool(2, 10, false); expect(await dao.balanceOf(account1)).to.equal(100); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(100); time.increase(POOL_DURATION + 1); await dao.evaluateOutcome(2); expect(await dao.balanceOf(account1)).to.equal(200); expect(await dao.balanceOf(account2)).to.equal(100); expect(await dao.balanceOf(dao.target)).to.equal(0); expect(await dao.totalSupply()).to.equal(300); }); }); }); });