ElvClient.js

Back
if(typeof globalThis.Buffer === "undefined") { globalThis.Buffer = require("buffer/").Buffer; }

const URI = require("urijs");
const Ethers = require("ethers");

const AuthorizationClient = require("./AuthorizationClient");
const ElvWallet = require("./ElvWallet");
const EthClient = require("./EthClient");
const UserProfileClient = require("./UserProfileClient");
const HttpClient = require("./HttpClient");
const RemoteSigner = require("./RemoteSigner");
const Utils = require("./Utils");
const Crypto = require("./Crypto");
const {LogMessage} = require("./LogMessage");

const Pako = require("pako");

const {
  ValidatePresence
} = require("./Validation");
const UrlJoin = require("url-join");

const networks = {
  "main": "https://main.net955305.contentfabric.io",
  "demo": "https://demov3.net955210.contentfabric.io",
  "demov3": "https://demov3.net955210.contentfabric.io",
  "local": "http://127.0.0.1:8008/config?qspace=dev&self",
  "test": "https://test.net955203.contentfabric.io"
};

if(Utils.Platform() === Utils.PLATFORM_NODE) {
  // Define Response in node
  // eslint-disable-next-line no-global-assign
  globalThis.Response = (require("node-fetch")).Response;
}

/**
 * See the Modules section on the sidebar for details about methods related to interacting with the Fabric.
 *
 * <br/>
 *
 * For information about the Eluvio Wallet Client, go <a href="wallet-client/index.html">here</a>.
 */
class ElvClient {
  Log(message, error = false) {
    LogMessage(this, message, error);
  }

  /**
   * Enable or disable verbose logging
   *
   * @methodGroup Miscellaneous
   *
   * @param {boolean} enable - Set logging
   * @param {Object=} options - Additional options for customizing logging
   * - log: custom log() function
   * - error: custom error() function
   * - (custom functions must accept same arguments as console.log/console.error)
   */
  ToggleLogging(enable, options = {}) {
    // define func with closure to pass to forEach
    const setDebug = (reporter) => {
      if(reporter) {
        reporter.debug = enable;
        reporter.debugOptions = options;
      }
    };

    [this,
      this.authClient,
      this.ethClient,
      this.HttpClient,
      this.AuthHttpClient,
      this.FileServiceHttpClient,
      this.SearchHttpClient,
      this.userProfileClient
    ].forEach(setDebug);

    if(enable) {
      this.Log(
        `Debug Logging Enabled:
        Content Space: ${this.contentSpaceId}
        Fabric URLs: [\n\t\t${this.fabricURIs.join(", \n\t\t")}\n\t]
        Ethereum URLs: [\n\t\t${this.ethereumURIs.join(", \n\t\t")}\n\t]
        Auth Service URLs: [\\n\\t\\t${this.authServiceURIs.join(", \n\t\t")}\\n\\t]
        File Service URLs: [\\n\\t\\t${this.fileServiceURIs.join(", \n\t\t")}\\n\\t]
        `
      );
    }
  }

  EnableMethodLogging() {
    const MethodLogger = (klass) => {
      Object.getOwnPropertyNames(Object.getPrototypeOf(klass))
        .filter(method => typeof klass[method] === "function")
        .forEach(methodName => {
          const originalMethod = klass[methodName].bind(klass);

          if(originalMethod.constructor.name === "AsyncFunction") {
            klass[methodName] = async (...args) => {
              const start = Date.now();
              const result = await originalMethod(...args);
              // eslint-disable-next-line no-console
              console.log(methodName, Date.now() - start, "ms", JSON.stringify(args));
              return result;
            };
          } else {
            klass[methodName] = (...args) => {
              const start = Date.now();
              const result = originalMethod(...args);
              // eslint-disable-next-line no-console
              console.log(methodName, Date.now() - start, "ms", JSON.stringify(args));
              return result;
            };
          }
        });
    };

    MethodLogger(this);
  }

  /**
   * Create a new ElvClient
   *
   * NOTE: It is highly recommended to use the <a href="#.FromConfigurationUrl">FromConfigurationUrl</a> or <a href="#.FromNetworkName">FromNetworkName</a> method
   * automatically import the client settings from the fabric
   *
   * @constructor
   *
   * @namedParams
   * @param {string} contentSpaceId - ID of the content space
   * @param {string} networkId - ID of the blockchain network
   * @param {string} networkName - Name of the blockchain network
   * @param {number} fabricVersion - The version of the target content fabric
   * @param {Array<string>} fabricURIs - A list of full URIs to content fabric nodes
   * @param {Array<string>} ethereumURIs - A list of full URIs to ethereum nodes
   * @param {Array<string>} authServiceURIs - A list of full URIs to auth service endpoints
   * @param {Array<string>} fileServiceURIs - A list of full URIs to file service endpoints
   * @param {Array<string>=} searchURIs - A list of full URIs to search service endpoints
   * @param {number=} ethereumContractTimeout=10 - Number of seconds to wait for contract calls
   * @param {string=} trustAuthorityId - (OAuth) The ID of the trust authority to use for OAuth authentication
   * @param {string=} staticToken - Static token that will be used for all authorization in place of normal auth. Also known as an anonymous token containing the space
   * @param {boolean=} noCache=false - If enabled, blockchain transactions will not be cached
   * @param {boolean=} noAuth=false - If enabled, blockchain authorization will not be performed
   * @param {boolean=} assumeV3=false - If enabled, V3 fabric will be assumed
   * @param {string=} service=default - The mode that determines how HttpClient will be initialized.
   * If 'default' is set, HttpClient uris will use fabricUris. If 'search' is used, searchUris will be used
   *
   * @return {ElvClient} - New ElvClient connected to the specified content fabric and blockchain
   */
  constructor({
    contentSpaceId,
    networkId,
    networkName,
    fabricVersion,
    fabricURIs,
    ethereumURIs,
    authServiceURIs,
    fileServiceURIs,
    searchURIs,
    ethereumContractTimeout = 10,
    trustAuthorityId,
    staticToken,
    noCache=false,
    noAuth=false,
    assumeV3=false,
    service="default"
  }) {
    this.Configuration = ElvClient.Configuration;

    this.utils = Utils;

    this.contentSpaceId = contentSpaceId;
    this.contentSpaceAddress = this.utils.HashToAddress(contentSpaceId);
    this.contentSpaceLibraryId = this.utils.AddressToLibraryId(this.contentSpaceAddress);
    this.contentSpaceObjectId = this.utils.AddressToObjectId(this.contentSpaceAddress);

    this.networkId = networkId;
    this.networkName = networkName;

    this.fabricVersion = fabricVersion;

    this.fabricURIs = fabricURIs;
    this.authServiceURIs = authServiceURIs;
    this.fileServiceURIs = fileServiceURIs || fabricURIs;
    this.ethereumURIs = ethereumURIs;
    this.searchURIs = searchURIs;
    this.ethereumContractTimeout = ethereumContractTimeout;

    this.trustAuthorityId = trustAuthorityId;

    this.noCache = noCache;
    this.noAuth = noAuth;
    this.assumeV3 = assumeV3;

    if(!["search", "default"].includes(service)) {
      throw Error(`Invalid service: ${service}`);
    }

    this.service = service;

    this.debug = false;

    this.InitializeClients({staticToken});
  }


  /**
   * Retrieve content space info and preferred fabric and blockchain URLs from the fabric
   *
   * @methodGroup Constructor
   * @namedParams
   * @param {string} configUrl - Full URL to the config endpoint
   * @param {Array<string>} kmsUrls - List of KMS urls to use for OAuth authentication
   * @param {string=} region - Preferred region - the fabric will auto-detect the best region if not specified
   * - Available regions: as-east, au-east, eu-east-north, eu-west-north, na-east-north, na-east-south, na-west-north, na-west-south, eu-east-south, eu-west-south
   * @param {string=} clientIP - IP address to use in determining the region to use
   * @return {Promise<Object>} - Object containing content space ID and fabric and ethereum URLs
   */
  static async Configuration({
    configUrl,
    kmsUrls=[],
    region,
    clientIP
  }) {
    try {
      const uri = new URI(configUrl);
      uri.pathname("/config");

      if(region) {
        uri.addSearch("elvgeo", region);
      }

      if(clientIP) {
        uri.addSearch("client_ip", clientIP);
      }

      const fabricInfo = await Utils.ResponseToJson(
        HttpClient.Fetch(uri.toString())
      );

      // If any HTTPS urls present, throw away HTTP urls so only HTTPS will be used
      const filterHTTPS = uri => uri.toLowerCase().startsWith("https");

      let fabricURIs = fabricInfo.network.services.fabric_api;
      if(fabricURIs.find(filterHTTPS)) {
        fabricURIs = fabricURIs.filter(filterHTTPS);
      }

      let ethereumURIs = fabricInfo.network.services.ethereum_api;
      if(ethereumURIs.find(filterHTTPS)) {
        ethereumURIs = ethereumURIs.filter(filterHTTPS);
      }

      let authServiceURIs = fabricInfo.network.services.authority_service || [];
      if(authServiceURIs.find(filterHTTPS)) {
        authServiceURIs = authServiceURIs.filter(filterHTTPS);
      }

      let fileServiceURIs = fabricInfo.network.services.file_service || fabricURIs;
      if(fileServiceURIs.find(filterHTTPS)) {
        fileServiceURIs = fileServiceURIs.filter(filterHTTPS);
      }

      const searchURIs = fabricInfo.network.services.search || [];

      const fabricVersion = Math.max(...(fabricInfo.network.api_versions || [2]));

      return {
        nodeId: fabricInfo.node_id,
        contentSpaceId: fabricInfo.qspace.id,
        networkId: (fabricInfo.qspace.ethereum || {}).network_id,
        networkName: ((fabricInfo.qspace || {}).names || [])[0],
        fabricURIs,
        ethereumURIs,
        authServiceURIs,
        fileServiceURIs,
        kmsURIs: kmsUrls,
        searchURIs,
        fabricVersion
      };
    } catch(error) {
      // eslint-disable-next-line no-console
      console.error("Error retrieving fabric configuration:");
      // eslint-disable-next-line no-console
      console.error(error);

      throw error;
    }
  }

  /**
   * Return a list of valid Eluvio Content Fabric network names and their associated configuration URLs
   *
   * @methodGroup Miscellaneous
   *
   * @return {Object} - An object using network names as keys and configuration URLs as values.
   */
  static Networks() {
    return Object.assign({}, networks);
  }

  /**
   * Create a new ElvClient for the specified network
   *
   * @methodGroup Constructor
   * @namedParams
   * @param {string} networkName - Name of the network to connect to ("main", "demo", "test)
   * @param {string=} region - Preferred region - the fabric will auto-detect the best region if not specified
   * - Available regions: as-east, au-east, eu-east-north, eu-west-north, na-east-north, na-east-south, na-west-north, na-west-south, eu-east-south, eu-west-south
   * @param {string=} clientIP - IP address to use in determining the region to use
   * @param {string=} trustAuthorityId - (OAuth) The ID of the trust authority to use for OAuth authentication   * @param {boolean=} noCache=false - If enabled, blockchain transactions will not be cached
   * @param {string=} staticToken - Static token that will be used for all authorization in place of normal auth
   * @param {number=} ethereumContractTimeout=10 - Number of seconds to wait for contract calls
   * @param {boolean=} noAuth=false - If enabled, blockchain authorization will not be performed
   *
   * @return {Promise<ElvClient>} - New ElvClient connected to the specified content fabric and blockchain
   */
  static async FromNetworkName({
    networkName,
    region,
    clientIP,
    trustAuthorityId,
    staticToken,
    ethereumContractTimeout=10,
    noCache=false,
    noAuth=false,
    assumeV3
  }) {
    const configUrl = networks[networkName];

    if(!configUrl) { throw Error("Invalid network name: " + networkName); }

    return await this.FromConfigurationUrl({
      configUrl,
      region,
      clientIP,
      trustAuthorityId,
      staticToken,
      ethereumContractTimeout,
      noCache,
      noAuth,
      assumeV3
    });
  }

  /**
   * Create a new ElvClient from the specified configuration URL
   *
   * @methodGroup Constructor
   * @namedParams
   * @param {string} configUrl - Full URL to the config endpoint
   * @param {string=} region - Preferred region - the fabric will auto-detect the best region if not specified
   * - Available regions: as-east, au-east, eu-east-north, eu-west-north, na-east-north, na-east-south, na-west-north, na-west-south, eu-east-south, eu-west-south
   * @param {string=} clientIP - IP address to use in determining the region to use
   * @param {string=} trustAuthorityId - (OAuth) The ID of the trust authority to use for OAuth authentication   * @param {boolean=} noCache=false - If enabled, blockchain transactions will not be cached
   * @param {string=} staticToken - Static token that will be used for all authorization in place of normal auth
   * @param {number=} ethereumContractTimeout=10 - Number of seconds to wait for contract calls
   * @param {boolean=} noAuth=false - If enabled, blockchain authorization will not be performed
   *
   * @return {Promise<ElvClient>} - New ElvClient connected to the specified content fabric and blockchain
   */
  static async FromConfigurationUrl({
    configUrl,
    region,
    clientIP,
    trustAuthorityId,
    staticToken,
    ethereumContractTimeout=10,
    noCache=false,
    noAuth=false,
    assumeV3=false
  }) {
    const {
      contentSpaceId,
      networkId,
      networkName,
      fabricURIs,
      ethereumURIs,
      authServiceURIs,
      fileServiceURIs,
      searchURIs,
      fabricVersion
    } = await ElvClient.Configuration({
      configUrl,
      clientIP,
      region
    });

    const client = new ElvClient({
      contentSpaceId,
      networkId,
      networkName,
      fabricVersion,
      fabricURIs,
      ethereumURIs,
      authServiceURIs,
      fileServiceURIs,
      searchURIs,
      ethereumContractTimeout,
      trustAuthorityId,
      staticToken,
      noCache,
      noAuth,
      assumeV3
    });

    client.configUrl = configUrl;
    client.region = region;
    client.clientIP = clientIP;

    return client;
  }

  async InitializeClients({staticToken}={}) {
    // Cached info
    this.contentTypes = {};
    this.encryptionConks = {};
    this.stateChannelAccess = {};
    this.objectTenantIds = {};
    this.objectLibraryIds = {};
    this.objectImageUrls = {};
    this.visibilityInfo = {};
    this.inaccessibleLibraries = {};

    const uris = this.service === "search" ? this.searchURIs : this.fabricURIs;
    this.HttpClient = new HttpClient({uris, debug: this.debug});
    this.AuthHttpClient = new HttpClient({uris: this.authServiceURIs, debug: this.debug});
    this.FileServiceHttpClient = new HttpClient({uris: this.fileServiceURIs, debug: this.debug});
    this.SearchHttpClient = new HttpClient({uris: this.searchURIs || [], debug: this.debug});
    this.ethClient = new EthClient({client: this, uris: this.ethereumURIs, networkId: this.networkId, debug: this.debug, timeout: this.ethereumContractTimeout});

    if(!this.signer) {
      const wallet = this.GenerateWallet();
      const signer = wallet.AddAccountFromMnemonic({mnemonic: wallet.GenerateMnemonic()});

      this.SetSigner({signer, reset: false});
      this.SetStaticToken({token: staticToken});
    }

    this.authClient = new AuthorizationClient({
      client: this,
      contentSpaceId: this.contentSpaceId,
      signer: this.signer,
      noCache: this.noCache,
      noAuth: this.noAuth,
      debug: this.debug
    });

    this.userProfileClient = new UserProfileClient({
      client: this,
      debug: this.debug
    });

    // Initialize crypto wasm
    this.Crypto = Crypto;
    this.Crypto.ElvCrypto();
  }

  ConfigUrl() {
    return this.configUrl;
  }

  SetAuth(auth) {
    this.noAuth = !auth;
    this.authClient.noAuth = !auth;
  }

  /**
   * Update fabric URLs to prefer the specified region.
   *
   * Note: Client must have been initialized with FromConfigurationUrl or FromNetworkName
   *
   * @methodGroup Nodes
   * @namedParams
   * @param {string} region - Preferred region - the fabric will auto-detect the best region if not specified
   * - Available regions: as-east, au-east, eu-east-north, eu-west-north, na-east-north, na-east-south, na-west-north, na-west-south, eu-east-south, eu-west-south
   *
   * @return {Promise<Object>} - An object containing the updated fabric, ethereum, auth service, and search URLs in order of preference
   */
  async UseRegion({region}) {
    if(!this.configUrl) {
      throw Error("Unable to change region: Configuration URL not set");
    }

    const {fabricURIs, ethereumURIs, authServiceURIs, fileServiceURIs, searchURIs} = await ElvClient.Configuration({
      configUrl: this.configUrl,
      region
    });

    this.region = region;

    this.SetNodes({
      fabricURIs,
      ethereumURIs,
      authServiceURIs,
      fileServiceURIs,
      searchURIs
    });

    return {
      fabricURIs,
      ethereumURIs,
      fileServiceURIs,
      authServiceURIs,
      searchURIs
    };
  }

  /**
   * Reset fabric URLs to prefer the best region auto-detected by the fabric.
   *
   * Note: Client must have been initialized with FromConfigurationUrl or FromNetworkName
   *
   * @methodGroup Nodes
   *
   * @return {Promise<Object>} - An object containing the updated fabric and ethereum URLs in order of preference
   */
  async ResetRegion() {
    if(!this.configUrl) {
      throw Error("Unable to change region: Configuration URL not set");
    }

    return await this.UseRegion({region: ""});
  }

  /**
   * Retrieve the node ID reported by the fabric for the specified region
   *
   * Note: Client must have been initialized with FromConfigurationUrl or FromNetworkName
   *
   * @methodGroup Nodes
   *
   * @namedParams
   * @param {string} region - Region from which to retrieve the node ID
   *
   * @return {Promise<string>} - The node ID reported by the fabric
   */
  async NodeId({region}) {
    const {nodeId} = await ElvClient.Configuration({
      configUrl: this.configUrl,
      region
    });

    return nodeId;
  }

  /**
   * Retrieve the fabric, ethereum, auth service, and search nodes currently used by the client, in preference order
   *
   * @methodGroup Nodes
   *
   * @return {Promise<Object>} - An object containing the lists of fabric, ethereum, auth service, and search urls in use by the client
   */
  Nodes() {
    return {
      fabricURIs: this.fabricURIs,
      ethereumURIs: this.ethereumURIs,
      authServiceURIs: this.authServiceURIs,
      fileServiceURIs: this.fileServiceURIs,
      searchURIs: this.searchURIs
    };
  }

  /**
   * Set the client to use the specified fabric, ethereum, auth service, and search nodes, in preference order
   *
   * @namedParams
   * @param {Array<string>=} fabricURIs - A list of URLs for the fabric, in preference order
   * @param {Array<string>=} ethereumURIs - A list of URLs for the blockchain, in preference order
   * @param {Array<string>=} authServiceURIs - A list of URLs for the auth service, in preference order
   * @param {Array<string>=} fileServiceURIs - A list of URLs for file service jobs, in preference order
   * @param {Array<string>=} searchURIs - A list of URLs for the search nodes, in preference order
   *
   * @methodGroup Nodes
   */
  SetNodes({fabricURIs, ethereumURIs, authServiceURIs, fileServiceURIs, searchURIs}) {
    if(fabricURIs) {
      this.fabricURIs = fabricURIs;

      this.HttpClient.uris = fabricURIs;
      this.HttpClient.uriIndex = 0;
    }

    if(ethereumURIs) {
      this.ethereumURIs = ethereumURIs;

      this.ethClient.ethereumURIs = ethereumURIs;
      this.ethClient.ethereumURIIndex = 0;
    }

    if(authServiceURIs) {
      this.authServiceURIs = authServiceURIs;

      this.AuthHttpClient.uris = authServiceURIs;
      this.AuthHttpClient.uriIndex = 0;
    }

    if(fileServiceURIs) {
      this.fileServiceURIs = fileServiceURIs;

      this.FileServiceHttpClient.uris = fileServiceURIs;
      this.FileServiceHttpClient.uriIndex = 0;
    }

    if(searchURIs) {
      this.searchURIs = searchURIs;

      this.SearchHttpClient.uris = searchURIs;
      this.SearchHttpClient.uriIndex = 0;
    }
  }

  /**
   * Return a list of nodes in the content space, optionally filtered by node ID or endpoint.
   *
   * @methodGroup Nodes
   * @namedParams
   * @param {string=} matchEndpoint - Return node(s) matching the specified endpoint
   * @param {string=} matchNodeId - Return node(s) matching the specified node ID
   *
   * @return {Promise<Array<Object>>} - A list of nodes in the space matching the parameters
   */
  async SpaceNodes({matchEndpoint, matchNodeId}={}) {
    let nodes;
    this.SetStaticToken();

    if(matchEndpoint) {
      ({nodes} = await this.utils.ResponseToJson(
        this.HttpClient.Request({
          path: UrlJoin("nodes"),
          method: "GET",
          headers: {
            Authorization: `Bearer ${this.staticToken}`
          }
        })
      ));

      if(!nodes || !Array.isArray(nodes) || nodes.length === 0) {
        return [];
      }

      return nodes.filter(node => {
        let match = false;

        if(
          node.services &&
          node.services.fabric_api &&
          node.services.fabric_api.urls
        ) {
          const results = (node.services.fabric_api.urls || []).find(url => url.includes(matchEndpoint));

          if(results) {
            match = true;
          }
        }

        if(matchNodeId && node.id === matchNodeId) {
          match = true;
        }

        this.ClearStaticToken();

        return match;
      });
    } else if(matchNodeId) {
      this.SetStaticToken();
      let node = await this.utils.ResponseToJson(
        this.HttpClient.Request({
          path: UrlJoin("nodes", matchNodeId),
          method: "GET",
          headers: {
            Authorization: `Bearer ${this.staticToken}`
          }
        })
      );

      this.ClearStaticToken();
      return [node];
    }
  }

  /**
   * Return information about how the client was connected to the network
   *
   * @methodGroup Nodes
   * @returns {Object} - The name, ID and configuration URL of the network
   */
  NetworkInfo() {
    return {
      name: this.networkName,
      id: this.networkId,
      configUrl: this.configUrl
    };
  }

  WriteTokenNodeUrl({writeToken}) {
    const nodeUrl = this.HttpClient.draftURIs[writeToken];

    return nodeUrl ? nodeUrl.toString() : undefined;
  }

  RecordWriteToken({writeToken, fabricNodeUrl}) {
    this.HttpClient.RecordWriteToken(writeToken, fabricNodeUrl);
  }

  /* Wallet and signers */

  /**
   * Generate a new ElvWallet that is connected to the client's provider
   *
   * @methodGroup Signers
   * @returns {ElvWallet} - ElvWallet instance with this client's provider
   */
  GenerateWallet() {
    return new ElvWallet(this.ethClient.Provider());
  }

  /**
   * Remove the signer from this client
   *
   * @methodGroup Signers
   */
  ClearSigner() {
    this.signer = undefined;

    this.InitializeClients();
  }

  /**
   * Clear saved access and state channel tokens
   *
   * @methodGroup Access Requests
   */
  ClearCache() {
    this.authClient.ClearCache();
  }

  /**
   * Set the signer for this client to use for blockchain transactions
   *
   * @methodGroup Signers
   * @namedParams
   * @param {object} signer - The ethers.js signer object
   */
  SetSigner({signer, reset=true}) {
    this.staticToken = undefined;

    signer.connect(this.ethClient.Provider());
    signer.provider.pollingInterval = 500;
    this.signer = signer;

    if(reset) {
      this.InitializeClients();
    }
  }

  /**
   * Set signer using OAuth ID token
   *
   * @methodGroup Signers
   * @namedParams
   * @param {string=} idToken - OAuth ID token
   * @param {string=} authToken - Eluvio authorization token previously issued from OAuth ID token
   * @param {string=} tenantId - If specified, user will be associated with the tenant
   * @param {Object=} extraData - Additional data to pass to the login API
   * @param {Array<string>=} signerURIs - (Only if using custom OAuth) - URIs corresponding to the key server(s) to use
   * @param {boolean=} unsignedPublicAuth=false - If specified, the client will use an unsigned static token for calls that don't require authorization (reduces remote signature calls)
   */
  async SetRemoteSigner({idToken, authToken, tenantId, extraData, signerURIs, unsignedPublicAuth}) {
    const signer = new RemoteSigner({
      signerURIs: signerURIs || this.authServiceURIs,
      idToken,
      authToken,
      tenantId,
      provider: await this.ethClient.Provider(),
      extraData,
      unsignedPublicAuth
    });

    await signer.Initialize();

    this.SetSigner({signer});
  }

  /**
   * Set the signer for this client to use for blockchain transactions from an existing web3 provider.
   * Useful for integrating with MetaMask
   *
   * @see https://github.com/ethers-io/ethers.js/issues/59#issuecomment-358224800
   *
   * @methodGroup Signers
   * @namedParams
   * @param {object} provider - The web3 provider object
   */
  async SetSignerFromWeb3Provider({provider}) {
    this.staticToken = undefined;

    let ethProvider = new Ethers.providers.Web3Provider(provider);
    ethProvider.pollingInterval = 250;
    this.signer = ethProvider.getSigner();
    this.signer.address = await this.signer.getAddress();
    await this.InitializeClients();
  }

  /**
   * Initialize a new account using the provided funding and group tokens.
   *
   * This method will redeem the tokens for the current account (or create a new one if not set) in order to
   * retrieve funds and optionally have the user added to appropriate access groups.
   *
   * @methodGroup Signers
   * @namedParams
   * @param {string} tenantId - The ID of the tenant
   * @param {string} fundingToken - A token permitting the user to retrieve funds
   * @param {number=} funds=0.5 - The amount to fund this user. The maximum amount is limited by the token issuer.
   * @param {string=} groupToken - A token permitting the user to be added to access groups
   *
   * @return {string} - The address of the user
   */
  async CreateAccount({tenantId, fundingToken, funds=0.5, groupToken}) {
    if(!this.signer) {
      const wallet = this.GenerateWallet();
      const signer = wallet.AddAccountFromMnemonic({mnemonic: wallet.GenerateMnemonic()});

      this.SetSigner({signer});
    }

    await this.authClient.MakeKMSRequest({
      method: "POST",
      path: `/ks/otp/fnd/${tenantId}`,
      body: {
        toAddr: this.signer.address,
        amtStr: this.utils.EtherToWei(funds)
      },
      headers: {
        Authorization: `Bearer ${fundingToken}`
      }
    });

    await this.userProfileClient.CreateWallet();

    await this.userProfileClient.ReplaceUserMetadata({
      metadataSubtree: "tenantContractId",
      metadata: tenantId
    });

    if(groupToken) {
      await this.authClient.MakeKMSRequest({
        method: "POST",
        path: `/ks/otp/grp/${tenantId}`,
        body: {
          addAddr: this.signer.address,
        },
        headers: {
          Authorization: `Bearer ${groupToken}`
        }
      });
    }

    return this.utils.FormatAddress(this.signer.address);
  }

  /*
    TOKEN                  211b  PREFIX + BODY | aplsjcJf1HYcDDUuCdXcSZtU86nYK162YmYJeuqwMczEBJVkD5D5EvsBvVwYDRsf4hzDvBWMoe9piBpqx...
    PREFIX                   6b  aplsjc | apl=plain s=ES256K jc=json-compressed
    BODY                   205b  base58(SIGNATURE + PAYLOAD)
    SIGNATURE + PAYLOAD    151b  151b * 138 / 100 + 1 = 209b (>= 205b)
    SIGNATURE               66b  ES256K_Di9Lu83mz4wMoehCEeQhKpJJ7ApmDZLumAa2Cge48F6EHYnbn8msATGGpjucScwimei1TWGd7aeyQY45AdXd5tT1Z
    PAYLOAD                 85b  json-compressed
    json                    79b  {"adr":"VVf4DQU357tDnZGYQeDrntRJ5rs=","spc":"ispc3ANoVSzNA3P6t7abLR69ho5YPPZU"}
   */

  async PersonalSign({
    message,
    addEthereumPrefix,
    Sign
  }) {
    if(!Sign) {
      Sign = async message => this.authClient.Sign(message);
    }

    if(addEthereumPrefix) {
      message = Ethers.utils.keccak256(Buffer.from(`\x19Ethereum Signed Message:\n${message.length}${message}`, "utf-8"));
    }

    return await Sign(message);
  }

  /**
   * Create a signed authorization token that can be used to authorize against the fabric
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {number} duration=86400000 - Time until the token expires, in milliseconds (1 hour = 60 * 60 * 1000 = 3600000). Default is 24 hours.
   * @param {Object=} spec - Additional attributes for this token
   * @param {string=} address - Address of the signing account - if not specified, the current signer address will be used.
   * @param {function=} Sign - If specified, this function will be used to produce the signature instead of the client's current signer
   * @param {boolean=} addEthereumPrefix=true - If specified, the 'Ethereum Signed Message' prefixed hash format will be performed. Disable this if the provided Sign method already does this (e.g. Metamask)
   */
  async CreateFabricToken({
    duration=24 * 60 * 60 * 1000,
    spec={},
    address,
    Sign,
    addEthereumPrefix=true,
    context={}
  }={}) {
    address = address || this.CurrentAccountAddress();

    let token = {
      ...spec,
      sub:`iusr${Utils.AddressToHash(address)}`,
      adr: Buffer.from(address.replace(/^0x/, ""), "hex").toString("base64"),
      spc: await this.ContentSpaceId(),
      iat: Date.now(),
      exp: Date.now() + duration,
      ctx: context
    };

    let message = `Eluvio Content Fabric Access Token 1.0\n${JSON.stringify(token)}`;

    const signature = await this.PersonalSign({message, addEthereumPrefix, Sign});

    const compressedToken = Pako.deflateRaw(Buffer.from(JSON.stringify(token), "utf-8"));
    return `acspjc${this.utils.B58(
      Buffer.concat([
        Buffer.from(signature.replace(/^0x/, ""), "hex"),
        Buffer.from(compressedToken)
      ])
    )}`;
  }


  /**
   * Issue a self-signed authorization token
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string=} libraryId - Library ID to authorize
   * @param {string=} objectId - Object ID to authorize
   * @param {string=} versionHash - Version hash to authorize
   * @param {string=} policyId - The object ID of the policy for this token
   * @param {string=} subject - The subject of the token
   * @param {string} grantType=read - Permissions to grant for this token. Options: "access", "read", "create", "update", "read-crypt"
   * @param {number} duration - Time until the token expires, in milliseconds (1 hour = 60 * 60 * 1000 = 3600000)
   * @param {boolean} allowDecryption=false - If specified, the re-encryption key will be included in the token,
   * enabling the user of this token to download encrypted content from the specified object
   * @param {Object=} context - Additional JSON context
   */
  async CreateSignedToken({
    libraryId,
    objectId,
    versionHash,
    policyId,
    subject,
    grantType="read",
    allowDecryption=false,
    duration,
    context={}
  }) {
    if(!subject) {
      subject = `iusr${this.utils.AddressToHash(await this.CurrentAccountAddress())}`;
    }

    if(policyId) {
      context["elv:delegation-id"] = policyId;
    }

    let token = {
      adr: Buffer.from(await this.CurrentAccountAddress().replace(/^0x/, ""), "hex").toString("base64"),
      sub: subject,
      spc: await this.ContentSpaceId(),
      iat: Date.now(),
      exp: Date.now() + duration,
      gra: grantType,
      ctx: context
    };

    if(versionHash) {
      objectId = this.utils.DecodeVersionHash(versionHash).objectId;
    }

    if(objectId) {
      token.qid = objectId;

      if(!libraryId) {
        libraryId = await this.ContentObjectLibraryId({objectId});
      }
    }

    if(libraryId) {
      token.lib = libraryId;
    }

    if(allowDecryption) {
      const cap = await this.authClient.ReEncryptionConk({libraryId, objectId});
      token.apk = cap.public_key;
    }

    const compressedToken = Pako.deflateRaw(Buffer.from(JSON.stringify(token), "utf-8"));
    const signature = await this.authClient.Sign(Ethers.utils.keccak256(compressedToken));

    return `aessjc${this.utils.B58(Buffer.concat([
      Buffer.from(signature.replace(/^0x/, ""), "hex"), 
      Buffer.from(compressedToken)
    ]))}`;
  }


  /**
   * Build a signed message (JSON) using the current signer.
   * Signed messages have a similar format to signed access tokens and they include the message itself
   * such that they can be both verified and decoded by the receiving entity.
   *
   * Messages can be encoded and signed using different methods, and the encoding and signature types
   * are described in the header of the resulting signed message blob.
   *
   * Note this type of message can not be verified and decoded on chain.
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string} messasge - A JSON object representing the message to sign
   */
  async CreateSignedMessageJSON({
    message
  }) {

    // Only one kind of signature supported currently
    const type = "mje_"; // JSON message, EIP192 signature

    const msg = JSON.stringify(message);
    const signature = await this.PersonalSign({message: msg, addEthereumPrefix: true});
    return `${type}${Utils.B58(
      Buffer.concat([
        Buffer.from(signature.replace(/^0x/, ""), "hex"),
        Buffer.from(msg)
      ])
    )}`;
  }

  /**
   * Verify and decode a signed message (JSON).
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string} signedMessage - a signed message as created by CreateSignedMessageJSON
   * @returns {Promise<Object>} - The decoded message, signer address, signature and signature type
   */
  async DecodeSignedMessageJSON({
    signedMessage
  })  {
    const type = signedMessage.slice(0,4);
    switch(type) {
      case "mje_":
        const msgBytes = Utils.FromB58(signedMessage.slice(4));
        const signature = msgBytes.slice(0, 65);
        const msg = msgBytes.slice(65);
        const obj = JSON.parse(msg);

        const prefixedMsgHash = Ethers.utils.keccak256(Buffer.from(`\x19Ethereum Signed Message:\n${msg.length}${msg}`, "utf-8"));
        const signerAddr = Ethers.utils.recoverAddress(prefixedMsgHash, signature);

        return {
          type: type,
          message: obj,
          signerAddress: signerAddr,
          signature: "0x" + signature.toString("hex")
        };
      default:
        throw new Error(`Bad message type: ${type}`);
    }
  }

  /**
   * Get the account address of the current signer
   *
   * @methodGroup Signers
   * @returns {string} - The address of the current signer
   */
  CurrentAccountAddress() {
    return this.signer ? this.utils.FormatAddress(this.signer.address) : "";
  }

  /**
   * Set the OAuth token for use in state channel calls
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string} token - The OAuth ID token
   */
  async SetOauthToken({token}) {
    this.oauthToken = token;

    const wallet = this.GenerateWallet();
    const signer = wallet.AddAccountFromMnemonic({mnemonic: wallet.GenerateMnemonic()});

    this.SetSigner({signer});
  }

  /**
   * Set the signer for this client via OAuth token. The client will exchange the given token
   * for the user's private key using the KMS specified in the configuration.
   *
   * NOTE: The KMS URL(s) must be set in the initial configuration of the client (FromConfigurationUrl or FromNetworkName)
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string} token - The OAuth ID
   */
  async SetSignerFromOauthToken({token}) {
    if(!this.trustAuthorityId) {
      throw Error("Unable to authorize with OAuth token: No trust authority ID set");
    }

    const wallet = this.GenerateWallet();

    try {
      if(!this.kmsURIs) {
        // Make dummy client with dummy account to allow calling of contracts
        const client = await ElvClient.FromConfigurationUrl({configUrl: this.configUrl});
        client.SetSigner({
          signer: wallet.AddAccountFromMnemonic({mnemonic: wallet.GenerateMnemonic()})
        });

        const {urls} = await client.authClient.KMSInfo({
          kmsId: this.trustAuthorityId
        });

        if(!urls || urls.length === 0) {
          throw Error("Unable to authorize with OAuth token: No KMS URLs set");
        }

        this.kmsURIs = urls;
      }

      this.oauthToken = token;

      const path = "/ks/jwt/wlt";
      const httpClient = new HttpClient({uris: this.kmsURIs, debug: this.debug});

      const response = await this.utils.ResponseToJson(
        httpClient.Request({
          headers: {Authorization: `Bearer ${token}`},
          method: "PUT",
          path,
          forceFailover: true
        })
      );

      const privateKey = response["UserSKHex"];

      this.SetSigner({signer: wallet.AddAccount({privateKey})});

      // Ensure wallet is initialized
      await this.userProfileClient.WalletAddress();
    } catch(error) {
      this.Log("Failed to set signer from OAuth token:", true);
      this.Log(error, true);

      await this.ClearSigner();

      throw error;
    }
  }

  /**
   * Set a static token for the client to use for all authorization
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string=} token - The static token to use. If not provided, the default static token will be set.
   */
  SetStaticToken({token}={}) {
    if(!token) {
      token = this.utils.B64(JSON.stringify({qspace_id: this.contentSpaceId}));
    }

    this.staticToken = token;
  }

  /**
   * Clear the set static token for the client
   */
  ClearStaticToken() {
    this.staticToken = undefined;
  }

  /**
   * Authorize the client against the specified policy.
   *
   * NOTE: After authorizing, the client will only be able to access content allowed by the policy
   *
   * @methodGroup Authorization
   * @namedParams
   * @param {string} objectId - The ID of the policy object
   */
  async SetPolicyAuthorization({objectId}) {
    this.SetStaticToken({
      token: await this.GenerateStateChannelToken({objectId})
    });
  }

  /**
   * Create a signature for the specified string
   *
   * @param {string} string - The string to sign
   * @return {Promise<string>} - The signed string
   */
  async Sign(string) {
    const signature = await this.authClient.Sign(Ethers.utils.keccak256(Ethers.utils.toUtf8Bytes(string)));
    return this.utils.FormatSignature(signature);
  }

  /**
   * Encrypt the given string or object with the current signer's public key
   *
   * @namedParams
   * @param {string | Object} message - The string or object to encrypt
   * @param {string=} publicKey - If specified, message will be encrypted with this public key instead of the current user's
   *
   * @return {Promise<string>} - The encrypted message
   */
  async EncryptECIES({message, publicKey}) {
    if(!this.signer) {
      throw "Signer not set";
    }

    ValidatePresence("message", message);

    return await this.Crypto.EncryptConk(message, publicKey || this.signer._signingKey().publicKey);
  }

  /**
   * Decrypt the given encrypted message with the current signer's private key
   *
   * @namedParams
   * @param {string} message - The message to decrypt
   *
   * @return {Promise<string | Object>} - The decrypted string or object
   */
  async DecryptECIES({message}) {
    if(!this.signer) {
      throw "Signer not set";
    }

    ValidatePresence("message", message);

    return await this.Crypto.DecryptCap(message, this.signer._signingKey().privateKey);
  }

  /**
   * Request the specified URL with the given method and body, and return the result in the specified format
   *
   * @param {string} url - URL to request
   * @param {string=} format="json" - Format of response
   * @param {string=} method="GET" - Request method
   * @param {object=} body - Request body
   * @param {object=} headers - Request headers
   *
   * @return {Promise<*>} - Response in the specified format
   */
  async Request({url, format="json", method="GET", headers = {}, body}) {
    return this.utils.ResponseToFormat(
      format,
      HttpClient.Fetch(
        url,
        {
          method,
          headers,
          body
        }
      )
    );
  }

  async MakeAuthServiceRequest({kmsId, objectId, versionHash, method="GET", path, bodyType, body={}, queryParams={}, headers}) {
    return this.authClient.MakeAuthServiceRequest({kmsId, objectId, versionHash, method, path, bodyType, body, queryParams, headers});
  }

  /* FrameClient related */

  // Whitelist of methods allowed to be called using the frame API
  FrameAllowedMethods() {
    const forbiddenMethods = [
      "constructor",
      "AccessGroupMembershipMethod",
      "CallFromFrameMessage",
      "ClearSigner",
      "CreateAccount",
      "EnableMethodLogging",
      "FormatBlockNumbers",
      "FrameAllowedMethods",
      "FromConfigurationUrl",
      "GenerateWallet",
      "InitializeClients",
      "Log",
      "PersonalSign",
      "SetRemoteSigner",
      "SetSigner",
      "SetSignerFromWeb3Provider",
      "Sign",
      "ToggleLogging"
    ];

    return Object.getOwnPropertyNames(Object.getPrototypeOf(this))
      .filter(method => typeof this[method] === "function" && !forbiddenMethods.includes(method));
  }

  // Call a method specified in a message from a frame
  async CallFromFrameMessage(message, Respond) {
    if(message.type !== "ElvFrameRequest") {
      return;
    }

    let callback;
    if(message.callbackId) {
      callback = (result) => {
        Respond(this.utils.MakeClonable({
          type: "ElvFrameResponse",
          requestId: message.callbackId,
          response: result
        }));
      };

      message.args.callback = callback;
    }

    try {
      const method = message.calledMethod;

      let methodResults;
      if(message.module === "walletClient") {
        if(!this.walletClient) {
          throw Error("Wallet client not set");
        }

        if(this.walletClient.ForbiddenMethods().includes(method)) {
          throw Error("Invalid user profile method: " + method);
        }

        methodResults = await this.walletClient[method](message.args);
      } else if(message.module === "userProfileClient") {
        if(!this.userProfileClient.FrameAllowedMethods().includes(method)) {
          throw Error("Invalid user profile method: " + method);
        }

        methodResults = await this.userProfileClient[method](message.args);
      } else {
        if(!this.FrameAllowedMethods().includes(method)) {
          throw Error("Invalid method: " + method);
        }

        methodResults = await this[method](message.args);
      }

      Respond(this.utils.MakeClonable({
        type: "ElvFrameResponse",
        requestId: message.requestId,
        response: methodResults
      }));
    } catch(error) {
      // eslint-disable-next-line no-console
      this.Log(
        `Frame Message Error:
        Method: ${message.calledMethod}
        Arguments: ${JSON.stringify(message.args, null, 2)}
        Error: ${typeof error === "object" ? JSON.stringify(error, null, 2) : error}`,
        true
      );

      // eslint-disable-next-line no-console
      console.error(error);

      const responseError = error instanceof Error ? error.message : error;
      Respond(this.utils.MakeClonable({
        type: "ElvFrameResponse",
        requestId: message.requestId,
        error: responseError
      }));
    }
  }
}

Object.assign(ElvClient.prototype, require("./client/AccessGroups"));
Object.assign(ElvClient.prototype, require("./client/ContentAccess"));
Object.assign(ElvClient.prototype, require("./client/Contracts"));
Object.assign(ElvClient.prototype, require("./client/Files"));
Object.assign(ElvClient.prototype, require("./client/ABRPublishing"));
Object.assign(ElvClient.prototype, require("./client/LiveStream"));
Object.assign(ElvClient.prototype, require("./client/ContentManagement"));
Object.assign(ElvClient.prototype, require("./client/NTP"));
Object.assign(ElvClient.prototype, require("./client/NFT"));

exports.ElvClient = ElvClient;