Understanding Smart Contract ABI, its Usage, Structure, and Evolution

Adekunle Michael Ajayi
8 min readOct 21, 2024

--

When you compile a smart contract, alongside other information about the smart contract and its compilation, you get two crucial pieces of information: the bytecode and the ABI (Application Binary Interface). While the bytecode is the actual EVM-executable code deployed to the blockchain, the ABI serves a different but equally important purpose.

What is the ABI?

ABI stands for Application Binary Interface. In the context of smart contracts, it provides a standardized way to interact with them. The ABI documents the functions, events, and errors present in a smart contract and is used to encode data sent to the contract and decode data received from it.

This is what an ERC20 contract ABI looks like. find the complete file here

An ABI file is a JSON array where each element is an object describing items in the smart contract. These items can be a constructor, function, event, error, or fallback. It specifies the inputs and outputs, including their names and types, as well as the state mutability of functions or constructors.

Why do we need ABI to interact with Smart Contracts?

Your smart contract’s source code (whether written in Solidity, Vyper, or any other language) is not the actual code executed on the blockchain. The bytecode generated during compilation is the real smart contract deployed to the Ethereum Virtual Machine (EVM).

The bytecode contains all the functions, structs, and events from your original code, but they’re no longer human-readable. Understanding and interacting with this bytecode directly would require in-depth knowledge of EVM and its operations.

How ABI aids communication between your application and your smart contract

Looking at the bytecode, We don’t know the functions present in the smart contract, what inputs they take and the output they produce. even if we do (because we wrote the original solidity code), the code is no longer in solidity, we cannot call them in our application like we would normaly do in solidity. This is the essence of ABI. The ABI serves as a bridge between your application and the deployed smart contract. It provides a clear description of all the functions, events, and errors present in the contract, including their inputs and outputs. This information is crucial for encoding function calls to the smart contract and decoding the results, errors, or event logs obtained from it.

In essence, interacting with a smart contract from an application involves three steps:

  1. Encode the function call and its inputs (if any).
  2. Send the call or transaction to the blockchain through a node on the network.
  3. Decode the result and use it in the application.

While libraries like ethers.js, web3.js, and Viem abstract away much of this process, understanding these steps is valuable.

Let’s walk through an example using the USDC contract on the Ethereum mainnet to illustrate this process: Checking USDC Balance

Following the three steps listed above We’ll use the balanceOf function of the USDC contract to check an address’s balance. We’ll demonstrate this using ethers.js, which provides two utilities for ABI operations: AbiCoder and Interface.

1 — Encoding the function and its input

As mentioned, there are two ways to achieve this in ethers: AbiCoder and Interface.

Using AbiCoder

To achieve this using AbiCoder, we first have to calculate the function selector, then ABI-encode the input of the function, and then finally concatenate them together.

import { ethers } from 'ethers';

// Define the function signature
const functionSignature = "balanceOf(address)";

// Calculate the function selector (first 4 bytes of the keccak256 hash of the function signature)
const functionSelector = ethers.id(functionSignature).slice(0, 10);

// Create an instance of AbiCoder
const abiCoder = new ethers.AbiCoder();

// Encode the function parameters
const encodedParams = abiCoder.encode(
['address'],
['0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640']
);

// Combine the function selector and encoded parameters
const encodedFunctionData = functionSelector + encodedParams.slice(2); // Remove '0x' from encodedParams

console.log("Encoded function data: ", encodedFunctionData);

Using the Interface class

Using the interface class is much more strightforward as you do not need to separately calculate the function signature, you just need to create an instance of Interface with the contract ABI, then proceed to encode or decode according to the ABI passed.

import { ethers } from 'ethers';

// Define the ABI for the balanceOf function. As seen here, you can remove other fragments you dont't need
const abi = [{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
}
];

// Create an instance of the Interface class
const interface = new ethers.Interface(abi);

// Encode the function call
const encodedFunctionData = interface.encodeFunctionData(
"balanceOf",
["0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"]
);

console.log("Encoded function data:", encodedFunctionData);

Given the same address as input, Both methods should produce the same output:

Encoded function data: 0x70a0823100000000000000000000000088e6A0c2dDD26FEEb64F039a2c41296FcB3f5640

2— Sending the call to a node on the network

There are several ways to make calls to the blockchain, to keep it simple, we’ll use curl, and send the request through a public rpc url obtained from chainlist.
USDC smart contract address on mainnet: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
data
is the result of step 1

curl https://eth.llamarpc.com \
-X POST \
-H "Content-Type: application/json" \
--data '{
"jsonrpc":"2.0",
"method":"eth_call",
"params":[{
"to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"data": "0x70a0823100000000000000000000000088e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"
}, "latest"],
"id":1
}'

The response will look something like this:

{"jsonrpc":"2.0","id":1,"result":"0x00000000000000000000000000000000000000000000000000004c2b301f1244"}

3 — Decoding the result

After interacting with the smart contract, an ABI-encoded result was returned, Now we need to decode it. Again, we can use either AbiCoder or Interface for this step.

From the ABI, we know that the function we called (balanceOf) returned a uint256, so decoding it is very simple

Using AbiCoder:

import { ethers } from 'ethers';

// The encoded uint256 value
const encodedValue = '0x00000000000000000000000000000000000000000000000000004c2b301f1244';

// Create an instance of AbiCoder
const abiCoder = new ethers.AbiCoder();

// Decode the value
const decodedValue = abiCoder.decode(['uint256'], encodedValue);

console.log("Decoded value: ", decodedValue[0].toString());

// If you want to format it using its decimals
console.log("Formatted value: ", ethers.formatUnits(decodedValue[0], 6));

Using the Interface:

To decode result using the interface instance is simple. it done by simply calling the decodeFunctionResult method of our interface

import { ethers } from 'ethers';

// Define the ABI for the balanceOf function. As seen here, you can remove other fragments you dont't need
const abi = [{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
}
];

// The encoded uint256 value
const encodedValue = '0x00000000000000000000000000000000000000000000000000004c2b301f1244';

// Create an instance of the Interface class
const interface = new ethers.Interface(abi);

// Decode the value
const decodedValue = interface.decodeFunctionResult("balanceOf", encodedValue);

console.log("Decoded value: ", decodedValue[0].toString());

// If you want to format it as a more human-readable number
console.log("Formatted value: ", ethers.formatUnits(decodedValue[0], 6));

Both methods should produce similar output:

Decoded value: 83748374647364
Formatted value: 83748374.647364

The decodeFunctionResult method takes in the function name, and the encoded values, then decodes and returns the decoded values to you.

But how does the interface know how to correctly encode and decode the values when we didn’t pass it the types of the values?

Remember, when instantiating the interface, an ABI was passed, the encodeFunctionData and decodeFunctionResultmethods of the interface uses the function name, which is the first argument you passed to get the types of the input and output of the function respectively, it then uses abiCoder under the hood to perform the encoding and decoding.

No matter what tool you’re using to interact with smart contracts from any application, these are the process taken, hence why ABI is very important.

Evolution of Smart contract ABI

Although the smart contract ABI has evolved from what it used to be, it still maintains backward compatibility. This makes newer ABIs still usable in old libraries and vice versa. Let’s explore how ABI fragments have changed over time:

The initial ABI structure was straightforward, consisting mainly of function and event fragments. Function fragments included a name, inputs, outputs, and a type indicator of “function”. Event fragments had a similar structure but were labeled as “type”: “event”.

As smart contracts grew more complex, the ABI adapted. The stateMutability field was added to function fragments, replacing the older constant and payable boolean flags. This allowed for more precise control with values like “pure”, “view”, “nonpayable”, and “payable”.

Special functions got their own representations in the ABI. The fallback function appeared as a nameless, inputless function fragment. Later, the receive function was added, specifically for handling ether transfers without data.

Constructor functions, initially represented simply, gained a more explicit format with “type”: “constructor”, including inputs but no name or outputs.

To improve error handling, custom error fragments were introduced. These new fragments, marked with “type”: “error”, included a name and inputs, allowing for more gas-efficient and informative error reporting.

As smart contracts’ type systems became more sophisticated, the ABI followed suit. internalType and components field were added to input and output array, providing more detailed information about complex types like structs.

Recent additions focus on optimization and gas estimation. An optional gas field for function fragments helps in estimating transaction costs. Basically, the gas limit to be use when sending a transaction for the function.

Throughout these changes, the core structure of ABI fragments has remained consistent. Functions still have names, inputs, outputs, and state mutability information. Events retain their structure with name, inputs, and an anonymous flag. New fragment types follow patterns similar to existing ones.

The ABI continues to evolve, adapting to new features in smart contract languages and the needs of the Ethereum ecosystem. Its evolution reflects the growing sophistication of blockchain technology while ensuring that contracts deployed years ago can still be interacted with using modern tools.

Conclusion

The ABI is a fundamental building block for interacting with smart contracts. It provides a standardized way to encode and decode data, ensuring seamless communication between your application and the blockchain. As smart contract technology continues to evolve, the ABI will adapt to ensure interoperability and efficiency, making the development and use of decentralized applications more accessible and robust.

Don’t forget to drop a clap and share if you find it helpful. Also do well to drop a comment if you have any question or would like to discuss more on this topic.

Reference

--

--

Adekunle Michael Ajayi
Adekunle Michael Ajayi

No responses yet