Solidity – Best Gas Efficient Way to Pass address[] Parameter in Contract Function Calls

gassolidity

Say we have a contract function which need some address[] parameter as input:

  function do_sth(
    address[] memory ads,
    uint256 in
  ) external returns (uint256 out) {
    ...
  }

This will produce the transaction Input Data as follows (detail data here just for example):

0x65432121
0x0000000000000000000000000000000000000000000000000000000000000040
0x00000000000000000000000000000000000000000000000783c5395de63b678d
0x0000000000000000000000000000000000000000000000000000000000000002
0x000000000000000000000000e9e7cea3dedca5984780bafc599bd69add087d56
0x000000000000000000000000b387c824cafe52b25fbbe793d7b6800b1ddbfe7d

For example the ads=[0xe9e7cea3dedca5984780bafc599bd69add087d56, 0xb387c824cafe52b25fbbe793d7b6800b1ddbfe7d] and in=0x783c5395de63b678d are the function inputs.

We can see that this is a little bit low efficient (higher gas), since there're so many zeros there.

Is there any way to get the exact Input Data as e9e7cea3dedca5984780bafc599bd69add087d56b387c824cafe52b25fbbe793d7b6800b1ddbfe7d00000000000000000000000000000000000000000000000783c5395de63b678d? How to implement it in Solidity?

Best Answer

Of course you can.

A better question is : should you ?

Probably not, because to do so you will have to write low level assembly code that's error-prone, difficult to debug / read and possibly make assumptions about the solidity compiler that might not hold over time.

Your input data will be :

  • function identifier : 4 bytes
  • address[] : N * 20 bytes
  • uint256 : 32 bytes

The assumption about the compiler will be the following :

A function can receive more calldata that its signature suggest.

The following function :

function do_sth() public view {}

Should only receive 4 bytes of calldata : the function identifier. It cannot receive less, but it can receive more. There is no saying if this assumption will hold on every subsequent versions of solidity...

Your parameters will therefore be embedded in the calldata, and not accessible from solidity, only from assembly reading directly from calldata.

This can be achieved with the following implementation that just reads from calldata, decodes the embedded input and returns them following the standard ABI specifications :

function do_sth() public view returns (address[] memory, uint256) {
        address[] memory ads;
        uint256 input;

        assembly {
            let tmp := 0

            // Skip : function selector    : 0x4 bytes
            let offset := 0x4

            // Compute the number of addresses :
            // ((array length - 0x04) - 0x20) / 0x14
            // ((array length - sizeof(function Selector)) - sizeof(uint256)) / sizeof(address)
            let adsCount := div(sub(sub(calldatasize(), 0x04), 0x20), 0x14)

            // Allocate memory for the address array
            ads := mload(0x40)
            mstore(0x40, add(ads, add(0x20, mul(adsCount, 0x20))))

            // Set the size of the array
            mstore(ads, adsCount)

            // Get an address from calldata on each iteration :
            // loads 0x20 bytes from calldata starting at offset : calldata[offset: offset + 0x20)
            // shift value by 96 bits (12 bytes) to the right to keep only the relevant portion (first 20 bytes)
            // store that value at ads[i]
            // increments calldata offset by 0x14 (20 bytes)
            for {let i := 0} lt(i, adsCount) {i := add(i, 1)} {
                tmp := calldataload(offset)
                tmp := shr(96, tmp)
                mstore(add(add(ads, 0x20), mul(i, 0x20)), tmp)
                offset := add(offset, 0x14)
            }

            // Get the remaining parameter : uint256 (32 bytes) 
            input := calldataload(offset)
        }

        return (ads, input);
    }

If you were to call this function with the following calldata (you can add as many addresses as you want before the uint256 value / function identifier was omitted):

0xe9e7cea3dedca5984780bafc599bd69add087d56b387c824cafe52b25fbbe793d7b6800b1ddbfe7d00000000000000000000000000000000000000000000000783c5395de63b678d

With this web3 code for example :

const Web3 = require("Web3");

const ENDPOINT = "http://localhost:8545";
const CONTRACT_ADDRESS = "CONTRACT-ADDRESS_HERE";
const YOUR_DATA =
  "0xe9e7cea3dedca5984780bafc599bd69add087d56b387c824cafe52b25fbbe793d7b6800b1ddbfe7d00000000000000000000000000000000000000000000000783c5395de63b678d";

let web3 = new Web3(ENDPOINT);

async function main() {
  let encodedData =
    web3.eth.abi.encodeFunctionSignature({
      name: "do_sth",
      type: "function",
      inputs: [],
    }) + YOUR_DATA.replace("0x", "");

  let callObject = {
    from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
    to: CONTRACT_ADDRESS,
    data: encodedData,
  };

  console.log(
    "This call will consume : " +
      ((await web3.eth.estimateGas(callObject)) + " gas")
  );

  let output = await web3.eth.call(callObject);

  console.log(output);

  let decodedData = web3.eth.abi.decodeParameters(
    ["address[]", "uint256"],
    output
  );

  console.log(decodedData);
}

main();

You get your addresses and your uint value extracted from the calldata :

Result {
  '0': [
    '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56',
    '0xb387C824CAfE52B25FBbE793d7b6800b1DdBfE7D' 
  ],
  '1': '138622266980804814733',
  __length__: 2
}

Which cost 23593 gas on default settings, a slight improvement over the 24331 gas cost of this solidity version on the same settings :

function do_sth2(address[] calldata ads, uint256 input) public view returns (address[] memory, uint256) {
    return (ads, input);
}

Now you shouldn't do that, and probably don't even need to. You should stick to solidity.

So, it's possible but clearly not recommended.

I hope that answers your question.