A Complete Typescript Example with Web3.js 4.x

Dilum Bandara
Coinmonks
Published in
6 min readJun 17, 2023

--

Immediately after the release of Web3.js 4.x, I had to convert my Software Architecture for Blockchain Applications course’s lab sheet to 4.x from 1.x. This wasn’t fun as there were too many breaking changes and little documentation. Even the examples on https://docs.web3js.org/ were somewhat disconnected, incomplete, and not fully Typescript compatible. Eventually, I managed to get something working (not as generic as it used to be though). Here is a complete example of deploying an ERC-20 contract and interacting with it using Web3 4.x.

The source code is available at https://github.com/dilumb/Web3_4x. It is tested with Ganache CLI (better not to use Ganache GUI as is not compatible with the latest solc compiler or you need to compile for a specific hardfork).

First, initialize the project using the following commands:

mkdir Web34x && cd Web34x
npm init -y # Initialise NodeJS project
npm install typescript ts-node - save-dev # Add TypeScript developer dependencies
npm install @types/node - save-dev # Add TypeScript node developer dependencies
npm install web3 solc @openzeppelin/contracts fs-extra path # Add Web3.js and Solidity compiler
npx tsc - init - rootDir src - outDir build - esModuleInterop - resolveJsonModule - lib es6 - module commonjs - allowJs true - noImplicitAny true # Configure typescript

Alternatively, you may use the following commands with yarn:

mkdir Web34x && cd Web34x
yarn init -y
mv Node.gitignore .gitignore # Move auto generate gitignore file
yarn add -D typescript ts-node
yarn add -D @types/node
yarn add web3 solc @openzeppelin/contracts fs-extra path # Install dependencies
npx tsc --init --rootDir src --outDir build --esModuleInterop --resolveJsonModule --lib es6 --module commonjs --allowJs true --noImplicitAny true # Configure typescript

Second, create 2 JSON files to keep track of the Web3 provider and private keys of accounts as follows and populate them based on the sample files:

mkdir eth_providers && touch eth_providers/providers.json # Create provider entry
mkdir eth_accounts && touch eth_accounts/accounts.json # Create account entry

Third, create the MyToken contract that embeds ERC-20 contracts from OpenZeppelin. Populate the contract based on the given sample files. It should look like the following (some comments are removed to keep the code short, but see them at https://github.com/dilumb/Web3_4x):

mkdir contracts # Create folder to hold smart contracts
touch contracts/MyToken.sol
pragma solidity ^0.8.2;

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

contract MyToken is ERC20 {

/**
* @dev Sets values for {name}, {symbol}, and {totalSupply} when
* the contract is deployed. Also, set total supply to contract creator
*/
constructor(string memory _name, string memory _symbol, uint256 _totalSupply) ERC20(_name, _symbol) {
_mint(msg.sender, _totalSupply);
}
}

Fourth, use the following Typescript helper code to compile the contract.

mkdir src && touch src/index.ts # Create source code entry
touch src/solc-lib.ts
const fs = require('fs')
const fsExtra = require('fs-extra')
const path = require('path')
const solc = require('solc')

/**
* Find files to import
*/
const findImports = (path: string): any => {
try {
return {
contents: fs.readFileSync(`node_modules/${path}`, 'utf8')
}
} catch (e: any) {
return {
error: e.message
}
}
}

/**
* Writes contracts from the compiled sources into JSON files
*/
export const writeOutput = (compiled: any, buildPath: string) => {
fsExtra.ensureDirSync(buildPath) // Make sure directory exists

for (let contractFileName in compiled.contracts) {
const contractName = contractFileName.replace('.sol', '')
console.log('Writing: ', contractName + '.json to ' + buildPath)
console.log(path.resolve(buildPath, contractName + '.json'))
fsExtra.outputJsonSync(
path.resolve(buildPath, contractName + '.json'),
compiled.contracts
)
}
}

/**
* Compile Solidity contracts
*/
export const compileSols = (names: string[]): any => {
// Collection of Solidity source files
interface SolSourceCollection {
[key: string]: any
}

let sources: SolSourceCollection = {}

names.forEach((value: string, index: number, array: string[]) => {
let file = fs.readFileSync(`contracts/${value}.sol`, 'utf8')
sources[value] = {
content: file
}
})

let input = {
language: 'Solidity',
sources,
settings: {
outputSelection: {
'*': {
'*': ['*']
}
},
// evmVersion: 'berlin' //Uncomment this line if using Ganache GUI
}
}

// Compile all contracts
try {
return JSON.parse(solc.compile(JSON.stringify(input), { import: findImports }))
} catch (error) {
console.log(error);
}
}

Fifth, use the following Typescript code to:

  1. deploy the contract while minting the total supply,
  2. check token details and initial account balances, and
  3. transact and then check the updated account balances.
import { compileSols, writeOutput } from './solc-lib'
const { Web3, ETH_DATA_FORMAT, DEFAULT_RETURN_FORMAT } = require('web3');
import type { Web3BaseProvider, AbiStruct, Address } from 'web3-types'

let fs = require('fs')
const path = require('path');

/**
* Helper class to calculate adjusted gas value that is higher than estimate
*/
class GasHelper {
static gasMulptiplier = 1.2 // Increase by 20%

static gasPay(gasLimit: string) {
return Math.ceil(Number(gasLimit) * GasHelper.gasMulptiplier).toString()
}
}

/**
* Init WebSocket provider
*/
const initProvider = (): Web3BaseProvider => {
try {
const providerData = fs.readFileSync('eth_providers/providers.json', 'utf8')
const providerJson = JSON.parse(providerData)

//Enable one of the next 2 lines depending on Ganache CLI or GUI
// const providerLink = providerJson['provider_link_ui']
const providerLink = providerJson['provider_link_cli']

return new Web3.providers.WebsocketProvider(providerLink)
} catch (error) {
throw 'Cannot read provider'
}
}

/**
* Get an account given its name
*/
const getAccount = (web3: typeof Web3, name: string) => {
try {
const accountData = fs.readFileSync('eth_accounts/accounts.json', 'utf8')
const accountJson = JSON.parse(accountData)
const accountPvtKey = accountJson[name]['pvtKey']

// Build an account object given private key
web3.eth.accounts.wallet.add(accountPvtKey)
} catch (error) {
throw 'Cannot read account'
}
}

/**
* Get ABI of given contract
*/
const getABI = (contractName: string, buildPath: string): AbiStruct => {
try {
const filePath = path.resolve(buildPath, contractName + '.json')
const contractData = fs.readFileSync(filePath, 'utf8')
const contractJson = JSON.parse(contractData)
return contractJson[contractName][contractName].abi
} catch (error) {
throw 'Cannot read account'
}
}

(async () => {

let web3Provider: Web3BaseProvider
let web3: typeof Web3
const buildPath = path.resolve(__dirname, '');

// Init Web3 provider
try {
web3Provider = initProvider()
web3 = new Web3(web3Provider)
} catch (error) {
console.error(error)
throw 'Web3 cannot be initialized.'
}
console.log('Connected to Web3 provider.')

// Deploy contract as account 0
const accountName = 'acc0'
const contractName = 'MyToken'
const tokenName = 'My Token'
const tokenSymbol = 'MyT'
const tokenTotalSupply = 100000

try {
getAccount(web3, 'acc0')
getAccount(web3, 'acc1')
getAccount(web3, 'acc2')
} catch (error) {
console.error(error)
throw 'Cannot access accounts'
}
console.log('Accessing account: ' + accountName)
let from = web3.eth.accounts.wallet[0].address

// Compile contract and save it into a file for future use
let compiledContract: any
try {
compiledContract = compileSols([contractName])
writeOutput(compiledContract, buildPath)
} catch (error) {
console.error(error)
throw 'Error while compiling contract'
}
console.log('Contract compiled')

// Deploy contract
const contract = new web3.eth.Contract(compiledContract.contracts[contractName][contractName].abi)
const data = compiledContract.contracts[contractName][contractName].evm.bytecode.object
const args = [tokenName, tokenSymbol, tokenTotalSupply]
let contractAddress: Address

// Deploy contract with given constructor arguments
try {
const contractSend = contract.deploy({
data,
arguments: args
});

// Get current average gas price
const gasPrice = await web3.eth.getGasPrice(ETH_DATA_FORMAT)
const gasLimit = await contractSend.estimateGas(
{ from },
DEFAULT_RETURN_FORMAT, // the returned data will be formatted as a bigint
);
const tx = await contractSend.send({
from,
gasPrice,
gas: GasHelper.gasPay(gasLimit)
})
console.log('Contract contract deployed at address: ' + tx.options.address)
contractAddress = tx.options.address
} catch (error) {
console.error(error)
throw 'Error while deploying contract'
}

// Transact with deployed contract

const abi = getABI(contractName, buildPath)
const contractDeployed = new web3.eth.Contract(abi, contractAddress)

// Verify token symbol
try {
const symbol = await contractDeployed.methods.symbol().call()
console.log(`Token symbol is: ${symbol}`)
} catch (error) {
console.error('Error while checking symbol')
console.error(error)
}

// Verify total token supply
try {
const totalSupply = await contractDeployed.methods.totalSupply().call()
console.log(`Token supply is: ${totalSupply}`)
} catch (error) {
console.error('Error while checking total supply')
console.error(error)
}

// Check token balance as token deployer
from = web3.eth.accounts.wallet[0].address
try {
const balance = await contractDeployed.methods.balanceOf(from).call()
console.log(`Balance of token deployer is: ${balance}`)
} catch (error) {
console.error(error)
}

// Transfer tokens from address 0 to address 1 and check balance
let to = web3.eth.accounts.wallet[1].address
try {

const gasPrice = await web3.eth.getGasPrice(ETH_DATA_FORMAT)
const gasLimit = await contractDeployed.methods.transfer(to, 2000).estimateGas(
{ from },
DEFAULT_RETURN_FORMAT, // the returned data will be formatted as a bigint
);
const tx = await contractDeployed.methods.transfer(to, 2000).send({
from,
gasPrice,
gas: GasHelper.gasPay(gasLimit)
})

console.log(`20.00 tokens transferred from address ${from} to address ${to} in transaction ${tx.transactionHash}`)

// Check balance as address 0 and 1
const balance0 = await contractDeployed.methods.balanceOf(from).call()
console.log(`Balance of address 0 is: ${balance0}`)

const balance1 = await contractDeployed.methods.balanceOf(to).call()
console.log(`Balance of address 1 is: ${balance1}`)

} catch (error) {
console.error('Error while transferring tokens and checking balance')
console.error(error)
}

process.exitCode = 0

})()

Finally, use the following command to compile the TypeScript files and execute them:

npx tsc
node build/index.js

You should see an output like the following:

Connected to Web3 provider.
Accessing account: acc0
Writing: @openzeppelin/contracts/token/ERC20/ERC20.json to /Users/user1/Documents/Workspace/tmp/Web34x/build
/Users/user1/Documents/Workspace/tmp/Web34x/build/@openzeppelin/contracts/token/ERC20/ERC20.json
Writing: @openzeppelin/contracts/token/ERC20/IERC20.json to /Users/user1/Documents/Workspace/tmp/Web34x/build
/Users/user1/Documents/Workspace/tmp/Web34x/build/@openzeppelin/contracts/token/ERC20/IERC20.json
Writing: @openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.json to /Users/user1/Documents/Workspace/tmp/Web34x/build
/Users/user1/Documents/Workspace/tmp/Web34x/build/@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.json
Writing: @openzeppelin/contracts/utils/Context.json to /Users/user1/Documents/Workspace/tmp/Web34x/build
/Users/user1/Documents/Workspace/tmp/Web34x/build/@openzeppelin/contracts/utils/Context.json
Writing: MyToken.json to /Users/user1/Documents/Workspace/tmp/Web34x/build
/Users/user1/Documents/Workspace/tmp/Web34x/build/MyToken.json
Contract compiled
Contract contract deployed at address: 0x03B1955C7d26a3b7955843664C2389D232f87c5E
Token symbol is: MyT
Token supply is: 100000
Balance of token deployer is: 100000
20.00 tokens transferred from address 0x2305839F6964a3778273777077aF10614Bc0FcF2 to address 0xf8E8335e4438227B6805FDaec79EF676F17f695f in transaction 0x06fa962debd2de205eee267af069ea0b938dc285c96db4f84360e5ca83861442
Balance of address 0 is: 98000
Balance of address 1 is: 2000

--

--

Dilum Bandara
Coinmonks

My world of technology, research, teaching, life, & everything else.