Step-by-step guide to MultiversX smart contract interactions with JavaScript SDK
Julian.io
Posted on June 30, 2024
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();
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}`
);
};
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,
});
};
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());
};
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!");
}
};
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
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
June 30, 2024