Infer Contract Types from JSON Artifact
📝 This article is for TypeScript developers. So, if you are using JavaScript, you do not need to read this. However, web3.js version 4.x has been rewritten in TypeScript. And we encorage you to use its strongly-typed features with TypeScript.
Web3.js is a popular library used for interacting with EVM blockchains. One of its key features is the ability to invoke EVM smart contracts deployed on the blockchain. In this blog post, we will show how to interact with the smart contract in TypeScript, with a special focus on how to infer types from JSON artifact files.
Before we dive into the problem, let's take a quick look at the problem. Web3.js provides a simple and convenient way to interact with Solidity contracts. To use Web3.js to interact with a Solidity contract, you need to know the contract's address and the ABI (Application Binary Interface) of the contract. The ABI is JSON data that contains the definition of the functions in the contract, including their, name, input parameters and return values.
Web3.js uses ABI type to dynamically load available methods and events but TypeScript currently doesn't support loading JSON as const. If you go to the Playground Link and choose '.d.ts' you can check type difference with and without as const
.
import { Contract, Web3 } from 'web3';
import ERC20 from './node_modules/@openzeppelin/contracts/build/contracts/ERC20.json';
(async function () {
const web3 = new Web3('rpc url');
const contract = new Contract(ERC20.abi, '0x7af963cF6D228E564e2A0aA0DdBF06210B38615D', web3);
const holder = '0xa8F6eB216e26C1F7d924A801E46eaE0CE8ed1A0A';
//Error because Contract doesn't know what methods exists
const balance = await contract.methods.balanceOf(holder).call();
})();
To work around it you need to copy abi into a TypeScript file like this:
import {Contract, Web3} from 'web3';
const ERC20 = [
...
// 'as const' is important part, without it typescript would create generic type and remove available methods from type
] as const;
(async function () {
const web3 = new Web3('rpc url');
const contract = new Contract(ERC20, '0x7af963cF6D228E564e2A0aA0DdBF06210B38615D', web3);
const holder = '0xa8F6eB216e26C1F7d924A801E46eaE0CE8ed1A0A';
//Works now
const balance = await contract.methods.balanceOf(holder).call();
})();
Now it's working but it also means that abi is no longer updated when you bump your npm dependencies. To solve this problem, you can use a custom script that copies the JSON artifact of the contract into a TypeScript file as a const variable. This script can be run as part of your build process so that the TypeScript file is always up-to-date with the latest version of the contract's ABI.
Script:
import fs from 'fs';
import path from 'path';
//read destination directory submitted as first param
var destination = process.argv.slice(2)[0];
//read all contract artifacts from artifacts.json which should be in the directory from where script should be executed
const artifactContent = fs.readFileSync('./artifacts.json', 'utf-8');
const artifacts: string[] = JSON.parse(artifactContent);
(async function () {
for (const artifact of artifacts) {
let content;
try {
//try to import from node_modules
content = JSON.stringify(await import(artifact));
} catch (e) {
//try to read as path on disc
content = fs.readFileSync(artifact, 'utf-8');
}
const filename = path.basename(artifact, '.json');
//create and write typescript file
fs.writeFileSync(
path.join(destination, filename + '.ts'),
`const artifact = ${content.trimEnd()} as const; export default artifact;`,
);
}
})();
To use this script, just create an artifacts.json
file at the root of your project with all the artifacts you are using.
[
"@openzeppelin/contracts/build/contracts/ERC20.json",
"@openzeppelin/contracts/build/contracts/ERC1155.json",
"./build/contracts/MyContract.json"
]
and run the script with
node -r ts-node/register <script name>.ts <destination>
and then you can use those generated files in your code:
import { Contract, ContractAbi, Web3 } from 'web3';
import ERC20 from './artifacts/ERC20';
(async function () {
const web3 = new Web3('https://goerli.infura.io/v3/fd1f29ab70844ef48e644489a411d4b3');
const contract = new Contract(
ERC20.abi as ContractAbi,
'0x7af963cF6D228E564e2A0aA0DdBF06210B38615D',
web3,
);
const holder = '0xa8F6eB216e26C1F7d924A801E46eaE0CE8ed1A0A';
const balance = await contract.methods.balanceOf(holder).call();
const ticker = await contract.methods.symbol().call();
console.log(`${holder} as ${balance.toString()} ${ticker} tokens`);
})();
You can see full example at https://github.com/web3/web3-contract-types-example
📝 You can use a web3.js plugin called web3-plugin-craftsman
to compile and save the ABI and ByteCode. You can find more on: https://www.npmjs.com/package/web3-plugin-craftsman#save-the-compilation-result
📝 If you are developing smart contracts using Hardhat, you can use @chainsafe/hardhat-ts-artifact-plugin to generate typescript files containing typed ABI JSON for each artifact.