index.js

/*
 * Copyright ConsenSys Software Inc.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
 * If a copy of the MPL was not distributed with this file, You can obtain one at
 *
 * http://mozilla.org/MPL/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.
 *
 * SPDX-License-Identifier: MPL-2.0
 */

const crypto = require("crypto");

const { privateToAddress } = require("./custom-ethjs-util");
const privacyProxyAbi = require("./solidity/PrivacyProxy.json").output.abi;
const PrivateTransaction = require("./privateTransaction");
const { generatePrivacyGroup } = require("./privacyGroup");
const { PrivateSubscription } = require("./privateSubscription");

/**
 * Handles elements
 * @name EEAClient
 * @class EEAClient
 */
function EEAClient(web3, chainId, gasPrice = 0, gasLimit = 3000000) {
  if (web3.currentProvider == null) {
    throw new Error("Missing provider");
  }

  /* eslint-disable no-param-reassign */
  // Initialize the extensions
  web3.priv = {
    subscriptionPollingInterval: 1000
  };
  web3.eea = {};
  web3.privx = {};
  /* eslint-enable no-param-reassign */

  // INTERNAL ==========
  web3.extend({
    property: "privInternal",
    methods: [
      // eea
      {
        name: "sendRawTransaction",
        call: "eea_sendRawTransaction",
        params: 1
      },
      // priv
      {
        name: "call",
        call: "priv_call",
        params: 3,
        inputFormatter: [
          null, // privacyGroupId
          null, // tx
          web3.extend.formatters.inputDefaultBlockNumberFormatter
        ]
      },
      {
        name: "getTransactionCount",
        call: "priv_getTransactionCount",
        params: 2,
        outputFormatter: output => {
          return parseInt(output, 16);
        }
      },
      {
        name: "getTransactionReceipt",
        call: "priv_getTransactionReceipt",
        params: 2
      },
      {
        name: "distributeRawTransaction",
        call: "priv_distributeRawTransaction",
        params: 1
      },
      {
        name: "findPrivacyGroup",
        call: "priv_findPrivacyGroup",
        params: 1
      },
      {
        name: "deletePrivacyGroup",
        call: "priv_deletePrivacyGroup",
        params: 1
      },
      {
        name: "subscribe",
        call: "priv_subscribe",
        params: 3 // type, privacyGroupId, filter
      },
      {
        name: "unsubscribe",
        call: "priv_unsubscribe",
        params: 2 // privacyGroupId, filterId
      },
      // privx
      {
        name: "findOnChainPrivacyGroup",
        call: "privx_findOnChainPrivacyGroup",
        params: 1
      }
    ]
  });

  /**
   * Send a transaction to `eea_sendRawTransaction` or `priv_distributeRawTransaction`
   * @param options Used to create the private transaction
   * - options.privateKey
   * - options.privateFrom
   * - options.privacyGroupId
   * - options.privateFor
   * - options.nonce
   * - options.gasPrice default value 0
   * - options.gasLimit default value 3000000
   * - options.to
   * - options.data
   * @param method Name of the method of the transaction to call.
   */
  const genericSendRawTransaction = (options, method) => {
    if (options.privacyGroupId && options.privateFor) {
      throw Error("privacyGroupId and privateFor are mutually exclusive");
    }
    const tx = new PrivateTransaction();
    const privateKeyBuffer = Buffer.from(options.privateKey, "hex");
    const from = `0x${privateToAddress(privateKeyBuffer).toString("hex")}`;
    return web3.priv
      .getTransactionCount({
        from,
        privateFrom: options.privateFrom,
        privateFor: options.privateFor,
        privacyGroupId: options.privacyGroupId
      })
      .then(transactionCount => {
        tx.nonce = options.nonce || transactionCount;
        tx.gasPrice = options.gasPrice || gasPrice;
        tx.gasLimit = options.gasLimit || gasLimit;
        tx.to = options.to;
        tx.value = 0;
        tx.data = options.data;
        // eslint-disable-next-line no-underscore-dangle
        tx._chainId = chainId;
        tx.privateFrom = options.privateFrom;

        if (options.privateFor) {
          tx.privateFor = options.privateFor;
        }
        if (options.privacyGroupId) {
          tx.privacyGroupId = options.privacyGroupId;
        }
        tx.restriction = options.restriction || "restricted";

        tx.sign(privateKeyBuffer);

        const signedRlpEncoded = tx.serialize().toString("hex");

        let result;
        if (method === "eea_sendRawTransaction") {
          result = web3.privInternal.sendRawTransaction(signedRlpEncoded);
        } else if (method === "priv_distributeRawTransaction") {
          result = web3.privInternal.distributeRawTransaction(signedRlpEncoded);
        }

        if (result != null) {
          return result;
        }

        throw new Error(`Unknown method ${method}`);
      });
  };

  /**
   * Returns the Private Marker transaction
   * @param {string} txHash The transaction hash
   * @param {int} retries Number of retries to be made to get the private marker transaction receipt
   * @param {int} delay The delay between the retries
   * @returns Promise to resolve the private marker transaction receipt
   * @memberOf EEAClient
   */
  const getMarkerTransaction = (txHash, retries, delay) => {
    /* eslint-disable promise/param-names */
    /* eslint-disable promise/avoid-new */

    const waitFor = ms => {
      return new Promise(r => {
        return setTimeout(r, ms);
      });
    };

    let notified = false;
    const retryOperation = (operation, times) => {
      return new Promise((resolve, reject) => {
        return operation()
          .then(result => {
            if (result == null) {
              if (!notified) {
                console.log("Waiting for transaction to be mined ...");
                notified = true;
              }
              if (delay === 0) {
                throw new Error(
                  `Timed out after ${retries} attempts waiting for transaction to be mined`
                );
              } else {
                const waitInSeconds = (retries * delay) / 1000;
                throw new Error(
                  `Timed out after ${waitInSeconds}s waiting for transaction to be mined`
                );
              }
            } else {
              return resolve();
            }
          })
          .catch(reason => {
            if (times - 1 > 0) {
              // eslint-disable-next-line promise/no-nesting
              return waitFor(delay)
                .then(retryOperation.bind(null, operation, times - 1))
                .then(resolve)
                .catch(reject);
            }
            return reject(reason);
          });
      });
    };

    const operation = () => {
      return web3.eth.getTransactionReceipt(txHash);
    };

    return retryOperation(operation, retries);
  };

  // PRIV ==========
  web3.extend({
    property: "priv",
    methods: [
      {
        name: "createPrivacyGroup",
        call: "priv_createPrivacyGroup",
        params: 1
      },
      {
        name: "getTransaction",
        call: "priv_getPrivateTransaction",
        params: 1
      },
      {
        name: "getPastLogs",
        call: "priv_getLogs",
        params: 3,
        inputFormatter: [
          null,
          null,
          web3.extend.formatters.inputDefaultBlockNumberFormatter
        ],
        outputFormatter: web3.extend.outputLogFormatter
      },
      {
        name: "createFilter",
        call: "priv_newFilter",
        params: 3,
        inputFormatter: [
          null,
          null,
          web3.extend.formatters.inputDefaultBlockNumberFormatter
        ]
      },
      {
        name: "getFilterLogs",
        call: "priv_getFilterLogs",
        params: 2,
        outputFormatter: web3.extend.outputLogFormatter
      },
      {
        name: "getFilterChanges",
        call: "priv_getFilterChanges",
        params: 2,
        outputFormatter: web3.extend.outputLogFormatter
      },
      {
        name: "uninstallFilter",
        call: "priv_uninstallFilter",
        params: 2
      }
    ]
  });

  /**
   * Get the transaction count
   * @param options Options passed into `eea_sendRawTransaction`
   * @returns Promise<transaction count | never>
   * @memberOf EEAClient
   */
  const getTransactionCount = options => {
    let privacyGroupId;
    if (options.privacyGroupId) {
      ({ privacyGroupId } = options);
    } else {
      privacyGroupId = generatePrivacyGroup(options);
    }

    return web3.privInternal.getTransactionCount(options.from, privacyGroupId);
  };

  /**
   * Delete a privacy group
   * @param options Options passed into `deletePrivacyGroup`
   * - options.privacyGroupId
   * @returns Promise<transaction count | never>
   * @memberOf EEAClient
   */
  const deletePrivacyGroup = options => {
    // TODO: remove this function and pass arguments individually (breaks API)
    return web3.privInternal.deletePrivacyGroup(options.privacyGroupId);
  };

  /**
   * Find privacy groups
   * @param options Options passed into `findPrivacyGroup`
   * - options.addresses
   * @returns Promise<transaction count | never>
   */
  const findPrivacyGroup = options => {
    // TODO: remove this function and pass arguments individually(breaks API)
    return web3.privInternal.findPrivacyGroup(options.addresses);
  };

  const distributeRawTransaction = options => {
    return genericSendRawTransaction(options, "priv_distributeRawTransaction");
  };

  /**
   * Get the private transaction Receipt.
   * @param {string} txHash Transaction Hash of the marker transaction
   * @param {string} enclavePublicKey Public key used to start-up the Enclave
   * @param {int} retries Number of retries to be made to get the private marker transaction receipt
   * @param {int} delay The delay between the retries
   * @returns {Promise<AxiosResponse<any> | never>}
   */
  const getTransactionReceipt = (
    txHash,
    enclavePublicKey,
    retries = 300,
    delay = 1000
  ) => {
    return getMarkerTransaction(txHash, retries, delay).then(() => {
      return web3.privInternal.getTransactionReceipt(txHash, enclavePublicKey);
    });
  };

  /**
   * Invokes a private contract function locally
   * @param options Options passed into `priv_call`
   * options map can contain the following:
   * - **privacyGroupId:** Enclave id representing the receivers of the transaction
   * - **to:** Contract address,
   * - **data:** Encoded function call (signature + data)
   * - **blockNumber:** Blocknumber defaults to "latest"
   * @returns {Promise<AxiosResponse<T>>}
   */
  const call = options => {
    const txCall = {};
    txCall.to = options.to;
    txCall.from = options.from;
    txCall.data = options.data;

    return web3.privInternal.call(
      options.privacyGroupId,
      txCall,
      options.blockNumber
    );
  };

  /**
   * Subscribe to new logs matching a filter
   *
   * If the provider supports subscriptions, it uses `priv_subscribe`, otherwise
   * it uses polling and `priv_getFilterChanges` to get new logs. Returns an
   * error to the callback if there is a problem subscribing or creating the filter.
   * @param {string} privacyGroupId
   * @param {*} filter
   * @param {function} callback returns the filter/subscription ID, or an error
   * @return {PrivateSubscription} a subscription object that manages the
   * lifecycle of the filter or subscription
   */
  const subscribe = async (privacyGroupId, filter, callback) => {
    const sub = new PrivateSubscription(web3, privacyGroupId, filter);

    let filterId;
    try {
      filterId = await sub.subscribe();
      callback(undefined, filterId);
    } catch (error) {
      callback(error);
    }

    return sub;
  };

  Object.assign(web3.priv, {
    generatePrivacyGroup,
    deletePrivacyGroup,
    findPrivacyGroup,
    distributeRawTransaction,
    getTransactionCount,
    getTransactionReceipt,
    call,
    subscribe
  });

  // EEA ==========

  /**
   * Send the Raw transaction to the Besu node
   * @param options Map to send a raw transaction to besu
   * options map can contain the following:
   * - privateKey : Private Key used to sign transaction with
   * - privateFrom : Enclave public key
   * - privateFor : Enclave keys to send the transaction to
   * - privacyGroupId : Enclave id representing the receivers of the transaction
   * - nonce(Optional) : If not provided, will be calculated using `eea_getTransctionCount`
   * - to : The address to send the transaction
   * - data : Data to be sent in the transaction
   *
   * @returns {Promise<AxiosResponse<any> | never>}
   */
  const sendRawTransaction = options => {
    return genericSendRawTransaction(options, "eea_sendRawTransaction");
  };

  Object.assign(web3.eea, {
    sendRawTransaction
  });

  // PRIVX ==========

  /**
   * Either lock or unlock the privacy group for member adding
   * @param options Map to lock the group
   * options map can contain the following:
   * - **privacyGroupId:** Privacy group ID to lock/unlock
   * - **privateKey:** Private Key used to sign transaction with
   * - **enclaveKey:** Orion public key
   * - **lock:** boolean indicating whether to lock or unlock
   * @returns {Promise<AxiosResponse<any> | never>}
   */
  const setPrivacyGroupLockState = options => {
    const contract = new web3.eth.Contract(privacyProxyAbi);
    // eslint-disable-next-line no-underscore-dangle
    const functionAbi = contract._jsonInterface.find(e => {
      return e.name === (options.lock ? "lock" : "unlock");
    });

    const functionCall = {
      to: "0x000000000000000000000000000000000000007c",
      data: functionAbi.signature,
      privateFrom: options.enclaveKey,
      privacyGroupId: options.privacyGroupId,
      privateKey: options.privateKey
    };

    return web3.eea
      .sendRawTransaction(functionCall)
      .then(async transactionHash => {
        return web3.priv.getTransactionReceipt(
          transactionHash,
          options.publicKey
        );
      });
  };

  /**
   * Create an on chain privacy group
   * @param options Map to add the members
   * options map can contain the following:
   * - **privacyGroupId:** Privacy group ID to add to
   * - **privateKey:** Private Key used to sign transaction with
   * - **enclaveKey:** Orion public key
   * - **participants:** list of enclaveKey to pass to the contract to add to the group
   * @returns {Promise<AxiosResponse<any> | never>}
   */
  const createXPrivacyGroup = options => {
    const contract = new web3.eth.Contract(privacyProxyAbi);
    // eslint-disable-next-line no-underscore-dangle
    const functionAbi = contract._jsonInterface.find(e => {
      return e.name === "addParticipants";
    });
    const functionArgs = web3.eth.abi
      .encodeParameters(functionAbi.inputs, [
        options.participants.map(e => {
          return Buffer.from(e, "base64");
        })
      ])
      .slice(2);

    // Generate a random ID if one was not passed in
    const privacyGroupId =
      options.privacyGroupId || crypto.randomBytes(32).toString("base64");

    const functionCall = {
      to: "0x000000000000000000000000000000000000007c",
      data: functionAbi.signature + functionArgs,
      privateFrom: options.enclaveKey,
      privacyGroupId,
      privateKey: options.privateKey
    };
    return web3.eea.sendRawTransaction(functionCall).then(transactionHash => {
      return web3.priv.getTransactionReceipt(
        transactionHash,
        options.publicKey
      );
    });
  };

  /**
   * Add to an existing on-chain privacy group
   * @param options Map to add the members
   * options map can contain the following:
   *
   * - **privacyGroupId:** Privacy group ID to add to
   * - **privateKey:** Private Key used to sign transaction with
   * - **enclaveKey:** Orion public key
   * - **participants:** list of enclaveKey to pass to the contract to add to the group
   * @returns {Promise<AxiosResponse<any> | never>}
   */
  const addToPrivacyGroup = options => {
    return setPrivacyGroupLockState(
      Object.assign(options, { lock: true })
    ).then(receipt => {
      if (receipt.status === "0x1") {
        return createXPrivacyGroup(options);
      }
      throw Error(
        `Locking the privacy group failed, receipt: ${JSON.stringify(receipt)}`
      );
    });
  };

  /**
   * Remove a member from an on-chain privacy group
   * @param options Map to add the members
   * options map can contain the following:
   * - **privacyGroupId:** Privacy group ID to add to
   * - **privateKey:** Private Key used to sign transaction with
   * - **enclaveKey:** Orion public key
   * - **participant:** single enclaveKey to pass to the contract to add to the group
   * @returns {Promise<AxiosResponse<any> | never>}
   */
  const removeFromPrivacyGroup = options => {
    const contract = new web3.eth.Contract(privacyProxyAbi);
    // eslint-disable-next-line no-underscore-dangle
    const functionAbi = contract._jsonInterface.find(e => {
      return e.name === "removeParticipant";
    });
    const functionArgs = web3.eth.abi
      .encodeParameters(functionAbi.inputs, [
        Buffer.from(options.participant, "base64")
      ])
      .slice(2);

    const functionCall = {
      to: "0x000000000000000000000000000000000000007c",
      data: functionAbi.signature + functionArgs,
      privateFrom: options.enclaveKey,
      privacyGroupId: options.privacyGroupId,
      privateKey: options.privateKey
    };
    return web3.eea.sendRawTransaction(functionCall).then(transactionHash => {
      return web3.priv.getTransactionReceipt(
        transactionHash,
        options.publicKey
      );
    });
  };

  /**
   * Find privacy groups
   * @param options Map to find the group
   * options map can contain the following:
   * - **addresses:** the members of the privacy group
   * @returns Promise<privacy group | never>
   */
  const findOnChainPrivacyGroup = options => {
    // TODO: remove this function and pass arguments individually (breaks API)
    return web3.privInternal.findOnChainPrivacyGroup(options.addresses);
  };

  Object.assign(web3.privx, {
    createPrivacyGroup: createXPrivacyGroup,
    findOnChainPrivacyGroup,
    removeFromPrivacyGroup,
    addToPrivacyGroup,
    setPrivacyGroupLockState
  });

  return web3;
}

module.exports = EEAClient;