EluvioLive.js

Back
const { ElvClient } = require("@eluvio/elv-client-js");
const Utils = require("@eluvio/elv-client-js/src/Utils.js");
const { Config } = require("./Config.js");
const { ElvAccount } = require("./ElvAccount");
const { ElvFabric } = require("../src/ElvFabric.js");
const { ElvUtils } = require("./Utils");
const Ethers = require("ethers");
const fs = require("fs");
const path = require("path");
const BigNumber = require("big-number");
const urljoin = require("url-join");
const url = require("url");

const crypto = require("crypto");
const ethers = require("ethers");
const { parse } = require("csv-parse");
const HttpClient = require("@eluvio/elv-client-js/src/HttpClient");

/**
 * EluvioLive is an application platform built on top of the Eluvio Content Fabric.
 * It provides a consumer-facing marketplace for digital content: live performances,
 * digital collectibles, etc.
 *
 * This SDK provides tools for working with EluvioLive APIs and services.
 */
class EluvioLive {
  /**
   * Instantiate the EluvioLive SDK
   *
   * @namedParams
   * @param {string} configUrl - The Content Fabric configuration URL
   * @param {string} mainObjectId - The top-level Eluvio Live object ID
   * @return {EluvioLive} - New EluvioLive object connected to the specified content fabric and blockchain
   */
  constructor({ configUrl, mainObjectId }) {
    this.configUrl = configUrl || ElvClient.main;
    this.mainObjectId = mainObjectId;

    this.debug = false;
  }

  async Init({debugLogging = false, asUrl}={}) {
    this.client = await ElvClient.FromConfigurationUrl({
      configUrl: this.configUrl,
    });

    if (asUrl) {
      // elv-client-js strips the path and only stores the host - save it here
      this.asUrlPath = url.parse(asUrl).path;
      this.client.SetNodes({
        authServiceURIs:[
          asUrl
        ]
      });
    } else {
      this.asUrlPath = "as";
    }

    let wallet = this.client.GenerateWallet();
    let signer = wallet.AddAccount({
      privateKey: process.env.PRIVATE_KEY,
    });
    this.client.SetSigner({ signer });
    this.client.ToggleLogging(debugLogging);
    this.debug = debugLogging;
  }

  /**
   * Show group info about this tenant.
   */
  async TenantGroupInfo({ tenantId }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);

    var tenant_admin_address = await this.client.CallContractMethod({
      contractAddress: tenantAddr,
      abi: JSON.parse(abi),
      methodName: "groupsMapping",
      methodArgs: ["tenant_admin", 0],
      formatArguments: true,
    });

    return {tenant_admin_address,
      tenant_admin_id: ElvUtils.AddressToId({prefix:"igrp", address:tenant_admin_address})
    };
  }

  /**
   * Show info about this account.
   */
  async AccountShow() {
    if (!this.client) {
      throw Error("EluvioLive not initialized");
    }

    const [tenantId, walletAddress, userWalletObject, userMetadata] = await Promise.all([
      this.client.userProfileClient.TenantId(),
      this.client.userProfileClient.WalletAddress(),
      this.client.userProfileClient.UserWalletObjectInfo(),
      this.client.userProfileClient.UserMetadata()
    ]);

    return { tenantId, walletAddress, userWalletObject, userMetadata };
  }


  /**
   * Set the Token URI for an NFT.
   * Currently only setting one and a time. Will support types, single, batch and all.
   *
   * @namedParams
   * @param {string} requestType - The type of request: single, batch, all
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} contractAddress - The NFT contract address
   * @param {string} tokenURI - The new token URI
   * @param {int} tokenId - The NFT token ID
   * @param {string} host - The host to use for the request, undefined to use config
   * @param {string} csv - The CSV file to use for the request, undefined to use config
   * @return {Promise<Object>} - An object containing tenant info, including 'warnings'
   */
  async TenantSetTokenURI({ requestType, tenantId, contractAddress, tokenURI, tokenId, host, csv }) {

    let body = {};
    body.request_type = requestType;
    body.contract_address = contractAddress;
    body.tokens = [];

    switch (requestType) {
      case "batch":
        if (!csv) {
          console.log("Error: CSV file required for batch request");
          return;
        }
        let csvFile;
        try {
          csvFile = fs.readFileSync(path.resolve(__dirname, csv));
        } catch (e) {
          console.log("Error reading CSV file: ", e);
          return;
        }
        // transform CSV to array for JSON request
        const records = parse(csvFile, {columns: true,
          skip_records_with_empty_values: true});

        try {
          // rows should be tokenURI, tokenId
          // tokenId should always exist
          await records.forEach(row => {
            // validate row.tokenURI is a valid URL
            if (!ElvUtils.IsValidURI(row.tokenURI)) {
              console.log("Error: Invalid tokenURI while parsing csv ", row.tokenURI);
              throw new Error("invalid tokenURI " + row.tokenURI);
            } else {
              body.tokens.push({
                "token_id": Number(row.tokenId),
                "token_id_str":  row.tokenId.toString(),
                "token_uri": row.tokenURI
              });
            }
          });
        } catch (e) {
          console.log("Error parsing CSV file: ", e);
          return;
        }

        break;
      case "all":
        body.tokens.push({
          "token_id": null,
          "token_id_str": "",
          "token_uri": tokenURI
        });
        break;
      case "single":
        body.tokens.push({
          "token_id": tokenId,
          "token_id_str": tokenId.toString(),
          "token_uri": tokenURI
        });
        break;
      default:
        console.log("Error: Invalid request type");
        return;
    }

    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/nft/stu", tenantId),  // /tnt/nft/stu/:tid/
      body,
      host
    });

    let tenantConfigResult = await res.json();
    if (this.debug){
      console.log("Create response: ", tenantConfigResult);
    }
    return tenantConfigResult;

  }

  /**
   * Update all token URIs for a given contract to the new hash provided as argument.
   *
   * @namedParams
   * @param {string} tenantId - Tenant ID (iten format)
   * @param {string} contractAddress - The NFT contract address
   * @param {string} hash - The new NFT template object hash
   * @param {bool} dryRun - Dry run flag (default 'true')
   * @return {Promise<Object>} - An object containing operation result
   */
  async TenantUpdateTokenURI({ tenantId, contractAddress, hash, dryRun = true }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    let nftInfo = {
      tokens: [],
      warns: []
    };

    let body = {
      request_type: "batch",
      contract_address:contractAddress,
      tokens: []
    };

    try {
      const totalSupply = await this.client.CallContractMethod({
        contractAddress,
        abi: JSON.parse(abi),
        methodName: "totalSupply",
        formatArguments: true,
      });
      nftInfo.totalSupply = Number(totalSupply);
    } catch (e) {
      console.log("Failed to retrieve supply", e);
      return;
    }

    // Retrieve the list of owners
    for (var i = 0; i < nftInfo.totalSupply; i ++) {
      nftInfo.tokens[i] = {};
      try {
        let tokenId = await this.client.CallContractMethod({
          contractAddress,
          abi: JSON.parse(abi),
          methodName: "tokenByIndex",
          methodArgs: [i],
          formatArguments: true,
        });
        nftInfo.tokens[i].tokenId = tokenId.toString();

        nftInfo.tokens[i].tokenUri = await this.client.CallContractMethod({
          contractAddress,
          abi: JSON.parse(abi),
          methodName: "tokenURI",
          methodArgs: [tokenId],
          formatArguments: true,
        });

        nftInfo.tokens[i].newTokenUri = await ElvUtils.UpdateFabricUrl({url: nftInfo.tokens[i].tokenUri, newHash: hash});

        body.tokens.push({
          "token_id": Number(tokenId),
          "token_id_str": tokenId.toString(),
          "token_uri": nftInfo.tokens[i].newTokenUri
        });

      } catch (e) {
        console.log("Failed to get token ID/URI (index: " + i + "): " + contractAddress, e);
        continue;
      }
    }

    if (dryRun) {
      console.log("DRY RUN - RETURNING REQUEST BODY");
      return body;
    }

    console.log("REQUEST BODY", body);
    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/nft/stu", tenantId),  // /tnt/nft/stu/:tid/
      body
    });

    let tenantConfigResult = await res.json();

    return tenantConfigResult;
  }


  /**
   * Show info about this tenant.
   * Currently only listing NFT marketplaces.
   *
   * @namedParams
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @return {Promise<Object>} - An object containing tenant info, including 'warnings'
   */
  async TenantShow({ tenantId, checkNft = false }) {
    var tenantInfo = {};

    let m = await this.List({ tenantId });

    tenantInfo.marketplaces = {};
    var warns = [];

    let tenantNftList = [];
    if (checkNft) {
      tenantNftList = await this.TenantNftList({ tenantId });
    }

    let minterConfigResp = {};
    let minterAddr = null;
    let minterID = null;
    let mintHelper = null;
    let proxyAddr = null;

    try {
      minterConfigResp = await this.TenantGetMinterConfig({tenant: tenantId});
      console.log("minterConfig: ", minterConfigResp);

      try {
        minterAddr = minterConfigResp.config.minter_address;
        minterID = minterConfigResp.config.minter;
      } catch (e){
        console.warn("tenant config minter_address error: ", e);
      }

      try {
        mintHelper = minterConfigResp.config.minter_helper;
      } catch (e){
        console.warn("tenant config minter_helper error: ", e);
      }

      try {
        proxyAddr = minterConfigResp.config.proxy_owner_address;
      } catch (e){
        console.warn("tenant config proxy_owner_address error: ", e);
      }

    } catch (e) {
      console.log("Warning: ", e);
    }


    for (var key in m.marketplaces) {
      tenantInfo.marketplaces[key] = {};
      tenantInfo.marketplaces[key].items = {};
      tenantInfo.marketplaces[key].summary = {};
      var totalNfts = m.marketplaces[key]?.info?.items?.length || 0;
      tenantInfo.marketplaces[key].summary.total_nfts = totalNfts;

      var totalMinted = 0;
      var totalCap = 0;
      var totalSupply = 0;
      var topMintedValue = 0;
      var topMintedList = [];

      for (var i in m.marketplaces[key]?.info?.items) {
        const item = m.marketplaces[key].info.items[i];

        const sku = item.sku;

        if (!item.nft_template) {
          warns.push("No NFT Template sku: " + sku);
          continue;
        }

        if (!item.nft_template.mint){
          warns.push("No nft_template.mint: " + sku);
          continue;
        }

        if (!item.nft_template.mint){
          warns.push("No nft_template.mint sku: " + sku);
          continue;
        }

        if (!item.nft_template.nft){
          warns.push("No nft_template.nft sku: " + sku);
          continue;
        }

        tenantInfo.marketplaces[key].items[sku] = {};
        tenantInfo.marketplaces[key].items[sku].name = item.name;
        tenantInfo.marketplaces[key].items[sku].description = item.description;
        tenantInfo.marketplaces[key].items[sku].mint_cauth =
          item.nft_template.mint.cauth_id;
        tenantInfo.marketplaces[key].items[sku].nft_addr =
          item.nft_template.nft.address;
        tenantInfo.marketplaces[key].items[sku].templateTotalSupply =
          item.nft_template.nft.total_supply;
        tenantInfo.marketplaces[key].items[sku].nft_template =
          item.nft_template["."].source;

        if (minterID && item.nft_template.mint.cauth_id && minterID != item.nft_template.mint.cauth_id) {
          warns.push("Wrong cauth_id for sku: " + sku + ". Config minter Id: " + minterID + ", item nft_template.mint.cauth_id: " + item.nft_template.mint.cauth_id);
        }

        if (item.nft_template.nft.address === "") {
          warns.push("No NFT address sku: " + sku);
        } else {
          // Check NFT contract parameters
          const nftInfo = await this.NftShow({
            addr: item.nft_template.nft.address,
            mintHelper,
            minterAddr,
            proxyAddr
          });

          if (checkNft) {
            let isInContract = tenantNftList.includes(
              item.nft_template.nft.address
            );

            tenantInfo.marketplaces[key].items[sku].isValid = checkNft;
            if (!isInContract) {
              warns.push(
                `${item.nft_template.nft.address} is not in the tenant contract.`
              );
            }
          }
          tenantInfo.marketplaces[key].items[sku].nftCap = nftInfo.cap;
          tenantInfo.marketplaces[key].items[sku].nftMinted = nftInfo.minted;
          tenantInfo.marketplaces[key].items[sku].nftTotalSupply =
            nftInfo.totalSupply;
          tenantInfo.marketplaces[key].items[sku].nftName = nftInfo.name;
          tenantInfo.marketplaces[key].items[sku].owner = nftInfo.owner;
          tenantInfo.marketplaces[key].items[sku].proxy = nftInfo.proxy;
          tenantInfo.marketplaces[key].items[sku].firstTokenUri =
            nftInfo.firstTokenUri;
          tenantInfo.marketplaces[key].items[sku].defHoldSecs =
            nftInfo.defHoldSecs;

          //Some nfts had -1 for some reason
          totalMinted += nftInfo.minted > 0 ? nftInfo.minted : 0;
          totalCap += nftInfo.cap > 0 ? nftInfo.cap : 0;
          totalSupply += nftInfo.totalSupply > 0 ? nftInfo.totalSupply : 0;

          if (topMintedValue <= nftInfo.minted) {
            topMintedList.unshift({
              name: nftInfo.name,
              minted: nftInfo.minted,
              sku: sku,
              address: item.nft_template.nft.address,
            });
            topMintedValue = nftInfo.minted;
          }

          if (nftInfo.cap != item.nft_template.nft.total_supply) {
            warns.push("NFT cap mismatch sku: " + sku);
          }
          if (nftInfo.warns.length > 0) {
            warns.push(...nftInfo.warns);
          }
        }
      }
      tenantInfo.marketplaces[key].summary.total_minted = totalMinted;
      tenantInfo.marketplaces[key].summary.total_cap = totalCap;
      tenantInfo.marketplaces[key].summary.total_supply = totalSupply;
      tenantInfo.marketplaces[key].summary.top_minted = topMintedList.slice(
        0,
        3
      );
    }

    tenantInfo.groups = await this.TenantGroupInfo({tenantId});
    tenantInfo.sites = {};
    tenantInfo.warns = warns;


    return tenantInfo;
  }

  /**
   * Get a list of the NFTs of this tenant owned by 'ownerAddr'
   *
   * @namedParams
   * @param {string} objectId - The ID of the tenant specific EluvioLive object
   * @param {string} ownerAddr - A user address to check the balance of
   * @return {Promise<Object>} - Number of tokens owned
   */
  async FabricTenantBalanceOf({ objectId, ownerAddr }) {
    var nftInfo = {};

    const libraryId = await this.client.ContentObjectLibraryId({
      objectId,
    });

    var m = await this.client.ContentObjectMetadata({
      libraryId,
      objectId,
      metadataSubtree: "/public/asset_metadata",
      resolveLinks: true,
      resolveIncludeSource: true,
      resolveIgnoreErrors: true,
      linkDepthLimit: 5,
    });

    nftInfo.marketplaces = {};
    var warns = [];

    for (var key in m.marketplaces) {
      nftInfo.marketplaces[key] = {};
      nftInfo.marketplaces[key].nfts = {};
      for (var i in m.marketplaces[key].info.items) {
        const item = m.marketplaces[key].info.items[i];

        const sku = item.sku;

        if (!item.nft_template) {
          warns.push("No NFT Template sku: " + sku);
          continue;
        }
        try {
          const nftAddr = item.nft_template.nft.address;
          const info = await this.NftBalanceOf({ addr: nftAddr, ownerAddr });

          if (info.length == 0) {
            continue;
          }
          var nft = await this.NftShow({ addr: nftAddr });
          nft.tokens = info;

          nftInfo.marketplaces[key].nfts[nftAddr] = nft;
        } catch (e) {
          warns.push(`Error parsing marketplace ${key}, item sku ${sku}. ${e}`);
        }
      }
    }

    nftInfo.warns = warns;

    return nftInfo;
  }

  /**
   * Get a list of the NFTs of this tenant owned by 'ownerAddr'
   *
   * @namedParams
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} ownerAddr - A user address to check the balance of
   * @param {integer} maxNumber - Max number of NFTs returned
   * @return {Promise<Object>} - Number of tokens owned
   */
  async TenantBalanceOf({
    tenantId,
    ownerAddr,
    maxNumber = Number.MAX_SAFE_INTEGER,
  }) {
    if (maxNumber < 1) {
      maxNumber = Number.MAX_SAFE_INTEGER;
    }
    const abiTenant = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );
    const tenantAddr = Utils.HashToAddress(tenantId);
    var arg = "tenant_nfts";

    var nftInfo = {};
    nftInfo.nfts = {};
    nftInfo.summary = {};

    var totalMinted = 0;
    var totalCap = 0;
    var totalSupply = 0;
    var topMintedValue = 0;
    var topMintedList = [];

    var num = 0;
    for (var i = 0; i < Number.MAX_SAFE_INTEGER && num < maxNumber; i++) {
      var ordinal = BigNumber(i).toString(16);
      try {
        var nftAddr = await this.client.CallContractMethod({
          contractAddress: tenantAddr,
          abi: JSON.parse(abiTenant),
          methodName: "groupsMapping",
          methodArgs: [arg, ordinal],
          formatArguments: true,
        });

        const info = await this.NftBalanceOf({ addr: nftAddr, ownerAddr });

        if (info.length == 0) {
          continue;
        }

        var nft = await this.NftShow({ addr: nftAddr });
        nft.tokens = info;

        nftInfo.nfts[nftAddr] = nft;
        num++;

        totalMinted += nft.minted > 0 ? nft.minted : 0;
        totalCap += nft.cap > 0 ? nft.cap : 0;
        totalSupply += nft.totalSupply > 0 ? nft.totalSupply : 0;

        if (topMintedValue <= nft.minted) {
          topMintedList.unshift({
            name: nft.name,
            minted: nft.minted,
            address: nftAddr,
          });
          topMintedValue = nft.minted;
        }
      } catch (e) {
        //We don't know the length so just stop on error and return
        break;
      }
    }

    nftInfo.summary.total_nfts = num;
    nftInfo.summary.total_minted = totalMinted;
    nftInfo.summary.total_cap = totalCap;
    nftInfo.summary.total_supply = totalSupply;
    nftInfo.summary.top_minted = topMintedList.slice(0, 3);

    return nftInfo;
  }

  /**
   * Add an NFT contract to the tenant's 'tenant_nfts' group
   *
   * @namedParams
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} nftAddr = The address of the NFT contract (hex format)
   */
  async TenantAddNft({ tenantId, nftAddr }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const addr = Utils.HashToAddress(tenantId);

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "addGroup",
      methodArgs: ["tenant_nfts", nftAddr],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Remove an NFT contract from the tenant's 'tenant_nfts' group
   *
   * @namedParams
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} nftAddr = The address of the NFT contract (hex format)
   */
  async TenantRemoveNft({ tenantId, nftAddr }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const addr = Utils.HashToAddress(tenantId);

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "removeGroup",
      methodArgs: ["tenant_nfts", nftAddr],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Returns true if an NFT contract is in the tenant's 'tenant_nfts' group
   *
   * @namedParams
   * @param {string} tenantId - The ID of the tenant (iten***)
   * @param {string} nftAddr = The address of the NFT contract (hex format)
   */
  async TenantHasNft({ tenantId, nftAddr }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    const tenantAddr = Utils.HashToAddress(tenantId);
    var arg = "tenant_nfts";

    for (var i = 0; i < Number.MAX_SAFE_INTEGER; i++) {
      var ordinal = BigNumber(i).toString(16);
      try {
        var currNftAddr = await this.client.CallContractMethod({
          contractAddress: tenantAddr,
          abi: JSON.parse(abi),
          methodName: "groupsMapping",
          methodArgs: [arg, ordinal],
          formatArguments: true,
        });

        if (currNftAddr.toLowerCase() != nftAddr.toLowerCase()) {
          continue;
        } else {
          return true;
        }
      } catch (e) {
        //We don't know the length so just stop on error and return
        break;
      }
    }

    return false;
  }

  /**
   * Returns list of NFT contracts in the tenant's 'tenant_nfts' group
   *
   * @namedParams
   * @param {string} tenantId - The ID of the tenant (iten***)
   */
  async TenantNftList({ tenantId }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantSpace.abi")
    );

    let result = [];
    const tenantAddr = Utils.HashToAddress(tenantId);
    var arg = "tenant_nfts";

    for (var i = 0; i < Number.MAX_SAFE_INTEGER; i++) {
      var ordinal = BigNumber(i).toString(16);
      try {
        var nftAddr = await this.client.CallContractMethod({
          contractAddress: tenantAddr,
          abi: JSON.parse(abi),
          methodName: "groupsMapping",
          methodArgs: [arg, ordinal],
          formatArguments: true,
        });

        result.push(nftAddr.toLowerCase());
      } catch (e) {
        //We don't know the length so just stop on error and return
        break;
      }
    }

    return result;
  }

  /**
   * Show info about this site (event)
   *
   * @namedParams
   * @param {string} libraryId - The 'properties' library ID
   * @param {string} objectId - The ID of the site object
   * @return {Promise<Object>} - An object containing site info, including 'warnings'
   */
  async SiteShow({ libraryId, objectId }) {
    var siteInfo = {};

    var m = await this.client.ContentObjectMetadata({
      libraryId,
      objectId,
      metadataSubtree: "/public/asset_metadata",
      select: "",
      resolveLinks: true,
      resolveIncludeSource: true,
      resolveIgnoreError: true,
      linkDepthLimit: 5,
    });

    siteInfo.drops = {};
    for (var key in m.info.drops) {
      const drop = m.info.drops[key];
      const uuid = drop.uuid;

      siteInfo.drops[uuid] = {};

      siteInfo.drops[uuid].event_header = drop.event_header;

      siteInfo.drops[uuid].start_date = drop.start_date;
      siteInfo.drops[uuid].end_date = drop.end_date;

      siteInfo.drops[uuid].stages = {};
      siteInfo.drops[uuid].stages["preroll"] = {};
      siteInfo.drops[uuid].stages["preroll"].start_date =
        drop.event_state_preroll.start_date;

      siteInfo.drops[uuid].stages["main"] = {};
      siteInfo.drops[uuid].stages["main"].start_date =
        drop.event_state_main.start_date;

      siteInfo.drops[uuid].stages["vote_end"] = {};
      siteInfo.drops[uuid].stages["vote_end"].start_date =
        drop.event_state_post_vote.start_date;

      siteInfo.drops[uuid].stages["mint_start"] = {};
      siteInfo.drops[uuid].stages["mint_start"].start_date =
        drop.event_state_mint_start.start_date;

      siteInfo.drops[uuid].nfts = {};
      for (var i in drop.nfts) {
        const nft = drop.nfts[i];
        siteInfo.drops[uuid].nfts[nft.sku] = nft.label;
      }
    }

    return siteInfo;
  }

  /**
   * Set start dates for a drop event (all stages)
   *
   * @namedParams
   * @param {string} libraryId - The 'properties' library ID
   * @param {string} objectId - The ID of the site object
   * @param {string} uuid - UUID of the drop (a site may contain multiple)
   * @param {string} start - the start date of the event
   * @param {string} end - the end date of the event (optional)
   * @param {string} endVote - the start date of the post vote stage (optional)
   * @param {string} startMint - the start date of the mint stage (optional)
   * @param {boolean} newUuid - create a new UUID for the drop (optional)
   * @param {string} update - Tenant-level EluvioLive object ID, to update
   * @return {Promise<Object>} - An object containing new drop info
   */
  async SiteSetDrop({
    libraryId,
    objectId,
    uuid,
    start,
    end,
    endVote,
    startMint,
    newUuid,
    update,
  }) {
    const defaultStageDurationMin = 2;

    // If stages are not specified use 2min for each
    const startMsec = Date.parse(start);
    if (!endVote || endVote == "") {
      endVote = new Date(startMsec + defaultStageDurationMin * 60 * 1000);
    }
    if (!startMint || startMint == "") {
      startMint = new Date(startMsec + 2 * defaultStageDurationMin * 60 * 1000);
    }
    if (!end || end == "") {
      end = new Date(startMsec + 3 * defaultStageDurationMin * 60 * 1000);
    }

    var dropInfo = {};

    var m = await this.client.ContentObjectMetadata({
      libraryId,
      objectId,
      metadataSubtree: "/public/asset_metadata",
      resolveLinks: false,
    });

    var found = false;
    for (var key in m.info.drops) {
      const drop = m.info.drops[key];
      const dropUuid = drop.uuid;

      if (dropUuid.slice(0, 22) == uuid) {
        console.log("Found drop uuid: ", uuid);
        console.log(drop);
        found = true;

        drop.start_date = start;
        drop.end_date = end;
        drop.event_state_preroll.start_date = "";
        drop.event_state_main.start_date = start;
        drop.event_state_post_vote.start_date = endVote;
        drop.event_state_mint_start.start_date = startMint;
        drop.event_state_event_end.start_date = end;

        dropInfo.start = start;
        dropInfo.end = end;
        dropInfo.endVote = endVote;
        dropInfo.startMint = startMint;

        if (newUuid) {
          drop.uuid = uuid.slice(0, 22) + startMsec / 1000;
        }
        dropInfo.uuid = drop.uuid;

        // Set new metadata
        m.info.drops[key] = drop;

        const dryRun = false;
        if (!dryRun) {
          var e = await this.client.EditContentObject({
            libraryId,
            objectId,
          });

          await this.client.ReplaceMetadata({
            libraryId,
            objectId,
            writeToken: e.write_token,
            metadataSubtree: "/public/asset_metadata",
            metadata: m,
          });

          var f = await this.client.FinalizeContentObject({
            libraryId,
            objectId,
            writeToken: e.write_token,
            commitMessage: "Set drop start " + uuid + " " + start,
          });

          dropInfo.hash = f.hash;
          console.log("Finalized: ", f);

          if (update != null && update != "") {
            await this.client.UpdateContentObjectGraph({
              libraryId,
              objectId: update,
            });
            console.log("Update ", update);
          }
        } else {
          console.log("New drop:", drop);
          console.log("New metadata:", m);
        }
        break;
      }
    }

    if (!found) {
      console.log("Drop not found - uuid: ", uuid);
    }

    return dropInfo;
  }

  /**
   * Create a new NFT contract (ElvTradable, ERC-721-based) and set it up for this tenant
   * - create a new contract
   * - add minter
   * - add NFT address to tenant 'tenant_nfts' group
   *
   * TODO: preflight - ensure signer is a tenant admin
   *
   * @namedParams
   * @param {string} tenantId - The tenant ID
   * @param {string} minterAddr - Address of the minter (hex format) (Optional, Default uses tenant minter config)
   * @param {string} mintHelperAddr - Address of the mint helper (hex format) (Optional,  Default uses tenant minter config)
   * @param {string} collectionName - Short name for the ERC-721 contract
   * @param {string} collectionSymbol - Short string for the ERC-721 contract
   * @param {string} contractUri - URI for the ERC-721 contract
   * @param {string} proxyAddress - Proxy address for the ERC721 contract (Optional, Default uses tenant minter config)
   * @param {number} totalSupply - the mint cap for this template (should be called 'cap')
   * @param {number} hold - the hold period (seconds)
   * @return {Promise<Object>} - New contract address
   */
  async CreateNftContract({
    tenantId,
    mintHelperAddr,
    minterAddr,
    collectionName,
    collectionSymbol,
    contractUri,
    proxyAddress,
    totalSupply /* PENDING: should be 'cap' */,
    hold,
  }) {
    const abistr = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );
    const bytecode = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.bin")
    );

    let minterConfigResp = {};

    try {
      minterConfigResp = await this.TenantGetMinterConfig({tenant: tenantId});

      if (this.debug){
        console.log("minterConfig: ", minterConfigResp);
      }
    } catch (e) {
      console.log("Warning: ", e);
    }

    if (!proxyAddress || proxyAddress == "") {
      proxyAddress = minterConfigResp.config.transfer_proxy_address;
      if (proxyAddress == ""){
        throw Error("Tenant configuration has no proxy_owner_address");
      }
      if (this.debug){
        console.log("Using tenant config proxy address: ", proxyAddress);
      }
    }

    //Create new Transfer Proxy if config and command line does not exist.
    if (!proxyAddress || proxyAddress == "") {
      proxyAddress = await this.CreateNftTransferProxy({});
    }
    console.log("TransferProxy addr:", proxyAddress);

    if (!minterAddr || minterAddr == "") {
      minterAddr = minterConfigResp.config.minter_address;
      if (minterAddr == ""){
        throw Error("Tenant configuration has no minter_address");
      }
      if (this.debug){
        console.log("Using tenant config minter address: ", minterAddr);
      }
    }

    if (!mintHelperAddr || mintHelperAddr == "") {
      mintHelperAddr = minterConfigResp.config.minter_helper;
      if (mintHelperAddr == ""){
        throw Error("Tenant configuration has no minter_helper");
      }
      if (this.debug){
        console.log("Using tenant config minter helper address: ", mintHelperAddr);
      }
    }

    if (hold == null || hold == 0) {
      hold = 604800;
    }

    var c = await this.client.DeployContract({
      abi: JSON.parse(abistr),
      bytecode: bytecode.toString("utf8").replace("\n", ""),
      constructorArgs: [
        collectionName,
        collectionSymbol,
        contractUri || "",
        proxyAddress,
        0,
        totalSupply /* this is the 'cap' */,
        hold,
      ],
    });

    console.log("NFT contract address:", c.contractAddress);

    await this.AddMinter({
      addr: c.contractAddress,
      minterAddr: mintHelperAddr,
    });
    console.log("- mint helper added", mintHelperAddr);

    await this.AddMinter({
      addr: c.contractAddress,
      minterAddr: minterAddr,
    });
    console.log("- minter added", minterAddr);

    await this.TenantAddNft({ tenantId, nftAddr: c.contractAddress });
    console.log("- tenant_nfts added", tenantId);

    return {
      nftAddr: c.contractAddress,
      mintShuffleKeyId: minterConfigResp.config.mint_shuffle_key_id
    };
  }

  /**
   * Set a TransferProxy for this NFT contract.  If no proxy address is specified, create a new one.
   * Must be run as the NFT contract owner.
   *
   * @namedParams
   * @param {string} addr - The NFT Transfer Proxy contract address
   * @param {string} proxyAddress - The address of the proxy contract (optional)
   * @return {Promise<Object>} - New contract address
   */
  async NftSetTransferProxy({ addr, proxyAddr }) {
    if (proxyAddr == null || proxyAddr.length == 0) {
      proxyAddr = await this.CreateNftTransferProxy({});
    }

    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "setProxyRegistryAddress",
      methodArgs: [proxyAddr],
      formatArguments: true,
    });

    return proxyAddr;
  }

  /**
   * Create a new NFT TransferProxy contract
   *
   * @namedParams
   * @return {Promise<Object>} - New contract address
   */
  async CreateNftTransferProxy() {
    const abistr = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/TransferProxyRegistry.abi")
    );
    const bytecode = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/TransferProxyRegistry.bin")
    );

    var c = await this.client.DeployContract({
      abi: JSON.parse(abistr),
      bytecode: bytecode.toString("utf8").replace("\n", ""),
      constructorArgs: [],
    });

    return c.contractAddress;
  }

  /**
   * Show NFT Transfer Proxy info
   *
   * @namedParams
   * @param {string} addr - The NFT Transfer Proxy contract address
   * @return {Promise<Object>} - Proxy info object
   */
  async ShowNftTransferProxy({ addr }) {
    const abistr = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/TransferProxyRegistry.abi")
    );

    var info = {};

    info.owner = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abistr),
      methodName: "owner",
      formatArguments: true,
    });

    return info;
  }

  /**
   * Show NFT mint helper info
   *
   * @namedParams
   * @param {string} addr - The mint helper contract address
   * @return {Promise<Object>} - Mint helper info object
   */
  async ShowMintHelper({ addr }) {
    const abistr = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTokenHelper.abi")
    );

    var info = {};
    try {
      info.owner = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abistr),
        methodName: "owner",
        formatArguments: true,
      });
    } catch (e) {
      info.warns = "Bad mint helper address " + addr;
    }
    return info;
  }

  /**
   *  WIP
   */
  /*
  async ContractCallMintHelper(client) {

    const abi = fs.readFileSync(path.resolve(__dirname, "../contracts/v3/ElvTokenHelper.abi"));

    var res = await client.CallContractMethodAndWait({
      contractAddress: addrHelper,
      abi: JSON.parse(abi),
      methodName: "mintWithTokenURIMany",
      methodArgs: [
        [nft2],
        [user1],
        [770],
        [""]
      ],
      formatArguments: true
    });

    console.log(res);
  }
*/

  /**
   * Get the NFT balance for a given user address
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} ownerAddr - A user address to check the balance of
   * @return {Promise<Object>} - Number of tokens owned
   */
  async NftBalanceOf({ addr, ownerAddr }) {
    var balance = [];
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );
    var res = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "balanceOf",
      methodArgs: [ownerAddr],
      formatArguments: true,
    });

    // List all tokens
    for (var i = 0; i < res; i++) {
      var tokenId = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "tokenOfOwnerByIndex",
        methodArgs: [ownerAddr, i],
        formatArguments: true,
      });

      var holdSecs = -1;
      var holdEnd = -1;
      try {
        holdSecs = await this.client.CallContractMethod({
          contractAddress: addr,
          abi: JSON.parse(abi),
          methodName: "_allTokensHolds",
          methodArgs: [tokenId],
          formatArguments: true,
        });
        holdEnd = new Date(holdSecs * 1000);
      } catch (e) {
        //FIXME: Do we want to print error?
        //console.error(e);
      }

      balance[i] = {
        tokenId: tokenId.toString(),
        hold: holdSecs.toString(),
        holdEnd: holdEnd,
      };
    }

    return balance;
  }

  /**
   * Show info on one token in the NFT contract
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {integer} tokenId - The token ID
   * @return {Promise<Object>} - An object containing token info, including 'warnings'
   */
  async NftShowToken({ addr, tokenId }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var tokenInfo = {};
    tokenInfo.warns = [];
    tokenInfo.tokenId = tokenId.toString();

    tokenInfo.owner = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "ownerOf",
      methodArgs: [tokenId],
      formatArguments: true,
    });

    try {
      const ordinal = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "ordinalOfToken",
        methodArgs: [tokenId],
        formatArguments: true,
      });
      tokenInfo.ordinal = ordinal.toString();
    } catch (e) {
      tokenInfo.ordinal = -1;
      tokenInfo.warns.push("Failed to get ordinal: " + addr);
    }

    tokenInfo.tokenURI = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "tokenURI",
      methodArgs: [tokenId],
      formatArguments: true,
    });

    try {
      const holdSecs = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "_allTokensHolds",
        methodArgs: [tokenId],
        formatArguments: true,
      });
      tokenInfo.holdSecs = holdSecs.toString();
      tokenInfo.holdEnd = new Date(tokenInfo.holdSecs * 1000);
    } catch (e) {
      tokenInfo.warns.push("Failed to get token hold: " + addr);
    }

    return tokenInfo;
  }

  /**
   * Show info about this NFT
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} mintHelper - Warn if this is not a minter for the NFT contract (hex)
   * @param {string} minter - Warn if this is not the owner of the mint helper contract (hex)
   * @param {string} proxyAddr - proxy owner address
   * @param {integer} showOwners - Enumerate all token owners using a fast indexed query. Only used if tokenId is undefined.
   * @param {integer} showOwnersViaContract - Enumerate all token info up to the given amount. Only used if tokenId is undefined.
   * @param {string} includeEmail - Include email address for owners, as bound to the given tenant ID. Only used if tokenId is undefined.
   * @param {integer} tokenId - The token ID to show info for. This will take precedence over showOwners
   * @return {Promise<Object>} - An object containing NFT info, including 'warnings'
   */
  async NftShow({ addr, mintHelper, minterAddr, proxyAddr,
    showOwners, showOwnersViaContract, includeEmail, tokenId }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );
    var nftInfo = {};
    var warns = [];

    nftInfo.name = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "name",
      formatArguments: true,
    });
    nftInfo.owner = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "owner",
      formatArguments: true,
    });
    nftInfo.symbol = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "symbol",
      formatArguments: true,
    });
    const totalSupply = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "totalSupply",
      formatArguments: true,
    });
    nftInfo.totalSupply = Number(totalSupply);

    nftInfo.transferFee = await this.NftGetTransferFee({address: addr});


    try {
      const minted = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "minted",
        formatArguments: true,
      });
      nftInfo.minted = Number(minted);
    } catch (e) {
      nftInfo.minted = -1; // Older contract
    }
    const cap = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "cap",
      formatArguments: true,
    });
    nftInfo.cap = Number(cap);

    const proxy = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "proxyRegistryAddress",
      formatArguments: true,
    });
    nftInfo.proxy = proxy;

    if (proxy == "0x0000000000000000000000000000000000000000") {
      warns.push("No proxy registered for nft address: " + addr);
    } else {
      nftInfo.proxyInfo = await this.ShowNftTransferProxy({ addr: proxy });
      // proxyAddr is the proxy owner address
      const actualProxyOwner = nftInfo.proxyInfo.owner;
      if (proxyAddr && actualProxyOwner.toLowerCase() != proxyAddr.toLowerCase()){
        warns.push("Bad proxy owner address for nft address: " + addr + ". expected: " + proxyAddr + " registered: " + proxy);
      }
    }

    if (mintHelper) {
      const isMinter = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "isMinter",
        methodArgs: [mintHelper],
        formatArguments: true,
      });
      if (!isMinter) {
        warns.push("Mint helper not set up for nft address: " + addr);
      }

      nftInfo.mintHelperInfo = await this.ShowMintHelper({ addr: mintHelper });
      if (nftInfo.warns && nftInfo.warns.length > 0) {
        warns.push(...nftInfo.warns);
      }

      if (!nftInfo.mintHelperInfo.owner || nftInfo.mintHelperInfo.owner == "") {
        warns.push("Bad mint helper - owner not available " + addr);
      } else if (
        minterAddr &&
        nftInfo.mintHelperInfo.owner.toLowerCase() != minterAddr.toLowerCase()
      ) {
        warns.push("Bad mint helper owner for nft address: " + addr
        + " config mint helper: " + mintHelper
        + " config minter address: " + minterAddr
        + " mint helper owner: " + nftInfo.mintHelperInfo.owner);
      }
    }

    if (minterAddr) {
      const isMinter = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "isMinter",
        methodArgs: [minterAddr],
        formatArguments: true,
      });
      if (!isMinter) {
        warns.push("Minter not set up for nft address: " + addr
        + " config minter address: " + minterAddr
        );
      }
    }

    try {
      const defHoldSecs = await this.client.CallContractMethod({
        contractAddress: addr,
        abi: JSON.parse(abi),
        methodName: "defHoldSecs",
        formatArguments: true,
      });
      nftInfo.defHoldSecs = defHoldSecs.toString();
    } catch (e) {
      nftInfo.defHoldSecs = "not supported";
      warns.push("Bad local tradable hold: " + addr);
    }

    nftInfo.tokens = [];

    var maxWarnsTokenUri = 1;
    if (tokenId != undefined) {
      try {
        var tokenInfo = await this.NftShowToken({ addr, tokenId });
        nftInfo.token = tokenInfo;
        if (tokenInfo.warns && tokenInfo.warns.length > 0) {
          warns.push(...tokenInfo.warns);
        }
      } catch (e){
        warns.push(`Could not get token info for addr: ${addr} - ${e}`);
      }

    } else if (showOwners && showOwners > 0) {
      // prefer this fast one to the contract code, which is left for reference below
      const maxShowOwners = showOwners;
      nftInfo.tokens = [];

      try {
        const url = urljoin("/nft/owners/", addr);
        let res = await this.GetServiceRequest({
          path: url,
          queryParams: { limit: maxShowOwners },
        });
        res = await res.json();

        nftInfo.tokens = res.contents;
      } catch (e) {
        warns.push("Failed to get token owners: " + addr + " - " + e);
      }

      for (var i = 0; i < maxShowOwners && i < nftInfo.totalSupply; i++) {
        if (nftInfo.tokens[i] === undefined) {
          warns.push("missing token information for index " + i + " of " + (totalSupply-1) + " (totalSupply=" + totalSupply +
            "); likely the blockchain indexer is not up to date; try using --show_owners_via_contract instead.");
          continue;
        }
        try {
          // adapt to format:
          // hold -> holdSecs
          nftInfo.tokens[i].holdSecs = nftInfo.tokens[i].hold;
          // add holdEnd
          const timestamp = nftInfo.tokens[i].hold;
          nftInfo.tokens[i].holdEnd = new Date(timestamp * 1000);
          // token_id -> tokenId
          nftInfo.tokens[i].tokenId = "" + nftInfo.tokens[i].token_id;
          // token_uri -> tokenURI
          nftInfo.tokens[i].tokenURI = nftInfo.tokens[i].token_uri;
          // token_owner -> owner
          nftInfo.tokens[i].owner = nftInfo.tokens[i].token_owner;
          // ordinal int -> string
          nftInfo.tokens[i].ordinal = "" + nftInfo.tokens[i].ordinal;

          delete nftInfo.tokens[i].block;
          delete nftInfo.tokens[i].created;
          delete nftInfo.tokens[i].contract_name;
          delete nftInfo.tokens[i].contract_addr;
          delete nftInfo.tokens[i].hold;
          delete nftInfo.tokens[i].token_id;
          delete nftInfo.tokens[i].token_id_str;
          delete nftInfo.tokens[i].token_owner;
          delete nftInfo.tokens[i].token_uri;
        } catch (e) {
          warns.push("Failed to process token " + addr + " index " + i + "/" + (totalSupply-1) + ", error: " + e +
            ", token: " + JSON.stringify(nftInfo.tokens[i]));
          continue;
        }

        if (i == 0) {
          nftInfo.firstTokenUri = nftInfo.tokens[i].tokenURI;
        }
      }

    } else if (showOwnersViaContract && showOwnersViaContract > 0) {
      var maxShowOwners = showOwnersViaContract;

      for (i = 0; i < maxShowOwners && i < nftInfo.totalSupply; i++) {
        nftInfo.tokens[i] = {};

        try {
          tokenId = await this.client.CallContractMethod({
            contractAddress: addr,
            abi: JSON.parse(abi),
            methodName: "tokenByIndex",
            methodArgs: [i],
            formatArguments: true,
          });
          nftInfo.tokens[i].tokenId = tokenId.toString();
        } catch (e) {
          warns.push("Failed to get token ID (index: " + i + "): " + addr);
          continue;
        }

        tokenInfo = await this.NftShowToken({ addr, tokenId });

        if (tokenInfo.warns.length > 0) {
          warns.push(...tokenInfo.warns);
        }

        nftInfo.tokens[i] = tokenInfo;

        if (i == 0) {
          nftInfo.firstTokenUri = nftInfo.tokens[i].tokenURI;
        }
        if (
          maxWarnsTokenUri-- > 0 &&
          (!nftInfo.tokens[i].tokenURI.startsWith(
            Config.consts[Config.net].tokenUriStart
          ) ||
            !nftInfo.tokens[i].tokenURI.endsWith(
              Config.consts[Config.net].tokenUriEnd
            ))
        ) {
          warns.push("Bad tokenURI: " + addr);
        }
      }
    }

    if (includeEmail) {
      try {
        const wallets = await this.TenantWallets({ tenant: includeEmail });
        if (nftInfo.tokens) {
          for (i = 0; i < nftInfo.tokens.length; i++) {
            const tok = nftInfo.tokens[i];
            const ownerObj = wallets.contents.filter(function (entry) { return entry.addr === tok.owner; });
            tok.owner_email = ownerObj.length > 0 ? ownerObj[0].ident : "";
          }
        } else {
          // single token queries do not include the token owner, just contract owner, or we'd fill it too
        }
      } catch (e) {
        warns.push({"error": "Unable to get email(s)", "exception": e });
      }
    }

    nftInfo.warns = warns;
    return nftInfo;
  }

  /**
   * Add a minter to NFT or ElvToken
   *
   * @namedParams
   * @param {string} addr - The NFT/ElvToken contract address
   * @param {string} mintAddr - The address of the minter (key or helper contract)
   */
  async AddMinter({ addr, minterAddr }) {
    console.log("Add minter, contract_addr=%s, minter_addr=%s", addr, minterAddr);
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/MinterRole.abi")
    );

    // Check if the address is already a minter
    var res = await this.IsMinter({addr, minterAddr});
    if (res.is_minter) {
      return res;
    }

    await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "addMinter",
      methodArgs: [minterAddr],
      formatArguments: true,
    });

    res = await this.IsMinter({addr, minterAddr});
    if (!res.is_minter) {
      throw new Error("minter address is not set");
    }
    return res;
  }

  /**
   * Renounce the minter(msg.sender) from NFT or ElvToken
    */
  async RenounceMinter({addr}) {
    console.log("Renounce minter, contract_addr=%s, user_addr=%s", addr.toString(), this.client.CurrentAccountAddress());
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/MinterRole.abi")
    );

    await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "renounceMinter",
      methodArgs: [],
      formatArguments: true,
    });

    var minterAddr = this.client.CurrentAccountAddress();
    var res = await this.IsMinter({addr, minterAddr});
    if (res.is_minter) {
      throw Error("minter address is not removed");
    }
    return res;
  }

  /**
   * check if minter to NFT or ElvToken
   *
   * @namedParams
   * @param {string} addr - The NFT/ElvToken contract address
   * @param {string} mintAddr - The address of the minter (key or helper contract)
   */
  async IsMinter({ addr, minterAddr }) {
    console.log("Check minter, contract_addr=%s, minter_addr=%s", addr, minterAddr);
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/MinterRole.abi")
    );

    var res = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "isMinter",
      methodArgs: [minterAddr],
      formatArguments: true,
    });

    return {
      is_minter: res,
    };
  }

  /**
   * Add a redeemable offer to the NFT contract
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   */
  async NFTAddRedeemableOffer({addr}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "addRedeemableOffer",
      formatArguments: true,
    });

    return res;
  }

  /**
   * Remove a redeemable offer from the NFT contract
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   */
  async NFTRemoveRedeemableOffer({addr, offerId}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "removeRedeemableOffer",
      methodArgs: [offerId],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Returns true if offer is active (has not been removed)
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} offerId - The Offer ID
   */
  async NFTIsOfferActive({addr, offerId}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "isOfferActive",
      methodArgs: [offerId],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Returns true if offer is redeemed
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} offerId - The Offer ID
   */
  async NFTIsOfferRedeemed({addr, tokenId, offerId}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "isOfferRedeemed",
      methodArgs: [tokenId, offerId],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Redeem an nft offer using the contract directly
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} offerId - The Offer ID
   */
  async NFTRedeemOffer({addr, redeemerAddr, tokenId, offerId}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "redeemOffer",
      methodArgs: [redeemerAddr, tokenId, offerId],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Redeem an nft offer using the Authority Service
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} offerId - The Offer ID
   */
  async ASNFTRedeemOffer({addr, tenantId, tokenId, offerId, mintHelperAddr}){

    let elvAccount = new ElvAccount({configUrl:this.configUrl, debugLogging: this.debug});
    elvAccount.InitWithClient({elvClient:this.client});
    let sig = await elvAccount.CreateOfferSignature({nftAddress:addr, mintHelperAddress:mintHelperAddr,tokenId,offerId});

    let address = Ethers.utils.verifyMessage(sig.messageHashBytes, sig.signature);
    if (this.debug){
      console.log("Recovered address: ", address);
      console.log("Signed return value: ", sig);
    }

    let refId = crypto.randomUUID();

    let body = {
      "op": "nft-offer-redeem",
      "client_reference_id": refId,
      "tok_addr": addr,
      "tok_id": `${tokenId}`,
      "offer_id": offerId,
      "sig_hex": sig.signedData,
    };

    let res = await this.PostServiceRequest({
      path: `/wlt/act/${tenantId}`,
      body,
      useFabricToken:true
    });

    return {request_id: refId, status: res.status};
  }


  /**
   * Sets the nft policy and permissions for a given object
   * throws error if something goes wrong.
   *
   * @namedParams
   * @param {string} objectId - The NFT object ID
   * @param {string} policyPath - Path to the policy file (eg. nft_owner_minter.yaml). Note that the policy must contain the minter address
   * @param {string[]} addresses - Array of addresses to set the policy permissions
   * @param {boolean} [clearAddresses=false] - Clear existing permissions by writing an empty address list before applying the new ones
   */
  async NftSetPolicyAndPermissions({ objectId, policyPath, addresses=[], clearAddresses=false}) {
    let elvFabric = new ElvFabric({
      configUrl: this.configUrl,
      debugLogging: this.debug
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY
    });

    // Policy can only be set by object owner
    const objectOwner = await this.client.authClient.Owner({id: objectId});
    if (objectOwner.toLowerCase() != this.client.signer.address.toLowerCase()) {
      throw Error("Policy must be set by object owner " + objectOwner);
    }

    //Set Policy
    const policyString = fs.readFileSync(
      policyPath
    ).toString();

    if (!policyString){
      throw Error("Policy file contents is empty.");
    }

    if (this.debug){
      console.log("Policy file contents: ", policyString);
    }

    let account = new ElvAccount({configUrl:this.configUrl, debugLogging: this.debug});
    account.InitWithClient({elvClient: this.client});

    let policyFormat = await ElvUtils.parseAndSignPolicy({policyString, configUrl:this.configUrl, elvAccount:account});

    if (this.debug){
      console.log("Policy Value To Set: ", policyFormat);
    }

    let res = await elvFabric.SetContractMeta({
      address: objectId,
      key: "_ELV",
      value: JSON.stringify(policyFormat)
    });

    if (!ElvUtils.isTransactionSuccess(res)) {
      throw res;
    }

    if (!clearAddresses && addresses.length == 0) {
      return;
    }

    if (clearAddresses){
      addresses = [];
    }

    for (const address of addresses){
      if (!ethers.utils.isAddress(address)){
        throw Error(`"${address}" is not a valid ethereum address.`);
      }
    }

    let addressesString = JSON.stringify(addresses);

    let res2 = await elvFabric.SetContractMeta({
      address: objectId,
      key: "_NFT_ACCESS",
      value: addressesString
    });

    if (this.debug){
      console.log("Set Policy response: ", res);
    }

    if (!ElvUtils.isTransactionSuccess(res)) {
      throw res2;
    }
  }

  /**
   * Gets the nft policy and permissions for a given contract
   *
   * @namedParams
   * @param {string} nftAddress - The NFT contract address. Can also be iqXXX format.
   * @return {object} object containing the policy string and the list of addresses from nft metadata
   */
  async NftGetPolicyAndPermissions({ address }) {

    if (address.startsWith("iq")){
      address =  Utils.HashToAddress(address);
    }

    let elvFabric = new ElvFabric({
      configUrl: this.configUrl,
      debugLogging: this.debug
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY
    });


    let policy = await elvFabric.GetContractMeta({
      address: address,
      key: "_ELV"
    });

    if (this.debug){
      console.log("Get _ELV response: ", policy);
    }

    let addressesString = await elvFabric.GetContractMeta({
      address: address,
      key: "_NFT_ACCESS"
    });

    if (this.debug){
      console.log("Get _NFT_ACCESS response: ", addressesString);
    }

    let addresses = [];
    try {
      addresses = JSON.parse(addressesString);
    } catch (e){
      if (this.debug){
        console.error("Couldn't parse _NFT_ACCESS response ", e);
      }
    }

    return {"policy": policy, "permissions": addresses};
  }

  /**
   * Sets the content object policy
   *
   * @namedParams
   * @param {string} objectId - The NFT object ID
   * @param {string} policyPath - Path to the content policy file (eg. policy.yaml).
   * @param {string} data - Metadata path within the policy object to link to
   */
  async ContentSetPolicy({ objectId, policyPath, data}) {
    console.log("ContentSetPolicy ", policyPath);
    let elvFabric = new ElvFabric({
      configUrl: this.configUrl,
      debugLogging: this.debug
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY,
      update:true
    });

    const policyString = fs.readFileSync(
      policyPath
    ).toString();

    if (!policyString){
      throw Error("Policy file contents is empty.");
    }

    //TODO: Parse and validate policy

    if (this.debug){
      console.log("Policy file contents: ", policyString);
    }

    let account = new ElvAccount({configUrl:this.configUrl, debugLogging: this.debug});
    account.InitWithClient({elvClient: this.client});
    var policyFormat = await ElvUtils.parseAndSignPolicy({policyString, data, configUrl:this.configUrl, elvAccount:account});

    if (this.debug){
      console.log("Policy format: ", policyFormat);
    }

    // Set policy in object metadata
    await elvFabric.setMeta({objectId,meta:policyFormat, merge:true});

    let contractResp = await this.ContentSetPolicyDelegate({objectId,delegateId:objectId});
    return contractResp;
  }

  /**
   * Sets the content object policy
   *
   * @namedParams
   * @param {string} objectId - The NFT object ID
   * @param {string} delegateId - Path to the content policy file (eg. policy.yaml).
   */
  async ContentSetPolicyDelegate({ objectId, delegateId}) {
    let elvFabric = new ElvFabric({
      configUrl: this.configUrl,
      debugLogging: this.debug
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY,
      update:true
    });
    var authContext = {"elv:delegation-id":delegateId};

    //Set delegate in object contract
    let res = await elvFabric.SetContractMeta({
      address: objectId,
      key: "_AUTH_CONTEXT",
      value: JSON.stringify(authContext)
    });

    return res;
  }

  /**
   * Get the content object policy from the Object metadata and the delegate from the Object's contract meta
   *
   * @namedParams
   * @param {string} objectId - The NFT object ID
   */
  async ContentGetPolicy({ objectId }) {
    let elvFabric = new ElvFabric({
      configUrl: this.configUrl,
      debugLogging: this.debug
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY,
      update:true
    });

    let contentPolicyResponse = await elvFabric.getMeta({objectId, select:"auth_policy"});

    //Get delegate from the object contract
    let delegateResp = await elvFabric.GetContractMeta({
      address: objectId,
      key: "_AUTH_CONTEXT"
    });

    return {contentPolicyResponse,delegateResp};
  }

  /**
 * Get the content object policy from the Object metadata and the delegate from the Object's contract meta
 *
 * @namedParams
 * @param {string} objectId - The NFT object ID
 */
  async ContentClearPolicy({ objectId }) {
    let elvFabric = new ElvFabric({
      configUrl: this.configUrl,
      debugLogging: this.debug
    });

    await elvFabric.Init({
      privateKey: process.env.PRIVATE_KEY,
      update:true
    });

    const libraryId = await this.client.ContentObjectLibraryId({objectId});

    const editResponse = await this.client.EditContentObject({
      libraryId,
      objectId
    });

    await elvFabric.client.DeleteMetadata({libraryId, objectId, writeToken:editResponse.write_token,metadataSubtree:"/auth_policy"});
    await this.client.FinalizeContentObject({
      libraryId,
      objectId,
      writeToken: editResponse.write_token
    });

    let deleteDelegateResp = await elvFabric.SetContractMeta({
      address: objectId,
      key: "_AUTH_CONTEXT",
      value: ""
    });

    return deleteDelegateResp;
  }

  /**
   * Create a new NFT contract and set it in the NFT Template object's metadata.
   *
   * @namedParams
   * @param {string} objectId - The ID of the NFT Template
   * @param {string} nftAddr - The NFT contract address (optional; by default create one)
   * @param {string} collectionName - Short name for the ERC-721 contract
   * @param {string} collectionSymbol - Short string for the ERC-721 contract
   * @param {string} hold - Hold period in seconds
   * @param {string} contractUri - URI for the ERC-721 contract
   * @param {string} totalSupply - the mint cap for this template (should be called 'cap')
   * @return {Promise<Object>} - An object containing info about the new NFT
   */
  async NftTemplateAddNftContract({
    objectId,
    tenantId,
    collectionName,
    collectionSymbol,
    hold,
    contractUri,
    totalSupply,
  }) {

    const nftInfo = await this.CreateNftContract({
      tenantId,
      totalSupply,
      collectionName,
      collectionSymbol,
      hold,
      contractUri,
    });

    const nftAddr = nftInfo.nftAddr;

    const libraryId = await this.client.ContentObjectLibraryId({objectId});

    // Update object metadata
    var m = await this.client.ContentObjectMetadata({
      libraryId,
      objectId,
      resolveLinks: false,
    });

    m.permissioned.mint_private.address = nftAddr;
    m.public.asset_metadata.nft.address = nftAddr;
    m.public.asset_metadata.nft.total_supply = totalSupply;
    m.public.asset_metadata.mint.mint_shuffle_key_id = nftInfo.mintShuffleKeyId;

    var e = await this.client.EditContentObject({
      libraryId,
      objectId,
    });

    await this.client.ReplaceMetadata({
      libraryId,
      objectId,
      writeToken: e.write_token,
      metadata: m,
    });

    await this.client.FinalizeContentObject({
      libraryId,
      objectId,
      writeToken: e.write_token,
      commitMessage: "Set NFT contract address " + nftAddr,
    });

    return nftAddr;
  }

  /**
   * Make the public/nft section based on asset metadata
   * Prerequisites:
   * - NFT contract set
   *
   * @namedParams
   * @param {Object} assetMetadata - The NFT Template asset metadata
   * @return {Promise<Object>} - The public/nft JSON
   */
  async NftMake({ assetMetadata, hash }) {
    const m = assetMetadata;
    var pnft = {};

    pnft.test = m.nft.test;
    pnft.name = m.nft.name;
    pnft.display_name = m.nft.display_name;
    pnft.description = m.nft.description; // + addtlInfo;
    pnft.edition_name = m.nft.edition_name;
    pnft.rich_text = m.nft.rich_text;

    pnft.address = m.nft.address;
    pnft.total_supply = m.nft.total_supply;
    pnft.template_id = m.nft.template_id;
    pnft.id_format = m.nft.id_format;

    pnft.copyright = m.nft.copyright;
    pnft.created_at = m.nft.created_at;
    pnft.creator = m.nft.creator;

    pnft.embed_url = m.nft.embed_url;
    pnft.external_url = m.nft.external_url;
    pnft.youtube_url = m.nft.marketplace_attributes.opensea.youtube_url;
    pnft.image = m.nft.image;
    pnft.playable = m.nft.playable;

    pnft.style = m.nft.style;

    let total_supply = pnft.total_supply.toString();

    // pnft.addtl_info = addtlInfo;

    pnft.attributes = [
      {
        trait_type: "Creator",
        value: "Eluvio NFT Central",
      },
      {
        trait_type: "Total Minted Supply",
        value: total_supply,
      },
      {
        trait_type: "Content Fabric Hash",
        value: hash,
      },
    ];

    return pnft;
  }

  /**
   * Read image and attributes info from a directory
   *
   * Required format:
   * - the directory should contain a list of '*.json' files (flat, not in a hierarchy)

   * Returns an array of objects containing:
   * - imgFile - file name (no path)
   * - imgFilePath - full path to source file
   * - attrs - attributes object (optional)
   *
   * @namedParams
   * @param {string} nftDir - the directory containing the nft json files
   * @return {Promise<Object>} - The 'images' object and calculated rarity
   */
  async readNftDir({ nftDir }) {
    let nftMetas = [];
    let files;
    let rarity = {};

    files = await fs.promises.readdir(nftDir);
    files.forEach(function (file) {
      // Only considering jpg files
      if (path.extname(file) == ".json") {
        let attrsBuf = fs.readFileSync(path.join(nftDir, file));
        let nftMeta = JSON.parse(attrsBuf);
        let count = 1;
        if (nftMeta.count && nftMeta.count > 1) {
          count = nftMeta.count;
        }

        for (var i = 0; i < count; i++) {
          // Calculate rarity
          if (nftMeta.attributes != null) {
            //console.log("attributes ", nftMeta.attributes);
            nftMeta.attributes.forEach((elem) => {
              // Fix up attributes - replace 'type' wit 'trait_type'
              if (elem.type != null) {
                elem.trait_type = elem.type;
                delete elem.type;
              }

              if (rarity[elem.trait_type]) {
                rarity[elem.trait_type].total =
                  rarity[elem.trait_type].total + 1;
              } else {
                rarity[elem.trait_type] = {};
                rarity[elem.trait_type].total = 1;
              }

              if (rarity[elem.trait_type][elem.value]) {
                rarity[elem.trait_type][elem.value] =
                  rarity[elem.trait_type][elem.value] + 1;
              } else {
                rarity[elem.trait_type][elem.value] = 1;
              }
            });
          }
          nftMetas.push(nftMeta);
        }
      }
    });

    return { nftMetas, rarity };
  }

  /**
   * Make a single element of the public/nfts section of a generative,
   * multi image/video token based on asset metadata and input parameters.
   * The public/nfts key is an array of objects, each equivalent to
   * the single NFT public/nft section.
   *
   * Prerequisites:
   * - NFT contract set
   *
   * @namedParams
   * @param {Object} assetMetadata - The NFT Template asset metadata
   * @param {string} hash - NFT Template hash
   * @param {string} imagePath - Local file path to the image
   * @param {Object} attrs - Extra attributes for this token
   * @param {Object} rarity - Stats for each trait and value
   * @return {Promise<Object>} - The public/nfts JSON array element
   */
  async NftMakeGenerative({ assetMetadata, hash, nftMeta, rarity, insertRarity = false }) {
    const m = assetMetadata;
    var pnft = {};

    pnft.test = m.nft.test;
    pnft.name = nftMeta.name || m.nft.name;
    pnft.display_name = nftMeta.display_name || m.nft.display_name;
    pnft.description = nftMeta.description || m.nft.description;
    pnft.edition_name = nftMeta.edition_name || m.nft.edition_name;
    pnft.rich_text = nftMeta.rich_text || m.nft.rich_text;

    pnft.address = m.nft.address;
    pnft.total_supply = m.nft.total_supply;
    pnft.template_id = m.nft.template_id;
    pnft.id_format = m.nft.id_format;

    pnft.copyright = m.nft.copyright;
    pnft.created_at = m.nft.created_at;
    pnft.creator = m.nft.creator;

    pnft.embed_url = nftMeta.embed_url;
    pnft.external_url = nftMeta.embed_url;
    pnft.youtube_url = nftMeta.embed_url;
    pnft.image = nftMeta.image;
    pnft.playable = m.nft.playable;

    pnft.style = m.nft.style;

    if (!pnft.total_supply) {
      throw Error("No Total supply found");
    }

    let total_supply = pnft.total_supply.toString();

    pnft.attributes = [
      {
        trait_type: "Creator",
        value: "Eluvio NFT Central",
      },
      {
        trait_type: "Total Minted Supply",
        value: total_supply,
      },
      {
        trait_type: "Content Fabric Hash",
        value: hash,
      },
    ];

    if (insertRarity) {
      // Insert rarity if doesn't exist
      for (const i in nftMeta.attributes) {
        if (nftMeta.attributes[i].rarity !== undefined){
          continue;
        }
        if (rarity && rarity[nftMeta.attributes[i].trait_type]) {
          let r = rarity[nftMeta.attributes[i].trait_type];
          nftMeta.attributes[i].rarity =
            r[nftMeta.attributes[i].value] + "/" + total_supply;
        }
      }
    }
    pnft.attributes = pnft.attributes.concat(nftMeta.attributes);

    return pnft;
  }

  /**
   * Set the public/nft section based on asset metadata
   *
   * For generative NFTs we use the following convention - nftDir must contain:
   * One or more json files with a '.json' extension (for example: nft001.json, nft002.json)
   * Example JSON File:
   *
   * {
   *   "count":3,                                   (OPTIONAL, Default: 1)
   *   "name": "Example NFT",                       (OPTIONAL, Default: from Content Object)
   *   "display_name": "Example NFT",               (OPTIONAL, Default: from Content Object)
   *   "description" : "This is an example NFT.",   (OPTIONAL, Default: from Content Object)
   *   "rich_text" : "",                            (OPTIONAL, Default: from Content Object)
   *   "image": "https://image003",
   *   "embed_url":"https://videoURL003",
   *   "attributes:":
   *   [
   *     {
   *       "trait_type":"trait01",
   *       "value": "test1",
   *       "rarity": 0.2                            (OPTIONAL, If not present, it will be calculated)
   *     }
   *   ]
   * }
   *
   * The 'count' is an optional parameter to generate copies of this nft element inside
   * the /public/nfts array
   *
   * All other optional keys (name, display_name, description, etc) will override the
   * NFT content object's value from /asset_metadata/nft if present.
   *
   * The required key 'attributes' is an array of objects {"trait_type": "", "value": ""}
   * and is used to calculate trait rarity. If rarity is already present in the attribute,
   * it will be used instead.
   *
   * @namedParams
   * @param {string} library ID
   * @param {string} hash - The NFT Template hash or id
   * @param {string} nftDir - Directory containing nft json file(s) for building nfts
   * @return {Promise<Object>} - The public/nft or public/nfts JSON
   */
  async NftBuild({ objectId, nftDir }) {
    var hash = await this.client.LatestVersionHash({
      objectId,
    });

    const libraryId = await this.client.ContentObjectLibraryId({objectId});

    var m = await this.client.ContentObjectMetadata({
      libraryId,
      objectId,
      metadataSubtree: "public/asset_metadata",
      resolveLinks: false,
    });

    var pnft;
    var pnfts = [];

    // Determine if this is a single or multi-image NFT

    if (nftDir && nftDir.length > 0) {
      // Generative NFT - build an nft array

      // Read image and attributes info from directory
      let { nftMetas, rarity } = await this.readNftDir({ nftDir });
      for (const nftMeta of nftMetas) {
        pnft = await this.NftMakeGenerative({
          assetMetadata: m,
          hash,
          nftMeta,
          rarity,
        });
        pnfts.push(pnft);
      }
    } else {
      // Single media NFT - build an nft object
      pnft = await this.NftMake({ assetMetadata: m, hash });
    }

    var e = await this.client.EditContentObject({
      libraryId,
      objectId,
    });

    if (nftDir && nftDir.length > 0) {
      // Replace the nft array
      await this.client.ReplaceMetadata({
        libraryId,
        objectId,
        writeToken: e.write_token,
        metadataSubtree: "public/nfts",
        metadata: pnfts,
      });
    } else {
      // Merge the single nft object
      await this.client.MergeMetadata({
        libraryId,
        objectId,
        writeToken: e.write_token,
        metadataSubtree: "public/nft",
        metadata: pnft,
      });
    }

    var f = await this.client.FinalizeContentObject({
      libraryId,
      objectId,
      writeToken: e.write_token,
      commitMessage: "Set NFT public/nft",
    });

    return f;
  }

  /**
   * Set the public/nft section based on asset metadata
   *
   * @namedParams
   * @param {string} addr - Local NFT contract address
   * @param {integer} tokenId - External NFT token ID
   * @return {Promise<Object>} - NFT info JSON
   */
  async NftLookup(/* addr, */ tokenId) {

    console.log("tokenId", tokenId);

    var x = new BigNumber(tokenId, 10);
    console.log(x.toString(16));
  }

  /**
   * Burn the specified NFT token as the owner
   *
   * @namedParams
   * @param {string} addr - Local NFT contract address
   * @param {integer} tokenId - External NFT token ID
   * @return {Promise<Object>} - NFT info JSON
   */
  async NftBurn({ addr, tokenId }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    await this.CheckIsOwner({addr, tokenId});

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "burn",
      methodArgs: [tokenId],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Burn the specified NFT token as a proxy owner
   *
   * @namedParams
   * @param {string} addr - Local NFT contract address
   * @param {integer} tokenId - External NFT token ID
   * @return {Promise<Object>} - NFT info JSON
   */
  // eslint-disable-next-line no-unused-vars
  async NftProxyBurn({ addr, tokenId }) {
    return "Sorry, not yet implemented.";
  }

  /**
   * Checks if the current signer address owns the NFT token. Throws error if not owner.
   *
   * @namedParams
   * @param {string} addr - Local NFT contract address
   * @param {integer} tokenId - External NFT token ID
   */
  async CheckIsOwner({ addr, tokenId}) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    let signerAddr = this.client.signer.address;

    var owner = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "ownerOf",
      methodArgs: [tokenId],
      formatArguments: true
    });

    if (signerAddr.toLowerCase() != owner.toLowerCase()){
      throw Error(`Not owner. (signer: ${signerAddr} owner: ${owner})`);
    }
  }

  /**
   * Transfer the specified NFT token as the owner through contract
   *
   * @namedParams
   * @param {string} addr - Local NFT contract address
   * @param {integer} tokenId - External NFT token ID
   * @return {Promise<Object>} - NFT info JSON
   */
  async NftTransfer({ addr, tokenId, toAddr }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    await this.CheckIsOwner({addr, tokenId});

    let fromAddr = this.client.signer.address;

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "safeTransferFrom",
      methodArgs: [fromAddr, toAddr, tokenId],
      formatArguments: true,
    });

    return res;
  }

  /**
   * Transfer the specified NFT token as the owner using Authority Service
   *
   * @namedParams
   * @param {string} addr - Local NFT contract address
   * @param {integer} tokenId - External NFT token ID
   * @return {Promise<Object>} - NFT info JSON
   */
  async AsNftTransfer({ addr, tokenId, toAddr }) {

    let body = {
      contract: addr,
      token: tokenId,
      to_addr: toAddr
    };

    let res = await this.PostServiceRequest({
      path: "/wlt/mkt/xfer",
      body,
      useFabricToken:true
    });

    return {status: res.status};
  }

  /**
   * Transfer an NFT as a proxy owner.
   *
   * @namedParams
   * @param {string} addr - The NFT contract address
   * @param {string} fromAddr - The current owner of the token
   * @param {string} toAddr - A user address to tranfer to
   * @param {integer} tokenId - The token ID
   * @return {Promise<Object>} - ?
   */
  async NftProxyTransferFrom({ addr, tokenId, fromAddr, toAddr }) {
    console.log("NFT Transfer", "from: ", fromAddr);
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );
    const pxabi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/TransferProxyRegistry.abi")
    );

    var ownerOf = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "ownerOf",
      methodArgs: [tokenId],
      formatArguments: true,
    });

    if (ownerOf.toLowerCase() != fromAddr.toLowerCase()) {
      console.log("Not owner", "(owner: " + ownerOf + ")");
      return;
    }

    const proxy = await this.NFTProxyAddress({ addr });

    console.log("Executing proxyTransferFrom");
    var res = await this.client.CallContractMethodAndWait({
      contractAddress: proxy,
      abi: JSON.parse(pxabi),
      methodName: "proxyTransferFrom",
      methodArgs: [addr, fromAddr, toAddr, tokenId],
      formatArguments: true,
    });

    return res;
  }

  async NFTProxyAddress({ addr }) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    const proxy = await this.client.CallContractMethod({
      contractAddress: addr,
      abi: JSON.parse(abi),
      methodName: "proxyRegistryAddress",
      formatArguments: true,
    });

    if (proxy == "0x0000000000000000000000000000000000000000") {
      throw "NFT has no proxy";
    }

    var proxyInfo = await this.ShowNftTransferProxy({ addr: proxy });
    if (proxyInfo.owner != this.client.signer.address) {
      throw `Bad key - not proxy owner (should be: ${proxyInfo.owner}`;
    }

    return proxy;
  }

  /**
   * Synchronize backend listings with fabric metadata for a specific tenant's NFT
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {integer} maxNumber - The address to mint to
   * @return {Promise<Object>} - The API Response containing list of Wallet Info
   */
  async NFTRefresh({ tenant, address}) {
    let res = await this.PutServiceRequest({
      path: urljoin("/mkt/refresh/", tenant, address)
    });
    return await res.json();
  }

  /**
   * Gets the baseTransferFee of the NFT Contract
   *
   * @namedParams
   * @param {string} address - The NFT contract address
   * @return {string} - The fee as a big number sring
   */
  async NftGetTransferFee({address}) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethod({
      contractAddress: address,
      abi: JSON.parse(abi),
      methodName: "baseTransferFee",
      formatArguments: true,
    });

    return Number(res);
  }

  /**
   * Sets the baseTransferFee of the NFT Contract
   *
   * @namedParams
   * @param {string} address - The NFT contract address
   * @param {string} fee - Fee in ETH to set as a big number string
   * @return {Promise<Object>} - The Contract transaction logs
   */
  async NftSetTransferFee({address, fee}) {
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/ElvTradableLocal.abi")
    );

    var res = await this.client.CallContractMethodAndWait({
      contractAddress: address,
      abi: JSON.parse(abi),
      methodName: "setBaseTransferFee",
      methodArgs: [fee],
      formatArguments: true,
    });

    return res;
  }

  async TenantSign({ message }) {
    const signature = await this.client.authClient.Sign(
      Ethers.utils.keccak256(Ethers.utils.toUtf8Bytes(message))
    );
    const multiSig = this.client.utils.FormatSignature(signature);
    return { signature, multiSig };
  }

  async PutServiceRequest({ path, body={}, headers = {}, queryParams={}, useFabricToken=false }) {
    return await this.TenantAuthServiceRequest({path, method: "PUT", queryParams, body, headers, useFabricToken});
  }

  async PostServiceRequest({ path, body={}, headers = {}, queryParams={}, useFabricToken=false }) {
    return await this.TenantAuthServiceRequest({path, method: "POST", queryParams, body, headers, useFabricToken});
  }

  async GetServiceRequest({ path, queryParams={}, headers = {}}) {
    return await this.TenantPathAuthServiceRequest({path, method:"GET", queryParams, headers});
  }

  async DeleteServiceRequest({ path, queryParams={}, headers = {}}) {
    return await this.TenantPathAuthServiceRequest({path, method:"DELETE", queryParams, headers});
  }

  async TenantAuthServiceRequest({ path, method, queryParams={}, body={}, headers = {}, useFabricToken=false }) {
    if (!body) {
      body = {};
    }
    let ts = Date.now();
    body.ts = ts;

    let token = "";
    if ( useFabricToken ) {
      token = await this.client.CreateFabricToken({
        duration:ElvAccount.TOKEN_DURATION
      });

    } else {
      const { multiSig } = await this.TenantSign({
        message: JSON.stringify(body),
      });
      token = multiSig;
    }

    let res;

    path = urljoin(this.asUrlPath, path);

    res = await this.client.authClient.MakeAuthServiceRequest({
      method,
      path,
      body,
      headers: {
        Authorization: `Bearer ${token}`,
        ...headers,
      },
      queryParams,
    });
    return res;
  }

  /**
   * Authority Service API request using path auth. Typically used for GET and DELETE
   *
   * @namedParams
   * @param {string} path - The request endpoint
   * @param {string} method - The rquest method
   * @param {Object} queryParams - The query parameters
   * @param {Object} headers - The headers
   * @return {Promise<Object>} - The API Response
   */
  async TenantPathAuthServiceRequest({ path, method, queryParams={}, headers = {}}) {
    let ts = Date.now();
    let params = { ts, ...queryParams };
    const paramString = new URLSearchParams(params).toString();

    var newPath = path + "?" + paramString;

    const { multiSig } = await this.TenantSign({
      message: newPath,
    });

    if (this.debug) {
      console.log(`Authorization: Bearer ${multiSig}`);
    }

    let res = {};

    path = urljoin(this.asUrlPath, path);

    res = await this.client.authClient.MakeAuthServiceRequest({
      method,
      path,
      headers: {
        Authorization: `Bearer ${multiSig}`,
        ...headers,
      },
      queryParams: { ts, ...queryParams },
    });

    return res;
  }

  /**
   * Mint an NFT using Tenant Auth
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {string} marketplace - Marketplace ID of the NFT
   * @param {string} sku - SKU of the NFTs
   * @param {string} addr - The address to mint to
   * @return {Promise<Object>} - API Response Object
   */
  async TenantMint({ tenant, marketplace, sku, addr, quantity = 1 }) {
    let now = Date.now();

    /* body can optionally include:
     * - ident: ""
     * - email: ""
     * - cust_name: ""
     */
    let body = {
      trans_id: "",
      tickets: null,
      products: [
        {
          prod_name: "",
          sku: sku,
          quant: quantity,
        },
      ],
      ts: now,
      extra: {
        elv_addr: addr,
      },
    };

    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/trans/base/", tenant, marketplace),
      body,
    });
    return res;
  }

  /**
   * Get the list of wallets bound by the Tenant
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {integer} maxNumber - The address to mint to
   * @return {Promise<Object>} - The API Response containing list of Wallet Info
   */
  async TenantWallets({ tenant, maxNumber = Number.MAX_SAFE_INTEGER }) {
    if (maxNumber < 1) {
      maxNumber = Number.MAX_SAFE_INTEGER;
    }

    let res = await this.GetServiceRequest({
      path: urljoin("/tnt/wlt/", tenant),
      queryParams: { limit: maxNumber },
    });
    return await res.json();
  }

  /**
   * Generate tickets (NTP) as a tenant.
   *
   * Tickets may be bound to emails - in this case, the list of emails has to be supplied
   * as an argument, and the 'quantity' is no longer allowed.
   *
   * Note that technically tickets can be bound to any user identifier. Emails are the most
   * common but other IDs such as usernames or oauth/openid subject IDs are also used, so
   * there is no required format for the strings in the file.
   *
   * Optionally creates embed URLs for each ticket code generated, based on a template URL
   * that contains all required embed URL parameters except for the ticket and optionally email.
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {string} otp - The OTP ID
   * @param {integer} otpClass - The OTP class (4 or 5) (default 5)
   * @param {integer} quantity - Number of tickets to generate
   * @param {string} emails - File containing one email per line
   * @param {string} embedUrlBase - Base embed URL, used to generate embed URLs for each ticket
   * @return {Promise<Object>} - Tickets API Response Object
   */
  async TenantTicketsGenerate({ tenant, otp, otpClass = 5, host, quantity, emails, embedUrlBase }) {
    let now = Date.now();

    // Make embed URL from base URL by adding code and optional email
    function makeEmbedUrl({code, email, embedUrlBase}) {
      // Make embed URL from base URL
      let u = new URL(embedUrlBase);
      const code64 = Buffer.from(code).toString("base64");
      u.searchParams.append("tk", code64);
      if (email) {
        const email64 = Buffer.from(email).toString("base64");
        u.searchParams.append("sbj", email64);
      }
      const embedUrl = u.href;
      return embedUrl;
    }

    // Validation - either emails or quantity may be specified
    if (emails && quantity) {
      throw "Only one of parameters emails and quantity may be specified";
    }
    if (!emails && !quantity) {
      quantity = 1;
    }

    if (otpClass && otpClass == 4) {

      var emailList = [];
      if (emails) {
        const emailsBuf = fs.readFileSync(emails, "utf-8");
        emailsBuf.split(/\r?\n/).forEach((line) => {
          if (line) {
            emailList.push(line);
          }
        });
        quantity = emailList.length;
      }

      let codes = [];
      for (let i = 0; i < quantity; i ++) {
        try {
          let code = await this.client.IssueNTPCode({
            tenantId: tenant,
            ntpId: otp,
            email: emails ? emailList[i] : ""
          });
          if (emails) code.email = emailList[i];
          if (embedUrlBase) {
            code.embed_url = makeEmbedUrl({code: code.token, email: code.email, embedUrlBase});
          }
          codes.push(code);
        } catch (e) {
          throw new Error("Failed to issue ticket code - " + e.message);
        }
      }
      return codes;
    }

    // Validation - currently 'emails' and 'embed_url_base' only work for class 4
    if (emails || embedUrlBase) {
      throw "Parameters emails and embed URL base require class 4";
    }

    // NTP class 5 (authd)
    let body = {
      tickets: {
        quantity: quantity,
      },
      ts: now
    };

    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/tix", tenant, otp),
      host,
      body,
    });
    return await res.json();

  }


  /**
   * Get the list of Tenant marketplaces/sites from the Main Live Object
   * No Tenant ID or Tenant Slug will return all tenants.
   *
   * @namedParams
   * @param {string} tenantId - The Tenant ID (Optional).
   * @param {string} tenantSlug - The Tenant ID (Optional). No Tenant ID will return all tenants.
   * @return {Promise<Object>} - List or single Tenant Info
   */
  async List({ tenantId, tenantSlug }) {
    let results = {};

    let objectId = this.mainObjectId;

    const libraryId = await this.client.ContentObjectLibraryId({
      objectId,
    });

    let staticToken = await this.client.authClient.AuthorizationToken({
      libraryId,
      channelAuth: false,
      noAuth: true,
    });

    //Create a new client using only staticToken
    let client = await ElvClient.FromConfigurationUrl({
      configUrl: this.configUrl,
      staticToken,
    });

    let warns = [];
    let meta = {};
    let metadataSubtree = "/public/asset_metadata/tenants";

    meta = await client.ContentObjectMetadata({
      libraryId,
      objectId,
      metadataSubtree,
      resolveLinks: true,
      resolveIncludeSource: true,
      resolveIgnoreErrors: true,
      linkDepthLimit: 5,
    });

    if (tenantSlug) {
      results = meta[tenantSlug];
    } else if (tenantId) {
      let tenants = meta || {};

      for (const index in tenants) {
        try {
          let tenantObj = tenants[index];
          for (var key in tenantObj.marketplaces) {
            let marketplace = tenantObj.marketplaces[key];
            if (marketplace && marketplace.info) {
              let testTenantId = marketplace.info.tenant_id;
              if (testTenantId === tenantId) {
                results = tenantObj;
                break;
              }
            }
          }
        } catch (e) {
          warns.push(`Error reading tenant: ${index} ${e}`);
        }
      }
    } else {
      results.tenants = meta;
    }

    results.warns = warns;

    return results;
  }

  /**
   * Get the list of domains from the Main Live Object info/domains_map
   *
   * @return {Promise<Object>} - List of domain objects
   */
  async Domains() {
    let objectId = this.mainObjectId;

    const libraryId = await this.client.ContentObjectLibraryId({
      objectId,
    });

    let staticToken = await this.client.authClient.AuthorizationToken({
      libraryId,
      channelAuth: false,
      noAuth: true,
    });

    let client = await ElvClient.FromConfigurationUrl({
      configUrl: this.configUrl,
      staticToken,
    });

    let metadataSubtree = "/public/asset_metadata/info/domain_map";
    const meta = await client.ContentObjectMetadata({
      libraryId,
      objectId,
      metadataSubtree,
      resolveLinks: true,
      resolveIncludeSource: true,
      resolveIgnoreErrors: true,
      linkDepthLimit: 5,
    });

    return meta;
  }

  /**
   * create account for tenant
   *
   * @namedParams
   * @param {string} email - The email to create, or @filename to read a list of emails from file
   * @param {string} tenant - The Tenant ID
   * @param {string} callbackUrl - The base URL info for the link in the email
   * @param {boolean} onlyCreateAccount - only create the account
   * @param {boolean} onlySendEmail - only send email, do not create the account
   * @param {string} scheduleAt - when to schedule the email
   * @return {Promise<Object>} - The API Response containing created account info
   */
  async CreateWalletAccount({ email, tenant, callbackUrl, onlyCreateAccount, onlySendEmail, scheduleAt}) {
    let headers = {};
    let res = "";
    console.log("email", email, "tenant", tenant, "callbackUrl", callbackUrl,
      "onlyCreateAccount", onlyCreateAccount, "onlySentEmail", onlySendEmail, "scheduleAt", scheduleAt);

    // if email is a file, read the file
    if (email.startsWith("@")) {
      let emails = fs.readFileSync(email.slice(1), "utf-8").split(/\r?\n/);
      let urlPath = "/wlt/ory/create_bulk_accounts";
      res = await this.PostServiceRequest({
        path: urlPath,
        body: {
          emails,
          tenant,
          "callback_url": callbackUrl,
          "only_create_account": onlyCreateAccount,
          "only_send_email": onlySendEmail,
          "schedule_at": scheduleAt
        },
        headers,
      });
    } else {
      let urlPath = "/wlt/ory/create_account";
      res = await this.PostServiceRequest({
        path: urlPath,
        body: {
          email,
          tenant,
          "callback_url": callbackUrl,
          "only_create_account": onlyCreateAccount,
          "only_send_email": onlySendEmail,
          "schedule_at": scheduleAt
        },
        headers,
      });
    }

    return await res.json();
  }

  /**
   * Get primary sales history for the tenant
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {string} marketplace - The marketplace ID
   * @return {Promise<Object>} - The API Response containing primary sales info
   */
  async TenantPrimarySales({ tenant, marketplace, processor, csv, offset, admin = false }) {
    let headers = {};
    let toJson = true;
    if (csv && csv != "") {
      headers = { Accept: "text/csv" };
      toJson = false;
    }

    let urlPathPrefix = admin ? "/adm/purchases" : "/tnt/purchases/";

    let res = await this.GetServiceRequest({
      path: urljoin(urlPathPrefix, tenant, marketplace),
      queryParams: { offset, processor },
      headers,
    });

    return toJson ? await res.json() : await res.text();
  }

  /**
   * Get secondary sales history for the tenant
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response containing primary sales info
   */
  async TenantSecondarySales({ tenant, processor, csv, offset }) {
    let headers = {};
    let toJson = true;
    if (csv && csv != "") {
      headers = { Accept: "text/csv" };
      toJson = false;
    }

    let res = await this.GetServiceRequest({
      path: urljoin("/tnt/payments/", tenant),
      queryParams: { offset, processor },
      headers
    });

    return toJson ? await res.json() : await res.text();
  }

  /**
   * Get unified primary&secondary sales history for the tenant
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response containing primary sales info
   */
  async TenantUnifiedSales({ tenant, processor, csv, offset }) {
    let headers = {};
    let toJson = true;
    if (csv && csv != "") {
      headers = { Accept: "text/csv" };
      toJson = false;
    }

    let res = await this.GetServiceRequest({
      path: urljoin("/tnt/report/", tenant),
      queryParams: { offset, processor },
      headers
    });

    return toJson ? await res.json() : await res.text();
  }

  /**
   * Fetch session data from the analytics API for a tenant
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {int} start_ts - Start timestamp in seconds since epoch
   * @param {int} end_ts - End timestamp in seconds since epoch
   * @return {Promise<Object>} - The API Response containing session csv info
   */
  async TenantSessionsCsv({ tenant, start_ts, end_ts }) {
    const method = "POST";
    const token = await this.client.CreateFabricToken({
      duration: ElvAccount.TOKEN_DURATION
    });
    const headers = {
      Accept: "text/csv",
      Authorization: `Bearer ${token}`
    };
    const net = this.client.networkName.replace("demov3", "dv3");

    const path = `https://appsvc.svc.eluv.io/casa/${net}/${tenant}/sessions`;
    const queryParams = {
      offset: 0,
      start_ts: start_ts,
      end_ts: end_ts,
      limit: 1000000,
      srt: false
    };
    const body = {};

    const httpClient = new HttpClient({uris: path});
    const res = await httpClient.Request({
      method,
      path,
      body,
      headers,
      queryParams
    });

    return await res.text();
  }

  async TenantAddConsumers({groupId, accountAddresses}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantConsumerGroup.abi")
    );
    const address = Utils.HashToAddress(groupId);

    var response = await this.client.CallContractMethodAndWait({
      contractAddress: address,
      abi: JSON.parse(abi),
      methodName: "grantAccessMany",
      methodArgs: [accountAddresses],
      formatArguments: true,
    });

    return response;
  }


  async TenantHasConsumer({groupId, accountAddress}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantConsumerGroup.abi")
    );
    const address = Utils.HashToAddress(groupId);

    var response = await this.client.CallContractMethod({
      contractAddress: address,
      abi: JSON.parse(abi),
      methodName: "hasAccess",
      methodArgs: [accountAddress],
      formatArguments: true,
    });

    return response;
  }

  async TenantRemoveConsumer({groupId, accountAddress}){
    const abi = fs.readFileSync(
      path.resolve(__dirname, "../contracts/v3/BaseTenantConsumerGroup.abi")
    );
    const address = Utils.HashToAddress(groupId);

    var response = await this.client.CallContractMethodAndWait({
      contractAddress: address,
      abi: JSON.parse(abi),
      methodName: "revokeAccess",
      methodArgs: [accountAddress],
      formatArguments: true,
    });

    return response;
  }

  /**
   * Get failed transfer report for the tenant. Used to identify payments collected on failed transfers.
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response containing the failed transfers report
   */
  async TenantTransferFailures({ tenant }) {
    let res = await this.GetServiceRequest({
      path: urljoin("/tnt/transfers/failed/", tenant),
    });
    return await res.json();
  }

  /**
   * Deploy minter helper contract using the authority service
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response for the request
   */
  async TenantDeployHelperContracts({ tenant, host, proxy=false, mintHelper=false }) {
    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/config", tenant, "deploy"),
      host,
      queryParams: {proxy,mint_helper:mintHelper}
    });
    return res;
  }

  /**
   * Get minter configuration from authority service
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response for the tenant's minter configuration
   */
  async TenantGetMinterConfig({ tenant, host }) {
    let res = await this.GetServiceRequest({
      path: urljoin("/tnt/config", tenant, "minter"),
      host
    });
    return res.json();
  }

  /**
   * Create minter configuration using the authority service
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response for the request
   */
  async TenantCreateMinterConfig({ tenant, host, funds=0, deploy=false }) {
    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/config", tenant, "minter"),
      host
    });

    let tenantConfigResult = await res.json();

    if (this.debug){
      console.log("Create response: ", tenantConfigResult);
    }

    if (tenantConfigResult.errors && tenantConfigResult.errors.length != 0){
      return res;
    }

    if (funds > 0){
      console.log ("Funding minter and proxy addresses.");
      let minterAddress = tenantConfigResult.config.minter_address;

      let account = new ElvAccount({configUrl:this.configUrl, debugLogging: this.debug});
      account.InitWithClient({elvClient: this.client});

      await account.Send({
        address: minterAddress,
        funds
      });

      console.log("Funds Sent to minter address: ", minterAddress);

      let proxyAddress = tenantConfigResult.config.proxy_owner_address;

      await account.Send({
        address: proxyAddress,
        funds
      });

      console.log("Funds Sent to proxy address: ", proxyAddress);
    }

    if (deploy){
      console.log("Deploying helper contracts");
      res = await this.TenantDeployHelperContracts({tenant, host});
      return await res.json();
    } else {
      return tenantConfigResult;
    }
  }

  /**
   * Replaces minter configuration using the authority service
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response for the request
   */
  async TenantReplaceMinterConfig({ tenant, host, proxyOwner, minter, mintHelper, proxy, mintShuffleKey, legacyShuffleSeed, purge=false}) {
    let res = await this.PutServiceRequest({
      path: urljoin("/tnt/config", tenant, "minter"),
      host,
      queryParams: {
        proxyowner:proxyOwner,
        minter,
        mint_helper: mintHelper,
        proxy,
        mint_shuffle_key: mintShuffleKey,
        legacy_shuffle_seed: legacyShuffleSeed,
        purge
      }
    });
    return res.json();
  }

  /**
   * Delete minter configuration using the authority service
   *
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @return {Promise<Object>} - The API Response for the request
   */
  async TenantDeleteMinterConfig({ tenant, host, force=false }) {
    let res = await this.DeleteServiceRequest({
      path: urljoin("/tnt/config", tenant, "minter"),
      host,
      queryParams: {force}
    });
    return res;
  }

  /**
   * Submits a new content version hash of this account's Tenant Object for updating to the Eluvio Live Tree.
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {string} host - Authority Service url (Optional)
   * @param {string} contentHash - Version hash of the new Tenant Object to submit
   * @param {boolean} updateLinks - True to update links
   * @param {string} env - Environment to update -- "production" or "staging", default production
   * @return {Promise<Object>} - The API Response for the request
   */
  async TenantPublishData({tenant, host, contentHash, updateLinks=false, env="production"}) {
    let latestVersionHash = contentHash;
    if (updateLinks){
      let objectId = this.client.utils.DecodeVersionHash(contentHash).objectId;
      let libraryId = await this.client.ContentObjectLibraryId({objectId});
      await this.client.UpdateContentObjectGraph({libraryId,objectId});

      latestVersionHash = await this.client.LatestVersionHash({objectId});
      contentHash = latestVersionHash;
      if (this.debug) {
        console.log("Submitting latest version hash:", contentHash, "objectId:", objectId, "libraryId:", libraryId);
      }
    }

    let tenantConfigResult = null;

    var body = {
      content_hash: contentHash,
      env: env,
    };

    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/config", tenant, "metadata"),
      host,
      body
    });

    tenantConfigResult = await res.json();

    if (this.debug){
      console.log("Create response: ", tenantConfigResult);
    }

    return {response:tenantConfigResult};
  }

  /**
   * Submits info to authd about the existence of a private (non-media-wallet-enabled) tenant.
   * The key reason for this is to allow the tenant to get its DB tables initialized.
   * @namedParams
   * @param {string} tenant - The Tenant ID
   * @param {string} host - Authority Service url (Optional)
   * @param {string} env - Environment to update -- "production" or "staging", default production
   * @return {Promise<Object>} - The API Response for the request
   */
  async TenantPublishPrivate({tenant, env="production"}) {
    var body = {
      env: env,
    };

    let res = await this.PostServiceRequest({
      path: urljoin("/tnt/config", tenant, "metadata"),
      queryParams: {media_wallet:false},
      body
    });

    let tenantConfigResult = await res.json();
    if (this.debug){
      console.log("Create response: ", tenantConfigResult);
    }

    if (tenantConfigResult.errors && tenantConfigResult.errors.length !== 0){
      throw Error(`error: ${tenantConfigResult}`);
    }
    return {response:tenantConfigResult};
  }

  /**
   * Pack - retrieve pack spec and pack distribtion from an NFT Template
   * using authd tenant-authenticated pack APIs.
   * The spec and distribition are saved in JSON files.
   *
   * @namedParams
   * @param {string} versionHash - NFT Template object hash
   * @return {object} - file names where spec and dist are saved
   */
  async NftPackGetDist({versionHash}) {

    const sf = "pack_spec." + versionHash + ".json";
    const df = "pack_dist." + versionHash + ".json";

    // Get pack 'spec'
    let res = await this.GetServiceRequest({
      path: urljoin("/nft/pack/spec", versionHash),
    });
    let spec = await res.json();
    fs.writeFileSync(sf, JSON.stringify(spec));

    // Get pack 'dist'
    res = await this.GetServiceRequest({
      path: urljoin("/nft/pack/dist", versionHash),
    });
    let dist = await res.json();
    fs.writeFileSync(df, JSON.stringify(dist));

    return {
      "spec_file": sf,
      "dist_file": df
    };
  }

  /**
   * Pack - set a previously created pack distribution in pack object metadata
   *
   * @namedParams
   * @param {string} versionHash - NFT Template content hash
   * @return {Promise<Object>} - The new object info
   */
  async NftPackSetDist({versionHash}) {

    let objectId = this.client.utils.DecodeVersionHash(versionHash).objectId;
    let libraryId = await this.client.ContentObjectLibraryId({objectId});

    const df = "pack_dist." + versionHash + ".json";
    let distBuf = fs.readFileSync(df);
    let dist = JSON.parse(distBuf);

    const e = await this.client.EditContentObject({
      libraryId,
      objectId
    });

    await this.client.ReplaceMetadata({
      libraryId,
      objectId,
      writeToken: e.write_token,
      metadataSubtree: "/public/asset_metadata/nft/pack_dist",
      metadata: dist.pack_dist
    });

    await this.client.ReplaceMetadata({
      libraryId,
      objectId,
      writeToken: e.write_token,
      metadataSubtree: "/public/asset_metadata/nft/pack_spec",
      metadata: dist.pack_spec
    });

    let res = await this.client.FinalizeContentObject({
      libraryId,
      objectId,
      writeToken: e.write_token,
      commitMessage: "Set pack_dist"
    });

    return res;
  }

  /**
   * Get Admin API status from Authority Service
   *
   * @namedParams
   * @param {string} host - Authority Service url (Optional)
   * @return {Promise<Object>} - The API Response for the adm/health api
   */
  async AdminHealth() {
    let res = await this.GetServiceRequest({
      path: "/adm/health"
    });
    return res.text();
  }

  FilterTenant({ object }) {
    let result = {};
    result.marketplaces = object.marketplaces;
    result.sites = object.sites;
    return result;
  }

  FilterMarketplace({ object }) {
    let result = {};
    let warns = [];

    result.title = object.title;

    result.tenant_id = object.info.tenant_id || null;
    if (!result.tenant_id) {
      warns.push(`No tenant_id for ${object.title}`);
    }

    result.items = object.info.items || null;
    if (!result.items || result.items.length === 0) {
      warns.push(`No Items found for ${object.title}`);
    }

    return { result, warns };
  }

  FilterNft({ object }) {
    let result = {};
    let warns = [];
    result.title = object.nft_template.title;

    result.sku = object.sku || null;
    if (!result.sku) warns.push(`No sku for ${object.title}`);

    result.address = object.nft_template.nft.address;
    if (!result.address) warns.push(`No address for ${object.title}`);

    result.version_hash = object.nft_template["."].source;
    if (!result.version_hash) warns.push(`No versionHash for ${object.title}`);

    return { result, warns };
  }

  FilterSite({ object }) {
    let result = {};
    let warns = [];

    result.title = object.title || null;

    result.tenant_id = object.info.tenant_id || null;
    if (!result.tenant_id) warns.push(`No tenant_id for ${object.title}`);

    result.marketplace_slug = object.info.marketplace_slug || null;
    if (!result.marketplace_slug)
      warns.push(`No marketplace_slug for ${object.title}`);

    return { result, warns };
  }
}

exports.EluvioLive = EluvioLive;