Call functions simultaneously – Same block data querying

In general, we query Ethereum Smart Contracts in a sequential manner, one call after the other. But this pattern is NOT suited for gathering separate data entries at the same time. One function call might happen at block X and the other one at block X+2, leaving your data unsynchronized. But, don’t worry, it is possible to call multiple functions simultaneously!

Ethereum - call multiple functions simultaneously


Why calling multiple functions simultaneously?

Sometimes your data must have the same timestamp (i.e. the time at which it was collected). For instance, you may need multiple tokens’ prices or volumes according to the latest block.

Content

This post describes an Ethereum solution that is able to query data from multiple contracts and functions through a single call. You can get synchronized data by using a few clever tricks.

The post was inspired by the EatTheBlocks Youtube channel which used the @index-finance library. 

I will also provide extensive explanations on every component to allow you to personalize it as you wish. We adapted the code from @index-finance to help you better understand how it works.

Sooo… let’s say we have two smart contracts that represent tokens and we want to query your balance in both of these tokens at the same time.

To do this, we will need:

  • The tokens’ contracts and their addresses
  • Your Ethereum Address
  • A helper contract 
  • A JS test script 

Dependencies

Before jumping into the coding bit, let’s all make sure we are on the same page.

We will be using Solidity (version 0.8.0) for the smart contracts and JavaScript for interacting with the blockchain.

For this tutorial, I am using the Truffle development environment and Ganache for my test network. To install and learn more about these tools, check out these posts: Set up your first Decentralised Application and How to work with Truffle and Ganache.

On top of that, we need two libraries.

  • Ethers.js – to interact with the blockchain
npm install --save ethers.js
  • Openzeppeline – to obtain the ERC20 token standard
npm install @openzeppelin/contracts


Token Contract

Our aim is to simultaneously find out an account’s balance in Token0 and Token1. 

For simplicity reasons, we can build a single Token contract that allows you to set its name and symbol when deployed.

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";


contract Token is  ERC20 {
    constructor (string memory name, string memory symbol)
        ERC20(name, symbol)
        public
    {
        // Mint 10 000 tokens to msg.sender
        _mint(msg.sender, 10000 * 10 ** uint(decimals()));
    }
}

In later steps, we will deploy this contract twice for our Token0 and Token1 using:

Token.new("Token0", "TKN0");
Token.new("Token1", "TKN1");

Now, let’s move on to the fun part.



Helper Contract

Trick no. 1

One call to a helper contract (let’s name it CallHelper) will aggregate our two calls to the Token0 and Token1 contracts.
Instead of querying the balance of our account from outside the blockchain (using JS), we will call it from CallHelper. Then, calling CallHelper (using JS) will give us the result of both balance queries.


Trick no. 2

More interestingly, the helper contract does not even need to be deployed, so we don’t have to spend any gas. We will do this by putting all the logic in the constructor and by simulating its deployment.

Let’s look at the code and understand what it does.

Constructor definition:

pragma solidity 0.8.0;

contract CallHelper {


  constructor( address contract0, bytes memory args0,
     address  contract1, bytes memory args1) public{
     .....
  }

}

This contract does not have any methods apart from the constructor, which is the core of our operations.

For each function call we want to aggregate, it receives as parameters:

  • (contract0, contract1) The address of the contracts we want to interact with 
  • (args0, args1) Bytes encoding of the tuple consisting of the function we want to call & the arguments to pass to this function

Part 1:

constructor( address contract0, bytes memory args0,
     address  contract1, bytes memory args1) public{

    // PART 1
    bytes[] memory returnDatas = new bytes[](2);
    returnDatas[0] = bytes("");
    returnDatas[1] = bytes("");

Reserve a memory location for a bytes array of length 2. Each entry is dedicated to the output of each call.

These values have to be of type bytes because we assume we don’t know for sure what the output type of each function call will be.

Part 2:

constructor( address contract0, bytes memory args0,
     address  contract1, bytes memory args1) public{

    // PART 1
    bytes[] memory returnDatas = new bytes[](2);
    returnDatas[0] = bytes("");
    returnDatas[1] = bytes("");

    // PART 2
    (bool success0, bytes memory returnData0) = contract0.call(args0);
    if (success0){
      returnDatas[0] = returnData0;
    }

    (bool success1, bytes memory returnData1) = contract1.call(args1);
    if (success1) {
      returnDatas[1] = returnData1;
    }

}

We use a low-level function contractAddress.call(). In general, this method is used in exceptional situations when we don’t know a contract’s source code but we know its address.

In our case, call() receives as parameters a payload representing an ABI encoding of the function name and the given parameters. 

This value is will already be in our args0 and args1, so we pass them to call().

The output of this low-level function has two parts: the success status and the returned value.

According to the success status, we add the returned value to our output array.

Part 3:

constructor( address contract0, bytes memory args0,
     address  contract1, bytes memory args1) public{

    // PART 1
    bytes[] memory returnDatas = new bytes[](2);
    returnDatas[0] = bytes("");
    returnDatas[1] = bytes("");

    // PART 2
    (bool success0, bytes memory returnData0) = contract0.call(args0);
    if (success0){
      returnDatas[0] = returnData0;
    }

    (bool success1, bytes memory returnData1) = contract1.call(args1);
    if (success1) {
      returnDatas[1] = returnData1;
    }

    // PART 3
    bytes memory data = abi.encode(returnDatas);

    assembly {
      return(add(data,32), 256)
    }

  }

This section ‘forces’ a return. Normally, constructors do not have a return statement. But we are able to break this rule by using inline assembly to return our result in a bytes format.

abi.encode():

We need to transform our return data into an ABI encoding using the abi.encode() function. This allows us to decipher it later.

By passing it an array as argument, we will obtain a number of words (each 32 bytes):

  • Word 1: length of encoding (number of words in total)
  • Word 2: offset of the 1st and only parameter passed to abi.encode()
  • The following words: encoding of our bytes array (returnDatas)

Assembly code:

We force the return statement using inline assembly.

The return command in assembly needs two parameters:

  • Starting Point: memory location from where it will start returning
    • Because we are only interested in the value returned by abi.encode(), we will skip the 1st word and start returning from the second one.
  • Interval length: how many bytes to return
    • Our result occupies 256 bytes. But if you wanted to call 3 functions and have 3 outputs, that would be 352. Each supplementary return value in the array would take another 96 bytes (assuming your return value fits in 32 bytes).

NOTE

In assembly “data” is treated as a memory location. So when we write “data”, the EVM understands it as the memory location of its 1st 32 bytes.



Test your contract

To test if we managed to call the two functions simultaneously, we need to call CallHelper’s constructor using JavaScript.

Before running our multi-call, we need to prepare the scene for it to work:

  • Deploy the two tokens using .new()
  • Create an ethers Interface for the tokens, to easily interact with their functions
  • Set up the RPC provider for connecting to our Ganache ethereum test-network
  • Send some tokens of each type to the address we want to check
const Token = artifacts.require('Token.sol');
const ethers = require('ethers');

const { abi } = require('../build/contracts/Token.json');
const { bytecode } =  require('../build/contracts/CallContract.json');
const { defaultAbiCoder, Interface } = require ("ethers/lib/utils");

contract('CallContract', accounts => {

  // declaring "global" variables
  let token0, token1; // token contracts instances
  let provider; // Ganache
  let tokenInterface; // Ethers contract interface
  let myAccount; // address to be checked

  /* before running the actual test, do: */
  before(async () => {
    // deploy tokens and get their instances
    token0 = await Token.new("Token0", "TKN0");
    token1 = await Token.new("Token1", "TKN1");
    tokenInterface = new Interface(abi); // create token interface

    // set provider
    provider = new ethers.providers.JsonRpcProvider("http://localhost:7545");

    // send tokens to account to be checked
    myAccount = accounts[1];
    await token0.transfer(myAccount, 200);
    await token1.transfer(myAccount, 670);
  });

});

Next, we will actually run the multi-call and check the balance of our address in both of the tokens at the same time.

Run calls simultaneously

........

contract('CallContract', accounts => {

  .......

  /* The actual test */
  it('should read the 2 token balances', async () => {

    /*****Compute arguments to pass to CallHelper contract******/

    // get each token's address
    let contract0 = token0.address
    let contract1 = token1.address;

    // encode function call (2nd param) - used to do the low level call
    let args0 = tokenInterface.encodeFunctionData('balanceOf', [myAccount]);
    let args1 = tokenInterface.encodeFunctionData('balanceOf', [myAccount]);

    /******Concatenate CallHelper bytecode & abi encoding of arguments and execute call******/

    // encode list of constructor arguments
    let inputData = defaultAbiCoder.encode(["address", "bytes", "address", "bytes"],
                [contract0, args0, contract1, args1]);

    // combine CallContract bytecode with constructor parameters
    const fulldata = bytecode.concat(inputData.slice(2)); // slice the 0x prefix
    const encodedReturnData = await provider.call({data: fulldata});

    ........

  });
});

The constructor of CallHelper requires two parameters for each function call:

  • (contract0 & contract1): The address of contract we want to call 
  • (args0 & args1) The ABI encoding of the function call (including function name and arguments)

We start by obtaining the addresses of the two tokens we just deployed. Then we encode (in bytes) the function we want to call and the values we want to pass. 

After having all the arguments ready for CallHelper, we need to put them into bytes form. The reason for this is to attach them at the end of the CallHelper bytecode as constructor parameters. Using this encoding, we can execute a low-level deployment of CallHelper.

Before glueing the arguments encoding to the bytecode, we need to get rid of the ‘0x’ prefix in inputData: .slice(2)

We use provider.call() to simulate the deployment of CallHelper. This will run the constructor and give us the result of both of our function calls.

To actually deploy the CallHelper contract on the blockchain, we would have to use the provider.sendTransaction(). But we want to avoid gas costs. That’s why we just ‘simulate’ its deployment with .call(). This just mimics the deployment transaction and outputs the result of the transaction if it were to happen.  

Decoding the result

To check if the result is the one we expected, we have to decode it into a bytes array format, then decode its items. The items in this array will represent the result of calling token0.balanceOf(myAccount) and token1.balanceOf(myAccount).

.......

contract('CallContract', accounts => {

  .......

  /* The actual test */
  it('should read the 2 token balances', async () => {
    .......

    /****Decode result*****/

    console.log("Encoded data");
    const [decoded] = defaultAbiCoder.decode(['bytes[]'], encodedReturnData);
    console.log(decoded);
    let result0 = tokenInterface.decodeFunctionResult('balanceOf', decoded[0]);
    let result1 = tokenInterface.decodeFunctionResult('balanceOf', decoded[1]);

    assert.equal(result0[0].toNumber(), 200, "Account does not own 200 of Token0");
    assert.equal(result1[0].toNumber(), 670, "Account does not own 670 of Token1");

  });
});


You now understand how to call multiple functions simultaneously and apply these tricks to your use case! Try doing the same thing but for 3 or more calls and make use of arrays. This helps you obtain synchronized data points.

Hope it helped. Leave any suggestions in the comments 😁

Leave a Reply