modules/Requests.js

/** A Javascript API wrapper module for the Bifrost Protocol.
 * Currently supports Bifrost v1.3
 * Documentation for Brambl-layer is available at https://Requests.docs.topl.co
 *
 * @author James Aman (j.aman@topl.me)
 * @author Raul Aragonez (r.aragonez@topl.me)
 * @date 2020.10.29
 *
 * Based on the original work of Yamir Tainwala - 2019
 */

"use strict";

const fetch = require("node-fetch");
const utils = require("../utils/address-utils.js");
const Base58 = require("bs58");

/**
 * General builder function for formatting API request
 *
 * @param {object} routeInfo - call specific information
 * @param {string} routeInfo.route - the route where the request will be sent
 * @param {string} routeInfo.method - the json-rpc method that will be triggered on the node
 * @param {string} routeInfo.id - an identifier for tracking requests sent to the node
 * @param {object} params - method specific parameter object
 * @param {object} self - internal reference for accessing constructor data
 * @returns {object} JSON response from the node
 */
async function bramblRequest(routeInfo, params, self) {
  try {
    // const projectId = self.projectId;
    const body = {
      jsonrpc: "2.0",
      id: routeInfo.id || "1",
      method: routeInfo.method,
      params: [
        {...params}
      ]
    };
    const payload = {
      url: self.url,
      method: "POST",
      headers: self.headers,
      body: JSON.stringify(body)
    };
    const response = await (await fetch(self.url, payload)).json();
    if (response.error) {
      throw response;
    } else {
      return response;
    }
  } catch (err) {
    throw err;
  }
};

/**
 * @class Requests
 * @classdesc A class for sending requests to the Brambl layer interface of the given chain provider
 */
class Requests {
  /**
   * @constructor
   * @param {string} [networkPrefix="private"] Network Prefix, defaults to "private"
   * @param {string} [url="http://localhost:9085/YOUR_PROJECT_ID"] Chain provider location, local and private default to http://localhost:9085/
   * @param {string} [apiKey="topl_the_world!"] Access key for authorizing requests to the client API ["x-api-key"], default to "topl_the_world!"
   */
  constructor(networkPrefix, url, apiKey) {
    // set networkPrefix and validate
    this.networkPrefix = networkPrefix || "private";

    if (this.networkPrefix !== "private" && !utils.isValidNetwork(this.networkPrefix)) {
      throw new Error(`Invalid Network Prefix. Must be one of: ${utils.getValidNetworksList()}`);
    }

    // set url if provided or set default
    this.url = url || "http://localhost:9085/";

    // set apiKey or set default
    this.apiKey = apiKey || "topl_the_world!";

    this.headers = {
      "Content-Type": "application/json",
      "x-api-key": this.apiKey
    };
  }

  /**
   * Allows setting a different url than the default from which to create and accept RPC connections
   * @param {string} url url string for instance
   * @returns {void}
   */
  setUrl(url) {
    this.url = url;
  }

  /**
   * Allows setting a different x-api-key than the default
   * @param {string} apiKey api-key for "x-api-key"
   * @returns {void}
   */
  setApiKey(apiKey) {
    this.headers["x-api-key"] = apiKey;
  }

  /* -------------------------------------------------------------------------- */
  /*                             Topl Api Routes                                */
  /* -------------------------------------------------------------------------- */

  /* ---------------------- Create Raw Asset Transfer ------------------------ */
  /**
   * Create a new asset on chain
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.propositionType - Proposition Type -> PublicKeyCurve25519 || TheresholdCurve25519
   * @param {string} params.recipients - 2-dimensional array (array of tuples) -> [["address of recipient", quantity, securityRoot, metadata]]
   * @param {string} params.recipients[i][0]: Required address of recipient
   * @param {string} params.recipients[i][1]: Required number of tokens to send to recipient
   * @param {string} params.recipients[i][2]: Optional security root which is a Base58 encoded 32 byte hash of the data to be stored in the AssetBox.
   * @param {string} params.recipients[i][3]: Optional metadata tag for asset, must be less than 128 Latin-1 characters.
   * @param {string} params.assetCode - Identifier of the asset
   * @param {string} params.sender - Public key of the asset issuer
   * @param {string} params.changeAddress - Public key of the change recipient
   * @param {boolean} params.minting - Minting boolean
   * @param {number} params.fee - Fee to apply to the transaction
   * @param {string} params.consolidationAddress - Address for recipient of unspent Assets
   * @param {string} [params.data] - Data string which can be associated with this transaction (may be empty). Must be less than or equal to 127 Latin-1 encoded characters.
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async createRawAssetTransfer(params, id = "1") {
    const validPropositions = ["PublicKeyCurve25519", "ThresholdCurve25519"];

    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.propositionType || !validPropositions.includes(params.propositionType)) {
      throw new Error("A propositionTYpe must be specified: <PublicKeyCurve25519, ThresholdCurve25519>");
    }
    if (!params.sender) {
      throw new Error("An asset sender must be specified");
    }
    if (!params.assetCode) {
      throw new Error("An assetCode must be specified");
    } else if (!utils.isValidAssetCode(params.assetCode)) {
      throw new Error("Invalid asset code");
    }
    if (!params.recipients || params.recipients.length < 1) {
      throw new Error("At least one recipient must be specified");
    }
    if (!params.changeAddress) {
      throw new Error("A changeAddress must be specified");
    }
    if (typeof params.minting !== "boolean") {
      throw new Error("Minting boolean value must be specified");
    }
    if (params.data && !utils.isValidMetadata(params.data)) {
      throw new Error(`Invalid data: ${params.data}`);
    }
    // 0 fee value is accepted
    if (!params.fee && params.fee !== 0) {
      throw new Error("A fee must be specified");
    }
    // fee must be >= 0
    if (params.fee < 0) {
      throw new Error("Invalid fee, a fee must be greater or equal to zero");
    }
    // fee must be a string
    params.fee = params.fee.toString();

    // validate all addresses
    const validationResult = utils.validateAddressesByNetwork(this.networkPrefix, params);
    if (!validationResult.success) {
      throw new Error("Invalid Addresses::" +
        " Network Type: <" + this.networkPrefix + ">" +
        " Invalid Addresses: <" + validationResult.invalidAddresses + ">" +
        " Invalid Checksums: <" + validationResult.invalidChecksums + ">");
    }

    // Include token value holder as tuple format
    for (let i = 0; i < params.recipients.length; i++) {
      // destructuring assingment syntax
      // basic: [address, quantity]
      // advance: [address, quantity, securityRoot, metadata]
      const [address, quantity, securityRoot, metadata] = params.recipients[i];

      // ensure quantitiy is part of the tuple ["address", 10]
      if (!quantity || quantity < 1) {
        throw new Error(`Invalid quantity in Recipient: ${params.recipients[i]}`);
      }

      // required fields
      const tokenValueHolder = {
        "type": "Asset",
        "quantity": quantity.toString(),
        "assetCode": params.assetCode
      };

      // advance option - securityRoot: base58 enconded string [32 bytes]
      if (securityRoot !== undefined) {
        if (Base58.decode(securityRoot).length !== 32) {
          throw new Error(`Invalid securityRoot in Recipient: ${params.recipients[i]}`);
        }
        tokenValueHolder.securityRoot = securityRoot;
      }

      if (metadata !== undefined) {
        // advance option - metadata: up to and including 127 bytes
        if (!utils.isValidMetadata(metadata)) {
          throw new Error(`Invalid metadata in Recipient: ${params.recipients[i]}`);
        }
        tokenValueHolder.metadata = metadata;
      }

      params.recipients[i] = [address, tokenValueHolder];
    }

    const method = "topl_rawAssetTransfer";
    return bramblRequest({id, method}, params, this);
  }

  /* ---------------------- Create Raw Poly Trasfer ------------------------ */
  /**
   * Create a raw transaction for transferring polys between addresses
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.propositionType - Proposition Type -> PublicKeyCurve25519 || TheresholdCurve25519
   * @param {string} params.recipients - 2-dimensional array (array of tuples) -> [["publicKey of asset recipient", quantity]]
   * @param {array} params.sender - List of senders addresses
   * @param {string} params.changeAddress - Address of the change recipient
   * @param {number} params.fee - Fee to apply to the transaction
   * @param {string} [params.data] - Data string which can be associated with this transaction (may be empty). This field is restricted to 127 Latin-1 encoded characters
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async createRawPolyTransfer(params, id = "1") {
    const validPropositions = ["PublicKeyCurve25519", "ThresholdCurve25519"];

    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.propositionType || !validPropositions.includes(params.propositionType)) {
      throw new Error("A propositionType must be specified: <PublicKeyCurve25519, ThresholdCurve25519>");
    }
    if (!params.sender) {
      throw new Error("An asset sender must be specified");
    }
    if (!params.recipients || params.recipients.length < 1) {
      throw new Error("At least one recipient must be specified");
    }
    if (!params.changeAddress) {
      throw new Error("A changeAddress must be specified");
    }
    // 0 fee value is accepted
    if (!params.fee && params.fee !== 0) {
      throw new Error("A fee must be specified");
    }
    // fee must be >= 0
    if (params.fee < 0) {
      throw new Error("Invalid fee, a fee must be greater or equal to zero");
    }
    // must be valid data
    if (params.data && !utils.isValidMetadata(params.data)) {
      throw new Error(`Invalid data: ${params.data}`);
    }
    // fee must be a string
    params.fee = params.fee.toString();

    // validate all addresses
    const validationResult = utils.validateAddressesByNetwork(this.networkPrefix, params);
    if (!validationResult.success) {
      throw new Error("Invalid Addresses::" +
        " Network Type: <" + this.networkPrefix + ">" +
        " Invalid Addresses: <" + validationResult.invalidAddresses + ">" +
        " Invalid Checksums: <" + validationResult.invalidChecksums + ">");
    }

    params.recipients.forEach((recipient) => {
      // ensure quantitiy is part of the tuple ["address", 10]
      if (!recipient[1]) {
        throw new Error("Recipient quantity must be specified");
      }
      // quantity must be a string
      recipient[1] = recipient[1].toString();
    });

    const method = "topl_rawPolyTransfer";
    return bramblRequest({id, method}, params, this);
  }

  /* ---------------------- Create Raw Arbit Trasfer ------------------------ */
  /**
   * Create a raw transaction for transferring arbits between addresses
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.propositionType - Proposition Type -> PublicKeyCurve25519 || TheresholdCurve25519
   * @param {string} params.recipients - 2-dimensional array (array of tuples) -> [["publicKey of asset recipient", quantity]]
   * @param {array} params.sender - List of senders addresses
   * @param {string} params.changeAddress - Address of the change recipient
   * @param {string} params.consolidationAddress - Address of the change recipient
   * @param {number} params.fee - Fee to apply to the transaction
   * @param {string} [params.data] - Data string which can be associated with this transaction (may be empty). Must be less than or equal to 127 Latin-1 encoded characters.
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async createRawArbitTransfer(params, id = "1") {
    const validPropositions = ["PublicKeyCurve25519", "ThresholdCurve25519"];

    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.propositionType || !validPropositions.includes(params.propositionType)) {
      throw new Error("A propositionType must be specified: <PublicKeyCurve25519, ThresholdCurve25519>");
    }
    if (!params.sender) {
      throw new Error("An asset sender must be specified");
    }
    if (!params.recipients || params.recipients.length < 1) {
      throw new Error("At least one recipient must be specified");
    }
    if (!params.changeAddress) {
      throw new Error("A changeAddress must be specified");
    }
    if (!params.consolidationAddress) {
      throw new Error("A consolidationAddress must be specified");
    }
    // 0 fee value is accepted
    if (!params.fee && params.fee !== 0) {
      throw new Error("A fee must be specified");
    }
    // fee must be >= 0
    if (params.fee < 0) {
      throw new Error("Invalid fee, a fee must be greater or equal to zero");
    }
    // data must be valid
    if (params.data && !utils.isValidMetadata(params.data)) {
      throw new Error(`Invalid data: ${params.data}`);
    }
    // fee must be a string
    params.fee = params.fee.toString();

    // validate all addresses
    const validationResult = utils.validateAddressesByNetwork(this.networkPrefix, params);
    if (!validationResult.success) {
      throw new Error("Invalid Addresses::" +
        " Network Type: <" + this.networkPrefix + ">" +
        " Invalid Addresses: <" + validationResult.invalidAddresses + ">" +
        " Invalid Checksums: <" + validationResult.invalidChecksums + ">");
    }

    params.recipients.forEach((recipient) => {
      // ensure quantitiy is part of the tuple ["address", 10]
      if (!recipient[1]) {
        throw new Error("Recipient quantity must be specified");
      }
      // quantity must be a string
      recipient[1] = recipient[1].toString();
    });

    const method = "topl_rawArbitTransfer";
    return bramblRequest({id, method}, params, this);
  }

  /* --------------------------------- Broadcast Tx --------------------------------------- */
  /**
   * Broadcast a valid signed transaction that will be gossiped about to other nodes
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.tx - a JSON formatted transaction (must include signature(s))
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async broadcastTx(params, id = "1") {
    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.tx) {
      throw new Error("A tx object must be specified");
    }
    if (!params.tx.signatures || !Object.keys(params.tx.signatures)[0]) {
      throw new Error("Tx must include signatures");
    }
    // this is not valid since also a signature is being sent, not a full Tx ???
    if (Object.keys(params.tx).length < 10 && params.tx.constructor === Object) {
      throw new Error("Invalid tx object, one or more tx keys not specified");
    }

    const method = "topl_broadcastTx";
    return bramblRequest({id, method}, params, this);
  }

  /* --------------------------------- Lookup Balances By Addresses --------------------------------------- */
  /**
   * Lookup the balances of specified addresses
   * @param {Object} params - body parameters passed to the specified json-rpc method
   * @param {string[]} params.addresses - An array of addresses to query the balance for
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async lookupBalancesByAddresses(params, id = "1") {
    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.addresses || !Array.isArray(params.addresses)) {
      throw new Error("A list of addresses must be specified");
    }
    // validate all addresses
    const validationResult = utils.validateAddressesByNetwork(this.networkPrefix, params.addresses);
    if (!validationResult.success) {
      throw new Error("Invalid Addresses::" +
        " Network Type: <" + this.networkPrefix + ">" +
        " Invalid Addresses: <" + validationResult.invalidAddresses + ">" +
        " Invalid Checksums: <" + validationResult.invalidChecksums + ">");
    }
    const method = "topl_balances";
    return bramblRequest({id, method}, params, this);
  }

  /* ----------------------------- Get Mempool ------------------------------------ */
  /**
   * Return the entire mempool of the node
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async getMempool(id = "1") {
    const params = {};
    const method = "topl_mempool";
    return bramblRequest({id, method}, params, this);
  }

  /* -------------------------- Get Tx By Id ---------------------------- */
  /**
   * Lookup a transaction from history by the provided id
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.transactionId - Unique identifier of the transaction to retrieve
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async getTransactionById(params, id = "1") {
    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.transactionId) {
      throw new Error("A transactionId must be specified");
    }
    const method = "topl_transactionById";
    return bramblRequest({id, method}, params, this);
  }

  /* -------------------------- Get Tx From Mempool ---------------------------- */
  /**
   * Lookup a transaction from the mempool by the provided id
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.transactionId - Unique identifier of the transaction to retrieve
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async getTransactionFromMempool(params, id = "1") {
    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.transactionId) {
      throw new Error("A transactionId must be specified");
    }
    const method = "topl_transactionFromMempool";
    return bramblRequest({id, method}, params, this);
  }

  /* --------------------------------- Get Latest Block --------------------------------------- */
  /**
   * Return the latest block in the chain
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async getLatestBlock(id = "1") {
    const params = {};
    const method = "topl_head";
    return bramblRequest({id, method}, params, this);
  }

  /* ----------------------------- Get Block By Id --------------------------------- */
  /**
   * Lookup a block from history by the provided id
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {string} params.blockId - Unique identifier of the block to retrieve
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async getBlockById(params, id = "1") {
    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.blockId) {
      throw new Error("A blockId must be specified");
    }
    const method = "topl_blockById";
    return bramblRequest({id, method}, params, this);
  }

  /* ----------------------------- Get Block By Height --------------------------------- */
  /**
   * Lookup a block from history by the provided id
   * @param {object} params - body parameters passed to the specified json-rpc method
   * @param {number} params.height - Height as an integer number
   * @param {string} [id="1"] - identifying number for the json-rpc request
   * @returns {object} json-rpc response from the chain
   * @memberof Requests
   */
  async getBlockByHeight(params, id = "1") {
    if (!params) {
      throw new Error("A parameter object must be specified");
    }
    if (!params.height) {
      throw new Error("A height must be specified");
    }
    if (isNaN(params.height) || !Number.isInteger(params.height) || params.height < 1) {
      throw new Error("Height must be an Integer greater than 0");
    }

    const method = "topl_blockByHeight";
    return bramblRequest({id, method}, params, this);
  }
}

/* -------------------------------------------------------------------------- */
module.exports = Requests;
/* -------------------------------------------------------------------------- */