Understanding Smart Contract ABI, its Usage, Structure, and Evolution
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:
- Encode the function call and its inputs (if any).
- Send the call or transaction to the blockchain through a node on the network.
- 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
is the result of step 1
data
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 decodeFunctionResult
methods 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.