utils.js

import chalk from "chalk";
import { StacksMainnet } from "@stacks/network";
import {
  callReadOnlyFunction,
  cvToJSON,
  cvToValue,
  standardPrincipalCV,
  uintCV,
} from "@stacks/transactions";

export const title = chalk.bold.blue;
export const success = chalk.bold.green;
export const warn = chalk.bold.yellow;
export const err = chalk.bold.red;

/** @module Utilities */

/**
 * @constant
 * @type {integer}
 * @description used to convert uSTX to STX and reverse
 * @default
 */
export const USTX = 1000000;

/**
 * @constant
 * @type {StacksNetwork}
 * @description default Stacks network to connect to
 * @default
 */
export const STACKS_NETWORK = new StacksMainnet();
//STACKS_NETWORK.coreApiUrl = "http://157.245.221.74:3999";
STACKS_NETWORK.coreApiUrl = "https://stacks-node-api.stacks.co";
//STACKS_NETWORK.coreApiUrl = "https://mainnet.syvita.org";

/**
 * @async
 * @function safeFetch
 * @param {string} url URL to fetch JSON content from
 * @description Returns the JSON content from the specified URL
 * @returns {Object[]} JSON object
 */
export async function safeFetch(url) {
  // wrapper to handle common errors for fetching from the API
  const response = await fetch(url);
  if (response.status === 200) {
    // success
    const responseJson = await response.json();
    return responseJson;
  } else {
    // error
    exitWithError(`safeFetch err: ${response.status} ${response.statusText}`);
  }
}

/**
 * @async
 * @function timer
 * @param {integer} ms number of milliseconds
 * @description Sleeps for the given amount of milliseconds
 */
export const timer = (ms) => new Promise((res) => setTimeout(res, ms));

/**
 * @function cancelPrompt
 * @param {Object[]} prompt An object that contains the current prompt displayed to the user
 * @description Catches a cancel event in prompts, sets the message, and exits the AutoMiner
 */
export const cancelPrompt = (prompt) => {
  exitWithError(`ERROR: cancelled by user at ${prompt.name}, exiting...`);
};

/**
 * @async
 * @function processTx
 * @param {TxBroadcastResult} broadcastedResult result from broadcastTransaction() in @stacks/transactions
 * @param {string} tx the txid of the transaction
 * @description Monitors a transaction and returns the block height it succeeds at
 * @returns {integer}
 */
export async function processTx(broadcastedResult, tx) {
  let count = 0;
  const countLimit = 50;
  const url = `${STACKS_NETWORK.coreApiUrl}/extended/v1/tx/${tx}`;

  do {
    const result = await fetch(url);
    const txResult = await result.json();

    printDivider();
    console.log(
      title(
        `TX STATUS: ${
          txResult.hasOwnProperty("tx_status")
            ? txResult.tx_status.toUpperCase()
            : "PENDING"
        }`
      )
    );
    printDivider();
    printTimeStamp();
    console.log(`https://explorer.stacks.co/txid/${txResult.tx_id}`);
    console.log(`attempt ${count + 1} of ${countLimit}`);

    if (broadcastedResult.error) {
      console.log(`error: ${broadcastedResult.reason}`);
      console.log(`details:\n${JSON.stringify(broadcastedResult.reason_data)}`);
      return 0;
    } else {
      if (txResult.tx_status === "success") {
        return txResult.block_height;
      }
      if (txResult.tx_status === "abort_by_post_condition") {
        exitWithError(
          `tx failed, exiting...\ntxid: ${txResult.tx_id}\nhttps://explorer.stacks.co/txid/${txResult.tx_id}`
        );
      }
    }
    // pause for 30min before checking again
    await timer(300000);
    count++;
  } while (count < countLimit);

  console.log(warning(`reached retry limit, check tx`));
  console.log(`https://explorer.stacks.co/txid/${txResult.tx_id}`);
  exitWithError(
    "Unable to find target block height for next transaction, exiting..."
  );
}

/**
 * @async
 * @function getBlockHeight
 * @description Returns the current Stacks block height
 * @returns {integer}
 */
export async function getBlockHeight() {
  const url = `${STACKS_NETWORK.coreApiUrl}/v2/info`;
  const result = await fetch(url);
  const resultJson = await result.json();
  return resultJson.stacks_tip_height;
}

/**
 * @async
 * @function getStxBalance
 * @param {string} address STX address to query
 * @description Returns the current STX balance of a given address
 * @returns {integer}
 */
export async function getStxBalance(address) {
  const url = `${STACKS_NETWORK.coreApiUrl}/extended/v1/address/${address}/balances`;
  const result = await fetch(url);
  const resultJson = await result.json();
  return resultJson.stx.balance;
}

/**
 * @async
 * @function getNonce
 * @param {string} address STX address to query
 * @description Returns the current nonce for the given address
 * @returns {integer}
 */
export async function getNonce(address) {
  const url = `${STACKS_NETWORK.coreApiUrl}/v2/accounts/${address}?proof=0`;
  const result = await safeFetch(url);
  return result.nonce;
}

/**
 * @async
 * @function getTotalMempoolTx
 * @description Returns the total number of transactions in the mempool
 * @returns {integer}
 */
export async function getTotalMempoolTx() {
  const url = `${STACKS_NETWORK.coreApiUrl}/extended/v1/tx/mempool`;
  const result = await fetch(url);
  const resultJson = await result.json();
  return resultJson.total;
}

/**
 * @async
 * @function getAccountTxs
 * @param {string} address STX address to query
 * @description Returns all account transactions for a given address or contract identifier
 * @returns {Array[]}
 */
export async function getAccountTxs(address) {
  let counter = 0;
  let total = 0;
  let limit = 50;
  let url = "";
  let txResults = [];

  // bonus points if you use your own node
  let stxApi = "https://stacks-node-api.mainnet.stacks.co";

  console.log(`getting txs for: ${address}`);

  // obtain all account transactions 50 at a time
  do {
    url = `${stxApi}/extended/v1/address/${address}/transactions?limit=${limit}&offset=${counter}`;
    const response = await fetch(url);
    if (response.status === 200) {
      // success
      const responseJson = await response.json();
      // get total number of tx
      if (total === 0) {
        total = responseJson.total;
        console.log(`Total Txs: ${total}`);
      }
      // add all transactions to main array
      responseJson.results.map((tx) => {
        txResults.push(tx);
        counter++;
      });
      // output counter
      console.log(`Processed ${counter} of ${total}`);
    } else {
      // error
      exitWithError(
        `getAccountTxs err: ${response.status} ${response.statusText}`
      );
    }
    // pause for 1sec, avoid rate limiting
    await timer(1000);
  } while (counter < total);

  // view the output
  //console.log(JSON.stringify(txResults));

  return txResults;
}

/**
 * @async
 * @function getOptimalFee
 * @param {integer} multiplier Mulitiplier for mempool average
 * @param {boolean} [checkAllTx=false] Boolean to check all transactions in mempool
 * @description Averages the fees for the first 200 transactions in the mempool, or optionally all transactions, and applies a multiplier
 * @returns {integer} Optimal fee in uSTX
 */
export async function getOptimalFee(multiplier, checkAllTx = false) {
  let counter = 0;
  let total = checkAllTx ? 0 : 200;
  let limit = 200;
  let url = "";
  let txResults = [];

  // query the stacks-node for multiple transactions
  do {
    url = `${STACKS_NETWORK.coreApiUrl}/extended/v1/tx/mempool?limit=${limit}&offset=${counter}&unanchored=true`;
    const result = await safeFetch(url);
    // get total number of tx
    if (total === 0) {
      total = result.total;
    }
    // add all transactions to main array
    result.results.map((tx) => {
      txResults.push(tx);
      counter++;
    });
    // output counter
    checkAllTx && console.log(`Processed ${counter} of ${total}`);
  } while (counter < total);

  const max = txResults
    .map((fee) => parseInt(fee.fee_rate))
    .reduce((a, b) => {
      return a > b ? a : b;
    });
  console.log(`maxFee: ${(max / USTX).toFixed(6)} STX`);
  const sum = txResults
    .map((fee) => parseInt(fee.fee_rate))
    .reduce((a, b) => a + b, 0);
  const avg = sum / txResults.length;
  console.log(`avgFee: ${(avg / USTX).toFixed(6)} STX`);

  return avg * multiplier;
}

/**
 * @async
 * @function getBlockCommit
 * @param {Object[]} userConfig An object that contains the user configuration
 * @param {Object[]} miningStrategy An object that contains properties for automatically calculating a commit
 * @description Returns a target block commit based on provided user config and mining strategy
 * @returns {integer}
 */
export async function getBlockCommit(userConfig, miningStrategy) {
  console.log(`strategyDistance: ${miningStrategy.strategyDistance}`);
  // get current block height
  const currentBlock = await getBlockHeight().catch((err) =>
    exitWithError(`getBlockHeight err: ${err}`)
  );
  // get average block commit for past blocks based on strategy distance
  const avgPast = await getBlockAvg(
    -1,
    currentBlock,
    miningStrategy,
    userConfig
  ).catch((err) => exitWithError(`getBlockAvg err: ${err}`));
  console.log(`avgPast: ${(avgPast / USTX).toFixed(6)} STX`);
  const commitPast = parseInt(
    avgPast * (miningStrategy.targetPercentage / 100)
  );
  // get average block commit for future blocks based on strategy distance
  const avgFuture = await getBlockAvg(
    1,
    currentBlock,
    miningStrategy,
    userConfig
  ).catch((err) => exitWithError(`getBlockAvg err: ${err}`));
  console.log(`avgFuture: ${(avgFuture / USTX).toFixed(6)} STX`);
  const commitFuture = parseInt(
    avgFuture * (miningStrategy.targetPercentage / 100)
  );
  // set commit amount by averaging past and future values
  const commitAmount = (commitPast + commitFuture) / 2;
  return commitAmount.toFixed();
}

/**
 * @async
 * @function getBlockAvg
 * @param {Object[]} userConfig An object that contains the user configuration
 * @description Returns the average block commit for strategyDistance blocks in the past/future
 * @returns {integer}
 */
async function getBlockAvg(
  direction,
  currentBlock,
  miningStrategy,
  userConfig
) {
  const targetBlock =
    currentBlock + miningStrategy.strategyDistance * direction;
  const blockStats = [];

  for (
    let i = currentBlock;
    direction > 0 ? i < targetBlock : i > targetBlock;
    direction > 0 ? i++ : i--
  ) {
    const result = await getMiningStatsAtBlock(
      userConfig.contractAddress,
      userConfig.contractName,
      i
    );
    blockStats.push(result);
    // avoid API rate limiting
    await timer(1000);
  }

  const sum = blockStats.reduce((a, b) => parseInt(a) + parseInt(b), 0);
  const avg = sum / miningStrategy.strategyDistance;

  return avg;
}

/**
 * @async
 * @function getMiningStatsAtBlock
 * @param {string} contractAddress STX address of the contract deployer
 * @param {string} contractName Name of the contract
 * @param {integer} blockHeight Block height to query
 * @description Returns the total amount of STX sent for a given block height in the specified contracts
 * @returns {integer}
 */
async function getMiningStatsAtBlock(
  contractAddress,
  contractName,
  blockHeight
) {
  const resultCV = await callReadOnlyFunction({
    contractAddress: contractAddress,
    contractName: contractName,
    functionName: "get-mining-stats-at-block-or-default",
    functionArgs: [uintCV(blockHeight)],
    network: STACKS_NETWORK,
    senderAddress: contractAddress,
  });
  const result = cvToJSON(resultCV);
  return result.value.amount.value;
}

/**
 * @async
 * @function getStackerAtCycleOrDefault
 * @param {string} contractAddress STX address of the contract deployer
 * @param {string} contractName Name of the contract
 * @param {integer} cycleId Reward cycle to query
 * @param {integer} userId User ID to query
 * @description Returns the amount stacked and amount to return for a given cycle and user
 * @returns {Object[]}
 */
export async function getStackerAtCycleOrDefault(
  contractAddress,
  contractName,
  cycleId,
  userId
) {
  const resultCv = await callReadOnlyFunction({
    contractAddress: contractAddress,
    contractName: contractName,
    functionName: "get-stacker-at-cycle-or-default",
    functionArgs: [uintCV(cycleId), uintCV(userId)],
    network: STACKS_NETWORK,
    senderAddress: contractAddress,
  });
  const result = cvToJSON(resultCv);
  return result;
}

/**
 * @async
 * @function getStackingReward
 * @param {string} contractAddress STX address of the contract deployer
 * @param {string} contractName Name of the contract
 * @param {integer} cycleId Reward cycle to query
 * @param {integer} userId User ID to query
 * @description Returns the amount of STX a user can claim in a given reward cycle in uSTX.
 * @returns {integer}
 */
export async function getStackingReward(
  contractAddress,
  contractName,
  cycleId,
  userId
) {
  const resultCv = await callReadOnlyFunction({
    contractAddress: contractAddress,
    contractName: contractName,
    functionName: "get-stacking-reward",
    functionArgs: [uintCV(userId), uintCV(cycleId)],
    network: STACKS_NETWORK,
    senderAddress: contractAddress,
  });
  const result = cvToJSON(resultCv);
  return parseInt(result.value);
}

/**
 * @async
 * @function getUserId
 * @param {string} contractAddress STX address of the contract deployer
 * @param {string} contractName Name of the contract
 * @param {string} address Stacks address to query
 * @description Returns the userId in the CityCoin contract for a given address
 * @returns {integer}
 */
export async function getUserId(contractAddress, contractName, address) {
  const resultCv = await callReadOnlyFunction({
    contractAddress: contractAddress,
    contractName: contractName,
    functionName: "get-user-id",
    functionArgs: [standardPrincipalCV(address)],
    network: STACKS_NETWORK,
    senderAddress: contractAddress,
  });
  const result = cvToValue(resultCv);
  return parseInt(result.value);
}

/**
 * @async
 * @function getRewardCycle
 * @param {string} contractAddress STX address of the contract deployer
 * @param {string} contractName Name of the contract
 * @param {integer} blockHeight Block height to query
 * @description Returns the reward cycle for a given block height
 * @returns {integer}
 */
export async function getRewardCycle(
  contractAddress,
  contractName,
  blockHeight
) {
  const resultCv = await callReadOnlyFunction({
    contractAddress: contractAddress,
    contractName: contractName,
    functionName: "get-reward-cycle",
    functionArgs: [uintCV(blockHeight)],
    network: STACKS_NETWORK,
    senderAddress: contractAddress,
  });
  const result = cvToJSON(resultCv);
  return parseInt(result.value.value);
}

/**
 * @async
 * @function canClaimMiningReward
 * @param {string} contractAddress
 * @param {string} contractName
 * @param {string} address
 * @param {integer} blockHeight
 * @description Returns true if the user can claim a reward for a given block height
 * @returns {bool}
 */
export async function canClaimMiningReward(
  contractAddress,
  contractName,
  address,
  blockHeight
) {
  const resultCv = await callReadOnlyFunction({
    contractAddress: contractAddress,
    contractName: contractName,
    functionName: "can-claim-mining-reward",
    functionArgs: [standardPrincipalCV(address), uintCV(blockHeight)],
    network: STACKS_NETWORK,
    senderAddress: contractAddress,
  });
  const result = cvToJSON(resultCv);
  return result.value;
}

/**
 * @function printDivider
 * @description Prints a consistent divider used for logging
 */
export function printDivider() {
  console.log(`-------------------------`);
}

/**
 * @function printTimeStamp
 * @description Prints a consistent timestamp used for logging
 */
export function printTimeStamp() {
  let newDate = new Date().toLocaleString();
  newDate = newDate.replace(/,/g, "");
  console.log(newDate);
}

/**
 * @function exitWithSuccess
 * @param {string} message
 * @description Prints a final message and exits the running script
 */
export function exitWithSuccess(message) {
  console.log(success(message));
  process.exit(1);
}

/**
 * @function exitWithError
 * @param {string} message
 * @description Prints an error message and exits the running script
 */
export function exitWithError(message) {
  console.log(err(message));
  process.exit(1);
}

/**
 * @async
 * @function waitUntilBlock
 * @param {Object[]} userConfig An object that contains the user configuration
 * @returns {boolean}
 */
export async function waitUntilBlock(userConfig) {
  // config
  var init = true;
  var currentBlock = 0;
  // loop until target block is reached
  do {
    if (init) {
      init = !init;
    } else {
      if (userConfig.targetBlockHeight - currentBlock > 25) {
        // over 25 blocks (4 hours / 240 minutes)
        // check every 2hr
        await timer(7200000);
      } else if (userConfig.targetBlockHeight - currentBlock > 5) {
        // between 5-25 blocks (50 minutes - 4 hours)
        // check every 30min
        await timer(1800000);
      } else {
        // less than 5 blocks (50 minutes)
        // check every 5min
        await timer(300000);
      }
    }

    printDivider();
    console.log(title(`STATUS: WAITING FOR TARGET BLOCK`));
    printDivider();
    printTimeStamp();
    console.log(
      `account: ${userConfig.stxAddress.slice(
        0,
        5
      )}...${userConfig.stxAddress.slice(userConfig.stxAddress.length - 5)}`
    );

    currentBlock = await getBlockHeight().catch((err) =>
      exitWithError(`getBlockHeight err: ${err}`)
    );
    console.log(`currentBlock: ${currentBlock}`);
    console.log(`targetBlock: ${userConfig.targetBlockHeight}`);
    if (currentBlock < userConfig.targetBlockHeight) {
      console.log(
        `distance: ${userConfig.targetBlockHeight - currentBlock} blocks to go`
      );
      const remainingTime =
        ((userConfig.targetBlockHeight - currentBlock) * 10) / 60;
      if (remainingTime >= 1) {
        console.log(`time: ${remainingTime.toFixed(2)} hours`);
      } else {
        console.log(`time: ${(remainingTime * 60).toFixed()} minutes`);
      }
    }

    const mempoolTxCount = await getTotalMempoolTx().catch((err) =>
      exitWithError(`getTotalMempoolTx err: ${err}`)
    );
    console.log(`mempoolTxCount: ${mempoolTxCount}`);
  } while (userConfig.targetBlockHeight > currentBlock);

  return true;
}