autominingclaimer.js

import BN from "bn.js";
import prompts from "prompts";
import {
  cancelPrompt,
  getBlockHeight,
  printDivider,
  printTimeStamp,
  timer,
  USTX,
  title,
  exitWithError,
  getNonce,
  STACKS_NETWORK,
  canClaimMiningReward,
  safeFetch,
  warn,
} from "./utils.js";
import {
  uintCV,
  PostConditionMode,
  makeContractCall,
  broadcastTransaction,
  AnchorMode,
} from "@stacks/transactions";

/** @module AutoMiningClaimer */

/**
 * @constant
 * @type {integer}
 * @description Default fee to use when no custom fee is set
 * @default
 */
const defaultFee = 100000;

/**
 * @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 questions = [
    {
      type: "select",
      name: "citycoin",
      message: "Select a CityCoin to look up mining claims:",
      choices: [
        { title: "MiamiCoin (MIA)", value: "MIA" },
        { title: "NewYorkCityCoin (NYC)", value: "NYC" },
      ],
    },
    {
      type: "text",
      name: "stxAddress",
      message: "Stacks Address to claim 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: "customFee",
      message: `Set custom fee? (default ${(defaultFee / USTX).toFixed(
        6
      )} STX)`,
    },
    {
      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";
          answers.blockWinnersUrl = "https://miamining.com/winners";
          break;
        case "NYC":
          answers.contractAddress = "SP2H8PY27SEZ03MWRKS5XABZYQN17ETGQS3527SA5";
          answers.contractName = "newyorkcitycoin-core-v1";
          answers.blockWinnersUrl = "https://mining.nyc/winners";
          break;
      }
    }
  };
  const userConfig = await prompts(questions, {
    onCancel: cancelPrompt,
    onSubmit: submit,
  });
  return userConfig;
}

/**
 * @async
 * @function autoMiningClaimer
 * @param {Object[]} userConfig
 * @description Searches for won blocks and submits the related claim transactions
 */
async function autoMiningClaimer(userConfig) {
  printDivider();
  printTimeStamp();

  const currentBlockHeight = await getBlockHeight().catch((err) =>
    exitWithError(`getBlockHeight err: ${err}`)
  );
  console.log(`currentBlockHeight: ${currentBlockHeight}`);

  const blockWinners = await safeFetch(userConfig.blockWinnersUrl);

  // get all blocks won from Jamils explorer
  const blocksWon = [];
  const blocksUnclaimed = [];
  for (const block in blockWinners) {
    if (blockWinners[block].miner === userConfig.stxAddress) {
      blocksWon.push(block);
      if (!blockWinners[block].claimed) {
        blocksUnclaimed.push(block);
      }
    }
  }

  // reverse array to start with highest block number
  blocksUnclaimed.reverse();

  // double-check that blocksUnclaimed is accurate by querying the contract for each block height
  console.log(`Checking ${blocksUnclaimed.length} unclaimed blocks...`);

  const claimLimit = 24;
  const blocksToClaim = [];
  let checkCounter = 0;
  do {
    printDivider();
    console.log(`block ${checkCounter + 1} of ${blocksUnclaimed.length}`);
    console.log(`block: ${blocksUnclaimed[checkCounter]}`);
    // pause between requests to avoid rate limits
    // await timer(1000);
    // check if user can claim mining reward
    const canClaim = await canClaimMiningReward(
      userConfig.contractAddress,
      userConfig.contractName,
      userConfig.stxAddress,
      blocksUnclaimed[checkCounter]
    ).catch((err) => exitWithError(`canClaimMiningReward err: ${err}`));
    console.log(`canClaim: ${canClaim}`);
    if (canClaim) {
      // add block height to claim array
      blocksToClaim.push(blocksUnclaimed[checkCounter]);
      console.log(`blocksToClaim: ${blocksToClaim.length}`);
    }
    // increment counter
    checkCounter++;
  } while (
    blocksToClaim.length < claimLimit &&
    checkCounter < blocksUnclaimed.length
  );

  printDivider();
  console.log(`total blocks to claim: ${blocksToClaim.length}`);

  printDivider();
  console.log(title("STATUS: CLAIMING MINING REWARDS"));

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

  const counterLimit = blocksToClaim.length < 24 ? blocksToClaim.length : 24;
  for (let i = 0; i < counterLimit; i++) {
    printDivider();
    console.log(
      `account: ${userConfig.stxAddress.slice(
        0,
        5
      )}...${userConfig.stxAddress.slice(userConfig.stxAddress.length - 5)}`
    );
    console.log(`nonce: ${nonce + i}`);
    console.log(`claiming block: ${blocksToClaim[i]}`);
    // set the fee
    const feeRate = userConfig.hasOwnProperty("customFeeValue")
      ? userConfig.customFeeValue
      : defaultFee;
    // set the block height to claim
    const blockHeightCV = uintCV(parseInt(blocksToClaim[i]));
    // create the claim tx
    const txOptions = {
      contractAddress: userConfig.contractAddress,
      contractName: userConfig.contractName,
      functionName: "claim-mining-reward",
      functionArgs: [blockHeightCV],
      senderKey: userConfig.stxPrivateKey,
      fee: new BN(feeRate),
      nonce: new BN(nonce + i),
      postConditionMode: PostConditionMode.Deny,
      postConditions: [],
      network: STACKS_NETWORK,
      anchorMode: AnchorMode.Any,
    };

    // pause 1sec
    console.log(`pausing 1sec before submit`);
    await timer(1000);

    // submit the tx
    const transaction = await makeContractCall(txOptions).catch((err) =>
      exitWithError(`makeContractCall err: ${err}`)
    );
    console.log(`https://explorer.stacks.co/txid/${transaction.txid()}`);
    await broadcastTransaction(transaction, STACKS_NETWORK).catch((err) =>
      exitWithError(`broadcastTransaction err: ${err}`)
    );
  }

  printDivider();
  console.log(`No more blocks to claim or claim limit reached.`);
}

// show title and disclaimer on first run
printDivider();
console.log(title("CITYCOINS AUTOMININGCLAIMER"));
printDivider();
console.log(
  "This utility provides a simple, easy-to-use, prompt-driven interface for checking if an address won any blocks, and if so, automatically submits the claim transaction.\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 AutoClaimer
printDivider();
console.log(title("STATUS: SETTING USER CONFIG"));
printDivider();
promptUserConfig().then((answers) => autoMiningClaimer(answers));