diff --git a/README.md b/README.md index 55d9f3c9..f2a8b61c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,14 @@ const cpk = await CPK.create({ Please refer to the `migrations/` folder of this package for information on how to deploy the required contracts on a network, and note that these addresses must be available for the connected network in order for *CPK* creation to be successful. +### CPK#getOwnerAccount + +This may be used to figure out which account the proxy considers the owner account. It returns a Promise which resolves to the owner account: + +```js +const ownerAccount = await cpk.getOwnerAccount() +``` + ### CPK#address Once created, the `address` property on a *CPK* instance will provide the proxy's checksummed Ethereum address: @@ -91,6 +99,17 @@ Once created, the `address` property on a *CPK* instance will provide the proxy' This address is calculated even if the proxy has not been deployed yet, and it is deterministically generated from the proxy owner address. +#### Support for WalletConnected Gnosis Safe + +If the provider underlying the *CPK* instance is connected to a Gnosis Safe via WalletConnect, the address will match the owner account: + +```js +const ownerAccount = await cpk.getOwnerAccount() +cpk.address === ownerAccount // this will be true in that case +``` + +*CPK* will use the Safe's native support for batching transactions, and will not create an additional proxy contract account. + ### CPK#execTransactions To execute transactions using a *CPK* instance, call `execTransactions` with an *Array* of transactions to execute. If the proxy has not been deployed, this will also batch the proxy's deployment into the transaction. Multiple transactions will be batched and executed together if the proxy has been deployed. @@ -155,7 +174,7 @@ const { promiEvent, receipt } = await cpk.execTransactions([ Suppose instead `erc20` and `exchange` are Truffle contract abstraction instances instead. Since Truffle contract abstraction instances contain a reference to an underlying *web3.eth.Contract* instance, they may be used in a similar manner: ```js -const { promiEvent, receipt } = await cpk.execTransactions([ +const { promiEvent, hash } = await cpk.execTransactions([ { operation: CPK.CALL, to: erc20.address, @@ -183,7 +202,7 @@ const { promiEvent, receipt } = await cpk.execTransactions([ Similarly to the example in the previous section, suppose that `erc20` is a *ethers.Contract* instance for an ERC20 token for which the proxy account holds a balance, and `exchange` is a *ethers.Contract* instance of an exchange contract with an deposit requirement, where calling the deposit function on the exchange requires an allowance for the exchange by the depositor. Batching these transactions may be done like so: ```js -const { transactionResponse, transactionReceipt } = await cpk.execTransactions([ +const { transactionResponse, hash } = await cpk.execTransactions([ { operation: CPK.CALL, to: erc20.address, @@ -228,3 +247,24 @@ const txObject = await cpk.execTransactions( { gasPrice: `${3e9}` }, ); ``` + +#### Support for WalletConnected Gnosis Safe + +When WalletConnected to Gnosis Safe, `execTransactions` will use the Safe's native support for sending batch transactions (via `gs_multi_send`). In this case, the gas price option is not available, and `execTransactions` will only return a transaction hash. + +```js +const { hash } = await cpk.execTransactions([ + { + operation: CPK.CALL, + to: '0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1', + value: `${1e18}`, + data: '0x', + }, + { + operation: CPK.CALL, + to: '0xffcf8fdee72ac11b5c542428b35eef5769c409f0', + value: `${1e18}`, + data: '0x', + }, +]); +``` diff --git a/migrations/1-deploy-contracts.js b/migrations/1-deploy-contracts.js index 1f39d2eb..2b28033a 100644 --- a/migrations/1-deploy-contracts.js +++ b/migrations/1-deploy-contracts.js @@ -4,6 +4,6 @@ module.exports = (deployer, network) => { ['Migrations', 'CPKFactory'].forEach(deploy); if (network === 'test') { - ['MultiSend', 'DefaultCallbackHandler', 'GnosisSafe'].forEach(deploy); + ['MultiSend', 'DefaultCallbackHandler', 'GnosisSafe', 'ProxyFactory'].forEach(deploy); } }; diff --git a/src/index.js b/src/index.js index 40ac3d72..50f1f5fd 100644 --- a/src/index.js +++ b/src/index.js @@ -180,26 +180,42 @@ const CPK = class CPK { const ownerAccount = await this.getOwnerAccount(); + const provider = this.apiType === 'web3' + ? this.web3.currentProvider + : this.signer.provider.provider + || this.signer.provider._web3Provider; // eslint-disable-line no-underscore-dangle + const wc = provider && (provider.wc || (provider.connection && provider.connection.wc)); + if ( + wc && wc.peerMeta && wc.peerMeta.name + && wc.peerMeta.name.startsWith('Gnosis Safe') + ) { + this.isConnectedToSafe = true; + } + if (this.apiType === 'web3') { - this.proxyFactory = new this.web3.eth.Contract(cpkFactoryAbi, proxyFactoryAddress); this.multiSend = new this.web3.eth.Contract(multiSendAbi, multiSendAddress); - const create2Salt = this.web3.utils.keccak256(this.web3.eth.abi.encodeParameters( - ['address', 'uint256'], - [ownerAccount, predeterminedSaltNonce], - )); + if (this.isConnectedToSafe) { + this.contract = new this.web3.eth.Contract(safeAbi, ownerAccount); + } else { + this.proxyFactory = new this.web3.eth.Contract(cpkFactoryAbi, proxyFactoryAddress); + const create2Salt = this.web3.utils.keccak256(this.web3.eth.abi.encodeParameters( + ['address', 'uint256'], + [ownerAccount, predeterminedSaltNonce], + )); - this.contract = new this.web3.eth.Contract(safeAbi, this.web3.utils.toChecksumAddress( - this.web3.utils.soliditySha3( - '0xff', - { t: 'address', v: this.proxyFactory.options.address }, - { t: 'bytes32', v: create2Salt }, + this.contract = new this.web3.eth.Contract(safeAbi, this.web3.utils.toChecksumAddress( this.web3.utils.soliditySha3( - await this.proxyFactory.methods.proxyCreationCode().call(), - this.web3.eth.abi.encodeParameters(['address'], [this.masterCopyAddress]), - ), - ).slice(-40), - )); + '0xff', + { t: 'address', v: this.proxyFactory.options.address }, + { t: 'bytes32', v: create2Salt }, + this.web3.utils.soliditySha3( + await this.proxyFactory.methods.proxyCreationCode().call(), + this.web3.eth.abi.encodeParameters(['address'], [this.masterCopyAddress]), + ), + ).slice(-40), + )); + } } else if (this.apiType === 'ethers') { const abiToViewAbi = (abi) => abi.map(({ constant, @@ -210,38 +226,47 @@ const CPK = class CPK { stateMutability: 'view', })); - this.proxyFactory = new this.ethers.Contract( - proxyFactoryAddress, - cpkFactoryAbi, - this.signer, - ); - this.viewProxyFactory = new this.ethers.Contract( - proxyFactoryAddress, - abiToViewAbi(cpkFactoryAbi), - this.signer, - ); - this.multiSend = new this.ethers.Contract(multiSendAddress, multiSendAbi, this.signer); - const create2Salt = this.ethers.utils.keccak256(this.ethers.utils.defaultAbiCoder.encode( - ['address', 'uint256'], - [ownerAccount, predeterminedSaltNonce], - )); - - const address = this.ethers.utils.getAddress( - this.ethers.utils.solidityKeccak256(['bytes', 'address', 'bytes32', 'bytes32'], [ - '0xff', - this.proxyFactory.address, - create2Salt, - this.ethers.utils.solidityKeccak256(['bytes', 'bytes'], [ - await this.proxyFactory.proxyCreationCode(), - this.ethers.utils.defaultAbiCoder.encode(['address'], [this.masterCopyAddress]), - ]), - ]).slice(-40), - ); + if (this.isConnectedToSafe) { + this.contract = new this.ethers.Contract(ownerAccount, safeAbi, this.signer); + this.viewContract = new this.ethers.Contract( + ownerAccount, + abiToViewAbi(safeAbi), + this.signer, + ); + } else { + this.proxyFactory = new this.ethers.Contract( + proxyFactoryAddress, + cpkFactoryAbi, + this.signer, + ); + this.viewProxyFactory = new this.ethers.Contract( + proxyFactoryAddress, + abiToViewAbi(cpkFactoryAbi), + this.signer, + ); - this.contract = new this.ethers.Contract(address, safeAbi, this.signer); - this.viewContract = new this.ethers.Contract(address, abiToViewAbi(safeAbi), this.signer); + const create2Salt = this.ethers.utils.keccak256(this.ethers.utils.defaultAbiCoder.encode( + ['address', 'uint256'], + [ownerAccount, predeterminedSaltNonce], + )); + + const address = this.ethers.utils.getAddress( + this.ethers.utils.solidityKeccak256(['bytes', 'address', 'bytes32', 'bytes32'], [ + '0xff', + this.proxyFactory.address, + create2Salt, + this.ethers.utils.solidityKeccak256(['bytes', 'bytes'], [ + await this.proxyFactory.proxyCreationCode(), + this.ethers.utils.defaultAbiCoder.encode(['address'], [this.masterCopyAddress]), + ]), + ]).slice(-40), + ); + + this.contract = new this.ethers.Contract(address, safeAbi, this.signer); + this.viewContract = new this.ethers.Contract(address, abiToViewAbi(safeAbi), this.signer); + } } } @@ -271,6 +296,8 @@ const CPK = class CPK { let checkSingleCall; let attemptTransaction; + let attemptSafeProviderSendTx; + let attemptSafeProviderMultiSendTxs; let codeAtAddress; let getContractAddress; let encodeMultiSendCalldata; @@ -282,6 +309,12 @@ const CPK = class CPK { gas: blockGasLimit, ...(options || {}), }; + const promiEventToPromise = (promiEvent) => new Promise( + (resolve, reject) => promiEvent.once( + 'transactionHash', + (hash) => resolve({ sendOptions, promiEvent, hash }), + ).catch(reject), + ); checkSingleCall = (to, value, data) => this.web3.eth.call({ from: this.address, @@ -295,12 +328,36 @@ const CPK = class CPK { const promiEvent = contract.methods[methodName](...params).send(sendOptions); - return new Promise( - (resolve, reject) => promiEvent.on( - 'confirmation', - (confirmationNumber, receipt) => resolve({ sendOptions, promiEvent, receipt }), - ).catch(reject), + return promiEventToPromise(promiEvent); + }; + + attemptSafeProviderSendTx = (txObj) => { + const promiEvent = this.web3.eth.sendTransaction({ + ...txObj, + ...sendOptions, + }); + + return promiEventToPromise(promiEvent); + }; + + attemptSafeProviderMultiSendTxs = async (txs) => { + const hash = await ( + this.web3.currentProvider.host === 'CustomProvider' + ? this.web3.currentProvider.send('gs_multi_send', txs) + : new Promise( + (resolve, reject) => this.web3.currentProvider.send({ + jsonrpc: '2.0', + id: new Date().getTime(), + method: 'gs_multi_send', + params: txs, + }, (err, result) => { + if (err) return reject(err); + if (result.error) return reject(result.error); + return resolve(result.result); + }), + ) ); + return { hash }; }; codeAtAddress = await this.web3.eth.getCode(this.address); @@ -330,8 +387,20 @@ const CPK = class CPK { ...params, ...(options == null ? [] : [options]), ); - const transactionReceipt = await transactionResponse.wait(); - return { transactionResponse, transactionReceipt }; + return { transactionResponse, hash: transactionResponse.hash }; + }; + + attemptSafeProviderSendTx = async (txObj) => { + const transactionResponse = await this.signer.sendTransaction({ + ...txObj, + ...(options || {}), + }); + return { transactionResponse, hash: transactionResponse.hash }; + }; + + attemptSafeProviderMultiSendTxs = async (txs) => { + const hash = await this.signer.provider.send('gs_multi_send', txs); + return { hash }; }; codeAtAddress = (await this.signer.provider.getCode(this.address)); @@ -365,32 +434,42 @@ const CPK = class CPK { if (operation === CPK.CALL) { await checkSingleCall(to, value, data); + + if (this.isConnectedToSafe) { + return attemptSafeProviderSendTx({ to, value, data }); + } } - if (codeAtAddress !== '0x') { + if (!this.isConnectedToSafe) { + if (codeAtAddress !== '0x') { + return attemptTransaction( + this.contract, this.viewContract, + 'execTransaction', + [ + to, value, data, operation, + 0, 0, 0, zeroAddress, zeroAddress, + signatureForAddress(ownerAccount), + ], + new Error('transaction execution expected to fail'), + ); + } + return attemptTransaction( - this.contract, this.viewContract, - 'execTransaction', + this.proxyFactory, this.viewProxyFactory, + 'createProxyAndExecTransaction', [ + this.masterCopyAddress, + predeterminedSaltNonce, + this.fallbackHandlerAddress, to, value, data, operation, - 0, 0, 0, zeroAddress, zeroAddress, - signatureForAddress(ownerAccount), ], - new Error('transaction execution expected to fail'), + new Error('proxy creation and transaction execution expected to fail'), ); } + } - return attemptTransaction( - this.proxyFactory, this.viewProxyFactory, - 'createProxyAndExecTransaction', - [ - this.masterCopyAddress, - predeterminedSaltNonce, - this.fallbackHandlerAddress, - to, value, data, operation, - ], - new Error('proxy creation and transaction execution expected to fail'), - ); + if (this.isConnectedToSafe) { + return attemptSafeProviderMultiSendTxs(transactions); } if (codeAtAddress !== '0x') { diff --git a/test/contract-proxy-kit.js b/test/contract-proxy-kit.js index e1ead3fa..1b7344b6 100644 --- a/test/contract-proxy-kit.js +++ b/test/contract-proxy-kit.js @@ -14,6 +14,13 @@ const Multistep = artifacts.require('Multistep'); const ConditionalTokens = artifacts.require('ConditionalTokens'); const ERC20Mintable = artifacts.require('ERC20Mintable'); +const GnosisSafe = artifacts.require('GnosisSafe'); +const CPKFactory = artifacts.require('CPKFactory'); +const MultiSend = artifacts.require('MultiSend'); +const DefaultCallbackHandler = artifacts.require('DefaultCallbackHandler'); +const ProxyFactory = artifacts.require('ProxyFactory'); +const zeroAddress = `0x${'0'.repeat(40)}`; + const toConfirmationPromise = (promiEvent) => new Promise( (resolve, reject) => promiEvent.on('confirmation', (confirmationNumber, receipt) => resolve(receipt)).catch(reject), @@ -31,6 +38,8 @@ function shouldSupportDifferentTransactions({ getGasUsed, testedTxObjProps, checkTxObj, + ownerIsRecognizedContract, + executor, }) { const { getConditionId } = makeConditionalTokensIdHelpers(web3.utils); @@ -40,6 +49,14 @@ function shouldSupportDifferentTransactions({ checkAddressChecksum(cpk.address).should.be.true(); }); + if (ownerIsRecognizedContract) { + it('has same owner as instance\'s address', async () => { + const cpk = await getCPK(); + const proxyOwner = await cpk.getOwnerAccount(); + proxyOwner.should.be.equal(cpk.address); + }); + } + describe('with mock contracts', () => { let cpk; let proxyOwner; @@ -135,9 +152,14 @@ function shouldSupportDifferentTransactions({ ]); (await multiStep.lastStepFinished(cpk.address)).toNumber().should.equal(2); - fromWei(await erc20.balanceOf(cpk.address)).should.equal(1); + + if (cpk.address === proxyOwner) { + fromWei(await erc20.balanceOf(cpk.address)).should.equal(98); + } else { + fromWei(await erc20.balanceOf(cpk.address)).should.equal(1); + fromWei(await erc20.balanceOf(proxyOwner)).should.equal(97); + } fromWei(await erc20.balanceOf(multiStep.address)).should.equal(2); - fromWei(await erc20.balanceOf(proxyOwner)).should.equal(97); }); it('can batch ERC-1155 token interactions', async () => { @@ -181,9 +203,13 @@ function shouldSupportDifferentTransactions({ }, ]); - fromWei(await erc20.balanceOf(cpk.address)).should.equal(2); + if (cpk.address === proxyOwner) { + fromWei(await erc20.balanceOf(cpk.address)).should.equal(99); + } else { + fromWei(await erc20.balanceOf(cpk.address)).should.equal(2); + fromWei(await erc20.balanceOf(proxyOwner)).should.equal(97); + } fromWei(await erc20.balanceOf(conditionalTokens.address)).should.equal(1); - fromWei(await erc20.balanceOf(proxyOwner)).should.equal(97); }); it('by default errors without transacting when single transaction would fail', async () => { @@ -203,7 +229,9 @@ function shouldSupportDifferentTransactions({ .should.eventually.equal(startingTransactionCount); }); - it('by default errors without transacting when any transaction in batch would fail', async () => { + ( + ownerIsRecognizedContract ? it.skip : it + )('by default errors without transacting when any transaction in batch would fail', async () => { (await multiStep.lastStepFinished(cpk.address)).toNumber().should.equal(0); const ownerAccount = await cpk.getOwnerAccount(); const startingTransactionCount = await getTransactionCount(ownerAccount); @@ -238,7 +266,7 @@ function shouldSupportDifferentTransactions({ it('can execute a single transaction with a specific gas price', async () => { const ownerAccount = await cpk.getOwnerAccount(); - const startingBalance = await getBalance(ownerAccount); + const startingBalance = await getBalance(executor || ownerAccount); (await multiStep.lastStepFinished(cpk.address)).toNumber().should.equal(0); @@ -252,17 +280,19 @@ function shouldSupportDifferentTransactions({ }], { gasPrice }, ); - const gasUsed = getGasUsed(txObj); + const gasUsed = await getGasUsed(txObj); - const endingBalance = await getBalance(ownerAccount); + const endingBalance = await getBalance(executor || ownerAccount); const gasCosts = startingBalance.sub(endingBalance).toNumber(); gasCosts.should.be.equal(gasPrice * gasUsed); }); - it('can execute a batch transaction with a specific gas price', async () => { + ( + ownerIsRecognizedContract ? it.skip : it + )('can execute a batch transaction with a specific gas price', async () => { const ownerAccount = await cpk.getOwnerAccount(); - const startingBalance = await getBalance(ownerAccount); + const startingBalance = await getBalance(executor || ownerAccount); (await multiStep.lastStepFinished(cpk.address)).toNumber().should.equal(0); @@ -283,9 +313,9 @@ function shouldSupportDifferentTransactions({ ], { gasPrice }, ); - const gasUsed = getGasUsed(txObj); + const gasUsed = await getGasUsed(txObj); - const endingBalance = await getBalance(ownerAccount); + const endingBalance = await getBalance(executor || ownerAccount); const gasCosts = startingBalance.sub(endingBalance).toNumber(); gasCosts.should.be.equal(gasPrice * gasUsed); @@ -294,26 +324,31 @@ function shouldSupportDifferentTransactions({ } -function shouldWorkWithWeb3(Web3, defaultAccount) { +function shouldWorkWithWeb3(Web3, defaultAccount, safeOwner, gnosisSafeProviderBox) { describe(`with Web3 version ${(new Web3()).version}`, () => { const ueb3 = new Web3(web3.currentProvider); - const ueb3TestHelpers = { - checkAddressChecksum: ueb3.utils.checkAddressChecksum, - sendTransaction: (txObj) => toConfirmationPromise(ueb3.eth.sendTransaction(txObj)), - randomHexWord: () => ueb3.utils.randomHex(32), - fromWei: (amount) => Number(ueb3.utils.fromWei(amount)), - getTransactionCount: ueb3.eth.getTransactionCount, - testedTxObjProps: 'the PromiEvent for the transaction and the receipt', - getBalance: (address) => ueb3.eth.getBalance(address) - .then((balance) => ueb3.utils.toBN(balance)), - getGasUsed: ({ receipt }) => receipt.gasUsed, - - checkTxObj: ({ promiEvent, receipt }) => { + const testHelperMaker = (web3Box) => ({ + checkAddressChecksum: (address) => web3Box[0].utils.checkAddressChecksum(address), + sendTransaction: (txObj) => toConfirmationPromise(web3Box[0].eth.sendTransaction(txObj)), + randomHexWord: () => web3Box[0].utils.randomHex(32), + fromWei: (amount) => Number(web3Box[0].utils.fromWei(amount)), + getTransactionCount: (account) => web3Box[0].eth.getTransactionCount(account), + testedTxObjProps: 'the PromiEvent for the transaction and the hash', + getBalance: (address) => web3Box[0].eth.getBalance(address) + .then((balance) => web3Box[0].utils.toBN(balance)), + getGasUsed: ({ promiEvent }) => new Promise( + (resolve, reject) => promiEvent + .on('confirmation', (confNumber, receipt) => resolve(receipt.gasUsed)) + .on('error', reject), + ), + checkTxObj: ({ promiEvent, hash }) => { should.exist(promiEvent); - should.exist(receipt); + should.exist(hash); }, - }; + }); + + const ueb3TestHelpers = testHelperMaker([ueb3]); it('should not produce instances when web3 not connected to a recognized network', async () => { await CPK.create({ web3: ueb3 }).should.be.rejectedWith(/unrecognized network ID \d+/); @@ -325,10 +360,10 @@ function shouldWorkWithWeb3(Web3, defaultAccount) { before('obtain addresses from artifacts', async () => { networks = { [await ueb3.eth.net.getId()]: { - masterCopyAddress: artifacts.require('GnosisSafe').address, - proxyFactoryAddress: artifacts.require('CPKFactory').address, - multiSendAddress: artifacts.require('MultiSend').address, - fallbackHandlerAddress: artifacts.require('DefaultCallbackHandler').address, + masterCopyAddress: GnosisSafe.address, + proxyFactoryAddress: CPKFactory.address, + multiSendAddress: MultiSend.address, + fallbackHandlerAddress: DefaultCallbackHandler.address, }, }; }); @@ -381,11 +416,35 @@ function shouldWorkWithWeb3(Web3, defaultAccount) { }, }); }); + + describe('with mock WalletConnected Gnosis Safe provider', () => { + const safeWeb3Box = []; + + before('create Web3 instance', async () => { + safeWeb3Box[0] = new Web3(gnosisSafeProviderBox[0]); + }); + + let cpk; + + before('create instance', async () => { + cpk = await CPK.create({ + web3: safeWeb3Box[0], + networks, + }); + }); + + shouldSupportDifferentTransactions({ + ...testHelperMaker(safeWeb3Box), + async getCPK() { return cpk; }, + ownerIsRecognizedContract: true, + executor: safeOwner, + }); + }); }); }); } -function shouldWorkWithEthers(ethers, defaultAccount) { +function shouldWorkWithEthers(ethers, defaultAccount, safeOwner, gnosisSafeProviderBox) { describe(`with ethers version ${ethers.version}`, () => { const signer = ethers.Wallet.createRandom() .connect(new ethers.providers.Web3Provider(web3.currentProvider)); @@ -397,11 +456,13 @@ function shouldWorkWithEthers(ethers, defaultAccount) { fromWei: (amount) => Number(ethers.utils.formatUnits(amount.toString(), 'ether')), getTransactionCount: signer.provider.getTransactionCount.bind(signer.provider), getBalance: signer.provider.getBalance.bind(signer.provider), - getGasUsed: ({ transactionReceipt }) => transactionReceipt.gasUsed.toNumber(), - testedTxObjProps: 'the TransactionResponse and the TransactionReceipt', - checkTxObj: ({ transactionResponse, transactionReceipt }) => { + getGasUsed: async ({ transactionResponse }) => ( + await transactionResponse.wait() + ).gasUsed.toNumber(), + testedTxObjProps: 'the TransactionResponse and the hash', + checkTxObj: ({ transactionResponse, hash }) => { should.exist(transactionResponse); - should.exist(transactionReceipt); + should.exist(hash); }, }); @@ -419,10 +480,10 @@ function shouldWorkWithEthers(ethers, defaultAccount) { before('obtain addresses from artifacts', async () => { networks = { [(await signer.provider.getNetwork()).chainId]: { - masterCopyAddress: artifacts.require('GnosisSafe').address, - proxyFactoryAddress: artifacts.require('CPKFactory').address, - multiSendAddress: artifacts.require('MultiSend').address, - fallbackHandlerAddress: artifacts.require('DefaultCallbackHandler').address, + masterCopyAddress: GnosisSafe.address, + proxyFactoryAddress: CPKFactory.address, + multiSendAddress: MultiSend.address, + fallbackHandlerAddress: DefaultCallbackHandler.address, }, }; }); @@ -486,11 +547,161 @@ function shouldWorkWithEthers(ethers, defaultAccount) { }, }); }); + + describe('with mock WalletConnected Gnosis Safe provider', () => { + const safeSignerBox = []; + + before('create Web3 instance', async () => { + const provider = new ethers.providers.Web3Provider(gnosisSafeProviderBox[0]); + safeSignerBox[0] = provider.getSigner(); + }); + + let cpk; + + before('create instance', async () => { + cpk = await CPK.create({ + ethers, + signer: safeSignerBox[0], + networks, + }); + }); + + shouldSupportDifferentTransactions({ + ...ethersTestHelpers(safeSignerBox), + async getCPK() { return cpk; }, + ownerIsRecognizedContract: true, + executor: safeOwner, + }); + }); }); }); } -contract('CPK', ([defaultAccount]) => { +contract('CPK', ([defaultAccount, safeOwner]) => { + const gnosisSafeProviderBox = []; + before('emulate Gnosis Safe WalletConnect provider', async () => { + const proxyFactory = await ProxyFactory.deployed(); + const safeMasterCopy = await GnosisSafe.deployed(); + const multiSend = await MultiSend.deployed(); + const safeSetupData = safeMasterCopy.contract.methods.setup( + [safeOwner], 1, + zeroAddress, '0x', + DefaultCallbackHandler.address, + zeroAddress, '0x', zeroAddress, + ).encodeABI(); + const safeCreationTx = await proxyFactory.createProxy(safeMasterCopy.address, safeSetupData); + const safeAddress = safeCreationTx.logs.find(({ event }) => event === 'ProxyCreation').args.proxy; + const safeSignature = `0x000000000000000000000000${ + safeOwner.replace(/^0x/, '').toLowerCase() + }000000000000000000000000000000000000000000000000000000000000000001`; + const safe = await GnosisSafe.at(safeAddress); + + const emulatedSafeProvider = { + ...web3.currentProvider, + wc: { + peerMeta: { + name: 'Gnosis Safe - Mock', + }, + }, + send(rpcData, callback) { + const { + id, jsonrpc, method, params, + } = rpcData; + + if (method === 'eth_accounts') { + return callback(null, { + id, jsonrpc, result: [safeAddress], + }); + } + + if (method === 'eth_sendTransaction') { + const [{ + from, to, gas, gasPrice, value, data, nonce, + }] = params; + + if (from.toLowerCase() !== safeAddress.toLowerCase()) { + return callback(new Error(`expected to be from safe address ${safeAddress} but got ${from}`)); + } + + return web3.currentProvider.send({ + id, + jsonrpc, + method, + params: [{ + from: safeOwner, + to: safeAddress, + gas, + gasPrice, + value, + nonce, + data: safeMasterCopy.contract.methods.execTransaction( + to, value || 0, data, CPK.CALL, + 0, 0, 0, zeroAddress, zeroAddress, safeSignature, + ).encodeABI(), + }], + }, callback); + } + + if (method === 'eth_getTransactionCount') { + const [account, block] = params; + if (account === safeAddress) { + return web3.currentProvider.send({ + id, jsonrpc, method, params: [safeOwner, block], + }, callback); + } + } + + if (method === 'eth_estimateGas') { + const [{ + from, to, gas, gasPrice, value, data, nonce, + }] = params; + + if (from.toLowerCase() === safeAddress.toLowerCase()) { + return web3.currentProvider.send({ + id, + jsonrpc, + method, + params: [{ + from: safeOwner, + to: safeAddress, + gas, + gasPrice, + value, + nonce, + data: safeMasterCopy.contract.methods.execTransaction( + to, value || 0, data, CPK.CALL, + 0, 0, 0, zeroAddress, zeroAddress, safeSignature, + ).encodeABI(), + }], + }, callback); + } + } + + if (method === 'gs_multi_send') { + const callData = multiSend.contract.methods.multiSend( + `0x${params.map((tx) => [ + web3.eth.abi.encodeParameter('uint8', tx.operation).slice(-2), + web3.eth.abi.encodeParameter('address', tx.to).slice(-40), + web3.eth.abi.encodeParameter('uint256', tx.value).slice(-64), + web3.eth.abi.encodeParameter('uint256', web3.utils.hexToBytes(tx.data).length).slice(-64), + tx.data.replace(/^0x/, ''), + ].join('')).join('')}`, + ).encodeABI(); + + return safe.execTransaction( + multiSend.address, 0, callData, CPK.DELEGATECALL, + 0, 0, 0, zeroAddress, zeroAddress, safeSignature, + { from: safeOwner }, + ).then((result) => callback(null, { id, jsonrpc, result }), callback); + } + + return web3.currentProvider.send(rpcData, callback); + }, + }; + + gnosisSafeProviderBox[0] = emulatedSafeProvider; + }); + it('should exist', () => { should.exist(CPK); }); @@ -503,6 +714,8 @@ contract('CPK', ([defaultAccount]) => { await CPK.create({}).should.be.rejectedWith('web3/ethers property missing from options'); }); - web3Versions.forEach((Web3) => { shouldWorkWithWeb3(Web3, defaultAccount); }); - shouldWorkWithEthers(ethersMaj4, defaultAccount); + web3Versions.forEach((Web3) => { + shouldWorkWithWeb3(Web3, defaultAccount, safeOwner, gnosisSafeProviderBox); + }); + shouldWorkWithEthers(ethersMaj4, defaultAccount, safeOwner, gnosisSafeProviderBox); });