diff --git a/contracts/schemes/WalletScheme.sol b/contracts/schemes/WalletScheme.sol new file mode 100644 index 00000000..2fd7532c --- /dev/null +++ b/contracts/schemes/WalletScheme.sol @@ -0,0 +1,179 @@ +pragma solidity 0.5.17; +pragma experimental ABIEncoderV2; + +import "@daostack/infra/contracts/votingMachines/IntVoteInterface.sol"; +import "@daostack/infra/contracts/votingMachines/VotingMachineCallbacksInterface.sol"; +import "../votingMachines/VotingMachineCallbacks.sol"; + + +/** + * @title WalletScheme. + * @dev A scheme for proposing and executing calls to any contract except itself and controller + */ +contract WalletScheme is VotingMachineCallbacks, ProposalExecuteInterface { + event NewCallProposal( + address[] _to, + bytes32 indexed _proposalId, + bytes[] _callData, + uint256[] _value, + string _descriptionHash + ); + + event ProposalExecuted( + bytes32 indexed _proposalId, + bytes[] _genericCallReturnValue + ); + + event ProposalExecutedByVotingMachine( + bytes32 indexed _proposalId, + int256 _param + ); + + event ProposalDeleted(bytes32 indexed _proposalId); + + // Details of a voting proposal: + struct CallProposal { + address[] to; + bytes[] callData; + uint256[] value; + bool exist; + bool passed; + } + + mapping(bytes32=>CallProposal) public organizationProposals; + + IntVoteInterface public votingMachine; + bytes32 public voteParams; + Avatar public avatar; + address public controller; + + /** + * @dev initialize + * @param _avatar the avatar address + * @param _controller the controller address + * @param _votingMachine the voting machines address to + * @param _voteParams voting machine parameters. + */ + function initialize( + Avatar _avatar, + address _controller, + IntVoteInterface _votingMachine, + bytes32 _voteParams + ) + external + { + require(avatar == Avatar(0), "can be called only one time"); + require(_avatar != Avatar(0), "avatar cannot be zero"); + require(_controller != address(0), "controller cannot be zero"); + avatar = _avatar; + controller = _controller; + votingMachine = _votingMachine; + voteParams = _voteParams; + } + + /** + * @dev Fallback function that allows the wallet to receive ETH + */ + function() external payable {} + + /** + * @dev execution of proposals, can only be called by the voting machine in which the vote is held. + * @param _proposalId the ID of the voting in the voting machine + * @param _decision a parameter of the voting result, 1 yes and 2 is no. + * @return bool success + */ + function executeProposal(bytes32 _proposalId, int256 _decision) + external + onlyVotingMachine(_proposalId) + returns(bool) { + CallProposal storage proposal = organizationProposals[_proposalId]; + require(proposal.exist, "must be a live proposal"); + require(proposal.passed == false, "cannot execute twice"); + + if (_decision == 1) { + proposal.passed = true; + execute(_proposalId); + } else { + delete organizationProposals[_proposalId]; + emit ProposalDeleted(_proposalId); + } + + emit ProposalExecutedByVotingMachine(_proposalId, _decision); + return true; + } + + /** + * @dev execution of proposals after it has been decided by the voting machine + * @param _proposalId the ID of the voting in the voting machine + */ + function execute(bytes32 _proposalId) public { + CallProposal storage proposal = organizationProposals[_proposalId]; + require(proposal.exist, "must be a live proposal"); + require(proposal.passed, "proposal must passed by voting machine"); + proposal.exist = false; + bytes[] memory genericCallReturnValues = new bytes[](proposal.to.length); + bytes memory genericCallReturnValue; + bool success; + for(uint i = 0; i < proposal.to.length; i ++) { + (success, genericCallReturnValue) = + address(proposal.to[i]).call.value(proposal.value[i])(proposal.callData[i]); + genericCallReturnValues[i] = genericCallReturnValue; + } + if (success) { + delete organizationProposals[_proposalId]; + emit ProposalDeleted(_proposalId); + emit ProposalExecuted(_proposalId, genericCallReturnValues); + } else { + proposal.exist = true; + } + } + + /** + * @dev propose to call an address + * The function trigger NewCallProposal event + * @param _to - The addresses to call + * @param _callData - The abi encode data for the calls + * @param _value value(ETH) to transfer with the calls + * @param _descriptionHash proposal description hash + * @return an id which represents the proposal + */ + function proposeCalls(address[] memory _to, bytes[] memory _callData, uint256[] memory _value, string memory _descriptionHash) + public + returns(bytes32) + { + for(uint i = 0; i < _to.length; i ++) { + require(_to[i] != controller && _to[i] != address(this), 'invalid proposal caller'); + } + require(_to.length == _callData.length, 'invalid callData length'); + require(_to.length == _value.length, 'invalid _value length'); + + bytes32 proposalId = votingMachine.propose(2, voteParams, msg.sender, _to[0]); + + organizationProposals[proposalId] = CallProposal({ + to: _to, + callData: _callData, + value: _value, + exist: true, + passed: false + }); + proposalsInfo[address(votingMachine)][proposalId] = ProposalInfo({ + blockNumber: block.number, + avatar: avatar + }); + emit NewCallProposal(_to, proposalId, _callData, _value, _descriptionHash); + return proposalId; + } + + function getOrganizationProposal(bytes32 proposalId) public view + returns (address[] memory to, bytes[] memory callData, uint256[] memory value, bool exist, bool passed) + { + return ( + organizationProposals[proposalId].to, + organizationProposals[proposalId].callData, + organizationProposals[proposalId].value, + organizationProposals[proposalId].exist, + organizationProposals[proposalId].passed + ); + } + +} diff --git a/test/walletScheme.js b/test/walletScheme.js new file mode 100644 index 00000000..a86ae565 --- /dev/null +++ b/test/walletScheme.js @@ -0,0 +1,267 @@ +import * as helpers from './helpers'; +const constants = require('./constants'); +const WalletScheme = artifacts.require('./WalletScheme.sol'); +const DaoCreator = artifacts.require("./DaoCreator.sol"); +const ControllerCreator = artifacts.require("./ControllerCreator.sol"); +const DAOTracker = artifacts.require("./DAOTracker.sol"); +const ERC20Mock = artifacts.require("./ERC20Mock.sol"); +const ActionMock = artifacts.require("./ActionMock.sol"); +const Wallet = artifacts.require("./Wallet.sol"); + +export class WalletSchemeParams { + constructor() { + } +} + +const setupWalletSchemeParams = async function( + walletScheme, + accounts, + contractToCall, + genesisProtocol = false, + tokenAddress = 0, + avatar, + controller + ) { + var walletSchemeParams = new WalletSchemeParams(); + if (genesisProtocol === true){ + walletSchemeParams.votingMachine = await helpers.setupGenesisProtocol(accounts,tokenAddress,0,helpers.NULL_ADDRESS); + await walletScheme.initialize( + avatar.address, + controller.address, + walletSchemeParams.votingMachine.genesisProtocol.address, + walletSchemeParams.votingMachine.params, + ); + } + else { + walletSchemeParams.votingMachine = await helpers.setupAbsoluteVote(helpers.NULL_ADDRESS,50,walletScheme.address); + await walletScheme.initialize( + avatar.address, + controller.address, + walletSchemeParams.votingMachine.absoluteVote.address, + walletSchemeParams.votingMachine.params, + ); + } + return walletSchemeParams; +}; + +const setup = async function (accounts,contractToCall = 0,reputationAccount=0,genesisProtocol = false,tokenAddress=0) { + var testSetup = new helpers.TestSetup(); + testSetup.standardTokenMock = await ERC20Mock.new(accounts[1],100); + testSetup.walletScheme = await WalletScheme.new(); + var controllerCreator = await ControllerCreator.new({gas: constants.ARC_GAS_LIMIT}); + var daoTracker = await DAOTracker.new({gas: constants.ARC_GAS_LIMIT}); + testSetup.daoCreator = await DaoCreator.new(controllerCreator.address,daoTracker.address,{gas:constants.ARC_GAS_LIMIT}); + testSetup.reputationArray = [20,10,70]; + + if (reputationAccount === 0) { + testSetup.org = await helpers.setupOrganizationWithArrays(testSetup.daoCreator,[accounts[0],accounts[1],accounts[2]],[1000,1000,1000],testSetup.reputationArray); + } else { + testSetup.org = await helpers.setupOrganizationWithArrays(testSetup.daoCreator,[accounts[0],accounts[1],reputationAccount],[1000,1000,1000],testSetup.reputationArray); + } + testSetup.walletSchemeParams= await setupWalletSchemeParams( + testSetup.walletScheme,accounts,contractToCall,genesisProtocol,tokenAddress,testSetup.org.avatar,controllerCreator + ); + var permissions = "0x00000010"; + + await testSetup.daoCreator.setSchemes(testSetup.org.avatar.address, + [testSetup.walletScheme.address], + [helpers.NULL_HASH],[permissions],"metaData"); + + return testSetup; +}; + +const createCallToActionMock = async function(_sender,_actionMock) { + return await new web3.eth.Contract(_actionMock.abi).methods.test2(_sender).encodeABI(); +}; + +contract('WalletScheme', function(accounts) { + + it("proposeCalls log", async function() { + + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + var callData = await createCallToActionMock(testSetup.walletScheme.address,actionMock); + + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[0],helpers.NULL_HASH); + assert.equal(tx.logs.length, 1); + assert.equal(tx.logs[0].event, "NewCallProposal"); + }); + + it("execute proposeCalls -no decision - proposal data delete", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + var callData = await createCallToActionMock(testSetup.walletScheme.address,actionMock); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + await testSetup.walletSchemeParams.votingMachine.absoluteVote.vote(proposalId,0,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + //check organizationsProposals after execution + var organizationProposal = await testSetup.walletScheme.getOrganizationProposal(proposalId); + assert.equal(organizationProposal.passed,false); + assert.equal(organizationProposal.callData[0],null); + }); + + it("execute proposeVote -positive decision - proposal data delete", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + var callData = await createCallToActionMock(testSetup.walletScheme.address,actionMock); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + var organizationProposal = await testSetup.walletScheme.getOrganizationProposal(proposalId); + assert.equal(organizationProposal[1][0],callData,helpers.NULL_HASH); + await testSetup.walletSchemeParams.votingMachine.absoluteVote.vote(proposalId,1,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + //check organizationsProposals after execution + organizationProposal = await testSetup.walletScheme.getOrganizationProposal(proposalId); + assert.equal(organizationProposal.callData[0],null);//new contract address + }); + + it("execute proposeVote -positive decision - destination reverts", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + var callData = await createCallToActionMock(helpers.NULL_ADDRESS,actionMock); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + + await testSetup.walletSchemeParams.votingMachine.absoluteVote.vote(proposalId,1,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + //actionMock revert because msg.sender is not the _addr param at actionMock thpugh the generic scheme not . + var organizationProposal = await testSetup.walletScheme.getOrganizationProposal(proposalId); + assert.equal(organizationProposal.exist,true);//new contract address + assert.equal(organizationProposal.passed,true);//new contract address + //can call execute + await testSetup.walletScheme.execute( proposalId); + }); + + + it("execute proposeVote -positive decision - destination reverts and then active", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + var activationTime = (await web3.eth.getBlock("latest")).timestamp + 1000; + await actionMock.setActivationTime(activationTime); + var callData = await new web3.eth.Contract(actionMock.abi).methods.test3().encodeABI(); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + + await testSetup.walletSchemeParams.votingMachine.absoluteVote.vote(proposalId,1,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + //actionMock revert because msg.sender is not the _addr param at actionMock thpugh the generic scheme not . + var organizationProposal = await testSetup.walletScheme.getOrganizationProposal(proposalId); + assert.equal(organizationProposal.exist,true);//new contract address + assert.equal(organizationProposal.passed,true);//new contract address + //can call execute + await testSetup.walletScheme.execute( proposalId); + await helpers.increaseTime(1001); + await testSetup.walletScheme.execute( proposalId); + + organizationProposal = await testSetup.walletScheme.getOrganizationProposal(proposalId); + assert.equal(organizationProposal.exist,false);//new contract address + assert.equal(organizationProposal.passed,false);//new contract address + try { + await testSetup.walletScheme.execute( proposalId); + assert(false, "cannot call execute after it been executed"); + } catch(error) { + helpers.assertVMException(error); + } + }); + + it("execute proposeVote without return value-positive decision - check action", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + const encodeABI = await new web3.eth.Contract(actionMock.abi).methods.withoutReturnValue(testSetup.org.avatar.address).encodeABI(); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[encodeABI],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + + await testSetup.walletSchemeParams.votingMachine.absoluteVote.vote(proposalId,1,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + + }); + + it("execute should fail if not executed from votingMachine", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + const encodeABI = await new web3.eth.Contract(actionMock.abi).methods.withoutReturnValue(testSetup.org.avatar.address).encodeABI(); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[encodeABI],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + + try { + await testSetup.walletScheme.execute( proposalId); + assert(false, "execute should fail if not executed from votingMachine"); + } catch(error) { + helpers.assertVMException(error); + } + + }); + + it("execute proposeVote -positive decision - check action - with GenesisProtocol", async function() { + var actionMock =await ActionMock.new(); + var standardTokenMock = await ERC20Mock.new(accounts[0],1000); + var testSetup = await setup(accounts,actionMock.address,0,true,standardTokenMock.address); + var value = 123; + var callData = await createCallToActionMock(testSetup.walletScheme.address,actionMock); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[value],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + //transfer some eth to avatar + await web3.eth.sendTransaction({from:accounts[0],to:testSetup.walletScheme.address, value: web3.utils.toWei('1', "ether")}); + assert.equal(await web3.eth.getBalance(actionMock.address),0); + tx = await testSetup.walletSchemeParams.votingMachine.genesisProtocol.vote(proposalId,1,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + await testSetup.walletScheme.getPastEvents('ProposalExecutedByVotingMachine', { + fromBlock: tx.blockNumber, + toBlock: 'latest' + }) + .then(function(events){ + assert.equal(events[0].event,"ProposalExecutedByVotingMachine"); + assert.equal(events[0].args._param,1); + }); + assert.equal(await web3.eth.getBalance(actionMock.address),value); + }); + + it("execute proposeVote -negative decision - check action - with GenesisProtocol", async function() { + var actionMock =await ActionMock.new(); + var standardTokenMock = await ERC20Mock.new(accounts[0],1000); + var testSetup = await setup(accounts,actionMock.address,0,true,standardTokenMock.address); + + var callData = await createCallToActionMock(testSetup.walletScheme.address,actionMock); + var tx = await testSetup.walletScheme.proposeCalls([actionMock.address],[callData],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + tx = await testSetup.walletSchemeParams.votingMachine.genesisProtocol.vote(proposalId,2,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + await testSetup.walletScheme.getPastEvents('ProposalExecutedByVotingMachine', { + fromBlock: tx.blockNumber, + toBlock: 'latest' + }) + .then(function(events){ + assert.equal(events[0].event,"ProposalExecutedByVotingMachine"); + assert.equal(events[0].args._param,2); + }); + }); + + it("Wallet - execute proposeVote -positive decision - check action - with GenesisProtocol", async function() { + var wallet =await Wallet.new(); + await web3.eth.sendTransaction({from:accounts[0],to:wallet.address, value: web3.utils.toWei('1', "ether")}); + var standardTokenMock = await ERC20Mock.new(accounts[0],1000); + var testSetup = await setup(accounts,wallet.address,0,true,standardTokenMock.address); + var callData = await new web3.eth.Contract(wallet.abi).methods.pay(accounts[1]).encodeABI(); + var tx = await testSetup.walletScheme.proposeCalls([wallet.address],[callData],[0],helpers.NULL_HASH); + var proposalId = await helpers.getValueFromLogs(tx, '_proposalId'); + assert.equal(await web3.eth.getBalance(wallet.address),web3.utils.toWei('1', "ether")); + await testSetup.walletSchemeParams.votingMachine.genesisProtocol.vote(proposalId,1,0,helpers.NULL_ADDRESS,{from:accounts[2]}); + assert.equal(await web3.eth.getBalance(wallet.address),web3.utils.toWei('1', "ether")); + await wallet.transferOwnership(testSetup.walletScheme.address); + await testSetup.walletScheme.execute(proposalId); + assert.equal(await web3.eth.getBalance(wallet.address),0); + }); + + it("cannot init twice", async function() { + var actionMock =await ActionMock.new(); + var testSetup = await setup(accounts,actionMock.address); + + try { + await testSetup.walletScheme.initialize( + testSetup.org.avatar.address, + testSetup.daoCreator.address, + accounts[0], + accounts[0] + ); + assert(false, "cannot init twice"); + } catch(error) { + helpers.assertVMException(error); + } + + }); + +});