Step-by-step guide to MultiversX smart contract interactions with JavaScript SDK

julian-io

Julian.io

Posted on June 30, 2024

Step-by-step guide to MultiversX smart contract interactions with JavaScript SDK

In the fourth article of the series on MultiversX JavaScript SDK, we will examine how to interact with deployed smart contracts. As in the previous article, where we tackled smart contract deployment, we will use the Piggy Bank smart contract as an example.

The script is more complex than the previous ones because we will handle calls for three different functions on the smart contract: createPiggy, addAmount, and getLockedAmount. The first two are write functions, and the third one is a read function.

Again, let's see the whole script first, and we will go through it step by step.

import { promises } from "node:fs";
import {
  Address,
  SmartContractTransactionsFactory,
  TransactionsFactoryConfig,
  TransactionComputer,
  AbiRegistry,
  QueryRunnerAdapter,
  SmartContractQueriesController,
} from "@multiversx/sdk-core";
import {
  syncAndGetAccount,
  senderAddress,
  getSigner,
  apiNetworkProvider,
} from "./setup.js";

/**
 * Replace with your own deployed piggy bank smart contract
 * check deploy-smart-contract.js on how to deploy one
 */
const PIGGYBANK_CONTRACT_ADDRESS =
  "erd1qqqqqqqqqqqqqpgqtrajzw4vq0zxccdt9u66cvgg6vz8c6cwnegqkfqkpq";

/**
 * Load ABI file
 */
const getAbi = async () => {
  const abiFile = await promises.readFile("./piggybank.abi.json", "UTF-8");
  return JSON.parse(abiFile);
};

const scTransaction = async ({ functionName, args, amount }) => {
  const user = await syncAndGetAccount();
  const computer = new TransactionComputer();
  const signer = await getSigner();

  const abiObj = await getAbi();

  const factoryConfig = new TransactionsFactoryConfig({ chainID: "D" });
  const factory = new SmartContractTransactionsFactory({
    config: factoryConfig,
    abi: AbiRegistry.create(abiObj),
 });

  const transaction = factory.createTransactionForExecute({
    sender: new Address(senderAddress),
    contract: Address.fromBech32(PIGGYBANK_CONTRACT_ADDRESS),
    function: functionName,
    gasLimit: 5000000,
    arguments: args || [],
    nativeTransferAmount: amount,
 });

  // Increase the nonce
  transaction.nonce = user.getNonceThenIncrement();

  // Serialize the transaction for signing
  const serializedTransaction = computer.computeBytesForSigning(transaction);

  // Sign the transaction with out signer
  transaction.signature = await signer.sign(serializedTransaction);

  // Broadcast the transaction
  const txHash = await apiNetworkProvider.sendTransaction(transaction);

  console.log(
    "Check in the Explorer: ",
    `https://devnet-explorer.multiversx.com/transactions/${txHash}`
 );
};

/**
 * Call the createPiggy endpoint on the PiggyBank smart contract
 * https://github.com/xdevguild/piggy-bank-sc/blob/master/src/lib.rs#L25
 * We pass the unix timestamp in the future
 */
const createPiggy = async () => {
  await scTransaction({
    functionName: "createPiggy",
    args: [1750686756],
 });
};

/**
 * Call the addAmount endpoint on the PiggyBank smart contract
 * https://github.com/xdevguild/piggy-bank-sc/blob/master/src/lib.rs#L42
 */
const addAmount = async () => {
  await scTransaction({
    functionName: "addAmount",
    amount: 1000000000000000n,
 });
};

/**
 * Query the getLockedAmount endpoint on the PiggyBank smart contract
 * https://github.com/xdevguild/piggy-bank-sc/blob/master/src/lib.rs#L92
 */
const getLockedAmount = async () => {
  const abiObj = await getAbi();

  const queryRunner = new QueryRunnerAdapter({
    networkProvider: apiNetworkProvider,
 });

  const controller = new SmartContractQueriesController({
    queryRunner: queryRunner,
    abi: AbiRegistry.create(abiObj),
 });

  const query = controller.createQuery({
    contract: PIGGYBANK_CONTRACT_ADDRESS,
    function: "getLockedAmount",
    arguments: [senderAddress],
 });

  const response = await controller.runQuery(query);

  const [amount] = controller.parseQueryResponse(response);

  // The returned amount is a BigNumber
  console.log("Locked amount is: ", amount.valueOf());
};

/**
 * Here we will manage which function to call
 */
const smartContractInteractions = () => {
  const args = process.argv.slice(2);
  const functionName = args[0];

  const functions = {
    createPiggy,
    addAmount,
    getLockedAmount,
 };

  if (functionName in functions) {
    functions[functionName]();
 } else {
    console.log("Function not found!");
 }
};

smartContractInteractions();
Enter fullscreen mode Exit fullscreen mode

We have a scTransaction function, which is our common helper for the first two smart contract calls: createPiggy and addAmount. Let's analyze it first. Remember to watch the video embedded at the end of this article, where you'll see each step in detail.

const scTransaction = async ({ functionName, args, amount }) => {
  const user = await syncAndGetAccount();
  const computer = new TransactionComputer();
  const signer = await getSigner();

  const abiObj = await getAbi();

  const factoryConfig = new TransactionsFactoryConfig({ chainID: "D" });
  const factory = new SmartContractTransactionsFactory({
    config: factoryConfig,
    abi: AbiRegistry.create(abiObj),
 });

  const transaction = factory.createTransactionForExecute({
    sender: new Address(senderAddress),
    contract: Address.fromBech32(PIGGYBANK_CONTRACT_ADDRESS),
    function: functionName,
    gasLimit: 5000000,
    arguments: args || [],
    nativeTransferAmount: amount,
 });

  // Increase the nonce
  transaction.nonce = user.getNonceThenIncrement();

  // Serialize the transaction for signing
  const serializedTransaction = computer.computeBytesForSigning(transaction);

  // Sign the transaction with our signer
  transaction.signature = await signer.sign(serializedTransaction);

  // Broadcast the transaction
  const txHash = await apiNetworkProvider.sendTransaction(transaction);

  console.log(
    "Check in the Explorer: ",
    `https://devnet-explorer.multiversx.com/transactions/${txHash}`
 );
};
Enter fullscreen mode Exit fullscreen mode

The scTransaction provides common logic for each smart contract write function. It looks similar to the transactions in previous articles. We prepare the transaction using a dedicated transaction factory. We increment the nonce, prepare for signing, sign, and broadcast the transaction.

What is different is that we use the SmartContractTransactionsFactory and then createTransactionForExecute which prepares the transaction for our functions on smart contract. It takes the sender, smart contract address, function name, and gas limit as arguments. Optionally, where required, you can pass arguments for the smart contract function, and if you need to send some native EGLD token amount, you can also do that with nativeTransferAmount. You'll see an example soon. Of course, we also need the address of the deployed smart contract. You can deploy yours using the deploy-smart-contract.js. Check the video below, and also, please check the previous article on smart contract deployments.

The scTransaction is used in the createPiggy and addAmount functions. For the first one, we pass the argument, which is the Unix timestamp in the future - our lock time for the Piggy Bank. In the second one, we don't have attributes, but we send the EGLD amount to lock. This amount will be locked until time passes.

/**
 * Call the createPiggy endpoint on the PiggyBank smart contract
 * https://github.com/xdevguild/piggy-bank-sc/blob/master/src/lib.rs#L25
 * We pass the unix timestamp in the future
 */
const createPiggy = async () => {
  await scTransaction({
    functionName: "createPiggy",
    args: [1750686756],
 });
};

/**
 * Call the addAmount endpoint on the PiggyBank smart contract
 * https://github.com/xdevguild/piggy-bank-sc/blob/master/src/lib.rs#L42
 */
const addAmount = async () => {
  await scTransaction({
    functionName: "addAmount",
    amount: 1000000000000000n,
 });
};
Enter fullscreen mode Exit fullscreen mode

The third function is different. We don't need to trigger a transaction on the blockchain. We need to read the data on smart contract storage. We can do that by interacting with the network. For that, we can use some helpers from MultiversX SDK. First, let's see how it looks.

const getLockedAmount = async () => {
  const abiObj = await getAbi();

  const queryRunner = new QueryRunnerAdapter({
    networkProvider: apiNetworkProvider,
 });

  const controller = new SmartContractQueriesController({
    queryRunner: queryRunner,
    abi: AbiRegistry.create(abiObj),
 });

  const query = controller.createQuery({
    contract: PIGGYBANK_CONTRACT_ADDRESS,
    function: "getLockedAmount",
    arguments: [senderAddress],
 });

  const response = await controller.runQuery(query);

  const [amount] = controller.parseQueryResponse(response);

  // The returned amount is a BigNumber
  console.log("Locked amount is: ", amount.valueOf());
};
Enter fullscreen mode Exit fullscreen mode

We need to define QueryRunnerAdapter, which takes the networkProvider. In our case, we use a standard API network provider from the MultiversX toolset (check the first article for more details), but you can also prepare your own network provider and use it here. Let's keep it simple and use the default API provider.

After that, we need to prepare SmartContractQueriesController. It will manage queries. It takes a previously defined query runner and the contents of the ABI file as AbiRegistry. With that, we will be able to parse the query's response.

So, with the controller, we can create a query where we pass the smart contract address, function name, and arguments, if any. Then, we can run the query and get the response. Finally we can parse the response and get the data we are interested in. In this case, it is the amount of locked EGLD on the smart contract. And that's it. We have the returned locked amount.

There is also a simple helper for this demo that manages which function we want to call. So, if you're going to call the createPiggy function, you use node smart-contract-interactions.js createPiggy. Let's see how it looks, but it isn't important here. Again, check the video below to see more of it.

const smartContractInteractions = () => {
  const args = process.argv.slice(2);
  const functionName = args[0];

  const functions = {
    createPiggy,
    addAmount,
    getLockedAmount,
 };

  if (functionName in functions) {
    functions[functionName]();
 } else {
    console.log("Function not found!");
 }
};
Enter fullscreen mode Exit fullscreen mode

Summary

There are two types of functions for MultiversX smart contracts. The write functions are where you need to broadcast a transaction, and read functions that don't need the transaction and are public to read through the network and APIs.

Interaction with both types is straightforward when you use the MultiversX JavaScript SDK. There are dedicated smart contract transaction factories and query controllers, which are especially helpful when you must parse the response. Here, we had a very simple value, but the returned data can be much more complex. When possible, use ABI files, and where it is not possible, use TypedValues from the SDK.

Follow me on X (@theJulianIo) and YouTube (@julian_io) or GitHub for more MultiversX magic.

Please check the tools I maintain: the Elven Family and Buildo.dev. With Buildo, you can do a lot of management operations using a nice web UI. You can issue fungible tokens, non-fungible tokens. You can also do other operations, like multi-transfers or claiming developer rewards. There is much more.

Walkthrough video

The demo code

💖 💪 🙅 🚩
julian-io
Julian.io

Posted on June 30, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related