autominer.js

import BN from "bn.js";
import prompts from "prompts";
import {
  getBlockHeight,
  getBlockCommit,
  getStxBalance,
  printDivider,
  printTimeStamp,
  processTx,
  timer,
  USTX,
  waitUntilBlock,
  warn,
  title,
  exitWithError,
  getOptimalFee,
  getNonce,
  STACKS_NETWORK,
  cancelPrompt,
} from "./utils.js";
import {
  uintCV,
  listCV,
  PostConditionMode,
  makeStandardSTXPostCondition,
  FungibleConditionCode,
  makeContractCall,
  broadcastTransaction,
} from "@stacks/transactions";

/** @module AutoMiner */

/**
 * @async
 * @function promptUserConfig
 * @description Prompts the user for configuration options at the start of the script
 * @returns {Object[]} An object that contains properties for each question name and related answers as a values
 */
async function promptUserConfig() {
  const currentBlockHeight = await getBlockHeight().catch((err) =>
    exitWithError(`getBlockHeight err: ${err}`)
  );
  const questions = [
    {
      type: "select",
      name: "citycoin",
      message: "Select a CityCoin to mine:",
      choices: [
        { title: "MiamiCoin (MIA)", value: "MIA" },
        { title: "NewYorkCityCoin (NYC)", value: "NYC" },
      ],
    },
    {
      type: "text",
      name: "stxAddress",
      message: "Stacks Address to mine with?",
      validate: (value) => (value === "" ? "Stacks address is required" : true),
    },
    {
      type: "password",
      name: "stxPrivateKey",
      message: "Private Key for Stacks Address?",
      validate: (value) =>
        value === "" ? "Stacks private key is required" : true,
    },
    {
      type: "confirm",
      name: "autoMine",
      message: "Continuously mine with full STX balance?",
    },
    {
      type: (prev) => (prev ? "confirm" : null),
      name: "autoMineConfirm",
      message: "Confirm mining with full STX balance?",
    },
    {
      type: (prev) => (!prev ? "number" : null),
      name: "numberOfRuns",
      message: "Number of mining TX to send?",
      validate: (value) => {
        if (value < 1) return "Value must be 1 or greater";
        return true;
      },
    },
    {
      type: "number",
      name: "numberOfBlocks",
      message: "Number of blocks to mine per TX? (1-200)",
      validate: (value) =>
        value < 1 || value > 200 ? "Value must be between 1 and 200" : true,
    },
    {
      type: "confirm",
      name: "startNow",
      message: "Start mining now?",
      initial: true,
    },
    {
      type: (prev) => (!prev ? "number" : null),
      name: "targetBlockHeight",
      message: `Target block height? (current: ${currentBlockHeight})`,
      validate: (value) =>
        value < currentBlockHeight
          ? `Value must be equal to or greater than current block height: ${currentBlockHeight}`
          : true,
    },
    {
      type: "confirm",
      name: "customCommit",
      message: "Set custom block commit?",
    },
    {
      type: (prev) => (prev ? "number" : null),
      name: "customCommitValue",
      message: "Custom block commit value in uSTX? (1,000,000 uSTX = 1 STX)",
      validate: (value) => (value > 0 ? true : "Value must be greater than 0"),
    },
    {
      type: "confirm",
      name: "customFee",
      message: "Set custom fee?",
    },
    {
      type: (prev) => (prev ? "number" : null),
      name: "customFeeValue",
      message: "Custom fee value in uSTX? (1,000,000 uSTX = 1 STX)",
      validate: (value) => (value > 0 ? true : "Value must be greater than 0"),
    },
  ];
  const submit = (prompt, answer, answers) => {
    if (prompt.name === "citycoin") {
      switch (answer) {
        case "MIA":
          answers.contractAddress = "SP466FNC0P7JWTNM2R9T199QRZN1MYEDTAR0KP27";
          answers.contractName = "miamicoin-core-v1";
          break;
        case "NYC":
          answers.contractAddress = "SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5";
          answers.contractName = "newyorkcitycoin-core-v1";
          break;
      }
    }
  };
  const userConfig = await prompts(questions, {
    onCancel: cancelPrompt,
    onSubmit: submit,
  });
  return userConfig;
}

/**
 * @async
 * @function promptMiningStrategy
 * @description Prompts the user for a custom mining strategy
 * @returns {Object[]} An object that contains properties for each question name and related answers as a values
 */
async function promptMiningStrategy() {
  // configure mining strategy
  const miningStrategyQuestions = [
    {
      type: "number",
      name: "strategyDistance",
      message: "Number of blocks to search for strategy?",
      validate: (value) => {
        if (value < 1 || value > 100) return "Value must be between 1 and 100";
        return true;
      },
    },
    {
      type: "number",
      name: "targetPercentage",
      message: "Target percentage of total block commit?",
      validate: (value) => {
        if (value === "") return "Target percentage is required";
        if (value < 1 || value > 100) return "Value must be between 1 and 100";
        return true;
      },
    },
    {
      type: "number",
      name: "maxCommitBlock",
      message: "Max commit per block in uSTX? (1,000,000 uSTX = 1 STX)",
    },
  ];
  const miningStrategy = await prompts(miningStrategyQuestions, {
    onCancel: cancelPrompt,
  });
  // verify max commit set by user or exit
  const confirmMax = await prompts(
    {
      type: "confirm",
      name: "confirmMax",
      message: `Confirm max commit: ${(
        miningStrategy.maxCommitBlock / USTX
      ).toFixed(6)} STX?`,
    },
    {
      onCancel: cancelPrompt,
    }
  );
  if (!confirmMax) {
    exitWithError("ERROR: max commit not confirmed, exiting...");
  }
  // return miningStrategy object
  return miningStrategy;
}

/**
 * @async
 * @function promptFeeStrategy
 * @description Prompts the user for a custom fee multiplier
 * @returns {Object[]} An object that contains the value for the fee multiplier
 */
async function promptFeeStrategy() {
  const feeMultiplier = await prompts(
    {
      type: "number",
      name: "value",
      message: "Fee multiplier for tx in mempool? (default: 1)",
      validate: (value) => (value > 0 ? true : "Value must be greater than 0"),
    },
    {
      onCancel: cancelPrompt,
    }
  );
  return feeMultiplier;
}

/**
 * @async
 * @function autoMine
 * @param {Object[]} userConfig An object that contains properties for each question name and related answers as a values
 * @param {Object[]} [miningStrategy={}] An object that contains properties for automatically calculating a commit
 * @param {boolean} [firstRun=true] A boolean that indicates if this is the first run of the script
 * @description Builds and submits a mining transaction based on the provided user configuration and mining strategy
 */
async function autoMine(userConfig, miningStrategy = {}, firstRun = true) {
  // set initial variables
  let commit = 0;
  let maxCommitBalance = 0;
  let targetFee = 0;
  let feeMultiplier = [];

  // set number of runs, 0 if continuous
  const numberOfRuns =
    userConfig.autoMine && userConfig.autoMineConfirm
      ? 0
      : userConfig.numberOfRuns;

  if (userConfig.startNow && firstRun === true) {
    // set block height to start tx on first run
    userConfig.targetBlockHeight = await getBlockHeight().catch((err) =>
      exitWithError(`getBlockHeight err: ${err}`)
    );
  }

  if (userConfig.customCommit) {
    if (firstRun) {
      // prompt user to confirm custom commit
      const confirmCommit = await prompts(
        {
          type: "confirm",
          name: "confirmCommit",
          message: `Confirm custom commit: ${(
            userConfig.customCommitValue / USTX
          ).toFixed(6)} STX`,
        },
        {
          onCancel: cancelPrompt,
        }
      );
      // exit if not confirmed
      if (!confirmCommit) {
        exitWithError("ERROR: custom commit not confirmed, exiting...");
      }
    }
    // set custom commit value
    commit = userConfig.customCommitValue;
  } else {
    // check if strategy already exists and if not set it
    if (
      !miningStrategy.hasOwnProperty("strategyDistance") &&
      !miningStrategy.hasOwnProperty("targetPercentage") &&
      !miningStrategy.hasOwnProperty("maxCommitBlock")
    ) {
      printDivider();
      console.log(title("STATUS: SETTING MINING STRATEGY"));
      printDivider();
      miningStrategy = await promptMiningStrategy();
    }
  }

  if (userConfig.customFee) {
    if (firstRun) {
      // verify custom fee set by user or exit
      const confirmFee = await prompts(
        {
          type: "confirm",
          name: "confirmFee",
          message: `Confirm custom fee: ${(
            userConfig.customFeeValue / USTX
          ).toFixed(6)} STX`,
        },
        {
          onCancel: cancelPrompt,
        }
      );
      if (!confirmFee) {
        exitWithError("ERROR: custom fee not confirmed, exiting...");
      }
    }
    targetFee = userConfig.customFeeValue;
  } else {
    // check if fee multiplier exists and if not set it
    if (!miningStrategy.hasOwnProperty("feeMultiplier")) {
      feeMultiplier = await promptFeeStrategy();
      miningStrategy.feeMultiplier = feeMultiplier.value;
    }
  }

  // loop until target block is reached
  const startMiner = await waitUntilBlock(userConfig);

  if (startMiner) {
    // output address and summary info
    printDivider();
    console.log(title("STATUS: BUILDING MINING TX"));
    printDivider();
    printTimeStamp();
    console.log(
      `account: ${userConfig.stxAddress.slice(
        0,
        5
      )}...${userConfig.stxAddress.slice(userConfig.stxAddress.length - 5)}`
    );
    const stxBalance = await getStxBalance(userConfig.stxAddress).catch((err) =>
      exitWithError(`getStxBalance err: ${err}`)
    );
    console.log(`balance: ${(stxBalance / USTX).toFixed(6)} STX`);
    console.log(
      `mining runs: ${userConfig.autoMineConfirm ? "infinite" : numberOfRuns}`
    );
    console.log(`mining ${userConfig.numberOfBlocks} blocks per TX`);

    // output commit info and calculate if needed
    printDivider();
    if (userConfig.customCommit) {
      console.log(title("STATUS: CUSTOM COMMIT SET"));
      printDivider();
      console.log(`customCommit: ${(commit / USTX).toFixed(6)} STX`);
    } else {
      console.log(title("STATUS: CALCULATING COMMIT"));
      printDivider();
      commit = await getBlockCommit(userConfig, miningStrategy).catch((err) =>
        exitWithError(`getBlockCommit err: ${err}`)
      );
      if (commit > miningStrategy.maxCommitBlock) {
        console.log(
          warn(
            `WARNING: commit of ${(commit / USTX).toFixed(
              6
            )} STX is greater than max threshold of ${(
              miningStrategy.maxCommitBlock / USTX
            ).toFixed(6)}`
          )
        );
        console.log(
          `setting commit to ${(miningStrategy.maxCommitBlock / USTX).toFixed(
            6
          )} STX`
        );
        commit = miningStrategy.maxCommitBlock;
      }
    }

    // compare commit value to balance
    maxCommitBalance = parseInt(stxBalance / userConfig.numberOfBlocks);
    if (commit > maxCommitBalance) {
      console.log(
        warn(
          `WARNING: commit of ${(commit / USTX).toFixed(
            6
          )} STX is greater than balance`
        )
      );
      console.log(
        `setting commit to ${(maxCommitBalance / USTX).toFixed(6)} STX`
      );
      commit = maxCommitBalance;
    }

    // output commit calculations
    printDivider();
    if (!userConfig.customCommit) {
      console.log(`target: ${miningStrategy.targetPercentage}%`);
      console.log(
        `maxPerBlock: ${(miningStrategy.maxCommitBlock / USTX).toFixed(6)} STX`
      );
    }
    console.log(`maxPerBalance: ${(maxCommitBalance / USTX).toFixed(6)} STX`);
    console.log(`commit: ${(commit / USTX).toFixed(6)} STX`);

    // output fee info and calculate if needed
    printDivider();
    if (userConfig.customFee) {
      console.log(title("STATUS: CUSTOM FEE SET"));
      printDivider();
      console.log(`customFee: ${(targetFee / USTX).toFixed(6)} STX`);
    } else {
      console.log(title("STATUS: CALCULATING FEES"));
      printDivider();
      targetFee = await getOptimalFee(miningStrategy.feeMultiplier).catch(
        (err) => exitWithError(`getOptimalFee err: ${err}`)
      );
      console.log(`targetFee: ${(targetFee / USTX).toFixed(6)} STX`);
    }

    // check that total commit + fee is not higher than the balance
    if (commit * userConfig.numberOfBlocks + targetFee >= stxBalance) {
      console.log(
        warn(
          `WARNING: commit of ${(commit / USTX).toFixed(6)} STX + fee of ${(
            targetFee / USTX
          ).toFixed(6)} STX is greater than balance`
        )
      );
      console.log(`subtracting fee from commit per block`);
      commit -= parseInt(targetFee / userConfig.numberOfBlocks);
      console.log(`newCommit: ${(commit / USTX).toFixed(6)} STX`);
    }

    printDivider();
    console.log(title("STATUS: SUBMITTING MINING TX"));
    printDivider();

    // get the current nonce
    const nonce = await getNonce(userConfig.stxAddress).catch((err) =>
      exitWithError(`getNonce err: ${err}`)
    );
    let mineManyArray = [];

    // create the clarity values for the mining tx
    for (let i = 0; i < userConfig.numberOfBlocks; i++) {
      mineManyArray.push(uintCV(commit));
    }
    const sumCV = uintCV(commit * userConfig.numberOfBlocks);
    mineManyArray = listCV(mineManyArray);

    console.log(
      `account: ${userConfig.stxAddress.slice(
        0,
        5
      )}...${userConfig.stxAddress.slice(userConfig.stxAddress.length - 5)}`
    );
    console.log(`nonce: ${nonce}`);
    console.log(
      `totalCommit: ${((commit * userConfig.numberOfBlocks) / USTX).toFixed(
        6
      )} STX`
    );

    // create the mining tx
    const txOptions = {
      contractAddress: userConfig.contractAddress,
      contractName: userConfig.contractName,
      functionName: "mine-many",
      functionArgs: [mineManyArray],
      senderKey: userConfig.stxPrivateKey,
      fee: new BN(targetFee),
      nonce: new BN(nonce),
      postConditionMode: PostConditionMode.Deny,
      postConditions: [
        makeStandardSTXPostCondition(
          userConfig.stxAddress,
          FungibleConditionCode.Equal,
          sumCV.value
        ),
      ],
      network: STACKS_NETWORK,
    };

    // pause 10sec
    console.log(`pausing 10sec before submit`);
    await timer(10000);

    // submit the tx
    const transaction = await makeContractCall(txOptions).catch((err) =>
      exitWithError(`makeContractCall err: ${err}`)
    );
    const result = await broadcastTransaction(
      transaction,
      STACKS_NETWORK
    ).catch((err) => exitWithError(`broadcastTransaction err: ${err}`));

    // monitor the tx status
    const processTxBlock = await processTx(result, transaction.txid()).catch(
      (err) => exitWithError(`processTx err: ${err}`)
    );

    // check if AutoMiner should restart with new value
    if (userConfig.autoMineConfirm === true || userConfig.numberOfRuns > 0) {
      userConfig.numberOfRuns -= 1;
      userConfig.targetBlockHeight = processTxBlock + userConfig.numberOfBlocks;
      printDivider();
      console.log(title("STATUS: RESTARTING WITH NEW TARGET"));
      printDivider();
      console.log(`newTarget: ${userConfig.targetBlockHeight}`);
      autoMine(userConfig, miningStrategy, false);
    } else {
      exitWithError("Selected number of runs complete, exiting...");
    }
  } else {
    exitWithError("ERROR: unable to start miner, exiting...");
  }
}

// show title and disclaimer on first run
printDivider();
console.log(title("CITYCOINS AUTOMINER"));
printDivider();
console.log(
  "This utility will build a CityCoin mining transaction and submit it to the blockchain, with options to set an automated or custom strategy, as well as the ability to mine continuously.\n"
);
console.log(
  "THIS IS ALPHA SOFTWARE THAT REQUIRES A STACKS PRIVATE KEY TO SEND A TRANSACTION.\n"
);
console.log("THE CODE IS FOR EDUCATIONAL AND DEMONSTRATION PURPOSES ONLY.\n");
console.log(warn("USE AT YOUR OWN RISK. PLEASE REPORT ANY ISSUES ON GITHUB."));

// get the user config and start the AutoMiner
printDivider();
console.log(title("STATUS: SETTING USER CONFIG"));
printDivider();
promptUserConfig().then((answers) => autoMine(answers));