diff --git a/packages/contracts/src/governance/MainVotingPlugin.sol b/packages/contracts/src/governance/MainVotingPlugin.sol index 3b9b4bf..5167979 100644 --- a/packages/contracts/src/governance/MainVotingPlugin.sol +++ b/packages/contracts/src/governance/MainVotingPlugin.sol @@ -248,7 +248,7 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers /// @inheritdoc MajorityVotingBase function createProposal( - bytes calldata _metadata, + bytes calldata _metadataContentUri, IDAO.Action[] calldata _actions, uint256 _allowFailureMap, VoteOption _voteOption, @@ -262,7 +262,7 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers proposalId = _createProposal({ _creator: msg.sender, - _metadata: _metadata, + _metadata: _metadataContentUri, _startDate: _startDate, _endDate: _startDate + duration(), _actions: _actions, @@ -297,10 +297,12 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } /// @notice Creates and executes a proposal that makes the DAO emit new content on the given space. - /// @param _contentUri The URI of the IPFS content to publish + /// @param _metadataContentUri The metadata of the proposal. + /// @param _editsContentUri The URI of the IPFS content to publish /// @param _spacePlugin The address of the space plugin where changes will be executed function proposeEdits( - string memory _contentUri, + bytes calldata _metadataContentUri, + string memory _editsContentUri, address _spacePlugin ) public onlyMembers returns (uint256 proposalId) { if (_spacePlugin == address(0)) { @@ -308,16 +310,18 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } proposalId = _proposeWrappedAction( - "", + _metadataContentUri, _spacePlugin, - abi.encodeCall(SpacePlugin.publishEdits, (_contentUri)) + abi.encodeCall(SpacePlugin.publishEdits, (_editsContentUri)) ); } /// @notice Creates a proposal to make the DAO accept the given DAO as a subspace. + /// @param _metadataContentUri The metadata of the proposal. /// @param _subspaceDao The address of the DAO that holds the new subspace /// @param _spacePlugin The address of the space plugin where changes will be executed function proposeAcceptSubspace( + bytes calldata _metadataContentUri, IDAO _subspaceDao, address _spacePlugin ) public onlyMembers returns (uint256 proposalId) { @@ -326,16 +330,18 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } proposalId = _proposeWrappedAction( - "", + _metadataContentUri, _spacePlugin, abi.encodeCall(SpacePlugin.acceptSubspace, (address(_subspaceDao))) ); } /// @notice Creates a proposal to make the DAO remove the given DAO as a subspace. + /// @param _metadataContentUri The metadata of the proposal. /// @param _subspaceDao The address of the DAO that holds the subspace to remove /// @param _spacePlugin The address of the space plugin where changes will be executed function proposeRemoveSubspace( + bytes calldata _metadataContentUri, IDAO _subspaceDao, address _spacePlugin ) public onlyMembers returns (uint256 proposalId) { @@ -344,18 +350,18 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } proposalId = _proposeWrappedAction( - "", + _metadataContentUri, _spacePlugin, abi.encodeCall(SpacePlugin.removeSubspace, (address(_subspaceDao))) ); } /// @notice Creates a proposal to add a new member. - /// @param _metadata The metadata of the proposal. + /// @param _metadataContentUri The metadata of the proposal. /// @param _proposedMember The address of the member who may eveutnally be added. /// @return proposalId NOTE: The proposal ID will belong to the Multisig plugin, not to this contract. function proposeAddMember( - bytes calldata _metadata, + bytes calldata _metadataContentUri, address _proposedMember ) public returns (uint256 proposalId) { if (isMember(_proposedMember)) { @@ -364,14 +370,15 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers /// @dev Creating the actual proposal on a separate plugin because the approval rules differ. /// @dev Keeping all wrappers on the MainVoting plugin, even if one type of approvals are handled on the MemberAccess plugin. - return memberAccessPlugin.proposeAddMember(_metadata, _proposedMember, msg.sender); + return + memberAccessPlugin.proposeAddMember(_metadataContentUri, _proposedMember, msg.sender); } /// @notice Creates a proposal to remove an existing member. - /// @param _metadata The metadata of the proposal. + /// @param _metadataContentUri The metadata of the proposal. /// @param _member The address of the member who may eveutnally be removed. function proposeRemoveMember( - bytes calldata _metadata, + bytes calldata _metadataContentUri, address _member ) public returns (uint256 proposalId) { if (!isEditor(msg.sender)) { @@ -381,17 +388,17 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } proposalId = _proposeWrappedAction( - _metadata, + _metadataContentUri, address(this), abi.encodeCall(MainVotingPlugin.removeMember, (_member)) ); } /// @notice Creates a proposal to remove an existing member. - /// @param _metadata The metadata of the proposal. + /// @param _metadataContentUri The metadata of the proposal. /// @param _proposedEditor The address of the wallet who may eveutnally be made an editor. function proposeAddEditor( - bytes calldata _metadata, + bytes calldata _metadataContentUri, address _proposedEditor ) public onlyMembers returns (uint256 proposalId) { if (isEditor(_proposedEditor)) { @@ -399,17 +406,17 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } proposalId = _proposeWrappedAction( - _metadata, + _metadataContentUri, address(this), abi.encodeCall(MainVotingPlugin.addEditor, (_proposedEditor)) ); } /// @notice Creates a proposal to remove an existing editor. - /// @param _metadata The metadata of the proposal. + /// @param _metadataContentUri The metadata of the proposal. /// @param _editor The address of the editor who may eveutnally be removed. function proposeRemoveEditor( - bytes calldata _metadata, + bytes calldata _metadataContentUri, address _editor ) public onlyMembers returns (uint256 proposalId) { if (!isEditor(_editor)) { @@ -417,7 +424,7 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } proposalId = _proposeWrappedAction( - _metadata, + _metadataContentUri, address(this), abi.encodeCall(MainVotingPlugin.removeEditor, (_editor)) ); @@ -439,11 +446,11 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers } /// @notice Creates a proposal with the given calldata as the only action. - /// @param _metadata The IPFS URI of the metadata. + /// @param _metadataContentUri The IPFS URI of the metadata. /// @param _to The contract to call with the action. /// @param _data The calldata to eventually invoke. function _proposeWrappedAction( - bytes memory _metadata, + bytes memory _metadataContentUri, address _to, bytes memory _data ) internal returns (uint256 proposalId) { @@ -470,7 +477,7 @@ contract MainVotingPlugin is Addresslist, MajorityVotingBase, IEditors, IMembers emit ProposalCreated({ proposalId: proposalId, creator: msg.sender, - metadata: _metadata, + metadata: _metadataContentUri, startDate: _startDate, endDate: proposal_.parameters.endDate, actions: proposal_.actions, diff --git a/packages/contracts/test/unit-testing/main-voting-plugin.ts b/packages/contracts/test/unit-testing/main-voting-plugin.ts index 52c6a08..6629f0c 100644 --- a/packages/contracts/test/unit-testing/main-voting-plugin.ts +++ b/packages/contracts/test/unit-testing/main-voting-plugin.ts @@ -54,6 +54,7 @@ import {ethers} from 'hardhat'; type InitData = {contentUri: string}; const mainVotingPluginInterface = MainVotingPlugin__factory.createInterface(); +const spacePluginInterface = SpacePlugin__factory.createInterface(); describe('Main Voting Plugin', function () { let signers: SignerWithAddress[]; @@ -256,6 +257,28 @@ describe('Main Voting Plugin', function () { }); context('Before proposals', () => { + it('Voting on a non-created proposal reverts', async () => { + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + + await expect(mainVotingPlugin.vote(0, VoteOption.Yes, false)).to.be + .reverted; + await expect(mainVotingPlugin.vote(10, VoteOption.No, false)).to.be + .reverted; + await expect(mainVotingPlugin.vote(50, VoteOption.Abstain, false)).to.be + .reverted; + await expect(mainVotingPlugin.vote(500, VoteOption.None, false)).to.be + .reverted; + + await expect(mainVotingPlugin.vote(0, VoteOption.Yes, true)).to.be + .reverted; + await expect(mainVotingPlugin.vote(1, VoteOption.No, true)).to.be + .reverted; + await expect(mainVotingPlugin.vote(2, VoteOption.Abstain, true)).to.be + .reverted; + await expect(mainVotingPlugin.vote(3, VoteOption.None, true)).to.be + .reverted; + }); + it('Only members can create proposals', async () => { await expect( mainVotingPlugin.connect(alice).createProposal( @@ -313,7 +336,11 @@ describe('Main Voting Plugin', function () { await expect( mainVotingPlugin .connect(alice) - .proposeEdits('ipfs://', spacePlugin.address) + .proposeEdits( + toUtf8Bytes('ipfs://meta'), + 'ipfs://edits', + spacePlugin.address + ) ).to.not.be.reverted; expect(await mainVotingPlugin.proposalCount()).to.equal( @@ -324,7 +351,11 @@ describe('Main Voting Plugin', function () { await expect( mainVotingPlugin .connect(bob) - .proposeAcceptSubspace(ADDRESS_TWO, spacePlugin.address) + .proposeAcceptSubspace( + toUtf8Bytes('ipfs://meta-2'), + ADDRESS_THREE, + spacePlugin.address + ) ).to.not.be.reverted; expect(await mainVotingPlugin.proposalCount()).to.equal( @@ -334,7 +365,11 @@ describe('Main Voting Plugin', function () { await expect( mainVotingPlugin .connect(bob) - .proposeRemoveSubspace(ADDRESS_THREE, spacePlugin.address) + .proposeRemoveSubspace( + toUtf8Bytes('ipfs://more-meta-here'), + bob.address, + spacePlugin.address + ) ).to.not.be.reverted; expect(await mainVotingPlugin.proposalCount()).to.equal( @@ -344,7 +379,11 @@ describe('Main Voting Plugin', function () { await expect( mainVotingPlugin .connect(carol) - .proposeEdits('ipfs://', spacePlugin.address) + .proposeEdits( + toUtf8Bytes('ipfs://meta'), + 'ipfs://edits', + spacePlugin.address + ) ) .to.be.revertedWithCustomError(mainVotingPlugin, 'NotAMember') .withArgs(carol.address); @@ -352,7 +391,11 @@ describe('Main Voting Plugin', function () { await expect( mainVotingPlugin .connect(dave) - .proposeAcceptSubspace(ADDRESS_TWO, spacePlugin.address) + .proposeAcceptSubspace( + toUtf8Bytes('ipfs://'), + ADDRESS_THREE, + spacePlugin.address + ) ) .to.be.revertedWithCustomError(mainVotingPlugin, 'NotAMember') .withArgs(dave.address); @@ -360,7 +403,11 @@ describe('Main Voting Plugin', function () { await expect( mainVotingPlugin .connect(dave) - .proposeRemoveSubspace(ADDRESS_TWO, spacePlugin.address) + .proposeRemoveSubspace( + toUtf8Bytes('ipfs://'), + ADDRESS_ONE, + spacePlugin.address + ) ) .to.be.revertedWithCustomError(mainVotingPlugin, 'NotAMember') .withArgs(dave.address); @@ -592,6 +639,379 @@ describe('Main Voting Plugin', function () { ).to.not.be.reverted; expect(await mainVotingPlugin.canExecute(pid)).to.eq(true); }); + + it("At least an editor who didn't create the proposal must vote", async () => { + let pid: number; + // Alice, Bob and Carol: editors + await proposeNewEditor(bob.address); + await proposeNewEditor(carol.address); + pid = (await mainVotingPlugin.proposalCount()).toNumber() - 1; + await expect( + mainVotingPlugin.connect(bob).vote(pid, VoteOption.Yes, true) + ).to.not.be.reverted; + + // Proposal 1 + await expect(createDummyProposal(alice, false)).to.not.be.reverted; + pid++; + expect(await mainVotingPlugin.canExecute(pid)).to.eq(false); + + // Alice votes Yes + await expect(mainVotingPlugin.vote(pid, VoteOption.Yes, false)).to.not.be + .reverted; + expect(await mainVotingPlugin.canExecute(pid)).to.eq(false); + + // Bob votes No (50/50) + await expect( + mainVotingPlugin.connect(bob).vote(pid, VoteOption.No, false) + ).to.not.be.reverted; + expect(await mainVotingPlugin.canExecute(pid)).to.eq(false); + + // Carol votes Yes (66% yes) + await expect( + mainVotingPlugin.connect(carol).vote(pid, VoteOption.Yes, false) + ).to.not.be.reverted; + expect(await mainVotingPlugin.canExecute(pid)).to.eq(true); + + // Proposal 2 + await expect(createDummyProposal(alice, true)).to.not.be.reverted; + pid++; + expect(await mainVotingPlugin.canExecute(pid)).to.eq(false); + + // Bob votes (66% yes) + await expect( + mainVotingPlugin.connect(bob).vote(pid, VoteOption.Yes, false) + ).to.not.be.reverted; + expect(await mainVotingPlugin.canExecute(pid)).to.eq(true); + }); + }); + + context('Proposal wrappers', () => { + it('proposeEdits creates a proposal with the right values', async () => { + let pid = 0; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + await expect( + mainVotingPlugin.proposeEdits( + toUtf8Bytes('ipfs://metadata'), + 'ipfs://edits-uri', + spacePlugin.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(1); + + let proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(spacePlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + spacePluginInterface.encodeFunctionData('publishEdits', [ + 'ipfs://edits-uri', + ]) + ); + + // 2 + pid++; + + await expect( + mainVotingPlugin.proposeEdits( + toUtf8Bytes('ipfs://more-metadata-here'), + 'ipfs://more-edits-uri', + '0x5555555555666666666677777777778888888888' + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(2); + + proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq( + '0x5555555555666666666677777777778888888888' + ); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + spacePluginInterface.encodeFunctionData('publishEdits', [ + 'ipfs://more-edits-uri', + ]) + ); + }); + + it('proposeAcceptSubspace creates a proposal with the right values', async () => { + let pid = 0; + let newSubspacePluginAddress = + '0x1234567890123456789012345678901234567890'; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + await expect( + mainVotingPlugin.proposeAcceptSubspace( + toUtf8Bytes('ipfs://'), + newSubspacePluginAddress, + spacePlugin.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(1); + + let proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(spacePlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + spacePluginInterface.encodeFunctionData('acceptSubspace', [ + newSubspacePluginAddress, + ]) + ); + + // 2 + pid++; + newSubspacePluginAddress = '0x0123456789012345678901234567890123456789'; + + await expect( + mainVotingPlugin.proposeAcceptSubspace( + toUtf8Bytes('ipfs://more-data-here'), + newSubspacePluginAddress, + '0x5555555555666666666677777777778888888888' + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(2); + + proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq( + '0x5555555555666666666677777777778888888888' + ); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + spacePluginInterface.encodeFunctionData('acceptSubspace', [ + newSubspacePluginAddress, + ]) + ); + }); + + it('proposeRemoveSubspace creates a proposal with the right values', async () => { + let pid = 0; + let subspaceToRemove = '0x1234567890123456789012345678901234567890'; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + await expect( + mainVotingPlugin.proposeRemoveSubspace( + toUtf8Bytes('ipfs://'), + subspaceToRemove, + spacePlugin.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(1); + + let proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(spacePlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + spacePluginInterface.encodeFunctionData('removeSubspace', [ + subspaceToRemove, + ]) + ); + + // 2 + pid++; + subspaceToRemove = '0x0123456789012345678901234567890123456789'; + + await expect( + mainVotingPlugin.proposeRemoveSubspace( + toUtf8Bytes('ipfs://more-data-here'), + subspaceToRemove, + '0x5555555555666666666677777777778888888888' + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(2); + + proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq( + '0x5555555555666666666677777777778888888888' + ); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + spacePluginInterface.encodeFunctionData('removeSubspace', [ + subspaceToRemove, + ]) + ); + }); + + it('proposeAddMember creates a proposal on the MemberAccessPlugin', async () => { + let msPid = 1; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + expect((await memberAccessPlugin.proposalCount()).toNumber()).to.eq(1); + await expect( + mainVotingPlugin.proposeAddMember( + toUtf8Bytes('ipfs://meta'), + carol.address + ) + ).to.not.be.reverted; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + expect((await memberAccessPlugin.proposalCount()).toNumber()).to.eq(2); + + let proposal = await memberAccessPlugin.getProposal(msPid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('addMember', [ + carol.address, + ]) + ); + + // 2 + msPid++; + await expect( + mainVotingPlugin.proposeAddMember( + toUtf8Bytes('ipfs://more-meta'), + ADDRESS_THREE + ) + ).to.not.be.reverted; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + expect((await memberAccessPlugin.proposalCount()).toNumber()).to.eq(3); + + proposal = await memberAccessPlugin.getProposal(msPid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('addMember', [ + ADDRESS_THREE, + ]) + ); + }); + + it('proposeRemoveMember creates a proposal with the right values', async () => { + let pid = 0; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + await expect( + mainVotingPlugin.proposeRemoveMember( + toUtf8Bytes('ipfs://meta'), + alice.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(1); + + let proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('removeMember', [ + alice.address, + ]) + ); + + // 2 + pid++; + + await expect( + mainVotingPlugin.proposeRemoveMember( + toUtf8Bytes('ipfs://more-meta'), + bob.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(2); + + proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('removeMember', [ + bob.address, + ]) + ); + }); + + it('proposeAddEditor creates a proposal with the right values', async () => { + let pid = 0; + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + await expect( + mainVotingPlugin.proposeAddEditor( + toUtf8Bytes('ipfs://meta'), + carol.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(1); + + let proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('addEditor', [ + carol.address, + ]) + ); + + // 2 + pid++; + + await expect( + mainVotingPlugin.proposeAddEditor( + toUtf8Bytes('ipfs://more-meta'), + bob.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(2); + + proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('addEditor', [bob.address]) + ); + }); + + it('proposeRemoveEditor creates a proposal with the right values', async () => { + let pid = 0; + await makeEditor(bob.address); + + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(0); + await expect( + mainVotingPlugin.proposeRemoveEditor( + toUtf8Bytes('ipfs://meta'), + alice.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(1); + + let proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('removeEditor', [ + alice.address, + ]) + ); + + // 2 + pid++; + + await expect( + mainVotingPlugin.proposeRemoveEditor( + toUtf8Bytes('ipfs://more-meta'), + bob.address + ) + ).to.not.be.reverted; + expect((await mainVotingPlugin.proposalCount()).toNumber()).to.eq(2); + + proposal = await mainVotingPlugin.getProposal(pid); + expect(proposal.actions.length).to.eq(1); + expect(proposal.actions[0].to).to.eq(mainVotingPlugin.address); + expect(proposal.actions[0].value.toNumber()).to.eq(0); + expect(proposal.actions[0].data).to.eq( + mainVotingPluginInterface.encodeFunctionData('removeEditor', [ + bob.address, + ]) + ); + }); }); context('Canceling', () => { @@ -1243,6 +1663,84 @@ describe('Main Voting Plugin', function () { }); }); + context('Joining a space via MemberAccessPlugin', () => { + it('Proposing new members via MemberAccess plugin grants membership', async () => { + expect(await mainVotingPlugin.isMember(carol.address)).to.be.false; + await mainVotingPlugin.proposeAddMember( + toUtf8Bytes('ipfs://'), + carol.address + ); + expect(await mainVotingPlugin.isMember(carol.address)).to.be.true; + + // 2 + expect(await mainVotingPlugin.isMember(ADDRESS_THREE)).to.be.false; + await mainVotingPlugin.proposeAddMember( + toUtf8Bytes('ipfs://'), + ADDRESS_THREE + ); + expect(await mainVotingPlugin.isMember(ADDRESS_THREE)).to.be.true; + }); + }); + + context('Leaving a space', () => { + it('Completely removes an editor', async () => { + await makeEditor(bob.address); + + // Bob leaves + expect(await mainVotingPlugin.isEditor(bob.address)).to.be.true; + expect(await mainVotingPlugin.isMember(bob.address)).to.be.true; + + await expect(mainVotingPlugin.connect(bob).leaveSpace()).to.not.be + .reverted; + + expect(await mainVotingPlugin.isEditor(bob.address)).to.be.false; + expect(await mainVotingPlugin.isMember(bob.address)).to.be.false; + + // Alice leaves + expect(await mainVotingPlugin.isEditor(alice.address)).to.be.true; + expect(await mainVotingPlugin.isMember(alice.address)).to.be.true; + + await expect(mainVotingPlugin.leaveSpace()).to.not.be.reverted; + + expect(await mainVotingPlugin.isEditor(alice.address)).to.be.false; + expect(await mainVotingPlugin.isMember(alice.address)).to.be.false; + }); + + it('Allows a member to leave', async () => { + await mainVotingPlugin.proposeAddMember( + toUtf8Bytes('ipfs://'), + carol.address + ); + + // Bob leaves + expect(await mainVotingPlugin.isMember(bob.address)).to.be.true; + await expect(mainVotingPlugin.connect(bob).leaveSpace()).to.not.be + .reverted; + expect(await mainVotingPlugin.isMember(bob.address)).to.be.false; + + // Carol leaves + expect(await mainVotingPlugin.isMember(carol.address)).to.be.true; + await expect(mainVotingPlugin.connect(carol).leaveSpace()).to.not.be + .reverted; + expect(await mainVotingPlugin.isMember(carol.address)).to.be.false; + }); + + it('Allows an editor to give editorship away', async () => { + await makeEditor(bob.address); + + // Bob leaves as admin + expect(await mainVotingPlugin.isEditor(bob.address)).to.be.true; + await expect(mainVotingPlugin.connect(bob).leaveSpaceAsEditor()).to.not.be + .reverted; + expect(await mainVotingPlugin.isEditor(bob.address)).to.be.false; + + // Alice leaves as editor + expect(await mainVotingPlugin.isEditor(alice.address)).to.be.true; + await expect(mainVotingPlugin.leaveSpaceAsEditor()).to.not.be.reverted; + expect(await mainVotingPlugin.isEditor(alice.address)).to.be.false; + }); + }); + // Helpers const createDummyProposal = (proposer = alice, approving = false) => { const actions: IDAO.ActionStruct[] = [ diff --git a/packages/contracts/test/unit-testing/member-access-plugin.ts b/packages/contracts/test/unit-testing/member-access-plugin.ts index aba26a9..4d7af09 100644 --- a/packages/contracts/test/unit-testing/member-access-plugin.ts +++ b/packages/contracts/test/unit-testing/member-access-plugin.ts @@ -170,7 +170,47 @@ describe('Member Access Plugin', function () { }); describe('Before approving', () => { - it('Allows any address to request membership', async () => { + it('Only addresses with PROPOSER_PERMISSION_ID can propose members', async () => { + // ok + await expect( + mainVotingPlugin.proposeAddMember(toUtf8Bytes('ipfs://'), carol.address) + ).to.not.be.reverted; + + await dao.revoke( + memberAccessPlugin.address, + mainVotingPlugin.address, + PROPOSER_PERMISSION_ID + ); + + // Now it fails + await expect( + mainVotingPlugin.proposeAddMember(toUtf8Bytes('ipfs://'), dave.address) + ).to.be.reverted; + }); + + it('Only callers implementing multisig can propose members', async () => { + // From a compatible plugin + await expect( + mainVotingPlugin.proposeAddMember(toUtf8Bytes('ipfs://'), carol.address) + ).to.not.be.reverted; + + await dao.grant( + memberAccessPlugin.address, + alice.address, + PROPOSER_PERMISSION_ID + ); + + // Fail despite the permission + await expect( + memberAccessPlugin.proposeAddMember( + toUtf8Bytes('ipfs://'), + dave.address, + alice.address + ) + ).to.be.reverted; + }); + + it('Allows any address to request membership via the MainVoting plugin', async () => { // Random expect(await mainVotingPlugin.isMember(carol.address)).to.be.false; pid = await memberAccessPlugin.proposalCount();