From fafd1a50d7a77a4de3a678c8df628ab3f121ea96 Mon Sep 17 00:00:00 2001 From: Lucas Saldanha Date: Fri, 2 Oct 2020 11:34:42 +1300 Subject: [PATCH] Added support for Besu private transactions Signed-off-by: Lucas Saldanha --- packages/caliper-cli/lib/lib/config.yaml | 6 +- .../lib/ethereum-connector.js | 219 ++++++++++++++++-- packages/caliper-ethereum/package.json | 5 +- .../besu_tests/config/docker-compose.yml | 10 +- .../besu_tests/config/orion/key/orion.key | 1 + .../besu_tests/config/orion/key/orion.pub | 1 + .../besu_tests/config/orion/orion.conf | 26 +++ .../besu_tests/package.json | 11 + .../besu_tests/privatetxs/benchconfig.yaml | 44 ++++ .../besu_tests/privatetxs/networkconfig.json | 49 ++++ .../besu_tests/run.sh | 11 + .../besu_tests/store.js | 109 +++++++++ 12 files changed, 467 insertions(+), 25 deletions(-) create mode 100644 packages/caliper-tests-integration/besu_tests/config/orion/key/orion.key create mode 100644 packages/caliper-tests-integration/besu_tests/config/orion/key/orion.pub create mode 100644 packages/caliper-tests-integration/besu_tests/config/orion/orion.conf create mode 100644 packages/caliper-tests-integration/besu_tests/package.json create mode 100644 packages/caliper-tests-integration/besu_tests/privatetxs/benchconfig.yaml create mode 100644 packages/caliper-tests-integration/besu_tests/privatetxs/networkconfig.json create mode 100644 packages/caliper-tests-integration/besu_tests/store.js diff --git a/packages/caliper-cli/lib/lib/config.yaml b/packages/caliper-cli/lib/lib/config.yaml index 486cf8a903..6e28166989 100644 --- a/packages/caliper-cli/lib/lib/config.yaml +++ b/packages/caliper-cli/lib/lib/config.yaml @@ -63,9 +63,9 @@ sut: besu: 1.3.2: - packages: ['web3@1.2.2'] + packages: ['web3@1.3.0'] 1.3: - packages: ['web3@1.2.2'] + packages: ['web3@1.3.0'] 1.4: &besu-latest - packages: ['web3@1.2.2'] + packages: ['web3@1.3.0'] latest: *besu-latest diff --git a/packages/caliper-ethereum/lib/ethereum-connector.js b/packages/caliper-ethereum/lib/ethereum-connector.js index 1d09ecc512..9d0a418a8d 100644 --- a/packages/caliper-ethereum/lib/ethereum-connector.js +++ b/packages/caliper-ethereum/lib/ethereum-connector.js @@ -16,6 +16,7 @@ const EthereumHDKey = require('ethereumjs-wallet/hdkey'); const Web3 = require('web3'); +const EEAClient = require('web3-eea'); const {ConnectorBase, CaliperUtils, ConfigUtil, TxStatus} = require('@hyperledger/caliper-core'); const logger = CaliperUtils.getLogger('ethereum-connector'); @@ -50,6 +51,9 @@ class EthereumConnector extends ConnectorBase { this.ethereumConfig = ethereumConfig; this.web3 = new Web3(this.ethereumConfig.url); + if (this.ethereumConfig.privacy) { + this.web3eea = new EEAClient(this.web3, ethereumConfig.chainId); + } this.web3.transactionConfirmationBlocks = this.ethereumConfig.transactionConfirmationBlocks; this.workerIndex = workerIndex; this.context = undefined; @@ -100,13 +104,29 @@ class EthereumConnector extends ConnectorBase { let self = this; logger.info('Creating contracts...'); for (const key of Object.keys(this.ethereumConfig.contracts)) { - let contractData = require(CaliperUtils.resolvePath(this.ethereumConfig.contracts[key].path)); // TODO remove path property - let contractGas = this.ethereumConfig.contracts[key].gas; - let estimateGas = this.ethereumConfig.contracts[key].estimateGas; + const contract = this.ethereumConfig.contracts[key]; + const contractData = require(CaliperUtils.resolvePath(contract.path)); // TODO remove path property + const contractGas = contract.gas; + const estimateGas = contract.estimateGas; + let privacy; + if (this.ethereumConfig.privacy) { + privacy = this.ethereumConfig.privacy[contract.private]; + } + this.ethereumConfig.contracts[key].abi = contractData.abi; promises.push(new Promise(async function(resolve, reject) { - let contractInstance = await self.deployContract(contractData); - logger.info('Deployed contract ' + contractData.name + ' at ' + contractInstance.options.address); + let contractInstance; + try { + if (privacy) { + contractInstance = await self.deployPrivateContract(contractData, privacy); + logger.info('Deployed private contract ' + contractData.name + ' at ' + contractInstance.options.address); + } else { + contractInstance = await self.deployContract(contractData); + logger.info('Deployed contract ' + contractData.name + ' at ' + contractInstance.options.address); + } + } catch (err) { + reject(err); + } self.ethereumConfig.contracts[key].address = contractInstance.options.address; self.ethereumConfig.contracts[key].gas = contractGas; self.ethereumConfig.contracts[key].estimateGas = estimateGas; @@ -171,6 +191,11 @@ class EthereumConnector extends ConnectorBase { await context.web3.eth.personal.unlockAccount(this.ethereumConfig.fromAddress, this.ethereumConfig.fromAddressPassword, 1000); } + if (this.ethereumConfig.privacy) { + context.web3eea = this.web3eea; + context.privacy = this.ethereumConfig.privacy; + } + this.context = context; return context; } @@ -189,6 +214,10 @@ class EthereumConnector extends ConnectorBase { * @return {Promise} Result and stats of the transaction invocation. */ async _sendSingleRequest(request) { + if (request.privacy) { + return this._sendSinglePrivateRequest(request); + } + const context = this.context; let status = new TxStatus(); let params = {from: context.fromAddress}; @@ -254,28 +283,129 @@ class EthereumConnector extends ConnectorBase { return status; } + /** + * Submit a private transaction to the ethereum context. + * @param {EthereumInvoke} request Methods call data. + * @return {Promise} Result and stats of the transaction invocation. + */ + async _sendSinglePrivateRequest(request) { + const context = this.context; + const web3eea = context.web3eea; + const contractInfo = context.contracts[request.contract]; + const privacy = request.privacy; + const sender = privacy.sender; + + const status = new TxStatus(); + + const onFailure = (err) => { + status.SetStatusFail(); + logger.error('Failed private tx on ' + request.contract + ' calling method ' + request.verb + ' private nonce ' + 0); + logger.error(err); + }; + + const onSuccess = (rec) => { + status.SetID(rec.transactionHash); + status.SetResult(rec); + status.SetVerification(true); + status.SetStatusSuccess(); + }; + + let payload; + if (request.args) { + payload = contractInfo.contract.methods[request.verb](...request.args).encodeABI(); + } else { + payload = contractInfo.contract.methods[request.verb]().encodeABI(); + } + + const transaction = { + to: contractInfo.contract._address, + data: payload + }; + + try { + if (request.readOnly) { + transaction.privacyGroupId = await this.resolvePrivacyGroup(privacy); + + const value = await web3eea.priv.call(transaction); + onSuccess(value); + } else { + transaction.nonce = sender.nonce; + transaction.privateKey = sender.privateKey.substring(2); + this.setPrivateTransactionParticipants(transaction, privacy); + + const txHash = await web3eea.eea.sendRawTransaction(transaction); + const rcpt = await web3eea.priv.getTransactionReceipt(txHash, transaction.privateFrom); + if (rcpt.status === '0x1') { + onSuccess(rcpt); + } else { + onFailure(rcpt); + } + } + } catch(err) { + onFailure(err); + } + + return status; + } + + /** * Deploys a new contract using the given web3 instance * @param {JSON} contractData Contract data with abi, bytecode and gas properties * @returns {Promise} The deployed contract instance */ - deployContract(contractData) { - let web3 = this.web3; - let contractDeployerAddress = this.ethereumConfig.contractDeployerAddress; - return new Promise(function(resolve, reject) { - let contract = new web3.eth.Contract(contractData.abi); - let contractDeploy = contract.deploy({ - data: contractData.bytecode - }); - contractDeploy.send({ + async deployContract(contractData) { + const web3 = this.web3; + const contractDeployerAddress = this.ethereumConfig.contractDeployerAddress; + const contract = new web3.eth.Contract(contractData.abi); + const contractDeploy = contract.deploy({ + data: contractData.bytecode + }); + + try { + return contractDeploy.send({ from: contractDeployerAddress, gas: contractData.gas - }).on('error', (error) => { - reject(error); - }).then((newContractInstance) => { - resolve(newContractInstance); }); - }); + } catch (err) { + throw(err); + } + } + + /** + * Deploys a new contract using the given web3 instance + * @param {JSON} contractData Contract data with abi, bytecode and gas properties + * @param {JSON} privacy Privacy options + * @returns {Promise} The deployed contract instance + */ + async deployPrivateContract(contractData, privacy) { + const web3 = this.web3; + const web3eea = this.web3eea; + // Using randomly generated account to deploy private contract to avoid public/private nonce issues + const deployerAccount = web3.eth.accounts.create(); + + const transaction = { + data: contractData.bytecode, + nonce: deployerAccount.nonce, + privateKey: deployerAccount.privateKey.substring(2), // web3js-eea doesn't not accept private keys prefixed by '0x' + }; + + this.setPrivateTransactionParticipants(transaction, privacy); + + try { + const txHash = await web3eea.eea.sendRawTransaction(transaction); + const txRcpt = await web3eea.priv.getTransactionReceipt(txHash, transaction.privateFrom); + + if (txRcpt.status === '0x1') { + return new web3.eth.Contract(contractData.abi, txRcpt.contractAddress); + } else { + logger.error('Failed private transaction hash ' + txHash); + throw new Error('Failed private transaction hash ' + txHash); + } + } catch (err) { + logger.error('Error deploying private contract: ', JSON.stringify(err)); + throw(err); + } } /** @@ -291,6 +421,57 @@ class EthereumConnector extends ConnectorBase { } return result; } + + /** + * Returns the privacy group id depending on the privacy mode being used + * @param {JSON} privacy Privacy options + * @returns {Promise} The privacyGroupId + */ + async resolvePrivacyGroup(privacy) { + const web3eea = this.context.web3eea; + + switch(privacy.groupType) { + case 'legacy': { + const privGroups = await web3eea.priv.findPrivacyGroup({addresses: [privacy.privateFrom, ...privacy.privateFor]}); + if (privGroups.length > 0) { + return privGroups.filter(function(el) { + return el.type === 'LEGACY'; + })[0].privacyGroupId; + } else { + throw new Error('Multiple legacy privacy groups with same members. Can\'t resolve privacyGroupId.'); + } + } + case 'pantheon': + case 'onchain': { + return privacy.privacyGroupId; + } default: { + throw new Error('Invalid privacy type'); + } + } + } + + /** + * Set the participants of a privacy transaction depending on the privacy mode being used + * @param {JSON} transaction Object representing the transaction fields + * @param {JSON} privacy Privacy options + */ + setPrivateTransactionParticipants(transaction, privacy) { + switch(privacy.groupType) { + case 'legacy': { + transaction.privateFrom = privacy.privateFrom; + transaction.privateFor = privacy.privateFor; + break; + } + case 'pantheon': + case 'onchain': { + transaction.privateFrom = privacy.privateFrom; + transaction.privacyGroupId = privacy.privacyGroupId; + break; + } default: { + throw new Error('Invalid privacy type'); + } + } + } } module.exports = EthereumConnector; diff --git a/packages/caliper-ethereum/package.json b/packages/caliper-ethereum/package.json index 01e1fe7109..af01402df0 100644 --- a/packages/caliper-ethereum/package.json +++ b/packages/caliper-ethereum/package.json @@ -23,10 +23,11 @@ }, "dependencies": { "@hyperledger/caliper-core": "0.4.0-unstable", - "ethereumjs-wallet": "^0.6.3" + "ethereumjs-wallet": "^0.6.3", + "web3": "1.3.0", + "web3-eea": "0.10.0" }, "devDependencies": { - "web3": "1.2.2", "eslint": "^5.16.0", "mocha": "3.4.2", "nyc": "11.1.0", diff --git a/packages/caliper-tests-integration/besu_tests/config/docker-compose.yml b/packages/caliper-tests-integration/besu_tests/config/docker-compose.yml index dbc98c1b58..e693b9ac7a 100644 --- a/packages/caliper-tests-integration/besu_tests/config/docker-compose.yml +++ b/packages/caliper-tests-integration/besu_tests/config/docker-compose.yml @@ -23,6 +23,14 @@ services: volumes: - ./keys:/root/.ethereum/keystore - ./data:/root + - ./orion:/orion ports: - 8545-8547:8545-8547 - command: --revert-reason-enabled --rpc-ws-enabled --rpc-ws-host 0.0.0.0 --host-whitelist=* --rpc-ws-apis admin,eth,miner,web3,net --graphql-http-enabled --discovery-enabled=false + command: --min-gas-price 0 --revert-reason-enabled --rpc-ws-enabled --rpc-ws-host 0.0.0.0 --host-whitelist=* --rpc-ws-apis admin,eth,miner,web3,net,priv,eea --graphql-http-enabled --discovery-enabled=false --privacy-enabled=true --privacy-url=http://orion:8888 --privacy-public-key-file=/orion/key/orion.pub + orion: + image: "pegasyseng/orion:1.6.0" + container_name: orion + command: ["/config/orion.conf"] + volumes: + - ./orion/orion.conf:/config/orion.conf + - ./orion/key/:/keys/ \ No newline at end of file diff --git a/packages/caliper-tests-integration/besu_tests/config/orion/key/orion.key b/packages/caliper-tests-integration/besu_tests/config/orion/key/orion.key new file mode 100644 index 0000000000..a52dad5063 --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/config/orion/key/orion.key @@ -0,0 +1 @@ +{"data":{"bytes":"uTJGpd4ZEEtDPFSZM0+GT11xn5NFIr2KGP2Q4SdVPRM="},"type":"unlocked"} \ No newline at end of file diff --git a/packages/caliper-tests-integration/besu_tests/config/orion/key/orion.pub b/packages/caliper-tests-integration/besu_tests/config/orion/key/orion.pub new file mode 100644 index 0000000000..ac89a91ce9 --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/config/orion/key/orion.pub @@ -0,0 +1 @@ +GGilEkXLaQ9yhhtbpBT03Me9iYa7U/mWXxrJhnbl1XY= \ No newline at end of file diff --git a/packages/caliper-tests-integration/besu_tests/config/orion/orion.conf b/packages/caliper-tests-integration/besu_tests/config/orion/orion.conf new file mode 100644 index 0000000000..6bd0ce9ad3 --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/config/orion/orion.conf @@ -0,0 +1,26 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +nodeurl = "http://127.0.0.1:8080/" +nodeport = 8080 +nodenetworkinterface = "0.0.0.0" + +clienturl = "http://127.0.0.1:8888/" +clientport = 8888 +clientnetworkinterface = "0.0.0.0" + +tls = "off" + +publickeys = ["/keys/orion.pub"] +privatekeys = ["/keys/orion.key"] \ No newline at end of file diff --git a/packages/caliper-tests-integration/besu_tests/package.json b/packages/caliper-tests-integration/besu_tests/package.json new file mode 100644 index 0000000000..10145dd048 --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/package.json @@ -0,0 +1,11 @@ +{ + "name": "besu_tests", + "version": "1.0.0", + "description": "Caliper Besu Integration Tests", + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "web3": "1.3.0" + } +} diff --git a/packages/caliper-tests-integration/besu_tests/privatetxs/benchconfig.yaml b/packages/caliper-tests-integration/besu_tests/privatetxs/benchconfig.yaml new file mode 100644 index 0000000000..add1a543fc --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/privatetxs/benchconfig.yaml @@ -0,0 +1,44 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +--- + test: + name: Simple Storage + description: Test private transactions + workers: + type: local + number: 1 + rounds: + - label: public store + txNumber: 5 + rateControl: + type: fixed-rate + opts: + tps: 100 + workload: + module: ./../store.js + arguments: + contract: public_storage + - label: private store + txNumber: 5 + rateControl: + type: fixed-rate + opts: + tps: 100 + workload: + module: ./../store.js + arguments: + contract: private_storage + private: group1 + \ No newline at end of file diff --git a/packages/caliper-tests-integration/besu_tests/privatetxs/networkconfig.json b/packages/caliper-tests-integration/besu_tests/privatetxs/networkconfig.json new file mode 100644 index 0000000000..16be5efa72 --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/privatetxs/networkconfig.json @@ -0,0 +1,49 @@ +{ + "caliper": { + "blockchain": "ethereum", + "command" : { + "start": "docker-compose -f ../config/docker-compose.yml up -d && sleep 30", + "end" : "docker-compose -f ../config/docker-compose.yml down" + } + }, + "ethereum": { + "url": "ws://localhost:8546", + "contractDeployerAddress": "0xD1cf9D73a91DE6630c2bb068Ba5fDdF9F0DEac09", + "contractDeployerAddressPrivateKey": "0x797c13f7235c627f6bd013dc17fff4c12213ab49abcf091f77c83f16db10e90b", + "fromAddressSeed": "0x3f841bf589fdf83a521e55d51afddc34fa65351161eead24f064855fc29c9580", + "transactionConfirmationBlocks": 1, + "chainId": 48122, + "privacy": { + "group1": { + "groupType": "legacy", + "privateFrom": "GGilEkXLaQ9yhhtbpBT03Me9iYa7U/mWXxrJhnbl1XY=", + "privateFor": ["GGilEkXLaQ9yhhtbpBT03Me9iYa7U/mWXxrJhnbl1XY="] + }, + "group2": { + "groupType": "pantheon", + "privateFrom": "GGilEkXLaQ9yhhtbpBT03Me9iYa7U/mWXxrJhnbl1XY=", + "privacyGroupId": "cskJg3WXZxU9kII4Tu42cZ6OmaB0ykldWsymFDhiTSQ=" + }, + "group3": { + "groupType": "onchain", + "privateFrom": "GGilEkXLaQ9yhhtbpBT03Me9iYa7U/mWXxrJhnbl1XY=", + "privacyGroupId": "cskJg3WXZxU9kII4Tu42cZ6OmaB0ykldWsymFDhiTSQ=" + } + }, + "contracts": { + "public_storage": { + "path": "../src/storage/storage.json", + "gas": { + "update": 30000 + } + }, + "private_storage": { + "path": "../src/storage/storage.json", + "gas": { + "update": 30000 + }, + "private": "group1" + } + } + } +} diff --git a/packages/caliper-tests-integration/besu_tests/run.sh b/packages/caliper-tests-integration/besu_tests/run.sh index 1fc1543f5c..23c7544952 100755 --- a/packages/caliper-tests-integration/besu_tests/run.sh +++ b/packages/caliper-tests-integration/besu_tests/run.sh @@ -20,6 +20,8 @@ set -v DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "${DIR}" +npm i + # bind during CI tests, using the package dir as CWD # Note: do not use env variables for binding settings, as subsequent launch calls will pick them up and bind again if [[ "${BIND_IN_PACKAGE_DIR}" = "true" ]]; then @@ -61,6 +63,15 @@ if [[ ${rc} != 0 ]]; then exit ${rc}; fi +# PHASE 4: private transactions +${CALL_METHOD} launch manager --caliper-workspace privatetxs --caliper-flow-skip-start --caliper-flow-skip-end +rc=$? +if [[ ${rc} != 0 ]]; then + echo "Failed CI step 4 - private txs"; + dispose; + exit ${rc}; +fi + # PHASE 5: just disposing of the network ${CALL_METHOD} launch manager --caliper-workspace phase1 --caliper-flow-only-end rc=$? diff --git a/packages/caliper-tests-integration/besu_tests/store.js b/packages/caliper-tests-integration/besu_tests/store.js new file mode 100644 index 0000000000..aaae5cacad --- /dev/null +++ b/packages/caliper-tests-integration/besu_tests/store.js @@ -0,0 +1,109 @@ +/* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +'use strict'; + +const { WorkloadModuleBase } = require('@hyperledger/caliper-core'); +const Web3 = require('web3'); + +class StoreWorkload extends WorkloadModuleBase { + + /** + * Initializes the parameters of the workload. + */ + constructor() { + super(); + this.txIndex = -1; + this.private = false; + this.contract; + } + + /** + * Generates simple workload. + * @returns {{verb: String, args: Object[]}[]} Array of workload argument objects. + */ + _generateWorkload() { + let web3 = new Web3(this.nodeUrl); + + let workload = []; + for(let i= 0; i < this.roundArguments.txnPerBatch; i++) { + this.txIndex++; + + let value = i; + + let args = { + contract: this.roundArguments.contract, + verb: 'update', + args: [value], + readOnly: false, + } + + if (this.isPrivate) { + args.privacy = this.privacyOpts; + args.privacy.sender = web3.eth.accounts.create(); + args.privacy.sender.nonce = 0; + } + + workload.push(args); + } + + return workload; + } + + /** + * Initialize the workload module with the given parameters. + * @param {number} workerIndex The 0-based index of the worker instantiating the workload module. + * @param {number} totalWorkers The total number of workers participating in the round. + * @param {number} roundIndex The 0-based index of the currently executing round. + * @param {Object} roundArguments The user-provided arguments for the round from the benchmark configuration file. + * @param {BlockchainConnector} sutAdapter The adapter of the underlying SUT. + * @param {Object} sutContext The custom context object provided by the SUT adapter. + * @async + */ + async initializeWorkloadModule(workerIndex, totalWorkers, roundIndex, roundArguments, sutAdapter, sutContext) { + await super.initializeWorkloadModule(workerIndex, totalWorkers, roundIndex, roundArguments, sutAdapter, sutContext); + + if (!this.roundArguments.contract) { + throw new Error('store - argument "contract" missing'); + } + + this.nodeUrl = sutContext.url; + + if(this.roundArguments.private) { + this.isPrivate = true; + this.privacyOpts = sutContext.privacy[this.roundArguments.private]; + this.privacyOpts['id'] = this.roundArguments.private; + } else { + this.private = false; + } + + if(!this.roundArguments.txnPerBatch) { + this.roundArguments.txnPerBatch = 1; + } + } + + /** + * Assemble TXs for opening new accounts. + */ + async submitTransaction() { + let args = this._generateWorkload(); + await this.sutAdapter.sendRequests(args); + } +} + +function createWorkloadModule() { + return new StoreWorkload(); +} + +module.exports.createWorkloadModule = createWorkloadModule; \ No newline at end of file