Querying full and archive EVM nodes with JavaScript

Querying full vs archive nodes tutorial on Chainstack

Introduction

Most EVM-based blockchains usually can run two types of nodes: full and archive nodes. 

Chainstack supports many popular EVM-based networks, including EthereumPolygonBNB Smart ChainC-Chain of AvalancheFantom, Harmony

The main difference between the full and archive nodes is that an archive node stores the entire transactional history of the blockchain since its genesis. On the other hand, full nodes store the most recent states, typically up to the latest 128 blocks. Full nodes may serve older historical data but are inefficient for this task.

A detailed explanation of how a full node differs from an archive node is on EVM nodes: A dive into the full vs. archive mode. 

In this series, we will focus on retrieving historical data from the blockchain programmatically, switching between a full and archive node provider when necessary. The tutorial is split into two parts that will demonstrate the process using two popular web3 languages: JavaScript and Python.

Initial setup

Let us dive deeper into how we can access the data stored in the network. Some essential functions include getting an address balance and storage at a given position, a contract bytecode, or even the whole transactions included on a given block. We can also query states from a given block for any custom smart contract of our choice.

Today’s tutorial will query the blockchain utilizing the web3 library in JavaScript.

First, set up a new Node.js project:

mkdir full-archive-querys-js && cd full-archive-querys-js 
npm init –y 
npm install web3 inquirer 

We added web3.js, which will be a crucial interface to interact with the blockchain. We also added the Inquirer npm package to build a console application. Our goal is that a user can select some desired query to perform between standard state functions or from a custom smart contract.

We will call state functions from the SHIBA INU token contract in this example. Initially, we will create three files: the main script, some utility functions, and the SHIBA-INU (or your custom contract) token contract ABI.

cd full-archive-querys-js 
touch index.js
mkdir src && cd src
touch utils.js abi.js 

Overall, our project structure should initially look like this:

├── index.js
├── package.json
├── package-lock.json
├── README.md
└── src
    ├── abi.js
    └── utils.js

Introducing common state functions

Let us begin setting up the queries for common state functions. We will implement a few popular ones, like fetching an address ETH balance or getting the transactions mined on a given block. A full and archive node is required to follow up on this tutorial. Get a free public node from Chainstack. Open the utils.js file and add the following code:

// Initial Config
const Web3 = require("web3");
// Init Full and Archive providers
const fullNodeProvider = new Web3("<YOUR_NODE_PROVIDER_ENDPOINT>");
const archiveNodeProvider = new Web3("<YOUR_NODE_PROVIDER_ENDPOINT>");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Returns current block of a network
const getCurrentBlock = async () => {
  return await fullNodeProvider.eth.getBlockNumber();
};
// Checks if performed query needs to be called to an archive node
const isArchiveQuery = (error) => {
  return error.message.includes("missing trie node");
};
// Returns eth balance of a given address in a certain block
const getETHBalance = async (address, block) => {
  console.log(
    `[QUERYING] Fetching ETH balance from address ${address} at block ${block}`
  );
  try {
    console.log("[QUERYING] Attempting with Full Node");
    return await fullNodeProvider.eth.getBalance(address, block);
  } catch (error) {
    if (isArchiveQuery(error)) {
      console.log("[OLD-BLOCK-QUERY] Switching to archive query");
      return await archiveNodeProvider.eth.getBalance(address, block);
    }
    return null;
  }
};
// Returns the storage of an address at a given position
const getStorageAt = async (address, position, block) => {
  console.log(
    `[QUERYING] Fetching storage at position ${position} from address ${address} at block ${block}`
  );
  try {
    console.log("[QUERYING] Attempting with Full Node");
    return await fullNodeProvider.eth.getStorageAt(address, position, block);
  } catch (error) {
    if (isArchiveQuery(error)) {
      console.log("[OLD-BLOCK-QUERY] Switching to archive query");
      return await archiveNodeProvider.eth.getStorageAt(
        address,
        position,
        block
      );
    }
    return null;
  }
};
// Returns the code at a given address and block
const getCode = async (address, block) => {
  console.log(address);
  console.log(
    `[QUERYING] Fetching code at address ${address} at block ${block}`
  );
  try {
    console.log("[QUERYING] Attempting with Full Node");
    return await fullNodeProvider.eth.getCode(address, block);
  } catch (error) {
    if (isArchiveQuery(error)) {
      console.log("[OLD-BLOCK-QUERY] Switching to archive query");
      return await archiveNodeProvider.eth.getCode(address, block);
    }
    return null;
  }
};
// Returns all transactions mined in a block
const getBlockTransactions = async (block, full) => {
  console.log(`[QUERYING] Fetching block ${block} transactions`);
  try {
    console.log("[QUERYING] Attempting with Full Node");
    return await fullNodeProvider.eth.getBlock(block, full);
  } catch (error) {
    if (isArchiveQuery(error)) {
      console.log("[OLD-BLOCK-QUERY] Switching to archive query");
      return await archiveNodeProvider.eth.getBlock(block);
    }
    return null;
  }
};
// Prints the result of querying the ETH balance
// of an address at a given block
const printETHBalanceOf = async (address, block) => {
  const ethBalance = await getETHBalance(address, block);
  ethBalance === null
    ? console.log("Invalid query")
    : console.log(
        `[BALANCE-RESULTS] Eth balance of address ${address} at block ${block}: ${ethBalance} $ETH`
      );
};
// Prints the result of querying the storage at an address
// at a given position and block
const printStorageAt = async (address, block, position) => {
  const storageAt = await getStorageAt(address, position, block);
  storageAt === null
    ? console.log("Invalid query")
    : console.log(
        `[STORAGE-RESULTS] Storage at address ${address} at postion ${position} at block ${block}: ${storageAt}`
      );
};
// Prints the result of querying the code at a given
// address and block
const printCodeAt = async (address, block) => {
  const codeAt = await getCode(address, block);
  codeAt === null
    ? console.log("Invalid query")
    : console.log(
        `[CODE-RESULTS] Code at address ${address} at block ${block}: ${codeAt}`
      );
};
// Prints the block transactions of a given block
// If full is set to false, it will just print the transaction hashes
const printBlockTransactions = async (block, full) => {
  const blockTransactions = await getBlockTransactions(block, full);
  blockTransactions === null
    ? console.log("Invalid query")
    : console.log(
        `[TRANSACTIONS] Transactions at block ${block}: \n`,
        blockTransactions
      );
};
module.exports = {
  getCurrentBlock,
  getETHBalance,
  getStorageAt,
  getCode,
  getBlockTransactions,
};

With these functions, you can query the network state of any block since the query execution will transition to the archive node provider if the block to fetch cannot be reached from a full node (usually 128 blocks past the latest block).

Introducing custom smart contract queries

Even though these typical functions are handy sometimes, most cases require interactions with smart contracts to retrieve their states along the network timeline. For this reason, we will use a popular ERC-20 smart contract token as an example. We choose SHIBA INU, which has been living a long time on the Ethereum mainnet, just for illustration purposes. Of course, any contract that exposes state calls will serve for testing. Just remember that the only interactions that we will perform are state calls. We will not change the network state. 

First, let us add the contract ABI to the abi.js file on our node project. Paste the following code into it: 

const abi = [
  {
    constant: true,
    inputs: [],
    name: "name",
    outputs: [{ name: "", type: "string" }],
    payable: false,
    stateMutability: "view",
    type: "function",
  },
  {
    constant: false,
    inputs: [
      { name: "spender", type: "address" },
      { name: "value", type: "uint256" },
    ],
    name: "approve",
    outputs: [{ name: "", type: "bool" }],
    payable: false,
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    constant: true,
    inputs: [],
    name: "totalSupply",
    outputs: [{ name: "", type: "uint256" }],
    payable: false,
    stateMutability: "view",
    type: "function",
  },
  {
    constant: false,
    inputs: [
      { name: "sender", type: "address" },
      { name: "recipient", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    name: "transferFrom",
    outputs: [{ name: "", type: "bool" }],
    payable: false,
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    constant: true,
    inputs: [],
    name: "decimals",
    outputs: [{ name: "", type: "uint8" }],
    payable: false,
    stateMutability: "view",
    type: "function",
  },
  {
    constant: false,
    inputs: [
      { name: "spender", type: "address" },
      { name: "addedValue", type: "uint256" },
    ],
    name: "increaseAllowance",
    outputs: [{ name: "", type: "bool" }],
    payable: false,
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    constant: false,
    inputs: [{ name: "value", type: "uint256" }],
    name: "burn",
    outputs: [],
    payable: false,
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    constant: true,
    inputs: [{ name: "account", type: "address" }],
    name: "balanceOf",
    outputs: [{ name: "", type: "uint256" }],
    payable: false,
    stateMutability: "view",
    type: "function",
  },
  {
    constant: true,
    inputs: [],
    name: "symbol",
    outputs: [{ name: "", type: "string" }],
    payable: false,
    stateMutability: "view",
    type: "function",
  },
  {
    constant: false,
    inputs: [
      { name: "spender", type: "address" },
      { name: "subtractedValue", type: "uint256" },
    ],
    name: "decreaseAllowance",
    outputs: [{ name: "", type: "bool" }],
    payable: false,
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    constant: false,
    inputs: [
      { name: "recipient", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    name: "transfer",
    outputs: [{ name: "", type: "bool" }],
    payable: false,
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    constant: true,
    inputs: [
      { name: "owner", type: "address" },
      { name: "spender", type: "address" },
    ],
    name: "allowance",
    outputs: [{ name: "", type: "uint256" }],
    payable: false,
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [
      { name: "name", type: "string" },
      { name: "symbol", type: "string" },
      { name: "decimals", type: "uint8" },
      { name: "totalSupply", type: "uint256" },
      { name: "feeReceiver", type: "address" },
      { name: "tokenOwnerAddress", type: "address" },
    ],
    payable: true,
    stateMutability: "payable",
    type: "constructor",
  },
  {
    anonymous: false,
    inputs: [
      { indexed: true, name: "from", type: "address" },
      { indexed: true, name: "to", type: "address" },
      { indexed: false, name: "value", type: "uint256" },
    ],
    name: "Transfer",
    type: "event",
  },
  {
    anonymous: false,
    inputs: [
      { indexed: true, name: "owner", type: "address" },
      { indexed: true, name: "spender", type: "address" },
      { indexed: false, name: "value", type: "uint256" },
    ],
    name: "Approval",
    type: "event",
  },
];
module.exports = {
  abi,
};

Now, we can create an instance of the SHIBA INU contract using this JavaScript interface that will allow us to interact with its functions. At the end of our utils.js (just before the exports), create a new instance for the contract using each node provider.

/* ############### Smart contract interactions ############### */
const { abi } = require("./abi");
// You can use your own contract address
const CONTRACT_ADDRESS = "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE";
// Init instances of our contract both in the full and archive node providers
const fullNodeContract = new fullNodeProvider.eth.Contract(
  abi,
  CONTRACT_ADDRESS
);
const archiveNodeContract = new archiveNodeProvider.eth.Contract(
  abi,
  CONTRACT_ADDRESS
);

A web3.eth.Contract instance contains the interface for every method, event, and public variable in the smart contract. We are interested (at first) in the _jsonInterface variable, which will contain an array of every function name along with their required inputs. Inside every object, another variable, stateMutability, indicates if this method changes the network state. For read-only methods, the value will be view, which tells us that this method only reads the smart contract state.

To extract this useful metadata, we will implement a getContractCallMethods function that will only return the names and inputs of the view functions. At the end of the utils.js file add:

/* Returns the contracts methods that only query  
a smart contract state and its inputs */
const getContractCallMethods = () => {
  let contractInputs = [];
  // The _jsoninterface array contains the methods metadata
  const contractNames = fullNodeContract._jsonInterface
    .filter((methodMetadata) => methodMetadata.stateMutability === "view")
    .map((method) => {
      contractInputs.push(method.inputs);
      return method.name;
    });
  return {
    contractNames,
    contractInputs,
  };
};

As told before, for this tutorial, we want to interact with our app through CLI, so the inputs required will be prompted to the user using inquirer.  

Inside the src folder, create a new folder called prompts. Then let us create three files:  

  • commonState.js: that will prompt the options to select a common state query. 
  • custom.js: this will prompt the options to select a custom smart contract function to query its state, and 
  • common.js: that will contain common functions to utilize on the previous files.

Now, our project folder structure must look like this:

├── index.js
├── package.json
├── package-lock.json
├── README.md
├── resume.js
└── src
    ├── abi.js
    ├── prompts
    │   ├── common.js
    │   ├── commonState.js
    │   └── custom.js
    └── utils.js

Querying a common state function

Let us focus on querying common state functions first. Every common state function requires some inputs, such as the block and address. Other functions, such as getting the storageAt and block transactions, need some custom inputs, too, so let us focus on writing the scaffolding for this. In our commonState.js file, we will add:

const inquirer = require("inquirer");
const {
  printETHBalanceOf,
  printCodeAt,
  printStorageAt,
  getCurrentBlock,
  printBlockTransactions,
  ZERO_ADDRESS,
} = require("../utils");
// Builds a prompt for block input
const blockPrompt = () => ({
  type: "input",
  default: LATEST_BLOCK,
  name: "block",
  message:
    "Enter the block number to perform the query (blank to use latest block)",
});
// Builds a prompt for address input
const addressPrompt = () => ({
  type: "input",
  default: ZERO_ADDRESS,
  name: "address",
  message: "Enter an address to perfom the query (blank to use zero address)",
});
/* Prompts the inputs for the block to query 
and select if go full details or just the hashes 
*/
const getBlockFull = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  return await inquirer.prompt([
    blockPrompt(),
    {
      type: "confirm",
      default: false,
      name: "full",
      message: "Do you wish to get the full transactions output?",
    },
  ]);
};
/* Prompts the inputs for the block 
and the address to perform a query 
*/
const getAddressBlock = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  return await inquirer.prompt([addressPrompt(), blockPrompt()]);
};
/* Prompts the inputs for the block, address 
and the position to perform a query 
*/
const getAddressBlockPosition = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  return await inquirer.prompt([
    addressPrompt(),
    blockPrompt(),
    {
      type: "input",
      name: "position",
      message:
        "Enter the position of the storage to perform the query (default 0)",
      default: 0,
    },
  ]);
};

We also have some functions that will help us manage these queries declaratively, mainly focused on fetching the results and printing them. In our commonState.js file, add:

// Performs query for getting ETH balance
const processETHBalance = async () => {
  const { address, block } = await getAddressBlock();
  await printETHBalanceOf(address, block);
  return;
};
// Performs query for getting code at given address
const processCodeAt = async () => {
  const { address, block } = await getAddressBlock();
  await printCodeAt(address, block);
  return;
};
// Performs query for getting storage at an address
// at a given position
const processStorageAt = async () => {
  const { address, block, position } = await getAddressBlockPosition();
  await printStorageAt(address, block, position);
  return;
};
// Performs query for getting a block transactions
const processTransactions = async () => {
  const { block, full } = await getBlockFull();
  await printBlockTransactions(block, full);
  return;
};

Finally, we will add the logic required to request the user inputs needed to query a specific function.

// Prompts the option to select the common state
// query function to call
const getCommonsSelection = async () => {
  return await inquirer.prompt([
    {
      type: "list",
      name: "commonsOption",
      choices: [
        "Get ETH balance of an address at a given block",
        "Get storage at an address at a given position and block",
        "Get code of an address at a given block",
        "Get block transactions from a given block",
      ],
    },
  ]);
};
// Executes the method select on CLI
const processCommonsSelection = async (selection) => {
  if (selection.includes("balance")) {
    await processETHBalance();
    return;
  }
  if (selection.includes("code")) {
    await processCodeAt();
    return;
  }
  if (selection.includes("storage")) {
    await processStorageAt();
    return;
  }
  if (selection.includes("transactions")) {
    await processTransactions();
    return;
  }
};
module.exports = {
  getCommonsSelection,
  processCommonsSelection,
};

At the end, the commonState.js file shall look like this:

const inquirer = require("inquirer");
const {
  printETHBalanceOf,
  printCodeAt,
  printStorageAt,
  getCurrentBlock,
  printBlockTransactions,
  ZERO_ADDRESS,
} = require("../utils");
// Builds a prompt for block input
const blockPrompt = () => ({
  type: "input",
  default: LATEST_BLOCK,
  name: "block",
  message:
    "Enter the block number to perform the query (blank to use latest block)",
});
// Builds a prompt for address input
const addressPrompt = () => ({
  type: "input",
  default: ZERO_ADDRESS,
  name: "address",
  message: "Enter an address to perfom the query (blank to use zero address)",
});
/* Prompts the inputs for the block to query
  and select if go full details or just the hashes
*/
const getBlockFull = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  return await inquirer.prompt([
    blockPrompt(),
    {
      type: "confirm",
      default: false,
      name: "full",
      message: "Do you wish to get the full transactions output?",
    },
  ]);
};
/* Prompts the inputs for the block
  and the address to perform a query
*/
const getAddressBlock = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  return await inquirer.prompt([addressPrompt(), blockPrompt()]);
};
/* Prompts the inputs for the block, address
  and the position to perform a query
*/
const getAddressBlockPosition = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  return await inquirer.prompt([
    addressPrompt(),
    blockPrompt(),
    {
      type: "input",
      name: "position",
      message:
        "Enter the position of the storage to perform the query (default 0)",
      default: 0,
    },
  ]);
};
// Performs query for getting ETH balance
const processETHBalance = async () => {
  const { address, block } = await getAddressBlock();
  await printETHBalanceOf(address, block);
  return;
};
// Performs query for getting code at given address
const processCodeAt = async () => {
  const { address, block } = await getAddressBlock();
  await printCodeAt(address, block);
  return;
};
// Performs query for getting storage at an address
// at a given position
const processStorageAt = async () => {
  const { address, block, position } = await getAddressBlockPosition();
  await printStorageAt(address, block, position);
  return;
};
// // Performs query for getting a block transactions
const processTransactions = async () => {
  const { block, full } = await getBlockFull();
  await printBlockTransactions(block, full);
  return;
};
// Prompts the option to select the common state
// query function to call
const getCommonsSelection = async () => {
  return await inquirer.prompt([
    {
      type: "list",
      name: "commonsOption",
      choices: [
        "Get ETH balance of an address at a given block",
        "Get storage at an address at a given position and block",
        "Get code of an address at a given block",
        "Get block transactions from a given block",
      ],
    },
  ]);
};
// Executes the method select on CLI
const processCommonsSelection = async (selection) => {
  if (selection.includes("balance")) {
    await processETHBalance();
    return;
  }
  if (selection.includes("code")) {
    await processCodeAt();
    return;
  }
  if (selection.includes("storage")) {
    await processStorageAt();
    return;
  }
  if (selection.includes("transactions")) {
    await processTransactions();
    return;
  }
};
module.exports = {
  getCommonsSelection,
  processCommonsSelection,
};

Querying a custom smart contract

We also want to be able to query a custom smart contract state. Querying smart contracts state is beneficial if we want to get historical data from them.

First, we want to get the inputs from a user required for querying a function in our smart contract. We need a way to build a custom prompt asking the user for these inputs. Let us do this, bearing in mind that every contract can be different and have distinct functions requiring different inputs. Let us add the following code to our custom.js file:

const inquirer = require("inquirer");
const {
  getContractCallMethods,
  ZERO_ADDRESS,
  callContractFunctionWithoutParams,
  callContractFunctionWithParams,
} = require("../utils");
/* Builds a new inquirer prompt for each required 
input for the selected smart contract method 
*/
const buildPrompt = (inputs, latest_block) => {
  let prompts = [];
  inputs.forEach((input) => {
    prompts.push({
      type: "input",
      name: input.name,
      message: `Enter an ${input.type} for the ${input.name} to perform query`,
      default: getDefaultValue(input.type),
    });
  });
  prompts.push({
    type: "input",
    name: "block",
    message: `Enter the block number to perform query`,
    default: latest_block,
  });
  return prompts;
};
/* Gets the user inputs required to pass as function 
parameters in the method selected for the custom contract 
*/
const getUserInput = async (inputs, latest_block) => {
  const prompt = buildPrompt(inputs, latest_block);
  const answer = await inquirer.prompt(prompt);
  const inputsAsArray = Object.keys(answer)
    .map((key) => answer[key])
    .slice(0, -1);
  const { block } = answer;
  return {
    userInput: inputsAsArray,
    block,
  };
};
/* Returns the default value of an inquirer prompt 
based on a smart contract function input type 
*/
const getDefaultValue = (type) => {
  if (type.includes("address")) {
    return ZERO_ADDRESS;
  }
  if (type.includes("uint") || type.includes("int") || type.includes("enum")) {
    return 0;
  }
  if (type.includes("bool")) {
    return false;
  }
  if (type.includes("bytes")) {
    return "0x0";
  }
  if (type.includes("array")) return [];
  if (type.inclues("string")) return "";
  return null;
};
/* Returns the inputs name and type of a given method */
const getInputsOfMethod = (contractInputs, indexOf) => {
  return contractInputs[indexOf];
};
/* Returns the index of a method in the methods name array */
const getIndexOfMethod = (contractNames, method) => {
  return contractNames.findIndex((name) => name === method);
};

These methods will ask the user input required regarding each function and return the function parameters entered by the user in array format. The previous is helpful as we can use the spread operator later to pass them as parameters to the function.

Finally, as in the common state query prompts, we want a gateway to process the selected option and trigger the desired function to query a state in the smart contract.

/* Returns the option selected to query on a custom 
smart contract. Also returns the contract methods names 
and their required inputs 
*/
const getCustomSelection = async () => {
  const { contractNames, contractInputs } = getContractCallMethods();
  const { customOption } = await inquirer.prompt([
    {
      type: "list",
      name: "customOption",
      choices: contractNames,
      message: "Select a read-only function from your smart contract",
    },
  ]);
  return { customOption, contractNames, contractInputs };
};
/* Performs the selection process for a method 
    on the smart contract to query  */
const processCustomSelection = async (
  selection,
  contractNames,
  contractInputs,
  latest_block
) => {
  const indexOf = getIndexOfMethod(contractNames, selection);
  const methodInputs = getInputsOfMethod(contractInputs, indexOf);
  const { userInput, block } = await getUserInput(methodInputs, latest_block);
  if (methodInputs.length < 1) {
    callContractFunctionWithoutParams(selection, block);
  } else {
    await callContractFunctionWithParams(selection, userInput, block);
  }
  return;
};

Note: Since some Solidity types like structs and mappings require a complex way of passing them as parameters (like tuples), trying to call functions that use them will trigger an error.

In the end, our custom.js file will look like this:

const inquirer = require("inquirer");
const {
  getContractCallMethods,
  ZERO_ADDRESS,
  callContractFunctionWithoutParams,
  callContractFunctionWithParams,
} = require("../utils");
/* Builds a new inquirer prompt for each required
    input for the selected smart contract method
*/
const buildPrompt = (inputs, latest_block) => {
  let prompts = [];
  inputs.forEach((input) => {
    prompts.push({
      type: "input",
      name: input.name,
      message: `Enter an ${input.type} for the ${input.name} to perform query`,
      default: getDefaultValue(input.type),
    });
  });
  prompts.push({
    type: "input",
    name: "block",
    message: `Enter the block number to perform query`,
    default: latest_block,
  });
  return prompts;
};
/* Gets the user inputs required to pass as function
    parameters in the method selected for the custom contract
*/
const getUserInput = async (inputs, latest_block) => {
  const prompt = buildPrompt(inputs, latest_block);
  const answer = await inquirer.prompt(prompt);
  const inputsAsArray = Object.keys(answer)
    .map((key) => answer[key])
    .slice(0, -1);
  const { block } = answer;
  return {
    userInput: inputsAsArray,
    block,
  };
};
/* Returns the default value of an inquirer prompt
    based on a smart contract function input type
*/
const getDefaultValue = (type) => {
  if (type.includes("address")) {
    return ZERO_ADDRESS;
  }
  if (type.includes("uint") || type.includes("int") || type.includes("enum")) {
    return 0;
  }
  if (type.includes("bool")) {
    return false;
  }
  if (type.includes("bytes")) {
    return "0x0";
  }
  if (type.includes("array")) return [];
  if (type.inclues("string")) return "";
  return null;
};
/* Returns the inputs name and type of a given method */
const getInputsOfMethod = (contractInputs, indexOf) => {
  return contractInputs[indexOf];
};
/* Returns the index of a method in the methods name array */
const getIndexOfMethod = (contractNames, method) => {
  return contractNames.findIndex((name) => name === method);
};
/* Returns the option selected to query on a custom
    smart contract. Also returns the contract methods names
    and their required inputs
*/
const getCustomSelection = async () => {
  const { contractNames, contractInputs } = getContractCallMethods();
  const { customOption } = await inquirer.prompt([
    {
      type: "list",
      name: "customOption",
      choices: contractNames,
      message: "Select a read-only function from your smart contract",
    },
  ]);
  return { customOption, contractNames, contractInputs };
};
/* Performs the selection process for a method
    on the smart contract to query
*/
const processCustomSelection = async (
  selection,
  contractNames,
  contractInputs,
  latest_block
) => {
  const indexOf = getIndexOfMethod(contractNames, selection);
  const methodInputs = getInputsOfMethod(contractInputs, indexOf);
  const { userInput, block } = await getUserInput(methodInputs, latest_block);
  if (methodInputs.length < 1) {
    callContractFunctionWithoutParams(selection, block);
  } else {
    await callContractFunctionWithParams(selection, userInput, block);
  }
  return;
};
module.exports = {
  getCustomSelection,
  processCustomSelection,
};

Final touches

Now, we need to implement our final prompt that will manage to choose if query common state or a custom smart contract function. The common.js file contains the code to select if a user will query a common state function or one given on the custom smart contract, so it serves as a middleware to decide what to execute next.

const inquirer = require("inquirer");
const { getCurrentBlock } = require("../utils");
const {
  getCommonsSelection,
  processCommonsSelection,
} = require("./commonState");
const { getCustomSelection, processCustomSelection } = require("./custom");
let LATEST_BLOCK;
// Prompts the main selection panel
const getSelection = async () => {
  LATEST_BLOCK = await getCurrentBlock();
  const { mainOption } = await inquirer.prompt([
    {
      type: "list",
      name: "mainOption",
      message: "Select the type of functions to query",
      choices: [
        "Common state functions (getBalance, getBlock...)",
        "Custom contract state view functions",
      ],
    },
  ]);
  if (mainOption.includes("Common")) {
    // Process the common state query options
    const { commonsOption } = await getCommonsSelection();
    await processCommonsSelection(commonsOption);
  } else {
    // Process the custom contract state query options
    const { customOption, contractNames, contractInputs } =
      await getCustomSelection();
    await processCustomSelection(
      customOption,
      contractNames,
      contractInputs,
      LATEST_BLOCK
    );
  }
};
module.exports = {
  getSelection,
};

Finally, and most importantly, our index.js file shall execute the getSelection function to prompt all user inputs.

const { getSelection } = require("./src/prompts/common");
const main = async () => {
  await getSelection();
};
main();

To run a demo of this app, on the projects root folder, we just run

node index

If we choose to run common state functions queries, it will ask at least for the block number (it will use latest as default). Some other functions will require an address (it will use the zero address by default).

Demo of querying the ETH balance of an address on a given block

On the other hand, we could query any state call function from our custom smart contract. The inputs will vary depending on the function to query (some functions will not even need an input).

Demo of querying the balanceOf method on the SHIBA-INU ERC20 token contract.

As always, the complete code is available in the tutorial’s repository

Conclusion

In this post, we have discovered how to perform, programmatically and interactively, state queries to a given network and a custom smart contract. This scripting tutorial allows fetching helpful information about the actual and historical states of the blockchain in an error-based approach. For example, this tool could perform different queries to a network state using both full and archive nodes. This transition is helpful in situations where the calls to an RPC provider get limited, and every one of them needs to be executed in the most efficient way possible.

Have you already explored what you can achieve with Chainstack? Get started for free today.

Chainstack uses cookies to provide you with a secure and
personalized experience on its website. Learn more.