How to interact with smart contracts in JavaScript

In this tutorial, we are going to learn how to interact with any smart contract on Ethereum or any EVM chain with JavaScript and the Web3 library.

In this tutorial, we are going to learn how to interact with any smart contract on Ethereum or any EVM chain with JavaScript and the Web3 library.

First of all, you're going to need the address of the smart contract you want to interact with.

You can find it on the blockchain explorer of the network you're using. If you use Ethereum, the explorer is etherscan.io.

If you deployed the contract yourself, you might already have the address.

For this article, we are going to use the smart contract of the Bored Ape Yacht Club NFT collection as an example. The address is: 0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D

Once you have the address of the smart contract, there are 2 types of methods that you can call:

  • Methods that read the smart contract's state
  • Methods that change the smart contract's state

You can get a full list of both in the blockchain explorer:

Now, we're going to need to construct an ABI which is an interface that defines how the functions work so you can interact with it.

To make that ABI, you need to get all the functions that you want to use in the smart contract. In our example, we are going to use:

  • MAX_APES – that returns the maximum amount of NFTs that can be minted
  • balanceOf – that checks the amount of NFTs an address has
  • mintApe – that mints an NFT

To make the ABI, you can either go to the "Code" section of the "Contract" tab on the image above and scroll down to "Contract ABI". From there you can copy the full contract ABI and simply remove what you're not using.

It's good to know how it works so I'll explain step-by-step how to create your own ABI JSON.

First, here is how to make the ABI for a constant like the MAX_APES (taken from the smart contract directly):

{
    "inputs":[],
    "name":"MAX_APES",
    "outputs": [{
	"internalType": "uint256",
        "name": "",
        "type": "uint256"
    }],
    "stateMutability": "view",
    "type": "function"
}

Above you can see that:

  • we define the inputs which is what the function takes as input parameters, here it's nothing as it doesn't take anything in parameters, it's a constant
  • name is the name of the function
  • outputs is what it returns. For every variable returned, we define an internalType (which is optional), the name of the variable and the actual type that is returned
  • type is the type of variable it is, here it's a function
  • stateMutability tells if the function mutates or not the state of the smart contract. It is optional.

And we could have also set "constant": true to say it's a constant. Other functions that are not constants will have "constant": false .

Next, functions that are not constants and that get input parameters like balanceOf:

{
    "inputs": [{
        "internalType": "address",
        "name": "owner",
        "type": "address"
    }],
    "name": "balanceOf",
    "outputs": [{
	"internalType": "uint256",
	"name": "",
	"type": "uint256"
    }],
    "stateMutability": "view",
    "type": "function"
}

You can see that we use the same properties, except that not, there is an input parameter of type address called owner. This will change how we call the function as we will need to pass that parameter in.

Lastly, for payable function, it will be slightly different. Payable functions like mintApe are functions that require you to send Ethereum as you call them. In our case, the amount to send is the mint price of their NFTs. Since they're all minted the function won't work but it's interesting to see how it works:

{
    "inputs": [{
        "internalType":"uint256",
        "name":"numberOfTokens",
        "type":"uint256"
    }],
    "name":"mintApe",
    "outputs":[]
    "stateMutability":"payable",
    "type":"function"
}

Here, the difference is that stateMutability is now "payable" since it's a payable function. You can also set "nonpayable" if the function changes the state but is not payable.

Now, let's put it all together:

const ABI = [
    {
        "inputs":[],
        "name":"MAX_APES",
        "outputs": [{
    	"internalType": "uint256",
            "name": "",
            "type": "uint256"
        }],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [{
            "internalType": "address",
            "name": "owner",
            "type": "address"
        }],
        "name": "balanceOf",
        "outputs": [{
    	"internalType": "uint256",
    	"name": "",
    	"type": "uint256"
        }],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [{
            "internalType":"uint256",
            "name":"numberOfTokens",
            "type":"uint256"
        }],
        "name":"mintApe",
        "outputs":[]
        "stateMutability":"payable",
        "type":"function"
    }
]

Now that we have the ABI, let's actually interact with the contract.

First, install Web3JS: npm install web3

As always, you need a provider to use web3. Either an API URL like the ones Infura provide or have a wallet connected to your website. In this example, we're going to use an Infura API URL. You can learn how to get an Infura API URL here.

To start using the functions in our ABI, we create an Contract instance like this:

import Web3 from 'Web3'

const INFURA_URL = "YOUR URL HERE"
const web3 = new Web3(new Web3.providers.HttpProvider(INFURA_URL))

const contractAddress = "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"

// ABI is defined above
const contract = new web3.eth.Contract(ABI, address)

Using that contract instance, we can now call any function defined in the ABI as if it was a normal JavaScript function:

// Getting MAX_APES
const maxApes = await contract.methods.MAX_APES().call()
// maxApes = 10000


// Getting the balance of an address
const addressToCheck = "0xbA23d58c7289AaBF1820cB35bB4b5Bb89170fFfD"
const balance = await contract.methods.balanceOf(addressToCheck).call()
// at the time of writing this article, balance = 3

Now, contract methods that change the state of the smart contract need to be part of a transaction because they consume gas. If you don't send them in a transaction, it won't work.

Here is an example with mintApe (we set the mint price to an example value):

const nftsToBuy = 1

const mintPrice = 2 // here we set the mint price to 2 ETH

// convert the amount in ETH to Wei to pass it to the contract
const payableAmount = Web3.utils.toWei(
    `${mintPrice * nftsToBuy}`,
    'ether'
)

web3.eth.sendTransaction({
    from: fromAddress, // address of the connected wallet here
    to: address, // smart contract address here
    value: payableAmount
    data: contract.methods.mintApe(payableAmount,nftsToBuy).encodeABI(),
})
.on((receipt) => {
    // do somehting when the transaction passed
    // receipt will have all the info about the transaction
})
.on((error) => {
    // do something if the transaction failed
})

Note that for the code above to work, you'll need to have an address to send the transaction from. Usually it will be a wallet connected to your website. To learn how to connect a wallet to your website check out this article if you're using React, this one if you want to use Web3Modal to connect any wallet, and this article if you're using vanilla JavaScript.

In the code above, we send a transaction containing our call to the contract method inside the data property of the transaction. This will indicate the contract what we want to do.

Since we need to send Ethereum when we mint, we added the correct amount of Ethereum to the transaction through the value property.

If a smart contract function changes the state but is not payable (doesn't require you to send ETH), then you don't set the value property.

Also note that the address you send the transaction to is always the smart contract.

Last example using the transferFrom function:

const ABI = [
    // define the ABI here using the method explained above
]

// ... create a web3 instance by connecting a wallet ...

const contractAddress = "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"

const contract = new web3.eth.Contract(ABI, address)

const data = contract.methods.transferFrom(
    fromAddress,
    toAddress,
    tokenId, // ID of the NFT, like 1, 524, 888 (it's in the name of the NFT)
).encodeABI()

web3.eth.sendTransaction({
    from: fromAddress, // address of the connected wallet here
    
    to: address, // smart contract address here
    
    data,
})
.on((receipt) => {
    // do somehting when the transaction passed
    // receipt will have all the info about the transaction
})
.on((error) => {
    // do something if the transaction failed
})

Alternatively, you can also call functions that mutate the state and send transactions using the .send method:

const nftsToBuy = 1

const mintPrice = 2 // here we set the mint price to 2 ETH

// convert the amount in ETH to Wei to pass it to the contract
const payableAmount = Web3.utils.toWei(
    `${mintPrice * nftsToBuy}`,
    'ether'
)

contract.methods.mintApe(payableAmount,nftsToBuy).send({
    from: fromAddress, // address of the connected wallet here
    to: address, // smart contract address here
    value: payableAmount
})
.on((receipt) => {
    // do somehting when the transaction passed
    // receipt will have all the info about the transaction
})
.on((error) => {
    // do something if the transaction failed
})

The object in the parameters of the .send function are the same as the transaction object, unless you don't need the data property.

And that's it! 👏

Thanks for reading this article!