diff --git a/client/src/App.jsx b/client/src/App.jsx index 6630177..0af0a1f 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -121,9 +121,9 @@ function App() { }; const fetchValidationPools = async () => { - // TODO: Pagination - // TODO: Memoization - // TODO: Caching + // TODO: Pagination + // TODO: Memoization + // TODO: Caching const count = await DAOContract.methods.validationPoolCount().call(); const promises = []; dispatchValidationPool({ type: 'refresh' }); @@ -234,7 +234,7 @@ function App() { ).send({ from: account, gas: 1000000, - value: 100, + value: 10000, }); }, [provider, DAO, account]); @@ -274,10 +274,15 @@ function App() { /* --------------------------- END UI ACTIONS ------------------------------------- */ /* -------------------------------------------------------------------------------- */ - const getAdressName = useCallback((address) => { + const getAddressName = useCallback((address) => { const contractName = getContractNameByAddress(chainId, address); if (contractName) return `${contractName} Contract`; - return address; + const addressParts = [ + address.slice(0, 7), + + address.slice(address.length - 5), + ]; + return addressParts.join('...'); }, [chainId]); return ( @@ -355,8 +360,8 @@ function App() { {posts.filter((x) => !!x).map((post) => ( {post.id.toString()} - {post.author} - {getAdressName(post.sender)} + {getAddressName(post.author)} + {getAddressName(post.sender)} + {' '} - diff --git a/client/src/contract-addresses.json b/client/src/contract-addresses.json index c7b064f..b49533f 100644 --- a/client/src/contract-addresses.json +++ b/client/src/contract-addresses.json @@ -1,9 +1,9 @@ { "localhost": { - "DAO": "0x65B0922fe7F0c4012aa38704071f26aeF6F22650", - "Work1": "0x95673D8710A8eD59f8551e9B12509D6812e0623e", - "Onboarding": "0xc6b3b8A641c52F7bC13a9D444e1f0759CA3b87b4", - "Proposals": "0x859cd550d5b3BDdde4Cf0ca71D060f945E9E42DD" + "DAO": "0x84A5F75A39e25bD39B69F7d096d159557EaF2a59", + "Work1": "0xaB3Bf8f9BE69289B0dd2a154a6390c8D9F780c59", + "Onboarding": "0xf10E261AFF9Aa8b05716002bFf44D3e990401C02", + "Proposals": "0xBD616B6331e0953Fc20281a54A684E614D8C4026" }, "sepolia": { "DAO": "0x8Cb4ab513A863ac29e855c85064ea53dec7dA24C", diff --git a/ethereum/contract-addresses.json b/ethereum/contract-addresses.json index c7b064f..b49533f 100644 --- a/ethereum/contract-addresses.json +++ b/ethereum/contract-addresses.json @@ -1,9 +1,9 @@ { "localhost": { - "DAO": "0x65B0922fe7F0c4012aa38704071f26aeF6F22650", - "Work1": "0x95673D8710A8eD59f8551e9B12509D6812e0623e", - "Onboarding": "0xc6b3b8A641c52F7bC13a9D444e1f0759CA3b87b4", - "Proposals": "0x859cd550d5b3BDdde4Cf0ca71D060f945E9E42DD" + "DAO": "0x84A5F75A39e25bD39B69F7d096d159557EaF2a59", + "Work1": "0xaB3Bf8f9BE69289B0dd2a154a6390c8D9F780c59", + "Onboarding": "0xf10E261AFF9Aa8b05716002bFf44D3e990401C02", + "Proposals": "0xBD616B6331e0953Fc20281a54A684E614D8C4026" }, "sepolia": { "DAO": "0x8Cb4ab513A863ac29e855c85064ea53dec7dA24C", diff --git a/ethereum/contracts/DAO.sol b/ethereum/contracts/DAO.sol index 3c44166..229da20 100644 --- a/ethereum/contracts/DAO.sol +++ b/ethereum/contracts/DAO.sol @@ -23,10 +23,11 @@ struct Stake { } struct ValidationPoolParams { - uint quorumPPB; + uint duration; + uint[2] quorum; // [ Numerator, Denominator ] + uint[2] winRatio; // [ Numerator, Denominator ] uint bindingPercent; bool redistributeLosingStakes; - uint[2] winRatio; // [ Numerator, Denominator ] } struct ValidationPool { @@ -37,7 +38,6 @@ struct ValidationPool { uint stakeCount; ValidationPoolParams params; uint256 fee; - uint duration; uint endTime; bool resolved; bool outcome; @@ -62,9 +62,9 @@ contract DAO is ERC20("Reputation", "REP") { // TODO: possible parameter for winningRatio // TODO: Add forum parameters - uint public constant minDuration = 1; // 1 second - uint public constant maxDuration = 365_000_000 days; // 1 million years - uint public constant minQuorumPPB = 100_000_000; // Parts per billion + 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); @@ -74,6 +74,10 @@ contract DAO is ERC20("Reputation", "REP") { bool quorumMet ); + function decimals() public pure override returns (uint8) { + return 9; + } + function addPost( address author, string calldata contentId @@ -103,7 +107,7 @@ contract DAO is ERC20("Reputation", "REP") { require(duration >= minDuration, "Duration is too short"); require(duration <= maxDuration, "Duration is too long"); require( - (1_000_000_000 * quorum[0]) / quorum[1] >= minQuorumPPB, + minQuorum[1] * quorum[0] >= minQuorum[0] * quorum[1], "Quorum is below minimum" ); require(quorum[0] <= quorum[1], "Quorum is greater than one"); @@ -116,11 +120,11 @@ contract DAO is ERC20("Reputation", "REP") { pool.sender = msg.sender; pool.postIndex = postIndex; pool.fee = msg.value; - pool.params.quorumPPB = (1_000_000_000 * quorum[0]) / quorum[1]; + pool.params.quorum = quorum; pool.params.winRatio = winRatio; pool.params.bindingPercent = bindingPercent; pool.params.redistributeLosingStakes = redistributeLosingStakes; - pool.duration = duration; + pool.params.duration = duration; pool.endTime = block.timestamp + duration; pool.id = poolIndex; pool.callbackOnValidate = callbackOnValidate; @@ -186,22 +190,39 @@ contract DAO is ERC20("Reputation", "REP") { } // Check that quorum is met if ( - 1_000_000_000 * (stakedFor + stakedAgainst) <= - totalSupply() * pool.params.quorumPPB + pool.params.quorum[1] * (stakedFor + stakedAgainst) <= + totalSupply() * pool.params.quorum[0] ) { - // TODO: refund fee - // TODO: refund stakes - // Callback if requested - if (pool.callbackOnValidate) { - console.log("quorum not met, calling onValidate"); - IOnValidate(pool.sender).onValidate( - votePasses, - false, - stakedFor, - stakedAgainst, - pool.callbackData - ); + // 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]; + // TODO: ensure this can't be repeated + _update(address(this), s.sender, s.amount); } + + // Callback if requested + + if (pool.callbackOnValidate) { + try + IOnValidate(pool.sender).onValidate( + votePasses, + false, + stakedFor, + stakedAgainst, + pool.callbackData + ) + { + console.log("callbackOnValidate succeed"); + } catch Error(string memory reason) { + console.log("callbackOnValidate failed:", reason); + } + } + + pool.resolved = true; emit ValidationPoolResolved(poolIndex, false, false); return false; } @@ -227,12 +248,12 @@ contract DAO is ERC20("Reputation", "REP") { uint256 totalAllocated; for (uint i = 0; i < pool.stakeCount; i++) { s = pool.stakes[i]; - bool redistributeLosingStakes = s.fromMint || - pool.params.redistributeLosingStakes; 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) @@ -241,18 +262,20 @@ contract DAO is ERC20("Reputation", "REP") { totalAllocated += reward; } else { // Losing stake - uint refund = (s.amount * (100 - bindingPercent)) / 100; - if (refund > 0) { - _update(address(this), s.sender, refund); - } // If this stake is from the minted fee, don't burn it - if (!redistributeLosingStakes) { - uint amountToBurn = (s.amount * - pool.params.bindingPercent) / 100; - _burn(address(this), amountToBurn); - totalAllocated += amountToBurn; + 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; } - totalAllocated += refund; } } // Due to rounding, there may be some REP left over. Include this as a reward to the author. @@ -269,14 +292,19 @@ contract DAO is ERC20("Reputation", "REP") { } // Callback if requested if (pool.callbackOnValidate) { - console.log("calling onValidate"); - IOnValidate(pool.sender).onValidate( - votePasses, - true, - stakedFor, - stakedAgainst, - pool.callbackData - ); + try + IOnValidate(pool.sender).onValidate( + votePasses, + true, + stakedFor, + stakedAgainst, + pool.callbackData + ) + { + console.log("callbackOnValidate succeed"); + } catch Error(string memory reason) { + console.log("callbackOnValidate failed:", reason); + } } } diff --git a/ethereum/contracts/Proposals.sol b/ethereum/contracts/Proposals.sol index b33e4e5..9748e98 100644 --- a/ethereum/contracts/Proposals.sol +++ b/ethereum/contracts/Proposals.sol @@ -18,14 +18,18 @@ contract Proposals is DAOContract, IOnValidate { struct Pool { uint poolIndex; + bool started; + bool completed; uint stakedFor; uint stakedAgainst; + bool votePasses; + bool quorumMet; } struct Referendum { uint duration; // Each referendum may retry up to 3x - Pool[] pools; + Pool[3] pools; uint retryCount; } @@ -45,12 +49,15 @@ contract Proposals is DAOContract, IOnValidate { uint public proposalCount; event NewProposal(uint proposalIndex); + event Attestation(uint proposalIndex); event ReferendumStarted(uint proposalIndex, uint poolIndex); event ProposalFailed(uint proposalIndex, string reason); event ProposalAccepted(uint proposalIndex); constructor(DAO dao) DAOContract(dao) {} + // TODO receive : we want to be able to accept refunds from validation pools + function propose( string calldata contentId, uint referendum0Duration, @@ -70,6 +77,15 @@ contract Proposals is DAOContract, IOnValidate { emit NewProposal(proposalIndex); } + function getPools( + uint proposalIndex + ) public view returns (Pool[3][3] memory pools) { + Proposal storage proposal = proposals[proposalIndex]; + pools[0] = proposal.referenda[0].pools; + pools[1] = proposal.referenda[1].pools; + pools[2] = proposal.referenda[2].pools; + } + /// External function for reputation holders to attest toward a given proposal; /// This is non-binding and non-encumbering, so it does not transfer any reputation. function attest(uint proposalIndex, uint amount) external { @@ -83,6 +99,7 @@ contract Proposals is DAOContract, IOnValidate { proposal.attestationTotal -= proposal.attestations[msg.sender]; proposal.attestations[msg.sender] = amount; proposal.attestationTotal += amount; + emit Attestation(proposalIndex); } // --- Sequences of validation pool parameters --- @@ -114,8 +131,10 @@ contract Proposals is DAOContract, IOnValidate { true, abi.encode(proposalIndex, referendumIndex, fee) ); - Pool storage pool = proposal.referenda[referendumIndex].pools.push(); + Referendum storage referendum = proposal.referenda[referendumIndex]; + Pool storage pool = referendum.pools[referendum.retryCount]; pool.poolIndex = poolIndex; + pool.started = true; emit ReferendumStarted(proposalIndex, poolIndex); } @@ -136,18 +155,22 @@ contract Proposals is DAOContract, IOnValidate { (uint, uint, uint) ); Proposal storage proposal = proposals[proposalIndex]; + Referendum storage referendum = proposal.referenda[referendumIndex]; + Pool storage pool = referendum.pools[referendum.retryCount]; + + // Make a record of this result + pool.completed = true; + pool.stakedFor = stakedFor; + pool.stakedAgainst = stakedAgainst; + pool.quorumMet = quorumMet; + pool.votePasses = votePasses; + if (!quorumMet) { proposal.stage = Stage.Failed; emit ProposalFailed(proposalIndex, "Quorum not met"); proposal.remainingFee += fee; - // TODO: Refund remaining fee return; } - Referendum storage referendum = proposal.referenda[referendumIndex]; - Pool storage pool = referendum.pools[referendum.pools.length - 1]; - // Make a record of this result - pool.stakedFor = stakedFor; - pool.stakedAgainst = stakedAgainst; // Participation threshold of 50% bool participationAboveThreshold = 2 * (stakedFor + stakedAgainst) >= @@ -235,4 +258,16 @@ contract Proposals is DAOContract, IOnValidate { initiateValidationPool(proposalIndex, 0, proposal.fee / 10); return true; } + + /// External function to reclaim remaining fees after a proposal has completed all referenda + function reclaimRemainingFee(uint proposalIndex) external { + Proposal storage proposal = proposals[proposalIndex]; + require( + proposal.stage == Stage.Failed || proposal.stage == Stage.Accepted, + "Remaining fees can only be reclaimed when proposal has been accepted or failed" + ); + uint amount = proposal.remainingFee; + proposal.remainingFee = 0; + payable(msg.sender).transfer(amount); + } } diff --git a/ethereum/test/DAO.js b/ethereum/test/DAO.js index 96e2120..cec0ccd 100644 --- a/ethereum/test/DAO.js +++ b/ethereum/test/DAO.js @@ -117,7 +117,7 @@ describe('DAO', () => { it('Should be able to fetch pool instance', async () => { const pool = await dao.validationPools(0); expect(pool).to.exist; - expect(pool.duration).to.equal(POOL_DURATION); + expect(pool.params.duration).to.equal(POOL_DURATION); expect(pool.postIndex).to.equal(0); expect(pool.resolved).to.be.false; expect(pool.sender).to.equal(account1); @@ -295,6 +295,25 @@ describe('DAO', () => { 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).stake(2, 10, true); + await dao.connect(account2).stake(2, 10, false); + expect(await dao.balanceOf(account1)).to.equal(90); + expect(await dao.balanceOf(account2)).to.equal(90); + expect(await dao.balanceOf(dao.target)).to.equal(120); + 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); + }); }); }); }); diff --git a/ethereum/test/Proposals.js b/ethereum/test/Proposals.js index cecd927..b2a7bed 100644 --- a/ethereum/test/Proposals.js +++ b/ethereum/test/Proposals.js @@ -158,6 +158,14 @@ describe('Proposal', () => { .to.emit(dao, 'ValidationPoolInitiated').withArgs(3); proposal = await proposals.proposals(0); expect(proposal.stage).to.equal(1); + + const pools = await proposals.getPools(0); + expect(pools[0][0].started).to.be.true; + expect(pools[0][1].started).to.be.true; + expect(pools[0][2].started).to.be.false; + expect(pools[0][0].completed).to.be.true; + expect(pools[0][1].completed).to.be.false; + expect(pools[0][2].completed).to.be.false; }); it('proposal fails if a referendum fails to meet participation rate 3 times', async () => { @@ -210,6 +218,11 @@ describe('Proposal', () => { console.log('evaluated pool 2'); }); + afterEach(async () => { + const pool = await dao.validationPools(3); + expect(pool.resolved).to.be.true; + }); + it('proposal dies if it fails to meet quorum', async () => { await time.increase(21); await expect(dao.evaluateOutcome(3)).to.emit(dao, 'ValidationPoolResolved').withArgs(3, false, false); @@ -273,7 +286,7 @@ describe('Proposal', () => { }); }); - describe('Referendum 10% binding', () => { + describe('Referendum 100% binding', () => { beforeEach(async () => { await proposals.attest(0, 200); await expect(proposals.evaluateAttestation(0)).to.emit(dao, 'ValidationPoolInitiated').withArgs(2); @@ -291,6 +304,11 @@ describe('Proposal', () => { expect(proposal.stage).to.equal(3); }); + afterEach(async () => { + const pool = await dao.validationPools(4); + expect(pool.resolved).to.be.true; + }); + it('proposal dies if it fails to meet quorum', async () => { await time.increase(21); await expect(dao.evaluateOutcome(4)).to.emit(dao, 'ValidationPoolResolved').withArgs(4, false, false);